The player's "source" link only worked for direct yt-dlp URLs. Two other cases had no linkable page: ListenBrainz picks resolved via ytsearch1: (the locator is a search query) and Subsonic library tracks (an opaque song id). Centralise the rule in Track.page_url and cover both: the yt-dlp fetcher now records the concrete video URL it resolved into source_url, and a Subsonic track links to the stream's new /share endpoint, which asks ingest to mint a public share (createShare) on demand and redirects to it — so a share is only created when a listener actually clicks, never per played track. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
"""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}]"
|