diff --git a/.env.example b/.env.example index c033ba4..7987060 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # radieo — configuration locale. Copier en `.env` et remplir. # docker compose lit automatiquement `.env` pour ces variables. -# --- Source Navidrome / OpenSubsonic --- +# --- Source OpenSubsonic (Navidrome, Gonic, Airsonic…) --- # URL de base de ton serveur (sans /rest). Laisser les champs vides désactive # la source : le stream joue alors uniquement les fichiers déjà dans cache/. -RADIEO_NAVIDROME_URL=https://navidrome.example.org -RADIEO_NAVIDROME_USER=monuser -RADIEO_NAVIDROME_PASSWORD=monmotdepasse +RADIEO_SUBSONIC_URL=https://subsonic.example.org +RADIEO_SUBSONIC_USER=monuser +RADIEO_SUBSONIC_PASSWORD=monmotdepasse # Nom OU identifiant de la playlist à diffuser. -RADIEO_NAVIDROME_PLAYLIST=Radio +RADIEO_SUBSONIC_PLAYLIST=Radio # --- Source yt-dlp --- # La liste d'URL se met dans config/urls.txt (copier config/urls.txt.example). @@ -18,12 +18,12 @@ RADIEO_NAVIDROME_PLAYLIST=Radio # Feed Atom de recommandations. URL http(s) du feed de syndication, ou chemin # local sous /config (ex. /config/recommendations.xml) pour tester. Vide = off. # ListenBrainz ne fait que *nommer* des morceaux : chacun est résolu vers -# Navidrome puis, à défaut, yt-dlp. +# la bibliothèque OpenSubsonic puis, à défaut, yt-dlp. RADIEO_LISTENBRAINZ_URL=https://listenbrainz.org/syndication-feed/user/monuser/recommendations/weekly-exploration # --- Dosage du mix entre sources (optionnel) --- # Poids relatifs de tirage de chaque source (0 désactive la source). -RADIEO_WEIGHT_NAVIDROME=3 +RADIEO_WEIGHT_SUBSONIC=3 RADIEO_WEIGHT_YTDLP=1 RADIEO_WEIGHT_LISTENBRAINZ=2 diff --git a/docker-compose.yml b/docker-compose.yml index 76c1f99..8fd80b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,12 @@ services: - RADIEO_CACHE_DIR=/cache - RADIEO_STATE_DIR=/state - RADIEO_HTTP_PORT=8080 - # Source Navidrome / OpenSubsonic (voir .env / .env.example). + # Source OpenSubsonic (Navidrome, Gonic, Airsonic… ; voir .env.example). # Laisser vide désactive la source : le stream joue alors son cache local. - - RADIEO_NAVIDROME_URL=${RADIEO_NAVIDROME_URL:-} - - RADIEO_NAVIDROME_USER=${RADIEO_NAVIDROME_USER:-} - - RADIEO_NAVIDROME_PASSWORD=${RADIEO_NAVIDROME_PASSWORD:-} - - RADIEO_NAVIDROME_PLAYLIST=${RADIEO_NAVIDROME_PLAYLIST:-} + - RADIEO_SUBSONIC_URL=${RADIEO_SUBSONIC_URL:-} + - RADIEO_SUBSONIC_USER=${RADIEO_SUBSONIC_USER:-} + - RADIEO_SUBSONIC_PASSWORD=${RADIEO_SUBSONIC_PASSWORD:-} + - RADIEO_SUBSONIC_PLAYLIST=${RADIEO_SUBSONIC_PLAYLIST:-} - RADIEO_RETENTION_KEEP=${RADIEO_RETENTION_KEEP:-20} # Source yt-dlp : liste d'URL dans config/urls.txt (créer depuis l'exemple). - RADIEO_YTDLP_URLS_FILE=/config/urls.txt @@ -23,7 +23,7 @@ services: # sous /config, ex. /config/recommendations.xml). Vide désactive la source. - RADIEO_LISTENBRAINZ_URL=${RADIEO_LISTENBRAINZ_URL:-} # Dosage du mix entre les sources (0 désactive). - - RADIEO_WEIGHT_NAVIDROME=${RADIEO_WEIGHT_NAVIDROME:-3} + - RADIEO_WEIGHT_SUBSONIC=${RADIEO_WEIGHT_SUBSONIC:-3} - RADIEO_WEIGHT_YTDLP=${RADIEO_WEIGHT_YTDLP:-1} - RADIEO_WEIGHT_LISTENBRAINZ=${RADIEO_WEIGHT_LISTENBRAINZ:-2} # Canonicalizer MusicBrainz (identité MBID inter-sources ; sans clé). diff --git a/ingest/radieo/__main__.py b/ingest/radieo/__main__.py index a1a76aa..5ee0dd9 100644 --- a/ingest/radieo/__main__.py +++ b/ingest/radieo/__main__.py @@ -35,24 +35,24 @@ def _build_pipeline(db: Database, canonicalizer): fetchers = {} # backend name -> fetcher subsonic_client = None # reused by ListenBrainz for resolution - if config.NAVIDROME_ENABLED: + if config.SUBSONIC_ENABLED: from .fetchers.subsonic import SubsonicFetcher - from .providers.navidrome import NavidromeProvider + from .providers.subsonic import SubsonicProvider from .subsonic import SubsonicClient subsonic_client = SubsonicClient( - config.NAVIDROME_URL, config.NAVIDROME_USER, config.NAVIDROME_PASSWORD + config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD ) - provider = NavidromeProvider(subsonic_client, config.NAVIDROME_PLAYLIST, db) - providers.append((provider, config.SOURCE_WEIGHTS.get("navidrome", 0))) + provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db) + providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0))) fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR) log.info( - "Navidrome source enabled (playlist=%r, weight=%d)", - config.NAVIDROME_PLAYLIST, - config.SOURCE_WEIGHTS.get("navidrome", 0), + "OpenSubsonic source enabled (playlist=%r, weight=%d)", + config.SUBSONIC_PLAYLIST, + config.SOURCE_WEIGHTS.get("subsonic", 0), ) else: - log.warning("Navidrome not configured (RADIEO_NAVIDROME_*): source off.") + log.warning("OpenSubsonic not configured (RADIEO_SUBSONIC_*): source off.") if config.SOURCE_WEIGHTS.get("ytdlp", 0) > 0 and config.YTDLP_URLS_FILE.exists(): from .fetchers.ytdlp import YtdlpFetcher @@ -77,7 +77,7 @@ def _build_pipeline(db: Database, canonicalizer): from .providers.listenbrainz import ListenBrainzProvider # ListenBrainz has no backend of its own: it resolves each suggestion to - # Navidrome then yt-dlp. Make sure the yt-dlp fetcher exists as a + # the Subsonic library then yt-dlp. Make sure the yt-dlp fetcher exists as a # resolution target even when the yt-dlp *source* is off. if "ytdlp" not in fetchers: from .fetchers.ytdlp import YtdlpFetcher @@ -94,7 +94,7 @@ def _build_pipeline(db: Database, canonicalizer): "ListenBrainz source enabled (feed=%s, weight=%d, resolve=%s)", config.LISTENBRAINZ_URL, config.SOURCE_WEIGHTS["listenbrainz"], - "navidrome+ytdlp" if subsonic_client else "ytdlp", + "subsonic+ytdlp" if subsonic_client else "ytdlp", ) else: log.info( diff --git a/ingest/radieo/config.py b/ingest/radieo/config.py index 2aa28f0..4965b55 100644 --- a/ingest/radieo/config.py +++ b/ingest/radieo/config.py @@ -19,7 +19,7 @@ HTTP_HOST = os.environ.get("RADIEO_HTTP_HOST", "0.0.0.0") HTTP_PORT = int(os.environ.get("RADIEO_HTTP_PORT", "8080")) # Transport-level retries for transient connection errors on outgoing HTTP -# (Navidrome, MusicBrainz, ListenBrainz). Applies to connect failures only. +# (OpenSubsonic, MusicBrainz, ListenBrainz). Applies to connect failures only. HTTP_RETRIES = int(os.environ.get("RADIEO_HTTP_RETRIES", "2")) # --- Prefetching / retention --- @@ -49,18 +49,18 @@ USER_AGENT = os.environ.get( "RADIEO_USER_AGENT", "radieo/0.1 (personal music radio)" ) -# --- Navidrome / OpenSubsonic source --- +# --- OpenSubsonic source (Navidrome, Gonic, Airsonic…) --- # Left empty means the provider is disabled (the stream then plays its own # local-cache fallback). Credentials are expected to come from a .env file. -NAVIDROME_URL = os.environ.get("RADIEO_NAVIDROME_URL", "").strip() -NAVIDROME_USER = os.environ.get("RADIEO_NAVIDROME_USER", "").strip() -NAVIDROME_PASSWORD = os.environ.get("RADIEO_NAVIDROME_PASSWORD", "") -NAVIDROME_PLAYLIST = os.environ.get("RADIEO_NAVIDROME_PLAYLIST", "").strip() +SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip() +SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip() +SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "") +SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip() # How often to reload the playlist contents, in seconds. PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300")) -NAVIDROME_ENABLED = bool( - NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD and NAVIDROME_PLAYLIST +SUBSONIC_ENABLED = bool( + SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST ) # --- yt-dlp source --- @@ -74,7 +74,7 @@ YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300")) # --- ListenBrainz suggestions source --- # Atom recommendations feed. May be an http(s) URL (the real syndication feed) # or a local file path (for testing with a saved sample). Empty disables it. -# ListenBrainz only *names* tracks; each is resolved to Navidrome then yt-dlp. +# ListenBrainz only *names* tracks; each is resolved to the Subsonic library then yt-dlp. LISTENBRAINZ_URL = os.environ.get("RADIEO_LISTENBRAINZ_URL", "").strip() # How often to reload the feed, in seconds (it refreshes weekly). LISTENBRAINZ_REFRESH = float(os.environ.get("RADIEO_LISTENBRAINZ_REFRESH", "3600")) @@ -84,7 +84,7 @@ LISTENBRAINZ_ENABLED = bool(LISTENBRAINZ_URL) # Relative odds of drawing from each source. Isolated here on purpose so the # mix can later move to a config file. A weight of 0 disables a source. SOURCE_WEIGHTS = { - "navidrome": int(os.environ.get("RADIEO_WEIGHT_NAVIDROME", "3")), + "subsonic": int(os.environ.get("RADIEO_WEIGHT_SUBSONIC", "3")), "ytdlp": int(os.environ.get("RADIEO_WEIGHT_YTDLP", "1")), "listenbrainz": int(os.environ.get("RADIEO_WEIGHT_LISTENBRAINZ", "2")), } diff --git a/ingest/radieo/models.py b/ingest/radieo/models.py index c9d26fb..6d8f41c 100644 --- a/ingest/radieo/models.py +++ b/ingest/radieo/models.py @@ -25,7 +25,7 @@ class Track: locator: str # backend-specific: Subsonic song id, or a media URL artist: str title: str - origin: str # provider that produced it, e.g. "navidrome" + 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 diff --git a/ingest/radieo/providers/listenbrainz.py b/ingest/radieo/providers/listenbrainz.py index 5ed89c4..0717cab 100644 --- a/ingest/radieo/providers/listenbrainz.py +++ b/ingest/radieo/providers/listenbrainz.py @@ -11,8 +11,8 @@ Track directly, which gives a source-agnostic identity for free (no MusicBrainz lookup needed). Unlike the other providers, ListenBrainz yields no audio of its own: it only -*names* tracks. Each pick is therefore **resolved** to a concrete backend — -Navidrome first (an OpenSubsonic ``search3``), then yt-dlp (a ``ytsearch1:`` +*names* tracks. Each pick is therefore **resolved** to a concrete backend — the +OpenSubsonic library first (a ``search3``), then yt-dlp (a ``ytsearch1:`` query) as a fallback — so the download layer is unchanged. ``RADIEO_LISTENBRAINZ_URL`` may be an ``http(s)`` URL (the real syndication @@ -114,7 +114,7 @@ class ListenBrainzProvider: def _resolve(self, rec: dict) -> Track | None: artist, title, mbid = rec["artist"], rec["title"], rec["mbid"] - song = self._search_navidrome(artist, title) + song = self._search_subsonic(artist, title) if song is not None: return Track( backend="subsonic", @@ -137,13 +137,13 @@ class ListenBrainzProvider: ) return None - def _search_navidrome(self, artist: str, title: str) -> dict | None: + def _search_subsonic(self, artist: str, title: str) -> dict | None: if self._subsonic is None: return None try: songs = self._subsonic.search_songs(title) except (SubsonicError, httpx.HTTPError, OSError) as exc: - log.warning("Navidrome search failed for %s — %s: %s", artist, title, exc) + log.warning("Subsonic search failed for %s — %s: %s", artist, title, exc) return None want_a, want_t = norm_name(artist), norm_name(title) for s in songs: diff --git a/ingest/radieo/providers/navidrome.py b/ingest/radieo/providers/subsonic.py similarity index 89% rename from ingest/radieo/providers/navidrome.py rename to ingest/radieo/providers/subsonic.py index 8a3fa0a..2aa7702 100644 --- a/ingest/radieo/providers/navidrome.py +++ b/ingest/radieo/providers/subsonic.py @@ -1,5 +1,6 @@ -"""NavidromeProvider: picks tracks from an OpenSubsonic playlist. +"""SubsonicProvider: picks tracks from an OpenSubsonic playlist. +Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Emits ``subsonic`` tracks (locator = song id). The playlist is cached in memory and refreshed periodically. A cheap local anti-repeat filters out songs whose id was played recently; if that empties the pool (short playlist), the @@ -18,11 +19,11 @@ from ..db import Database from ..models import Track from ..subsonic import SubsonicClient, SubsonicError -log = logging.getLogger("radieo.provider.navidrome") +log = logging.getLogger("radieo.provider.subsonic") -class NavidromeProvider: - name = "navidrome" +class SubsonicProvider: + name = "subsonic" def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database): self._client = client diff --git a/ingest/radieo/subsonic.py b/ingest/radieo/subsonic.py index 8118a9f..a23b521 100644 --- a/ingest/radieo/subsonic.py +++ b/ingest/radieo/subsonic.py @@ -1,7 +1,8 @@ -"""Minimal OpenSubsonic client (enough for Navidrome playback). +"""Minimal OpenSubsonic client (enough for playlist playback). +Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Uses salted-token authentication (``t = md5(password + salt)``), the scheme -recommended by the Subsonic API since 1.13.0 and supported by Navidrome. +recommended by the Subsonic API since 1.13.0. """ import hashlib @@ -105,7 +106,7 @@ class SubsonicClient: def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str: """Download a song to ``dest``; return the file extension used. - ``format=raw`` asks Navidrome for the original file (no transcoding), + ``format=raw`` asks the server for the original file (no transcoding), keeping quality and letting Liquidsoap decode it. """ params = {**self._auth_params(), "id": song_id, "format": "raw"}