Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, StrictBool, field_validator

from app.core.config import (
DISCOGS_LOOKUP_BUDGET_SEC,
Expand Down Expand Up @@ -44,6 +44,7 @@
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
from app.core.registry import set_favorite as registry_set_favorite
from app.core.registry import set_trashed as registry_set_trashed
from app.core.settings import (
get_acoustid_api_key,
Expand All @@ -68,6 +69,7 @@
copy_lyrics,
find_lyrics,
keep_answer,
lookup_lyrics,
lyrics_path,
lyrics_settled,
read_candidates,
Expand Down Expand Up @@ -423,6 +425,25 @@ def restore_job(job_id: str) -> dict:
return {"job_id": job.id, "trashed_at": job.trashed_at}


class FavoriteBody(BaseModel):
"""Strict, so a string such as "false" is refused rather than read as true."""

favorite: StrictBool


@router.put("/{job_id}/favorite")
def set_favorite(job_id: str, body: FavoriteBody) -> dict:
"""Mark a job as a favourite or take it back out (#734). Kept on the server
so the desktop and the phone share one answer."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_set_favorite(job_id, body.favorite)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
registry_persist(JOBS_DIR)
return {"job_id": job.id, "favorite": job.favorite}


@router.get("/{job_id}")
def get_job(job_id: str) -> dict:
"""Get the current state of a job by ID."""
Expand Down Expand Up @@ -1174,6 +1195,84 @@ def get_lyrics(job_id: str) -> Response:
raise HTTPException(status_code=404, detail="no lyrics")


class LookupBand(BaseModel):
"""The band saved on the track from the artist box, which lives in the
studio's own store and may not be on the job yet."""

id: str = Field(pattern=r"^Q\d{1,12}$")
name: str = Field(default="", max_length=300)
englishName: str = Field(default="", max_length=300) # noqa: N815 (the store's own key)


class LyricsLookupBody(BaseModel):
band: LookupBand | None = None


# Jobs whose lyrics the tab is looking up now, so a second request for the
# same track waits for the first rather than asking LRCLIB twice.
_LYRICS_LOOKUPS: set[str] = set()


@router.post("/{job_id}/lyrics/lookup")
async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None) -> Response:
"""Look the track's lyrics up on LRCLIB now, for the Lyrics tab (#719).

The tab used to search LRCLIB itself, with its own copy of the rules that
decide which version is this song by this artist and which length fits.
The two copies had to be kept equal by hand, and when they drifted the
same track could get lyrics at import and none in the tab. This runs the
import's own lookup instead, keeps what it finds the way the import does,
and answers exactly as GET .../lyrics would afterwards.

Only ever asked when the tab is opened on a track with no lyrics, or the
user presses Look up again: LRCLIB is never reached on its own. 502 when
LRCLIB cannot be reached, so the tab can offer to try again; 404 with
{"nothing_known": true} when the track says too little to look up.
"""
job, job_dir = _lyrics_job(job_id)
if await asyncio.to_thread(lyrics_path(job_dir).is_file):
return await asyncio.to_thread(get_lyrics, job_id)
if job_id in _LYRICS_LOOKUPS:
raise HTTPException(status_code=409, detail="already looking")
band = body.band.model_dump() if body and body.band else None
query = build_query(job, band=band)
if query is None:
return JSONResponse(
{"detail": "nothing to look up", "others": [], "nothing_known": True}, status_code=404
)
_LYRICS_LOOKUPS.add(job_id)
try:
answer = await asyncio.wait_for(
asyncio.to_thread(
lookup_lyrics,
query,
cancelled=lambda: registry_get(job_id) is not job,
fallback_title=job.title or "",
),
timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1,
)
except asyncio.TimeoutError:
logger.info("[%s] lyrics lookup timed out", job_id)
raise HTTPException(status_code=502, detail="lyrics service unreachable") from None
except Exception:
logger.info("[%s] lyrics lookup failed", job_id, exc_info=True)
raise HTTPException(status_code=502, detail="lyrics service unreachable") from None
finally:
_LYRICS_LOOKUPS.discard(job_id)
if registry_get(job_id) is not job:
raise HTTPException(status_code=404, detail="job not found")
if (
answer is not None
and not lyrics_path(job_dir).is_file()
and await asyncio.to_thread(keep_answer, job, job_dir, answer)
):
registry_persist(JOBS_DIR)
try:
return await asyncio.to_thread(get_lyrics, job_id)
except HTTPException:
return JSONResponse({"detail": "no lyrics", "others": []}, status_code=404)


class LyricsOffsetBody(BaseModel):
"""How many seconds later than their own timing the lyrics are shown:
negative for earlier, 0 for their own timing again."""
Expand Down
11 changes: 11 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,16 @@ class Job:
# A timestamp rather than a bool so the Trash can say when, and so a future
# auto-purge has something to work from.
trashed_at: float | None = None
# Whether the user marked this track as a favourite, or None if no
# client has said either way yet (#734).
#
# Server-side for the same reason as trashed_at: favourites lived only in
# the desktop page's catalog store, so the phone, which builds its library
# from GET /api/jobs, had no way to set one or to filter by them. None
# rather than False lets the desktop tell "never recorded here" from
# "taken back out", so it can hand up favourites it set before this field
# existed without undoing one removed on the phone.
favorite: bool | None = None
# Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages.
# Not surfaced via to_state() -- it's internal control state.
cancel_requested: bool = False
Expand Down Expand Up @@ -366,6 +376,7 @@ def to_state(self) -> dict[str, Any]:
"stage_timings": self.stage_timings,
"vocal_split": self.vocal_split,
"trashed_at": self.trashed_at,
"favorite": self.favorite,
"created_at": self.created_at,
}

Expand Down
10 changes: 10 additions & 0 deletions app/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ def set_trashed(job_id: str, trashed: bool) -> Job | None:
return job


def set_favorite(job_id: str, favorite: bool) -> Job | None:
"""Mark a job as a favourite, or take it back out (#734)."""
with _lock:
job = _jobs.get(job_id)
if job is None:
return None
job.favorite = favorite
return job


def all_jobs() -> dict[str, Job]:
"""Return a snapshot of the registry for sweep / cleanup."""
with _lock:
Expand Down
12 changes: 6 additions & 6 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,10 +924,11 @@ def download_logs_zip() -> StreamingResponse:
# it does not re-enable JS eval, new Function or string timers. 'unsafe-inline' is allowed for *styles*
# only (the UI sets many style attributes). Allowances:
# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC,
# Wikidata/Wikipedia for the artist box the now-playing card
# opens, and LRCLIB for the Lyrics tab (#699). All four are
# read-only public APIs, asked only about the open track, and
# sent only an artist and a song name.
# and Wikidata/Wikipedia for the artist box the now-playing
# card opens. Read-only public APIs, asked only about the
# open track, and sent only an artist and a song name. Not
# LRCLIB: the Lyrics tab asks the server to look lyrics up
# (#719), so the page never reaches it.
# img-src https: -> remote YouTube/SoundCloud thumbnails
# style/font -> the Google Fonts <link>
_CSP = (
Expand All @@ -942,8 +943,7 @@ def download_logs_zip() -> StreamingResponse:
# (#186). They are inline/same-origin schemes, not network endpoints, so
# they add no exfiltration channel — script-src below stays locked.
"connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob: "
"https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org "
"https://lrclib.net; "
"https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org; "
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
)

Expand Down
24 changes: 15 additions & 9 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading