4da0db4a1d
Probe each album exactly once and thread the ProbeResult through for tags, quality, and format instead of hardcoding LibraryItem.format to "FLAC" and re-probing on the tag-fallback path. Also skip album-level directories starting with "." (previously only artist-level dirs were skipped).
101 lines
4.4 KiB
Python
101 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, fmt: str, 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, fmt, 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 album_folder.startswith(".") or not os.path.isdir(album_path):
|
|
continue
|
|
audio = _first_audio(album_path)
|
|
if audio is None:
|
|
continue # no audio → not an album
|
|
pr = probe.probe(audio)
|
|
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
|
|
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
|
|
if _upsert(conn, target, pr.quality_class, pr.fmt, album_path):
|
|
imported += 1
|
|
return ScanResult(imported, skipped)
|