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
159 changes: 98 additions & 61 deletions app.py

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ is the vendored copy. Snapshot of the catalog at the time of writing:
|--------|-------------|------|
| `fnack.spotiflac` | `download.track` | Primary lossless downloader (FLAC via Tidal/Qobuz/Deezer/SoundCloud, zero-auth) |
| `fnack.ytdlp` | `download.track` | Fallback downloader (YouTube / YouTube Music / SoundCloud) |
| `fnack.deezer-batch` | `artist.search`, `artist.discography`, `artist.info`, `track.metadata`, `album.metadata`, `album.search`, `track.search`, `album.tracks` | Authoritative Deezer metadata |
| `fnack.deezer-batch` | `artist.search`, `artist.discography`, `artist.info`, `track.metadata`, `album.metadata`, `album.search`, `track.search`, `album.tracks` | Deezer metadata provider |
| `fnack.musicbrainz` | `artist.search`, `artist.discography` (+ `enrich`) | Catalogue enrichment |
| `fnack.itunes` | `artist.search`, `artist.discography`, `track.metadata`, `album.tracks` | iTunes metadata fallback |
| `fnack.spotify` | `track.resolve` | Spotify track-URL resolution (ISRC-first, zero-auth) |
Expand Down Expand Up @@ -136,6 +136,20 @@ the out-of-box first-run workflow: `fnack.spotiflac`, `fnack.ytdlp`,
- Maintenance tasks run as a core subprocess (`scripts/run_maintenance.py`),
not via `library.task` — the library-task plugins are optional.

## Identity model (provider-neutral)

Core stores each artist/album/track's **external identity** as an opaque
string (`Artist.external_id`, `Album.external_id`, `Track.external_id`):
the id the metadata provider that supplied the record uses. Core never
parses, converts, or int()s it — when the provider chain resolves an
artist/album/track, the selected provider interprets its own identity (a
Deezer provider converts a numeric Deezer id, a MusicBrainz provider an
MBID, and so on). Providers that don't recognize an id decline it
gracefully (`None`/empty), so the chain moves on; a foreign id never
crashes core. Values may be prefixed for uniqueness when a record was
created outside the provider chain (e.g. `acoustid:<name>`); the prefix is
also opaque to core.

## Configuration model

Configuration is split into **core** settings (fnack itself, in the
Expand Down
20 changes: 15 additions & 5 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ class Artist(db.Model):
__tablename__ = "artists"

id = db.Column(db.Integer, primary_key=True)
spotify_id = db.Column(db.String(64), unique=True, nullable=False) # deezer_id or custom
# Provider-neutral external identity: the opaque id the metadata provider
# that created this artist uses (e.g. a Deezer artist id when the
# fnack.deezer-batch provider supplied it, or a prefixed self-identity
# like "acoustid:<name>" / "lidarr:<name>" for artists created from a
# single unknown track). Core never interprets the value — the provider
# chain owns parsing/conversion.
external_id = db.Column(db.String(64), unique=True, nullable=False)
name = db.Column(db.String(256), nullable=False, index=True)
image_url = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
Expand Down Expand Up @@ -60,15 +66,17 @@ class Album(db.Model):
name = db.Column(db.String(512), nullable=False, index=True)
year = db.Column(db.Integer, nullable=True)
cover_url = db.Column(db.Text, nullable=True)
deezer_id = db.Column(db.String(32), nullable=True, index=True)
# Provider-neutral external release identity (opaque; interpreted by the
# metadata provider chain that supplied this release).
external_id = db.Column(db.String(64), nullable=True, index=True)
record_type = db.Column(db.String(32), default="album", nullable=False, index=True) # album, single, compile, ep, other
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
is_downloaded = db.Column(db.Boolean, default=False, nullable=False, index=True)
monitored = db.Column(db.Boolean, default=True, nullable=False, index=True)
size_bytes = db.Column(db.BigInteger, default=0, nullable=False)
local_path = db.Column(db.Text, nullable=True)

# MusicBrainz enrichment (additive only — Deezer stays authoritative)
# MusicBrainz enrichment (additive only; enrichment is provider-owned)
mb_release_group_id = db.Column(db.String(64), nullable=True, index=True)
mb_title = db.Column(db.String(512), nullable=True)
mb_year = db.Column(db.Integer, nullable=True)
Expand All @@ -93,7 +101,9 @@ class Track(db.Model):
track_number = db.Column(db.Integer, nullable=True)
disc_number = db.Column(db.Integer, default=1, nullable=True)
isrc = db.Column(db.String(64), nullable=True, index=True)
deezer_id = db.Column(db.String(32), nullable=True, index=True)
# Provider-neutral external track identity (opaque; interpreted by the
# metadata provider chain that supplied this track).
external_id = db.Column(db.String(64), nullable=True, index=True)
spotify_url = db.Column(db.Text, nullable=True)
genre = db.Column(db.String(128), nullable=True)
file_path = db.Column(db.Text, default="", nullable=False)
Expand Down Expand Up @@ -127,7 +137,7 @@ class DownloadJob(db.Model):
album_id = db.Column(db.Integer, db.ForeignKey("albums.id"), nullable=True, index=True)
artist_id = db.Column(db.Integer, db.ForeignKey("artists.id"), nullable=False, index=True)
item_type = db.Column(db.String(16), default="track", nullable=False) # track | album
album_spotify_id = db.Column(db.String(64), nullable=True)
album_external_id = db.Column(db.String(64), nullable=True)
album_name = db.Column(db.String(512), nullable=False)
album_type = db.Column(db.String(32), default="album", nullable=False)
album_url = db.Column(db.Text, default="", nullable=False)
Expand Down
12 changes: 9 additions & 3 deletions plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ class TrackRef:
isrc: Optional[str] = None
duration: Optional[float] = None # expected duration, seconds
spotify_url: Optional[str] = None
deezer_id: Optional[str] = None
# Provider-neutral external track identity (opaque — the metadata
# provider chain that supplied the track interprets it). Core populates
# `external_id`; `deezer_id` is kept as a deprecated alias for plugins
# built against older SDK versions.
external_id: Optional[str] = None
deezer_id: Optional[str] = None # deprecated alias of external_id
disc_number: int = 1
track_number: Optional[int] = None

Expand Down Expand Up @@ -65,7 +70,7 @@ class TaskResult:
@dataclass
class RecommendationItem:
kind: str # "artist" | "album" | "track"
ref_id: str # provider-specific id (e.g. deezer id)
ref_id: str # provider-specific external id (opaque)
title: str
subtitle: str = ""
score: float = 0.0
Expand Down Expand Up @@ -153,7 +158,8 @@ def is_rate_limited(self) -> bool:


class MetadataProviderPlugin(PluginBase):
"""Artist/album/track metadata lookup, e.g. a Deezer/MusicBrainz-style source."""
"""Artist/album/track metadata lookup: a provider supplying
music metadata (the capability chain resolves providers)."""

priority: int = 100

Expand Down
35 changes: 18 additions & 17 deletions plugins/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,14 @@ def get_or_create_api_key(self) -> str:

def search_albums(self, query: str, limit: int = 10) -> list[dict]:
"""Live album search (Phase 4: via MetadataService — album.search
capability, served by the fnack.deezer-batch plugin). Provider-
generic: the plugin boundary owns the implementation."""
capability; the provider chain owns the implementation and the
plugin boundary stays provider-generic."""
from services.metadata_service import MetadataService
return MetadataService().search_album(query, limit=limit)

def search_tracks(self, query: str, limit: int = 10) -> list[dict]:
"""Live track search (Phase 4: via MetadataService — track.search
capability, served by the fnack.deezer-batch plugin)."""
capability; the provider chain owns the implementation."""
from services.metadata_service import MetadataService
return MetadataService().search_track(query, limit=limit)

Expand All @@ -157,11 +157,12 @@ def get_track_info(self, track_id: int) -> dict:
return MetadataService().get_track_metadata(str(track_id)) or {}

def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
"""Expand a Lidarr grab (a Deezer album or track id) into the local
library — creates Artist / Album / Track rows and queues one
DownloadJob per track so the queue worker downloads them like any
other track. Returns the created/queued job ids (moved verbatim from
the former `services/lidarr_service.py::_create_lidarr_grab_job`)."""
"""Expand a Lidarr grab (an opaque album or track id from the
Lidarr request) into the local library — creates Artist / Album /
Track rows and queues one DownloadJob per track so the queue worker
downloads them like any other track. Returns the created/queued job
ids (moved verbatim from the former
`services/lidarr_service.py::_create_lidarr_grab_job`)."""
from services.metadata_service import MetadataService
from models import Album, Artist, DownloadJob, Track, db

Expand All @@ -173,7 +174,7 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
artist_name = info.get("artist_name") or "Unknown Artist"
track_title = info.get("title") or "Unknown Track"
album_title = info.get("album_title") or track_title
album_deezer_id = info.get("album_id")
album_external_id = info.get("album_id")
cover_url = None
year = None
record_type = "single"
Expand All @@ -188,7 +189,7 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
info = _md.get_album_metadata(str(item_id)) or {}
artist_name = info.get("artist_name") or "Unknown Artist"
album_title = info.get("title") or "Unknown Album"
album_deezer_id = info.get("id") or item_id
album_external_id = info.get("id") or item_id
cover_url = info.get("cover_url")
year = info.get("year")
record_type = info.get("record_type") or "album"
Expand All @@ -201,7 +202,7 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
artist = Artist.query.filter_by(name=artist_name).first()
if not artist:
artist = Artist(
spotify_id=f"lidarr:{artist_name}",
external_id =f"lidarr:{artist_name}",
name=artist_name,
source="lidarr",
monitored=True,
Expand All @@ -210,22 +211,22 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
db.session.flush()

album = None
if album_deezer_id:
album = Album.query.filter_by(artist_id=artist.id, deezer_id=str(album_deezer_id)).first()
if album_external_id:
album = Album.query.filter_by(artist_id=artist.id, external_id=str(album_external_id)).first()
if not album:
album = Album(
artist_id=artist.id,
name=album_title,
year=year,
cover_url=cover_url,
deezer_id=str(album_deezer_id) if album_deezer_id else None,
external_id=str(album_external_id) if album_external_id else None,
record_type=record_type,
)
db.session.add(album)
db.session.flush()

for t in tracks_to_queue:
track = Track.query.filter_by(album_id=album.id, deezer_id=str(t["id"])).first()
track = Track.query.filter_by(album_id=album.id, external_id=str(t["id"])).first()
if not track:
track = Track(
album_id=album.id,
Expand All @@ -234,7 +235,7 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
track_number=t.get("track_position") or 0,
disc_number=t.get("disk_number") or 1,
duration=t.get("duration"),
deezer_id=str(t["id"]),
external_id=str(t["id"]),
status="missing",
)
db.session.add(track)
Expand All @@ -250,7 +251,7 @@ def queue_lidarr_grab(self, item_type: str, item_id: int) -> list[int]:
album_id=album.id,
artist_id=artist.id,
item_type="track",
album_spotify_id=str(album.deezer_id or ""),
album_external_id=str(album.external_id or ""),
album_name=album.name,
album_type=album.record_type,
album_url="",
Expand Down
10 changes: 6 additions & 4 deletions scripts/reverify_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""fnack library re-verification tool.

Checks every downloaded track against the OFFICIAL duration (fetched from the
Deezer public API, no authentication) and marks confirmed mismatches as failed
metadata provider chain) and marks confirmed mismatches as failed
so they can be inspected or re-downloaded. Files are NOT deleted.

Also repairs the stored track duration with the official value (an earlier bug
Expand Down Expand Up @@ -57,12 +57,14 @@ def main() -> int:
skipped += 1
continue

# Official expected duration: Deezer first, else the stored value
# Official expected duration: ask the metadata chain, else the
# stored value. The provider chain interprets the opaque external
# id (non-matching providers return nothing gracefully).
official = None
if t.deezer_id and str(t.deezer_id).isdigit():
if t.external_id:
try:
from services.metadata_service import MetadataService
info = MetadataService().get_track_metadata(str(t.deezer_id)) or {}
info = MetadataService().get_track_metadata(str(t.external_id)) or {}
official = info.get("duration")
except Exception:
official = None
Expand Down
Loading
Loading