Fix track ID generation in repair flows

Repair-worker album fills now generate explicit track IDs when copying rows, instead of relying on SQLite auto-assignment that no longer exists for TEXT primary keys. The unknown-artist fixer now does the same for new artists.

Also add a regression test for the album-fill copy branch and keep the AcoustID scanner resilient to legacy null-ID rows.
This commit is contained in:
Antti Kettunen 2026-04-17 19:25:56 +03:00
parent 88e2527b96
commit 0e85931cc8
3 changed files with 187 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import os
import re import re
import shutil import shutil
import sys import sys
import uuid
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
from core.repair_jobs import register_job from core.repair_jobs import register_job
@ -529,8 +530,16 @@ class UnknownArtistFixerJob(RepairJob):
if artist_row: if artist_row:
new_artist_id = artist_row[0] new_artist_id = artist_row[0]
else: else:
cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) safe_artist_name = re.sub(
new_artist_id = cursor.lastrowid r'[^A-Za-z0-9_.-]+',
'_',
corrected_artist.strip() or 'unknown'
)
new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}"
cursor.execute(
"INSERT INTO artists (id, name) VALUES (?, ?)",
(new_artist_id, corrected_artist),
)
# Update track's artist_id and file_path # Update track's artist_id and file_path
cursor.execute(""" cursor.execute("""

View file

@ -14,6 +14,7 @@ import shutil
import sys import sys
import threading import threading
import time import time
import uuid
from difflib import SequenceMatcher from difflib import SequenceMatcher
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -1486,8 +1487,16 @@ class RepairWorker:
row = cursor.fetchone() row = cursor.fetchone()
new_artist_id = row[0] if row else None new_artist_id = row[0] if row else None
if not new_artist_id: if not new_artist_id:
cursor.execute("INSERT INTO artists (name) VALUES (?)", (corrected_artist,)) safe_artist_name = re.sub(
new_artist_id = cursor.lastrowid r'[^A-Za-z0-9_.-]+',
'_',
corrected_artist.strip() or 'unknown'
)
new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}"
cursor.execute(
"INSERT INTO artists (id, name) VALUES (?, ?)",
(new_artist_id, corrected_artist),
)
cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?", cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?",
(new_artist_id, final_path, track_id)) (new_artist_id, final_path, track_id))
@ -1601,9 +1610,18 @@ class RepairWorker:
if row: if row:
cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?", (row[0], track_id)) cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?", (row[0], track_id))
else: else:
cursor.execute("INSERT INTO artists (name) VALUES (?)", (aid_artist,)) safe_artist_name = re.sub(
r'[^A-Za-z0-9_.-]+',
'_',
aid_artist.strip() or 'unknown'
)
new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}"
cursor.execute(
"INSERT INTO artists (id, name) VALUES (?, ?)",
(new_artist_id, aid_artist),
)
cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?", cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?",
(cursor.lastrowid, track_id)) (new_artist_id, track_id))
conn.commit() conn.commit()
conn.close() conn.close()
except Exception as e: except Exception as e:
@ -2140,6 +2158,12 @@ class RepairWorker:
# COPY: duplicate file, create new DB record # COPY: duplicate file, create new DB record
shutil.copy2(src_path, target_path) shutil.copy2(src_path, target_path)
action = 'copied' action = 'copied'
source_track_id = re.sub(
r'[^A-Za-z0-9_.-]+',
'_',
str(getattr(candidate, 'id', 'unknown'))
)
new_track_id = f"album_fill_{source_track_id}_{uuid.uuid4().hex[:8]}"
conn = self.db._get_connection() conn = self.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -2149,10 +2173,10 @@ class RepairWorker:
target_artist_id = artist_row[0] if artist_row else candidate.artist_id target_artist_id = artist_row[0] if artist_row else candidate.artist_id
cursor.execute(""" cursor.execute("""
INSERT INTO tracks (album_id, artist_id, title, track_number, duration, INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration,
file_path, bitrate, created_at, updated_at) file_path, bitrate, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", (album_id, target_artist_id, track_name, track_number, """, (new_track_id, album_id, target_artist_id, track_name, track_number,
candidate.duration, target_path, candidate.bitrate)) candidate.duration, target_path, candidate.bitrate))
conn.commit() conn.commit()

View file

@ -0,0 +1,145 @@
import sqlite3
import sys
import types
from pathlib import Path
from types import SimpleNamespace
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
class _DummySpotify:
def __init__(self, *args, **kwargs):
pass
oauth2 = types.ModuleType("spotipy.oauth2")
class _DummyOAuth:
def __init__(self, *args, **kwargs):
pass
spotipy.Spotify = _DummySpotify
oauth2.SpotifyOAuth = _DummyOAuth
oauth2.SpotifyClientCredentials = _DummyOAuth
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.repair_worker import RepairWorker
def test_perform_album_fill_copy_branch_generates_track_id(tmp_path, monkeypatch):
src_path = tmp_path / "source.flac"
src_path.write_bytes(b"fake-audio")
album_folder = tmp_path / "album"
db_path = tmp_path / "tracks.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"""
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
track_number INTEGER,
duration INTEGER,
file_path TEXT,
bitrate INTEGER,
created_at TEXT,
updated_at TEXT
)
"""
)
conn.execute(
"""
INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
("target-track-1", "target-album", "target-artist", "Existing Track", 1, 180000, str(src_path), 320),
)
conn.commit()
class _FakeDB:
def _get_connection(self):
return sqlite3.connect(db_path)
def get_tracks_by_album(self, album_id):
return [
SimpleNamespace(
id="source-track-1",
album_id="source-album",
artist_id="source-artist",
duration=180000,
file_path=str(src_path),
bitrate=320,
),
SimpleNamespace(
id="source-track-2",
album_id="source-album",
artist_id="source-artist",
duration=181000,
file_path=str(src_path),
bitrate=320,
),
]
worker = RepairWorker.__new__(RepairWorker)
worker.db = _FakeDB()
worker.transfer_folder = str(tmp_path)
worker._config_manager = None
worker._enhance_file_metadata = None
worker._enhance_placed_track = lambda *args, **kwargs: None
monkeypatch.setattr(
"core.repair_worker.uuid.uuid4",
lambda: SimpleNamespace(hex="deadbeefcafebabe"),
)
result = worker._perform_album_fill(
candidate=SimpleNamespace(
id="source-track-1",
album_id="source-album",
artist_id="source-artist",
duration=180000,
file_path=str(src_path),
bitrate=320,
),
album_id="target-album",
album_title="Target Album",
artist_name="Target Artist",
track_name="New Track",
track_number=2,
disc_number=1,
album_folder=str(album_folder),
filename_pattern="{num:02d} - {title}",
download_folder=None,
)
assert result["success"] is True
assert result["action"] == "copied"
with sqlite3.connect(db_path) as verify_conn:
row = verify_conn.execute(
"SELECT id, title, file_path FROM tracks WHERE title = ?",
("New Track",),
).fetchone()
assert row is not None
assert row[0] is not None
assert row[0].startswith("album_fill_source-track-1_deadbeef")
assert row[1] == "New Track"
assert Path(row[2]).exists()
assert verify_conn.execute("SELECT COUNT(*) FROM tracks WHERE id IS NULL").fetchone()[0] == 0