"""Shared data model. A ``Track`` is the uniform object every provider emits: a *resolved* reference (which backend can download it, and where) plus display metadata. Fetchers turn it into a local file; the queue and the state database use ``key`` for de-duplication and anti-repeat. """ import re from dataclasses import dataclass from urllib.parse import quote _WS = re.compile(r"\s+") def norm_name(value: str) -> str: """Normalize an artist/title for stable keying and cache lookups: case-fold and collapse whitespace. Kept deliberately light (no accent stripping) so distinct titles never collapse together.""" return _WS.sub(" ", value.strip()).casefold() @dataclass(frozen=True) class Track: backend: str # which fetcher handles it: "subsonic" | "ytdlp" locator: str # backend-specific: Subsonic song id, or a media URL artist: str title: str origin: str # provider that produced it, e.g. "subsonic" mbid: str | None = None # filled by the Canonicalizer (milestone 5) source_ext: str | None = None # filename hint, e.g. "mp3", "flac" source_url: str | None = None # container URL a track was picked from @property def key(self) -> str: """Stable, source-agnostic identity for de-duplication and anti-repeat. The Canonicalizer fills ``mbid`` when it can, giving a truly cross-source identity. When it can't, we fall back to the normalized ``(artist, title)`` — still source-agnostic, so the same track fetched from two backends collapses to one key. """ if self.mbid: return f"mbid:{self.mbid}" return f"name:{norm_name(self.artist)}|{norm_name(self.title)}" @property def page_url(self) -> str | None: """A link a listener can open for this track, or None. - a direct yt-dlp URL is its own page (the locator); - a ListenBrainz pick resolved via ``ytsearch1:`` links to the resolved video URL the fetcher recorded in ``source_url``; - a Subsonic song id is opaque, so it links to the stream's ``/share`` endpoint, which mints a public share on demand (only when clicked) and redirects to it — avoiding a share per played track. """ for candidate in (self.locator, self.source_url): if candidate and candidate.startswith(("http://", "https://")): return candidate if self.backend == "subsonic": return f"/share?song={quote(self.locator, safe='')}" return None def __str__(self) -> str: return f"{self.artist} — {self.title} [{self.origin}]"