File size: 5,021 Bytes
66de9c8
 
070c931
e1222dc
 
070c931
 
 
 
66de9c8
070c931
 
 
 
66de9c8
070c931
 
e1222dc
070c931
 
 
 
 
 
 
 
 
 
 
 
1aa4a16
070c931
66de9c8
1aa4a16
 
 
070c931
 
 
 
 
66de9c8
070c931
 
 
 
 
 
 
66de9c8
070c931
 
 
 
66de9c8
 
 
 
 
 
070c931
 
 
 
66de9c8
070c931
66de9c8
 
 
 
070c931
66de9c8
070c931
 
66de9c8
070c931
e1222dc
1aa4a16
66de9c8
070c931
 
 
 
 
 
 
 
1aa4a16
 
 
 
66de9c8
e1222dc
1aa4a16
 
66de9c8
 
 
 
 
070c931
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""Voices for videos: the workspace's saved voices, the free built-in library, and its Notestack voices (made on the Voice
page; they speak audio overviews, not videos).

"Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
here: blog2video's saved-voice list is one list for the whole account.
"""

import logging

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select

from app.auth import Ctx, get_ctx
from app.models import UserSavedVoice
from app.routers.videos import b2v_ready, upstream
from app.services import blog2video as b2v
from app.services.notestack_voices import notestack_voices, save_notestack_voice

log = logging.getLogger(__name__)

router = APIRouter(prefix="/api/video-voices", tags=["videos"])

STARTER_VOICES = 4  # a new workspace starts with this many free built-in voices saved


def _labels(v: dict) -> dict:
    return v.get("labels") or {}


def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
    return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
            "accent": v.accent,
            "source": "notestack" if v.voice_id in ours else "blog2video"}


def _library_out(v: dict, saved: set[str]) -> dict:
    labels = _labels(v)
    return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
            "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
            "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
            "saved": v["voice_id"] in saved}


def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
    labels = _labels(v)
    row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
                         name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
                         preview_url=v.get("preview_url"),
                         gender=labels.get("gender"), accent=labels.get("accent"))
    ctx.db.merge(row)
    return row


def _saved(ctx: Ctx, ours: set[str] = frozenset()) -> list[UserSavedVoice]:
    """The saved voices that can still be used: free built-in voices and the workspace's Notestack voices. Voices that
    were designed or cloned for videos, and paid built-in ones, are gone."""
    rows = ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
                          .order_by(UserSavedVoice.created_at))
    return [v for v in rows if v.voice_id in ours or not (v.is_custom or v.premium)]


@router.get("")
def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
    """Your voices (audio overviews and videos), the free built-in library (to save from) and your Notestack voices."""
    with upstream():
        library = [v for v in b2v.prebuilt_voices() if v.get("plan") != "paid"]
    mine = notestack_voices(ctx.db, ctx.workspace.id)
    ours = {v["voice_id"] for v in mine}
    saved = _saved(ctx, ours)
    if not saved:
        for v in [v for v in library if v.get("voice_id")][:STARTER_VOICES]:
            _save_builtin(ctx, v)
        ctx.db.commit()
        saved = _saved(ctx, ours)
    ids = {v.voice_id for v in saved}
    return {"saved": [_voice_out(v, ours) for v in saved],
            "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
            "library": [_library_out(v, ids) for v in library if v.get("voice_id")]}


class SaveIn(BaseModel):
    voice_id: str = Field(min_length=1, max_length=100)


@router.post("/saved", status_code=201)
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
    if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
        return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
    mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
    if mine:
        # A Notestack voice: saved on any plan, for audio overviews. No preview link (the Voice page plays it).
        row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
        ctx.db.commit()
        return _voice_out(row, {row.voice_id})
    with upstream():
        v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
    if not v or v.get("plan") == "paid":
        raise HTTPException(404, "Voice not found")
    row = _save_builtin(ctx, v)
    ctx.db.commit()
    return _voice_out(row)


@router.delete("/saved/{voice_id}")
def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
    row = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": voice_id})
    if not row:
        raise HTTPException(404, "Voice not found")
    ctx.db.delete(row)
    ctx.db.commit()
    return {"ok": True}