radieo/ingest/radieo/models.py
Pierre-Olivier Mercier efd7307cc6 ingest: give every track a source link
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>
2026-07-04 11:42:29 +08:00

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}]"