4907d0d5a4
Adds the AudioProbe seam (probe.py, _probe.py MutagenProbe with a lazy mutagen import, FakeAudioProbe) and scan_library, which walks /music, resolves each album against MusicBrainz (falling back to embedded tags when the folder name doesn't resolve), and idempotently records matched albums as have (LibraryItem), monitored (MonitoredRelease, fulfilled), and followed (WatchedArtist, autoMonitorFuture=false). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
4.4 KiB
Python
102 lines
4.4 KiB
Python
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
import psycopg
|
|
|
|
from lyra_worker.probe import AudioProbe
|
|
|
|
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
|
|
_YEAR = re.compile(r"^(.*) \((\d{4})\)$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScanResult:
|
|
imported: int
|
|
skipped: int
|
|
|
|
|
|
def _parse_album_dir(name: str) -> str:
|
|
m = _YEAR.match(name)
|
|
return m.group(1) if m else name
|
|
|
|
|
|
def _first_audio(album_path: str) -> str | None:
|
|
for name in sorted(os.listdir(album_path)):
|
|
if os.path.splitext(name)[1].lower() in _AUDIO_EXT:
|
|
return os.path.join(album_path, name)
|
|
return None
|
|
|
|
|
|
def _upsert(conn, target, quality_class: int, path: str) -> bool:
|
|
"""Record have + monitored + followed for a resolved album. Returns True if newly imported."""
|
|
with conn.cursor() as cur:
|
|
watched_id = None
|
|
if target.artist_mbid:
|
|
cur.execute(
|
|
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", "monitorFrom", "createdAt") '
|
|
"VALUES (gen_random_uuid()::text, %s, %s, false, now(), now()) "
|
|
'ON CONFLICT (mbid) DO NOTHING',
|
|
(target.artist_mbid, target.artist),
|
|
)
|
|
cur.execute('SELECT id FROM "WatchedArtist" WHERE mbid = %s', (target.artist_mbid,))
|
|
row = cur.fetchone()
|
|
watched_id = row[0] if row else None
|
|
|
|
if target.rg_mbid:
|
|
cur.execute(
|
|
'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", '
|
|
'"rgMbid", album, "secondaryTypes", monitored, state, "currentQualityClass", '
|
|
'"firstGrabbedAt", "createdAt") '
|
|
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, '{}', true, 'fulfilled', %s, now(), now()) "
|
|
'ON CONFLICT ("rgMbid") DO UPDATE SET monitored = true, state = \'fulfilled\', '
|
|
' "currentQualityClass" = EXCLUDED."currentQualityClass", '
|
|
' "firstGrabbedAt" = COALESCE("MonitoredRelease"."firstGrabbedAt", now()), '
|
|
' "watchedArtistId" = COALESCE("MonitoredRelease"."watchedArtistId", EXCLUDED."watchedArtistId")',
|
|
(watched_id, target.artist_mbid, target.artist, target.rg_mbid, target.album, quality_class),
|
|
)
|
|
|
|
cur.execute(
|
|
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
|
|
'"qualityClass", "importedAt") '
|
|
"VALUES (gen_random_uuid()::text, NULL, %s, %s, %s, 'scan', %s, %s, now()) "
|
|
'ON CONFLICT (artist, album) DO NOTHING',
|
|
(target.artist, target.album, path, "FLAC", quality_class),
|
|
)
|
|
newly = cur.rowcount == 1 # a new LibraryItem means this album wasn't recorded before
|
|
conn.commit()
|
|
return newly
|
|
|
|
|
|
def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, dest_root: str = "/music") -> ScanResult:
|
|
"""Walk `{dest_root}/{Artist}/{Album}/`, resolve each album against MusicBrainz, and record
|
|
matched albums as have + monitored + followed. Unmatched or audio-less folders are skipped."""
|
|
imported = 0
|
|
skipped = 0
|
|
if not os.path.isdir(dest_root):
|
|
return ScanResult(0, 0)
|
|
for artist_name in sorted(os.listdir(dest_root)):
|
|
artist_path = os.path.join(dest_root, artist_name)
|
|
if artist_name.startswith(".") or not os.path.isdir(artist_path):
|
|
continue
|
|
for album_folder in sorted(os.listdir(artist_path)):
|
|
album_path = os.path.join(artist_path, album_folder)
|
|
if not os.path.isdir(album_path):
|
|
continue
|
|
audio = _first_audio(album_path)
|
|
if audio is None:
|
|
continue # no audio → not an album
|
|
album_name = _parse_album_dir(album_folder)
|
|
target = resolver.resolve(artist_name, album_name) if artist_name and album_name else None
|
|
if target is None: # fall back to the file's embedded tags
|
|
pr = probe.probe(audio)
|
|
if pr.artist and pr.album and (pr.artist, pr.album) != (artist_name, album_name):
|
|
target = resolver.resolve(pr.artist, pr.album)
|
|
if target is None:
|
|
skipped += 1
|
|
continue
|
|
q = probe.probe(audio).quality_class
|
|
if _upsert(conn, target, q, album_path):
|
|
imported += 1
|
|
return ScanResult(imported, skipped)
|