Auto-reconcile embedded IDs for new tracks on library scans
Extends the manual "Import IDs from File Tags" backfill so newly-scanned
files get their embedded provider IDs pulled into the DB automatically —
no button press needed to keep up with new music.
How it works:
- insert_or_update_media_track now returns 'inserted' / 'updated' / False
(truthy-compatible; existing `if track_success` callers unaffected) so
the scan worker can tell a genuinely new row from an update.
- DatabaseUpdateWorker collects the ids it newly INSERTED this run
(self._new_track_ids) across all insert paths (Plex/Jellyfin/deep).
- After run()/run_deep_scan(), web_server calls _reconcile_after_scan(),
which gap-fills embedded IDs for just those new tracks. Runs as a
post-scan pass (the scan loop itself is untouched/fast — the media
server API never exposes these custom IDs, so the file must be read
once regardless; batching at the end keeps it out of the hot loop and
best-effort so it can never abort a scan). A progress phase ("Reading
file tags for N new tracks…") surfaces the full-refresh tail.
Shared engine:
- New reconcile_library() in core does the paging + lazy parent-map
loading (only loads albums/artists actually referenced — cheap when
scoped to a few new tracks) + per-page commits. BOTH the manual button
and the scan hook call it, so there's one tested orchestration, no
duplication. The backfill job was refactored onto it.
Same hardened safety: gap-fill only, atomically guarded against
overwrite, schema-introspected, idempotent. Scoped to new arrivals for
incremental/deep; full refresh re-inserts everything as new (recovering
the IDs a full-refresh wipe destroys).
+10 reconcile tests (reconcile_library scope/idempotency/progress/stop +
the engine). Full suite clean (only pre-existing soundcloud /app env
failures remain).
This commit is contained in:
parent
e6d86dea26
commit
83c1cd92aa
5 changed files with 310 additions and 57 deletions
|
|
@ -42,6 +42,12 @@ class DatabaseUpdateWorker:
|
||||||
self.full_refresh = full_refresh
|
self.full_refresh = full_refresh
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
|
|
||||||
|
# Track ids of rows newly INSERTED this run (not updates). The web
|
||||||
|
# layer reads this after the scan to gap-fill embedded provider IDs
|
||||||
|
# for the new files (auto-reconcile), so newly-added music contributes
|
||||||
|
# its Spotify/MusicBrainz/etc. ids without a manual backfill.
|
||||||
|
self._new_track_ids = set()
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
self.processed_artists = 0
|
self.processed_artists = 0
|
||||||
self.processed_albums = 0
|
self.processed_albums = 0
|
||||||
|
|
@ -880,6 +886,8 @@ class DatabaseUpdateWorker:
|
||||||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||||
if track_success:
|
if track_success:
|
||||||
total_processed_tracks += 1
|
total_processed_tracks += 1
|
||||||
|
if track_success == 'inserted':
|
||||||
|
self._new_track_ids.add(str(track.ratingKey))
|
||||||
logger.debug(f"Processed new track: {track.title}")
|
logger.debug(f"Processed new track: {track.title}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
|
|
@ -1344,6 +1352,8 @@ class DatabaseUpdateWorker:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
elif track_success:
|
elif track_success:
|
||||||
track_count += 1
|
track_count += 1
|
||||||
|
if track_success == 'inserted':
|
||||||
|
self._new_track_ids.add(track_id_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,122 @@ def reconcile_track_row(
|
||||||
return TrackReconcileResult(applied, len(plan.conflicts), readable=True)
|
return TrackReconcileResult(applied, len(plan.conflicts), readable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReconcileTotals:
|
||||||
|
"""Accumulated counts over a reconcile run."""
|
||||||
|
total: int = 0
|
||||||
|
processed: int = 0
|
||||||
|
ids_filled: int = 0
|
||||||
|
entities_updated: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unreadable: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_missing_rows(cursor, ids, table, target: Dict[str, Dict[str, Any]]) -> None:
|
||||||
|
"""Load any not-yet-cached entity rows for ``ids`` into ``target`` in place.
|
||||||
|
|
||||||
|
Ids with no row get an empty dict so they're never re-queried. Chunked to
|
||||||
|
keep the IN clause bounded.
|
||||||
|
"""
|
||||||
|
missing = [i for i in {x for x in ids if x} if i not in target]
|
||||||
|
for start in range(0, len(missing), 500):
|
||||||
|
chunk = missing[start:start + 500]
|
||||||
|
ph = ','.join('?' * len(chunk))
|
||||||
|
cursor.execute(f"SELECT * FROM {table} WHERE id IN ({ph})", chunk)
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
target[str(r['id'])] = dict(r)
|
||||||
|
for i in missing:
|
||||||
|
target.setdefault(i, {}) # mark absent → don't re-query
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_library(
|
||||||
|
conn,
|
||||||
|
read_tags,
|
||||||
|
track_ids=None,
|
||||||
|
page_size: int = 500,
|
||||||
|
on_progress=None,
|
||||||
|
should_stop=None,
|
||||||
|
) -> ReconcileTotals:
|
||||||
|
"""Gap-fill embedded provider IDs into the DB for a set of tracks.
|
||||||
|
|
||||||
|
Shared orchestration used by both the manual backfill job and the
|
||||||
|
auto-reconcile hook on library scans. Pages the track list (bounded
|
||||||
|
memory), lazily loads only the parent album/artist rows actually
|
||||||
|
referenced (cheap when scoped to a handful of new tracks), and commits
|
||||||
|
per page so concurrent enrichment workers aren't starved of the write
|
||||||
|
lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: open DB connection; this function commits per page.
|
||||||
|
read_tags: callable ``(file_path) -> tags dict | None``. The caller
|
||||||
|
injects path resolution + ``read_embedded_tags`` so this module
|
||||||
|
stays free of Flask / docker-path concerns. ``None`` => unreadable.
|
||||||
|
track_ids: iterable of track ids to reconcile, or ``None`` for every
|
||||||
|
track that has a ``file_path``.
|
||||||
|
page_size: rows materialised per page.
|
||||||
|
on_progress: optional ``(totals, current_title) -> None`` after each
|
||||||
|
track (for live UI).
|
||||||
|
should_stop: optional ``() -> bool`` checked between tracks/pages to
|
||||||
|
abort early.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`ReconcileTotals`.
|
||||||
|
"""
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
logger = get_logger("library.reconcile")
|
||||||
|
|
||||||
|
totals = ReconcileTotals()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
if track_ids is None:
|
||||||
|
cur.execute("SELECT id FROM tracks WHERE file_path IS NOT NULL AND TRIM(file_path) != ''")
|
||||||
|
ids = [str(r[0]) for r in cur.fetchall()]
|
||||||
|
else:
|
||||||
|
ids = [str(t) for t in track_ids if t is not None]
|
||||||
|
totals.total = len(ids)
|
||||||
|
|
||||||
|
album_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
artist_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for start in range(0, len(ids), page_size):
|
||||||
|
if should_stop and should_stop():
|
||||||
|
break
|
||||||
|
page = ids[start:start + page_size]
|
||||||
|
ph = ','.join('?' * len(page))
|
||||||
|
cur.execute(f"SELECT * FROM tracks WHERE id IN ({ph})", page)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
_load_missing_rows(cur, [str(r['album_id']) for r in rows if r.get('album_id') is not None],
|
||||||
|
'albums', album_map)
|
||||||
|
_load_missing_rows(cur, [str(r['artist_id']) for r in rows if r.get('artist_id') is not None],
|
||||||
|
'artists', artist_map)
|
||||||
|
|
||||||
|
for tr in rows:
|
||||||
|
if should_stop and should_stop():
|
||||||
|
break
|
||||||
|
title = tr.get('title') or '?'
|
||||||
|
try:
|
||||||
|
tags = read_tags(tr.get('file_path'))
|
||||||
|
result = reconcile_track_row(cur, tr, album_map, artist_map, tags)
|
||||||
|
if not result.readable:
|
||||||
|
totals.unreadable += 1
|
||||||
|
else:
|
||||||
|
totals.ids_filled += result.applied.ids_filled
|
||||||
|
totals.entities_updated += result.applied.rows_updated
|
||||||
|
totals.conflicts += result.conflicts
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("reconcile: skipped track %s: %s", tr.get('id'), e)
|
||||||
|
totals.unreadable += 1
|
||||||
|
finally:
|
||||||
|
totals.processed += 1
|
||||||
|
if on_progress:
|
||||||
|
on_progress(totals, title)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
def _existing_columns(cursor, table: str) -> set:
|
def _existing_columns(cursor, table: str) -> set:
|
||||||
"""Return the set of column names on ``table`` (migration-safe guard)."""
|
"""Return the set of column names on ``table`` (migration-safe guard)."""
|
||||||
cursor.execute(f"PRAGMA table_info({table})")
|
cursor.execute(f"PRAGMA table_info({table})")
|
||||||
|
|
|
||||||
|
|
@ -6033,7 +6033,11 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("history logging: %s", e)
|
logger.debug("history logging: %s", e)
|
||||||
|
|
||||||
return True
|
# Truthy on success (existing `if track_success` callers keep
|
||||||
|
# working); the specific value lets the scan worker tell a
|
||||||
|
# genuinely new row from an updated one so it can reconcile
|
||||||
|
# embedded IDs only for new arrivals.
|
||||||
|
return 'inserted' if is_new_track else 'updated'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_text = str(e).lower()
|
error_text = str(e).lower()
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ from core.library.embedded_id_reconcile import (
|
||||||
Fill,
|
Fill,
|
||||||
ReconcileApplied,
|
ReconcileApplied,
|
||||||
ReconcilePlan,
|
ReconcilePlan,
|
||||||
|
ReconcileTotals,
|
||||||
apply_reconcile_plan,
|
apply_reconcile_plan,
|
||||||
plan_reconcile,
|
plan_reconcile,
|
||||||
|
reconcile_library,
|
||||||
reconcile_track_row,
|
reconcile_track_row,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -271,3 +273,91 @@ def test_reconcile_track_row_handles_null_parent_ids():
|
||||||
assert result.applied.ids_filled == 1 # album fill has no album id to land on
|
assert result.applied.ids_filled == 1 # album fill has no album id to land on
|
||||||
cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'")
|
cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'")
|
||||||
assert cur.fetchone()[0] == 'TRK'
|
assert cur.fetchone()[0] == 'TRK'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reconcile_library — the shared orchestration (paging, lazy maps, scope)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_library_db():
|
||||||
|
conn = sqlite3.connect(':memory:')
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""CREATE TABLE tracks (id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT,
|
||||||
|
file_path TEXT, title TEXT, spotify_track_id TEXT, spotify_match_status TEXT,
|
||||||
|
spotify_last_attempted TIMESTAMP)""")
|
||||||
|
cur.execute("""CREATE TABLE albums (id TEXT PRIMARY KEY, spotify_album_id TEXT,
|
||||||
|
spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""")
|
||||||
|
cur.execute("""CREATE TABLE artists (id TEXT PRIMARY KEY, spotify_artist_id TEXT,
|
||||||
|
spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""")
|
||||||
|
# Two tracks on the same album/artist, one orphan track with no file.
|
||||||
|
cur.execute("INSERT INTO artists (id) VALUES ('ar1')")
|
||||||
|
cur.execute("INSERT INTO albums (id) VALUES ('al1')")
|
||||||
|
cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t1','al1','ar1','/a.flac','A')")
|
||||||
|
cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t2','al1','ar1','/b.flac','B')")
|
||||||
|
cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t3','al1','ar1','','NoFile')")
|
||||||
|
conn.commit()
|
||||||
|
return conn, cur
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(mapping):
|
||||||
|
"""read_tags stub: file_path -> tags dict (or None)."""
|
||||||
|
return lambda fp: mapping.get(fp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_library_whole_library_fills_all():
|
||||||
|
conn, cur = _make_library_db()
|
||||||
|
tags = {
|
||||||
|
'/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'},
|
||||||
|
'/b.flac': {'spotify_track_id': 'TB', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'},
|
||||||
|
}
|
||||||
|
totals = reconcile_library(conn, _reader(tags))
|
||||||
|
assert isinstance(totals, ReconcileTotals)
|
||||||
|
# t3 has empty file_path -> excluded from the whole-library SELECT entirely.
|
||||||
|
assert totals.total == 2 and totals.processed == 2
|
||||||
|
# t1: track+album+artist (3); t2: only its own track id (parents already filled) (1)
|
||||||
|
assert totals.ids_filled == 4
|
||||||
|
cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'")
|
||||||
|
assert cur.fetchone()[0] == 'TB'
|
||||||
|
cur.execute("SELECT spotify_album_id FROM albums WHERE id='al1'")
|
||||||
|
assert cur.fetchone()[0] == 'ALB'
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_library_scoped_to_given_ids():
|
||||||
|
conn, cur = _make_library_db()
|
||||||
|
tags = {'/b.flac': {'spotify_track_id': 'TB'}, '/a.flac': {'spotify_track_id': 'TA'}}
|
||||||
|
totals = reconcile_library(conn, _reader(tags), track_ids=['t2'])
|
||||||
|
assert totals.total == 1 and totals.ids_filled == 1
|
||||||
|
cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'")
|
||||||
|
assert cur.fetchone()[0] == 'TB'
|
||||||
|
cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'")
|
||||||
|
assert cur.fetchone()[0] is None # not in scope
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_library_unreadable_counted():
|
||||||
|
conn, cur = _make_library_db()
|
||||||
|
totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2']) # reader returns None
|
||||||
|
assert totals.unreadable == 2 and totals.ids_filled == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_library_is_idempotent():
|
||||||
|
conn, cur = _make_library_db()
|
||||||
|
tags = {'/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}}
|
||||||
|
first = reconcile_library(conn, _reader(tags), track_ids=['t1'])
|
||||||
|
second = reconcile_library(conn, _reader(tags), track_ids=['t1'])
|
||||||
|
assert first.ids_filled == 3
|
||||||
|
assert second.ids_filled == 0 # nothing left to fill
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_library_progress_and_stop():
|
||||||
|
conn, cur = _make_library_db()
|
||||||
|
seen = []
|
||||||
|
reconcile_library(conn, _reader({'/a.flac': {'spotify_track_id': 'TA'}}),
|
||||||
|
track_ids=['t1', 't2'],
|
||||||
|
on_progress=lambda totals, title: seen.append(title))
|
||||||
|
assert seen == ['A', 'B']
|
||||||
|
|
||||||
|
# should_stop halts before processing anything
|
||||||
|
totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2'],
|
||||||
|
should_stop=lambda: True)
|
||||||
|
assert totals.processed == 0
|
||||||
|
|
|
||||||
141
web_server.py
141
web_server.py
|
|
@ -9837,6 +9837,71 @@ def get_write_tags_batch_status():
|
||||||
# the API lookup entirely — large API savings on an already-tagged library.
|
# the API lookup entirely — large API savings on an already-tagged library.
|
||||||
# Gap-fill only: an existing id is never overwritten (see
|
# Gap-fill only: an existing id is never overwritten (see
|
||||||
# core/library/embedded_id_reconcile.py).
|
# core/library/embedded_id_reconcile.py).
|
||||||
|
def _reconcile_library_tracks(conn, track_ids=None, on_progress=None, should_stop=None):
|
||||||
|
"""Run the embedded-ID reconcile with web-server path resolution injected.
|
||||||
|
|
||||||
|
Thin wrapper over core.library.embedded_id_reconcile.reconcile_library that
|
||||||
|
supplies the read_tags callable (docker/library path resolution + mutagen
|
||||||
|
read). Used by the manual backfill job and available for any scoped
|
||||||
|
reconcile. ``track_ids=None`` => whole library.
|
||||||
|
"""
|
||||||
|
from core.library.file_tags import read_embedded_tags
|
||||||
|
from core.library.embedded_id_reconcile import reconcile_library
|
||||||
|
|
||||||
|
def _read_tags(file_path):
|
||||||
|
resolved = _resolve_library_file_path(file_path)
|
||||||
|
if not resolved:
|
||||||
|
return None
|
||||||
|
info = read_embedded_tags(resolved)
|
||||||
|
return info.get('tags') if info.get('available') else None
|
||||||
|
|
||||||
|
return reconcile_library(conn, _read_tags, track_ids=track_ids,
|
||||||
|
on_progress=on_progress, should_stop=should_stop)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_after_scan(worker):
|
||||||
|
"""Gap-fill embedded provider IDs for tracks a scan newly inserted.
|
||||||
|
|
||||||
|
Runs after a library scan/deep-scan completes, scoped to the rows the
|
||||||
|
worker actually INSERTED this run (``worker._new_track_ids``). New files
|
||||||
|
are written with empty provider-id columns, so this reads their tags and
|
||||||
|
fills any IDs present — keeping the DB current without a manual backfill.
|
||||||
|
Best-effort: never raises into the scan flow.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
new_ids = list(getattr(worker, '_new_track_ids', None) or [])
|
||||||
|
if not new_ids:
|
||||||
|
return
|
||||||
|
n = len(new_ids)
|
||||||
|
try:
|
||||||
|
_db_update_phase_callback(
|
||||||
|
f"Reading file tags for {n} new track{'s' if n != 1 else ''}…")
|
||||||
|
except Exception: # noqa: S110 — best-effort UI phase, never block the reconcile
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_progress(totals, title):
|
||||||
|
try:
|
||||||
|
pct = (totals.processed / totals.total * 100) if totals.total else 100
|
||||||
|
_db_update_progress_callback(title, totals.processed, totals.total, pct)
|
||||||
|
except Exception: # noqa: S110 — best-effort UI progress tick
|
||||||
|
pass
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
conn = database._get_connection()
|
||||||
|
try:
|
||||||
|
totals = _reconcile_library_tracks(conn, track_ids=new_ids, on_progress=_on_progress)
|
||||||
|
logger.info(
|
||||||
|
"[Reconcile] Post-scan: filled %d id(s) across %d row(s) from %d new "
|
||||||
|
"track(s) (%d unreadable, %d conflicts)",
|
||||||
|
totals.ids_filled, totals.entities_updated, totals.processed,
|
||||||
|
totals.unreadable, totals.conflicts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[Reconcile] Post-scan reconcile failed (non-fatal): %s", e)
|
||||||
|
|
||||||
|
|
||||||
_reconcile_ids_state = {
|
_reconcile_ids_state = {
|
||||||
'status': 'idle', # idle | running | done
|
'status': 'idle', # idle | running | done
|
||||||
'total': 0,
|
'total': 0,
|
||||||
|
|
@ -9868,71 +9933,32 @@ def reconcile_embedded_ids():
|
||||||
database = get_database()
|
database = get_database()
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
from core.library.file_tags import read_embedded_tags
|
|
||||||
from core.library.embedded_id_reconcile import reconcile_track_row
|
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = database._get_connection()
|
conn = database._get_connection()
|
||||||
cur = conn.cursor()
|
|
||||||
# Parent IDs in memory (these tables are far smaller than tracks).
|
|
||||||
cur.execute("SELECT * FROM albums")
|
|
||||||
album_map = {str(r['id']): dict(r) for r in cur.fetchall()}
|
|
||||||
cur.execute("SELECT * FROM artists")
|
|
||||||
artist_map = {str(r['id']): dict(r) for r in cur.fetchall()}
|
|
||||||
# Track IDs only first (light); rows are pulled per page below so
|
|
||||||
# memory stays bounded on large libraries. Each page's SELECT is
|
|
||||||
# fully fetched before any UPDATE, so reusing one cursor is safe.
|
|
||||||
cur.execute("SELECT id FROM tracks WHERE file_path IS NOT NULL AND TRIM(file_path) != ''")
|
|
||||||
track_ids = [str(r['id']) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
with _reconcile_ids_lock:
|
def _on_progress(totals, title):
|
||||||
_reconcile_ids_state['total'] = len(track_ids)
|
with _reconcile_ids_lock:
|
||||||
|
_reconcile_ids_state.update({
|
||||||
|
'total': totals.total,
|
||||||
|
'processed': totals.processed,
|
||||||
|
'entities_updated': totals.entities_updated,
|
||||||
|
'ids_filled': totals.ids_filled,
|
||||||
|
'conflicts': totals.conflicts,
|
||||||
|
'unreadable': totals.unreadable,
|
||||||
|
'current': title,
|
||||||
|
})
|
||||||
|
|
||||||
PAGE = 500
|
# Whole-library backfill (track_ids=None). Shares the exact
|
||||||
for start in range(0, len(track_ids), PAGE):
|
# orchestration used by the on-scan auto-reconcile hook.
|
||||||
page = track_ids[start:start + PAGE]
|
_reconcile_library_tracks(conn, on_progress=_on_progress)
|
||||||
ph = ','.join('?' * len(page))
|
|
||||||
cur.execute(f"SELECT * FROM tracks WHERE id IN ({ph})", page)
|
|
||||||
rows = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
for tr in rows:
|
|
||||||
title = tr.get('title') or '?'
|
|
||||||
with _reconcile_ids_lock:
|
|
||||||
_reconcile_ids_state['current'] = title
|
|
||||||
|
|
||||||
# One bad file must never abort the whole library scan.
|
|
||||||
try:
|
|
||||||
resolved = _resolve_library_file_path(tr.get('file_path'))
|
|
||||||
info = read_embedded_tags(resolved) if resolved else {'available': False}
|
|
||||||
tags = info.get('tags') if info.get('available') else None
|
|
||||||
|
|
||||||
result = reconcile_track_row(cur, tr, album_map, artist_map, tags)
|
|
||||||
|
|
||||||
with _reconcile_ids_lock:
|
|
||||||
if not result.readable:
|
|
||||||
_reconcile_ids_state['unreadable'] += 1
|
|
||||||
else:
|
|
||||||
_reconcile_ids_state['entities_updated'] += result.applied.rows_updated
|
|
||||||
_reconcile_ids_state['ids_filled'] += result.applied.ids_filled
|
|
||||||
_reconcile_ids_state['conflicts'] += result.conflicts
|
|
||||||
except Exception as _te:
|
|
||||||
logger.debug("reconcile: skipped track %s: %s", tr.get('id'), _te)
|
|
||||||
with _reconcile_ids_lock:
|
|
||||||
_reconcile_ids_state['unreadable'] += 1
|
|
||||||
finally:
|
|
||||||
with _reconcile_ids_lock:
|
|
||||||
_reconcile_ids_state['processed'] += 1
|
|
||||||
|
|
||||||
# Commit per page — releases the write lock so concurrent
|
|
||||||
# enrichment workers aren't starved during a long scan.
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Reconcile embedded IDs background error: {e}")
|
logger.error(f"Reconcile embedded IDs background error: {e}")
|
||||||
finally:
|
finally:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — connection cleanup, nothing to recover
|
||||||
pass
|
pass
|
||||||
with _reconcile_ids_lock:
|
with _reconcile_ids_lock:
|
||||||
_reconcile_ids_state['status'] = 'done'
|
_reconcile_ids_state['status'] = 'done'
|
||||||
|
|
@ -15193,6 +15219,10 @@ def _run_db_update_task(full_refresh, server_type):
|
||||||
# This is a blocking call that runs the worker logic
|
# This is a blocking call that runs the worker logic
|
||||||
db_update_worker.run()
|
db_update_worker.run()
|
||||||
|
|
||||||
|
# Auto-reconcile: pull embedded provider IDs from newly-added files into
|
||||||
|
# the DB so enrichment workers skip those API lookups (#tag-id-backfill).
|
||||||
|
_reconcile_after_scan(db_update_worker)
|
||||||
|
|
||||||
|
|
||||||
def _run_deep_scan_task(server_type):
|
def _run_deep_scan_task(server_type):
|
||||||
"""Run a deep library scan in the background thread."""
|
"""Run a deep library scan in the background thread."""
|
||||||
|
|
@ -15241,6 +15271,9 @@ def _run_deep_scan_task(server_type):
|
||||||
# Run deep scan instead of normal run()
|
# Run deep scan instead of normal run()
|
||||||
db_update_worker.run_deep_scan()
|
db_update_worker.run_deep_scan()
|
||||||
|
|
||||||
|
# Auto-reconcile newly-inserted files' embedded provider IDs into the DB.
|
||||||
|
_reconcile_after_scan(db_update_worker)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/database/stats', methods=['GET'])
|
@app.route('/api/database/stats', methods=['GET'])
|
||||||
def get_database_stats():
|
def get_database_stats():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue