Add manual library track matching
This commit is contained in:
parent
4d882180bb
commit
42f4aa5eac
12 changed files with 1288 additions and 4 deletions
|
|
@ -285,21 +285,28 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
download_batches[batch_id]['analysis_processed'] = 0
|
||||
|
||||
from database.music_database import MusicDatabase
|
||||
from core.library import manual_library_match as _mlm
|
||||
db = MusicDatabase()
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
analysis_results = []
|
||||
|
||||
# Get force download flag and album context from batch
|
||||
force_download_all = False
|
||||
ignore_manual_matches = False
|
||||
batch_album_context = None
|
||||
batch_artist_context = None
|
||||
batch_is_album = False
|
||||
batch_profile_id = 1
|
||||
batch_source = 'spotify'
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
||||
ignore_manual_matches = download_batches[batch_id].get('ignore_manual_matches', False)
|
||||
batch_is_album = download_batches[batch_id].get('is_album_download', False)
|
||||
batch_album_context = download_batches[batch_id].get('album_context')
|
||||
batch_artist_context = download_batches[batch_id].get('artist_context')
|
||||
batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1
|
||||
batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
|
||||
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
|
||||
|
|
@ -373,6 +380,24 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
artists = track_data.get('artists', [])
|
||||
found, confidence = False, 0.0
|
||||
|
||||
# Manual library matches are authoritative unless the user explicitly
|
||||
# requested a force re-download from the normal download modal.
|
||||
_stid = track_data.get('spotify_track_id') or track_data.get('id', '')
|
||||
if not ignore_manual_matches and _stid and _mlm.get_match(db, batch_profile_id, batch_source, _stid):
|
||||
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
except Exception as _wl_err:
|
||||
logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}")
|
||||
analysis_results.append({
|
||||
'track_index': track_index,
|
||||
'track': track_data,
|
||||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'manual_library_match',
|
||||
})
|
||||
continue
|
||||
|
||||
# Skip database check if force download is enabled
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing")
|
||||
|
|
|
|||
193
core/library/manual_library_match.py
Normal file
193
core/library/manual_library_match.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Manual library match service.
|
||||
|
||||
Lets users explicitly link a source track (wishlist/sync-history candidate) to
|
||||
an existing library track so SoulSync stops trying to re-download it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("library.manual_library_match")
|
||||
|
||||
|
||||
def save_match(
|
||||
db,
|
||||
profile_id: int,
|
||||
source: str,
|
||||
source_track_id: str,
|
||||
library_track_id: int,
|
||||
**meta,
|
||||
) -> bool:
|
||||
"""Save (insert or replace) a manual match."""
|
||||
return db.save_manual_library_match(
|
||||
profile_id, source, source_track_id, library_track_id, **meta
|
||||
)
|
||||
|
||||
|
||||
def get_match(
|
||||
db,
|
||||
profile_id: int,
|
||||
source: str,
|
||||
source_track_id: str,
|
||||
server_source: str = "",
|
||||
) -> Optional[dict]:
|
||||
"""Return match row dict or None if not found."""
|
||||
return db.get_manual_library_match(profile_id, source, source_track_id, server_source)
|
||||
|
||||
|
||||
def delete_match(db, match_id: int, profile_id: int) -> bool:
|
||||
"""Delete match by PK id, scoped to profile."""
|
||||
return db.delete_manual_library_match(match_id, profile_id)
|
||||
|
||||
|
||||
def list_matches(db, profile_id: int, limit: int = 100) -> list[dict]:
|
||||
"""Return all matches for profile, most-recently-updated first."""
|
||||
rows = db.list_manual_library_matches(profile_id, limit)
|
||||
return [_enrich_match(row, db) for row in rows]
|
||||
|
||||
|
||||
def search_source_candidates(db, query: str, profile_id: int, limit: int = 15) -> list[dict]:
|
||||
"""Search wishlist + sync history for source track candidates matching query."""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
q = query.strip()
|
||||
like = f"%{q}%"
|
||||
results: dict[tuple, dict] = {}
|
||||
|
||||
# 1) Wishlist tracks
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
json_extract(spotify_data, '$.id') AS track_id,
|
||||
json_extract(spotify_data, '$.name') AS title,
|
||||
json_extract(spotify_data, '$.artists[0].name') AS artist,
|
||||
json_extract(spotify_data, '$.album.name') AS album,
|
||||
date_added AS added_at
|
||||
FROM wishlist_tracks
|
||||
WHERE profile_id = ?
|
||||
AND (
|
||||
json_extract(spotify_data, '$.name') LIKE ?
|
||||
OR json_extract(spotify_data, '$.artists[0].name') LIKE ?
|
||||
)
|
||||
ORDER BY date_added DESC
|
||||
LIMIT ?
|
||||
""", (profile_id, like, like, limit * 2))
|
||||
for row in cursor.fetchall():
|
||||
r = dict(row)
|
||||
if not r.get("track_id"):
|
||||
continue
|
||||
key = ("spotify", r["track_id"])
|
||||
if key not in results:
|
||||
results[key] = {
|
||||
"source": "spotify",
|
||||
"source_track_id": r["track_id"],
|
||||
"title": r["title"] or "",
|
||||
"artist": r["artist"] or "",
|
||||
"album": r["album"] or "",
|
||||
"context": "Wishlist",
|
||||
"added_at": r["added_at"] or "",
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("source_candidates wishlist query failed: %s", exc)
|
||||
|
||||
# 2) Sync history — scan tracks_json blobs
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT playlist_name, source, tracks_json, started_at
|
||||
FROM sync_history
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
for row in cursor.fetchall():
|
||||
sh = dict(row)
|
||||
try:
|
||||
tracks = json.loads(sh["tracks_json"] or "[]")
|
||||
except Exception:
|
||||
continue
|
||||
for t in tracks:
|
||||
title = t.get("name", "")
|
||||
artist = ""
|
||||
artists = t.get("artists", [])
|
||||
if artists:
|
||||
first = artists[0]
|
||||
artist = first.get("name", "") if isinstance(first, dict) else str(first)
|
||||
if q.lower() not in title.lower() and q.lower() not in artist.lower():
|
||||
continue
|
||||
src = sh["source"] or "spotify"
|
||||
tid = t.get("id") or t.get("spotify_track_id") or ""
|
||||
if not tid:
|
||||
continue
|
||||
key = (src, tid)
|
||||
if key not in results:
|
||||
album = ""
|
||||
alb = t.get("album")
|
||||
if isinstance(alb, dict):
|
||||
album = alb.get("name", "")
|
||||
elif isinstance(alb, str):
|
||||
album = alb
|
||||
results[key] = {
|
||||
"source": src,
|
||||
"source_track_id": tid,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": album,
|
||||
"context": sh["playlist_name"] or "",
|
||||
"added_at": sh["started_at"] or "",
|
||||
}
|
||||
if len(results) >= limit * 3:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("source_candidates sync_history query failed: %s", exc)
|
||||
|
||||
# Sort by recency and cap
|
||||
sorted_results = sorted(results.values(), key=lambda r: r.get("added_at", ""), reverse=True)
|
||||
return sorted_results[:limit]
|
||||
|
||||
|
||||
def search_library_candidates(db, query: str, limit: int = 15) -> list[dict[str, Any]]:
|
||||
"""Search library tracks using the existing api_search_tracks method."""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
q = query.strip()
|
||||
# Pass the full query as title (covers most single-field searches).
|
||||
# Also try as artist in parallel and merge, deduped by track id.
|
||||
title_rows = db.api_search_tracks(title=q, limit=limit)
|
||||
artist_rows = db.api_search_tracks(artist=q, limit=limit)
|
||||
seen: set[int] = set()
|
||||
merged = []
|
||||
for row in title_rows + artist_rows:
|
||||
rid = row.get('id')
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
merged.append(row)
|
||||
if len(merged) >= limit:
|
||||
break
|
||||
return merged
|
||||
|
||||
|
||||
def _enrich_match(match_row: dict, db) -> dict:
|
||||
"""Add library track details to a match row."""
|
||||
out = dict(match_row)
|
||||
lib_id = match_row.get("library_track_id")
|
||||
if lib_id:
|
||||
try:
|
||||
tracks = db.api_get_tracks_by_ids([lib_id])
|
||||
if tracks:
|
||||
t = tracks[0]
|
||||
out["library_title"] = t.get("title", "")
|
||||
out["library_artist"] = t.get("artist_name", "")
|
||||
out["library_album"] = t.get("album_title", "")
|
||||
out["library_file_path"] = t.get("file_path", "")
|
||||
out["library_bitrate"] = t.get("bitrate")
|
||||
except Exception as exc:
|
||||
logger.debug("enrich_match track lookup failed for id=%s: %s", lib_id, exc)
|
||||
return out
|
||||
|
|
@ -232,13 +232,18 @@ def remove_tracks_already_in_library(
|
|||
log_prefix: str = "[Auto-Wishlist]",
|
||||
) -> int:
|
||||
"""Remove wishlist entries that are already present in the library."""
|
||||
from core.library import manual_library_match as _mlm
|
||||
|
||||
all_profiles = profiles_database.get_all_profiles()
|
||||
cleanup_tracks = []
|
||||
# Carry (profile_id, track) so the match check can be profile-scoped.
|
||||
cleanup_tracks: list[tuple[int, dict]] = []
|
||||
for profile in all_profiles:
|
||||
cleanup_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"]))
|
||||
pid = profile["id"]
|
||||
for t in wishlist_service.get_wishlist_tracks_for_download(profile_id=pid):
|
||||
cleanup_tracks.append((pid, t))
|
||||
|
||||
cleanup_removed = 0
|
||||
for track in cleanup_tracks:
|
||||
for profile_id, track in cleanup_tracks:
|
||||
if skip_track_fn and skip_track_fn(track):
|
||||
continue
|
||||
|
||||
|
|
@ -250,6 +255,18 @@ def remove_tracks_already_in_library(
|
|||
if not track_name or not artists or not spotify_track_id:
|
||||
continue
|
||||
|
||||
# Manual match check — skip fuzzy search if user already linked this track.
|
||||
_track_source = track.get('provider') or 'spotify'
|
||||
if _mlm.get_match(music_database, profile_id, _track_source, spotify_track_id):
|
||||
try:
|
||||
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
|
||||
if removed:
|
||||
cleanup_removed += 1
|
||||
logger.info(f"{log_prefix} [Manual Match] Skipped already-matched track: '{track_name}'")
|
||||
except Exception as _mlm_err:
|
||||
logger.error(f"{log_prefix} [Manual Match] Error removing track: {_mlm_err}")
|
||||
continue
|
||||
|
||||
found_in_db = False
|
||||
matched_artist_name = ''
|
||||
for artist in artists:
|
||||
|
|
|
|||
|
|
@ -770,6 +770,8 @@ class MusicDatabase:
|
|||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
self._init_manual_library_match_table()
|
||||
|
||||
def _add_mirrored_playlist_explored_column(self, cursor):
|
||||
"""Add explored_at column to mirrored_playlists to persist explore badge."""
|
||||
try:
|
||||
|
|
@ -4137,6 +4139,114 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error creating repair worker v2 tables: {e}")
|
||||
|
||||
def _init_manual_library_match_table(self):
|
||||
"""Create manual_library_track_matches table and indexes."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS manual_library_track_matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER DEFAULT 1,
|
||||
source TEXT NOT NULL,
|
||||
source_track_id TEXT NOT NULL,
|
||||
source_title TEXT,
|
||||
source_artist TEXT,
|
||||
source_album TEXT,
|
||||
source_context_json TEXT,
|
||||
server_source TEXT DEFAULT '',
|
||||
library_track_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(profile_id, source, source_track_id, server_source)
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_mltm_lookup
|
||||
ON manual_library_track_matches (profile_id, source, source_track_id, server_source)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_mltm_lib_track
|
||||
ON manual_library_track_matches (library_track_id)
|
||||
""")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating manual_library_track_matches table: {e}")
|
||||
|
||||
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
||||
library_track_id: int, **meta) -> bool:
|
||||
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
||||
source_album, source_context_json, server_source."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO manual_library_track_matches
|
||||
(profile_id, source, source_track_id, library_track_id,
|
||||
source_title, source_artist, source_album,
|
||||
source_context_json, server_source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(profile_id, source, source_track_id, server_source)
|
||||
DO UPDATE SET
|
||||
library_track_id = excluded.library_track_id,
|
||||
source_title = excluded.source_title,
|
||||
source_artist = excluded.source_artist,
|
||||
source_album = excluded.source_album,
|
||||
source_context_json = excluded.source_context_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
profile_id, source, source_track_id, library_track_id,
|
||||
meta.get('source_title'), meta.get('source_artist'),
|
||||
meta.get('source_album'), meta.get('source_context_json'),
|
||||
meta.get('server_source', ''),
|
||||
))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"save_manual_library_match error: {e}")
|
||||
return False
|
||||
|
||||
def get_manual_library_match(self, profile_id: int, source: str,
|
||||
source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]:
|
||||
"""Return match row dict or None."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM manual_library_track_matches
|
||||
WHERE profile_id = ? AND source = ? AND source_track_id = ? AND server_source = ?
|
||||
""", (profile_id, source, source_track_id, server_source))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"get_manual_library_match error: {e}")
|
||||
return None
|
||||
|
||||
def delete_manual_library_match(self, match_id: int, profile_id: int) -> bool:
|
||||
"""Delete match by PK id, scoped to profile_id."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("""
|
||||
DELETE FROM manual_library_track_matches WHERE id = ? AND profile_id = ?
|
||||
""", (match_id, profile_id))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"delete_manual_library_match error: {e}")
|
||||
return False
|
||||
|
||||
def list_manual_library_matches(self, profile_id: int, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""Return matches for profile ordered by updated_at DESC."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM manual_library_track_matches
|
||||
WHERE profile_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
""", (profile_id, limit))
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"list_manual_library_matches error: {e}")
|
||||
return []
|
||||
|
||||
# ── Profile CRUD ──────────────────────────────────────────────────
|
||||
|
||||
def get_all_profiles(self) -> List[Dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -296,6 +296,62 @@ def test_force_download_treats_all_as_missing(monkeypatch):
|
|||
assert download_batches['B2']['phase'] == 'downloading'
|
||||
|
||||
|
||||
def test_manual_match_overrides_internal_force_download(monkeypatch):
|
||||
"""Internal wishlist force mode still honors explicit manual library matches."""
|
||||
db = _FakeDB()
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
monkeypatch.setattr(
|
||||
'core.library.manual_library_match.get_match',
|
||||
lambda *_args, **_kwargs: {'id': 1, 'library_track_id': 42},
|
||||
)
|
||||
|
||||
removed = []
|
||||
_seed_batch(
|
||||
'B2a',
|
||||
force_download_all=True,
|
||||
ignore_manual_matches=False,
|
||||
profile_id=1,
|
||||
batch_source='spotify',
|
||||
)
|
||||
deps = _build_deps(wishlist_remove=lambda td: removed.append(td.get('name')))
|
||||
tracks = [{'id': 'spotify-track-1', 'name': 'T1', 'artists': ['A']}]
|
||||
|
||||
mw.run_full_missing_tracks_process('B2a', 'wishlist', tracks, deps)
|
||||
|
||||
assert download_batches['B2a']['queue'] == []
|
||||
assert download_batches['B2a']['analysis_results'][0]['found'] is True
|
||||
assert download_batches['B2a']['analysis_results'][0]['match_reason'] == 'manual_library_match'
|
||||
assert removed == ['T1']
|
||||
|
||||
|
||||
def test_explicit_force_download_ignores_manual_match(monkeypatch):
|
||||
"""User-facing Force Download All can intentionally bypass manual matches."""
|
||||
db = _FakeDB()
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
'core.library.manual_library_match.get_match',
|
||||
lambda *_args, **_kwargs: calls.append(True) or {'id': 1, 'library_track_id': 42},
|
||||
)
|
||||
|
||||
_seed_batch(
|
||||
'B2b',
|
||||
force_download_all=True,
|
||||
ignore_manual_matches=True,
|
||||
profile_id=1,
|
||||
batch_source='spotify',
|
||||
)
|
||||
deps = _build_deps()
|
||||
tracks = [{'id': 'spotify-track-1', 'name': 'T1', 'artists': ['A']}]
|
||||
|
||||
mw.run_full_missing_tracks_process('B2b', 'playlist1', tracks, deps)
|
||||
|
||||
assert calls == []
|
||||
assert len(download_batches['B2b']['queue']) == 1
|
||||
assert download_batches['B2b']['analysis_results'][0]['found'] is False
|
||||
|
||||
|
||||
def test_found_tracks_trigger_wishlist_removal(monkeypatch):
|
||||
"""When DB lookup succeeds, master worker invokes wishlist removal callback."""
|
||||
db = _FakeDB(found_tracks={('t1', 'a'): 0.9})
|
||||
|
|
|
|||
336
tests/test_manual_library_match.py
Normal file
336
tests/test_manual_library_match.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Tests for core/library/manual_library_match.py and DB methods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.library import manual_library_match as mlm
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return MusicDatabase(str(tmp_path / "music.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-layer tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_save_and_get_roundtrip(db):
|
||||
ok = db.save_manual_library_match(1, "spotify", "track-abc", 42,
|
||||
source_title="HUMBLE.", source_artist="Kendrick Lamar",
|
||||
source_album="DAMN.", server_source="")
|
||||
assert ok is True
|
||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||
assert row is not None
|
||||
assert row["library_track_id"] == 42
|
||||
assert row["source_title"] == "HUMBLE."
|
||||
assert row["source_artist"] == "Kendrick Lamar"
|
||||
|
||||
|
||||
def test_save_upserts_existing(db):
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 42)
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 99, source_title="Updated")
|
||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||
assert row["library_track_id"] == 99
|
||||
assert row["source_title"] == "Updated"
|
||||
|
||||
|
||||
def test_delete_by_id(db):
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 42)
|
||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||
assert row is not None
|
||||
ok = db.delete_manual_library_match(row["id"], 1)
|
||||
assert ok is True
|
||||
assert db.get_manual_library_match(1, "spotify", "track-abc") is None
|
||||
|
||||
|
||||
def test_list_matches_scoped_to_profile(db):
|
||||
db.save_manual_library_match(1, "spotify", "t1", 10)
|
||||
db.save_manual_library_match(1, "spotify", "t2", 20)
|
||||
rows = db.list_manual_library_matches(1)
|
||||
assert len(rows) == 2
|
||||
# Ordered by updated_at DESC — most recent first
|
||||
ids = {r["library_track_id"] for r in rows}
|
||||
assert ids == {10, 20}
|
||||
|
||||
|
||||
def test_profile_isolation(db):
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 10)
|
||||
db.save_manual_library_match(2, "spotify", "track-abc", 20)
|
||||
assert db.get_manual_library_match(1, "spotify", "track-abc")["library_track_id"] == 10
|
||||
assert db.get_manual_library_match(2, "spotify", "track-abc")["library_track_id"] == 20
|
||||
assert len(db.list_manual_library_matches(1)) == 1
|
||||
assert len(db.list_manual_library_matches(2)) == 1
|
||||
|
||||
|
||||
def test_server_source_isolation(db):
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 10, server_source="plex")
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 20, server_source="jellyfin")
|
||||
assert db.get_manual_library_match(1, "spotify", "track-abc", "plex")["library_track_id"] == 10
|
||||
assert db.get_manual_library_match(1, "spotify", "track-abc", "jellyfin")["library_track_id"] == 20
|
||||
|
||||
|
||||
def test_get_returns_none_when_absent(db):
|
||||
assert db.get_manual_library_match(1, "spotify", "nonexistent") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service-layer tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_service_save_and_get(db):
|
||||
mlm.save_match(db, 1, "spotify", "t1", 42, source_title="Song A")
|
||||
row = mlm.get_match(db, 1, "spotify", "t1")
|
||||
assert row is not None
|
||||
assert row["library_track_id"] == 42
|
||||
|
||||
|
||||
def test_service_delete(db):
|
||||
mlm.save_match(db, 1, "spotify", "t1", 42)
|
||||
row = mlm.get_match(db, 1, "spotify", "t1")
|
||||
mlm.delete_match(db, row["id"], 1)
|
||||
assert mlm.get_match(db, 1, "spotify", "t1") is None
|
||||
|
||||
|
||||
def test_service_list_enriches(db):
|
||||
mlm.save_match(db, 1, "spotify", "t1", 42, source_title="Song A", source_artist="Artist X")
|
||||
with patch.object(db, "api_get_tracks_by_ids", return_value=[{"title": "Song A", "artist_name": "Artist X", "album_title": "Album Z", "file_path": "/music/a.flac", "bitrate": 320}]):
|
||||
matches = mlm.list_matches(db, 1)
|
||||
assert len(matches) == 1
|
||||
assert matches[0]["library_title"] == "Song A"
|
||||
|
||||
|
||||
def test_search_library_candidates(db):
|
||||
with patch.object(db, "api_search_tracks", return_value=[{"id": 1, "title": "HUMBLE.", "artist_name": "Kendrick Lamar"}]) as mock_search:
|
||||
results = mlm.search_library_candidates(db, "HUMBLE")
|
||||
# searches by title AND artist, deduped
|
||||
assert mock_search.call_count == 2
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "HUMBLE."
|
||||
|
||||
|
||||
def test_search_source_candidates_empty_query(db):
|
||||
results = mlm.search_source_candidates(db, "", 1)
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: wishlist processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_wishlist_skips_manual_matched_track():
|
||||
"""Manual match causes track to be removed from wishlist without fuzzy check."""
|
||||
track = {
|
||||
"name": "HUMBLE.",
|
||||
"artists": [{"name": "Kendrick Lamar"}],
|
||||
"spotify_track_id": "spotify-track-123",
|
||||
}
|
||||
|
||||
mock_wishlist_svc = MagicMock()
|
||||
mock_wishlist_svc.get_wishlist_tracks_for_download.return_value = [track]
|
||||
mock_wishlist_svc.mark_track_download_result.return_value = True
|
||||
|
||||
mock_profiles_db = MagicMock()
|
||||
mock_profiles_db.get_all_profiles.return_value = [{"id": 1}]
|
||||
|
||||
mock_music_db = MagicMock()
|
||||
mock_music_db.check_track_exists = MagicMock()
|
||||
|
||||
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}):
|
||||
from core.wishlist.processing import remove_tracks_already_in_library
|
||||
removed = remove_tracks_already_in_library(
|
||||
mock_wishlist_svc,
|
||||
mock_profiles_db,
|
||||
mock_music_db,
|
||||
active_server="plex",
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
mock_wishlist_svc.mark_track_download_result.assert_called_once_with("spotify-track-123", success=True)
|
||||
mock_music_db.check_track_exists.assert_not_called()
|
||||
|
||||
|
||||
def test_wishlist_falls_through_when_no_match():
|
||||
"""No manual match → fuzzy path runs normally."""
|
||||
track = {
|
||||
"name": "HUMBLE.",
|
||||
"artists": [{"name": "Kendrick Lamar"}],
|
||||
"spotify_track_id": "spotify-track-123",
|
||||
}
|
||||
|
||||
mock_wishlist_svc = MagicMock()
|
||||
mock_wishlist_svc.get_wishlist_tracks_for_download.return_value = [track]
|
||||
mock_wishlist_svc.mark_track_download_result.return_value = False
|
||||
|
||||
mock_profiles_db = MagicMock()
|
||||
mock_profiles_db.get_all_profiles.return_value = [{"id": 1}]
|
||||
|
||||
mock_music_db = MagicMock()
|
||||
mock_music_db.check_track_exists.return_value = (None, 0.0)
|
||||
|
||||
with patch("core.library.manual_library_match.get_match", return_value=None):
|
||||
from core.wishlist.processing import remove_tracks_already_in_library
|
||||
removed = remove_tracks_already_in_library(
|
||||
mock_wishlist_svc,
|
||||
mock_profiles_db,
|
||||
mock_music_db,
|
||||
active_server="plex",
|
||||
)
|
||||
|
||||
assert removed == 0
|
||||
mock_music_db.check_track_exists.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: downloads/master analysis loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_master_analysis_marks_found():
|
||||
"""Manual match causes the real analysis loop to mark track found=True."""
|
||||
from core.downloads.master import MasterDeps, run_full_missing_tracks_process
|
||||
from core.runtime_state import download_batches, tasks_lock
|
||||
import threading
|
||||
|
||||
batch_id = "test-batch-real-123"
|
||||
track_data = {"name": "HUMBLE.", "artists": [], "id": "spotify-track-abc"}
|
||||
|
||||
with tasks_lock:
|
||||
download_batches[batch_id] = {
|
||||
"phase": "analysis",
|
||||
"analysis_total": 1,
|
||||
"analysis_processed": 0,
|
||||
"force_download_all": False,
|
||||
"is_album_download": False,
|
||||
"album_context": None,
|
||||
"artist_context": None,
|
||||
"profile_id": 1,
|
||||
"batch_source": "spotify",
|
||||
"wing_it": False,
|
||||
"playlist_folder_mode": False,
|
||||
"queue": [],
|
||||
"active_count": 0,
|
||||
"max_concurrent": 1,
|
||||
"queue_index": 0,
|
||||
"permanently_failed_tracks": [],
|
||||
"cancelled_tracks": set(),
|
||||
"analysis_results": [],
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.check_track_exists.return_value = (None, 0.0)
|
||||
mock_db.update_sync_history_completion = MagicMock()
|
||||
|
||||
mock_deps = MagicMock()
|
||||
mock_deps.config_manager.get.return_value = False
|
||||
mock_deps.config_manager.get_active_media_server.return_value = "plex"
|
||||
mock_deps.mb_worker = None
|
||||
mock_deps.mb_release_cache = {}
|
||||
mock_deps.mb_release_cache_lock = threading.Lock()
|
||||
mock_deps.mb_release_detail_cache = {}
|
||||
mock_deps.mb_release_detail_cache_lock = threading.Lock()
|
||||
mock_deps.normalize_album_cache_key = lambda x: x.lower().strip()
|
||||
mock_deps.check_and_remove_track_from_wishlist_by_metadata = MagicMock()
|
||||
mock_deps.is_explicit_blocked = MagicMock(return_value=False)
|
||||
mock_deps.youtube_playlist_states = {}
|
||||
mock_deps.tidal_discovery_states = {}
|
||||
mock_deps.deezer_discovery_states = {}
|
||||
mock_deps.spotify_public_discovery_states = {}
|
||||
mock_deps.missing_download_executor = MagicMock()
|
||||
mock_deps.process_failed_tracks_to_wishlist_exact_with_auto_completion = MagicMock()
|
||||
mock_deps.source_reuse_logger = MagicMock()
|
||||
mock_deps.download_monitor = MagicMock()
|
||||
mock_deps.start_next_batch_of_downloads = MagicMock()
|
||||
mock_deps.reset_wishlist_auto_processing = MagicMock()
|
||||
|
||||
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}), \
|
||||
patch("database.music_database.MusicDatabase", return_value=mock_db):
|
||||
run_full_missing_tracks_process(batch_id, "playlist-1", [track_data], mock_deps)
|
||||
|
||||
with tasks_lock:
|
||||
results = download_batches.get(batch_id, {}).get("analysis_results", [])
|
||||
download_batches.pop(batch_id, None)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["found"] is True
|
||||
assert results[0]["match_reason"] == "manual_library_match"
|
||||
mock_deps.check_and_remove_track_from_wishlist_by_metadata.assert_called_once_with(track_data)
|
||||
|
||||
|
||||
def test_master_analysis_manual_match_wins_over_internal_force_download():
|
||||
"""Manual match overrides internal force_download_all used by wishlist batches."""
|
||||
from core.downloads.master import run_full_missing_tracks_process
|
||||
from core.runtime_state import download_batches, tasks_lock
|
||||
import threading
|
||||
|
||||
batch_id = "test-batch-force-456"
|
||||
track_data = {"name": "HUMBLE.", "artists": [], "id": "spotify-track-abc"}
|
||||
|
||||
with tasks_lock:
|
||||
download_batches[batch_id] = {
|
||||
"phase": "analysis",
|
||||
"analysis_total": 1,
|
||||
"analysis_processed": 0,
|
||||
"force_download_all": True, # would normally bypass DB check and queue download
|
||||
"ignore_manual_matches": False,
|
||||
"is_album_download": False,
|
||||
"album_context": None,
|
||||
"artist_context": None,
|
||||
"profile_id": 1,
|
||||
"batch_source": "spotify",
|
||||
"wing_it": False,
|
||||
"playlist_folder_mode": False,
|
||||
"queue": [],
|
||||
"active_count": 0,
|
||||
"max_concurrent": 1,
|
||||
"queue_index": 0,
|
||||
"permanently_failed_tracks": [],
|
||||
"cancelled_tracks": set(),
|
||||
"analysis_results": [],
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.check_track_exists.return_value = (None, 0.0)
|
||||
mock_db.update_sync_history_completion = MagicMock()
|
||||
|
||||
mock_deps = MagicMock()
|
||||
mock_deps.config_manager.get.return_value = False
|
||||
mock_deps.config_manager.get_active_media_server.return_value = "plex"
|
||||
mock_deps.mb_worker = None
|
||||
mock_deps.mb_release_cache = {}
|
||||
mock_deps.mb_release_cache_lock = threading.Lock()
|
||||
mock_deps.mb_release_detail_cache = {}
|
||||
mock_deps.mb_release_detail_cache_lock = threading.Lock()
|
||||
mock_deps.normalize_album_cache_key = lambda x: x.lower().strip()
|
||||
mock_deps.check_and_remove_track_from_wishlist_by_metadata = MagicMock()
|
||||
mock_deps.is_explicit_blocked = MagicMock(return_value=False)
|
||||
mock_deps.youtube_playlist_states = {}
|
||||
mock_deps.tidal_discovery_states = {}
|
||||
mock_deps.deezer_discovery_states = {}
|
||||
mock_deps.spotify_public_discovery_states = {}
|
||||
mock_deps.missing_download_executor = MagicMock()
|
||||
mock_deps.process_failed_tracks_to_wishlist_exact_with_auto_completion = MagicMock()
|
||||
mock_deps.source_reuse_logger = MagicMock()
|
||||
mock_deps.download_monitor = MagicMock()
|
||||
mock_deps.start_next_batch_of_downloads = MagicMock()
|
||||
mock_deps.reset_wishlist_auto_processing = MagicMock()
|
||||
|
||||
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}), \
|
||||
patch("database.music_database.MusicDatabase", return_value=mock_db):
|
||||
run_full_missing_tracks_process(batch_id, "playlist-1", [track_data], mock_deps)
|
||||
|
||||
with tasks_lock:
|
||||
results = download_batches.get(batch_id, {}).get("analysis_results", [])
|
||||
queue = download_batches.get(batch_id, {}).get("queue", [])
|
||||
download_batches.pop(batch_id, None)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["found"] is True
|
||||
assert results[0]["match_reason"] == "manual_library_match"
|
||||
# Track must NOT enter the download queue despite internal force_download_all=True
|
||||
assert queue == []
|
||||
mock_deps.check_and_remove_track_from_wishlist_by_metadata.assert_called_once_with(track_data)
|
||||
|
|
@ -18189,6 +18189,100 @@ def library_search_tracks():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/manual-library-matches', methods=['GET'])
|
||||
def mlm_list():
|
||||
"""List manual library matches for the current profile."""
|
||||
try:
|
||||
from core.library import manual_library_match as mlm
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
return jsonify({"success": True, "matches": mlm.list_matches(db, profile_id)})
|
||||
except Exception as e:
|
||||
logger.error(f"mlm_list error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/manual-library-matches/source-search', methods=['GET'])
|
||||
def mlm_source_search():
|
||||
"""Search wishlist + sync history for source track candidates."""
|
||||
try:
|
||||
from core.library import manual_library_match as mlm
|
||||
q = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 15)), 50)
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
return jsonify({"success": True, "tracks": mlm.search_source_candidates(db, q, profile_id, limit)})
|
||||
except Exception as e:
|
||||
logger.error(f"mlm_source_search error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/manual-library-matches/library-search', methods=['GET'])
|
||||
def mlm_library_search():
|
||||
"""Search the local library for candidate tracks."""
|
||||
try:
|
||||
from core.library import manual_library_match as mlm
|
||||
q = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 15)), 50)
|
||||
db = get_database()
|
||||
return jsonify({"success": True, "tracks": mlm.search_library_candidates(db, q, limit)})
|
||||
except Exception as e:
|
||||
logger.error(f"mlm_library_search error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/manual-library-matches', methods=['POST'])
|
||||
def mlm_save():
|
||||
"""Create or replace a manual library match."""
|
||||
try:
|
||||
from core.library import manual_library_match as mlm
|
||||
data = request.get_json(force=True) or {}
|
||||
source = data.get('source', '').strip()
|
||||
source_track_id = data.get('source_track_id', '').strip()
|
||||
library_track_id = data.get('library_track_id')
|
||||
if not source or not source_track_id or not library_track_id:
|
||||
return jsonify({"success": False, "error": "source, source_track_id, library_track_id required"}), 400
|
||||
try:
|
||||
library_track_id = int(library_track_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False, "error": "Invalid library track id"}), 400
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
# Validate library track exists before saving
|
||||
if not db.api_get_tracks_by_ids([library_track_id]):
|
||||
return jsonify({"success": False, "error": "Library track not found — it may have been removed"}), 400
|
||||
ok = mlm.save_match(
|
||||
db, profile_id, source, source_track_id, library_track_id,
|
||||
source_title=data.get('source_title', ''),
|
||||
source_artist=data.get('source_artist', ''),
|
||||
source_album=data.get('source_album', ''),
|
||||
source_context_json=data.get('source_context_json', ''),
|
||||
server_source=data.get('server_source', ''),
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "Failed to save match"}), 500
|
||||
match = db.get_manual_library_match(profile_id, source, source_track_id, data.get('server_source', ''))
|
||||
enriched = mlm._enrich_match(match, db) if match else {}
|
||||
return jsonify({"success": True, "match": enriched})
|
||||
except Exception as e:
|
||||
logger.error(f"mlm_save error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/manual-library-matches/<int:match_id>', methods=['DELETE'])
|
||||
def mlm_delete(match_id):
|
||||
"""Delete a manual library match by ID."""
|
||||
try:
|
||||
from core.library import manual_library_match as mlm
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
mlm.delete_match(db, match_id, profile_id)
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"mlm_delete error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/playlists/<playlist_id>/start-missing-process', methods=['POST'])
|
||||
def start_missing_tracks_process(playlist_id):
|
||||
"""
|
||||
|
|
@ -18201,6 +18295,9 @@ def start_missing_tracks_process(playlist_id):
|
|||
force_download_all = data.get('force_download_all', False)
|
||||
playlist_folder_mode = data.get('playlist_folder_mode', False)
|
||||
wing_it = data.get('wing_it', False)
|
||||
ignore_manual_matches = data.get('ignore_manual_matches')
|
||||
if ignore_manual_matches is None:
|
||||
ignore_manual_matches = bool(force_download_all and not wing_it)
|
||||
|
||||
# Get album/artist context for artist album downloads
|
||||
is_album_download = data.get('is_album_download', False)
|
||||
|
|
@ -18249,12 +18346,14 @@ def start_missing_tracks_process(playlist_id):
|
|||
'analysis_processed': 0,
|
||||
'analysis_results': [],
|
||||
'force_download_all': force_download_all, # Pass the force flag to the batch
|
||||
'ignore_manual_matches': ignore_manual_matches,
|
||||
'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder
|
||||
# Album context for artist album downloads (explicit folder structure)
|
||||
'is_album_download': is_album_download,
|
||||
'album_context': album_context,
|
||||
'artist_context': artist_context,
|
||||
'wing_it': wing_it,
|
||||
'batch_source': _downloads_history.detect_sync_source(playlist_id),
|
||||
}
|
||||
|
||||
# Record sync history — derive source_page from context
|
||||
|
|
|
|||
|
|
@ -915,7 +915,10 @@
|
|||
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media
|
||||
server</p>
|
||||
</div>
|
||||
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
|
||||
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -6570,6 +6573,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="manual-library-match-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Manual Library Match</h4>
|
||||
</div>
|
||||
<p class="tool-card-info">Link source tracks to existing library tracks to stop re-downloads</p>
|
||||
<div class="tool-card-controls">
|
||||
<button onclick="openManualLibraryMatchTool()">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="retag-tool-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Retag Tool</h4>
|
||||
|
|
|
|||
|
|
@ -2478,6 +2478,7 @@ async function startMissingTracksProcess(playlistId) {
|
|||
const requestBody = {
|
||||
tracks: selectedTracks,
|
||||
force_download_all: forceDownloadAll || isWingIt,
|
||||
ignore_manual_matches: forceDownloadAll,
|
||||
wing_it: isWingIt,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3415,6 +3415,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.5.4': [
|
||||
{ date: 'May 16, 2026 — 2.5.4 release' },
|
||||
{ title: 'Manual Library Match', desc: 'stop SoulSync from re-downloading tracks it already has. new centralized tool (Tools page → Manual Library Match, or Sync page → Library Match button) lets you search your wishlist / sync history on the left and your library on the right, then link them. once matched, that source track is skipped in wishlist cleanup and normal download analysis; the explicit Force Download All toggle can still redownload everything on purpose. manage all your matches in one place with remove support.', page: 'tools' },
|
||||
{ title: 'Amazon Music Download Source', desc: 'new download source backed by T2Tunes proxy. searches the Amazon Music catalog, downloads 24-bit/48kHz FLAC (or Opus 320kbps / Dolby Atmos EAC3 fallback). codec waterfall mirrors Tidal/Qobuz — best quality first, auto-fallback. selectable as a standalone or hybrid source from Settings.', page: 'settings' },
|
||||
{ title: 'Amazon Music Search Quality', desc: 'search results now show album art, artist images (album cover stand-in, same as iTunes), and correct track/disc numbers. feat. credits stripped from artist names so the same artist does not show as duplicates. [Explicit] stripped from album names so MusicBrainz matching works cleanly — Clean / Edited / Censored labels kept as-is. album clicks and artist detail pages now open instead of 404ing.', page: 'search' },
|
||||
{ title: 'Amazon Music Enrichment Worker', desc: 'background enrichment worker matches library artists, albums, and tracks to Amazon ASINs and backfills artist thumbnails from album covers. shows up in the enrichment panel with its own orb and rate-limit gauge. pauses automatically during library scans.', page: 'settings' },
|
||||
|
|
|
|||
|
|
@ -8221,4 +8221,257 @@ async function updateLibraryWatchlistButtonStatus(artistId) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Manual Library Match ──────────────────────────────────────────────────────
|
||||
|
||||
let _mlmOverlay = null;
|
||||
let _mlmSelectedSource = null;
|
||||
let _mlmSelectedLibrary = null;
|
||||
let _mlmSourceTimer = null;
|
||||
let _mlmLibraryTimer = null;
|
||||
|
||||
function openManualLibraryMatchTool(prefill) {
|
||||
if (_mlmOverlay) _mlmOverlay.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'mlm-overlay';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) _mlmClose(); };
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="playlist-modal mlm-modal">
|
||||
<div class="playlist-modal-header">
|
||||
<div class="playlist-header-content">
|
||||
<h2>Manual Library Match</h2>
|
||||
<div class="playlist-quick-info">
|
||||
<span class="playlist-owner">Link source tracks to library tracks to stop re-downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="playlist-modal-close" onclick="_mlmClose()">×</span>
|
||||
</div>
|
||||
|
||||
<div class="mlm-modal-body">
|
||||
<div class="mlm-panels">
|
||||
<div class="mlm-panel source">
|
||||
<div class="server-col-header">
|
||||
<span class="server-col-icon">📋</span>
|
||||
Source Track
|
||||
</div>
|
||||
<div class="mlm-panel-search-wrap">
|
||||
<input class="mlm-search" id="mlm-source-search" placeholder="Search wishlist & sync history…" oninput="_mlmSourceDebounce(this.value)">
|
||||
</div>
|
||||
<div class="server-col-scroll" id="mlm-source-results"><p class="mlm-hint">Type to search</p></div>
|
||||
</div>
|
||||
<div class="mlm-panel library">
|
||||
<div class="server-col-header">
|
||||
<span class="server-col-icon">🎵</span>
|
||||
Library Track
|
||||
</div>
|
||||
<div class="mlm-panel-search-wrap">
|
||||
<input class="mlm-search" id="mlm-library-search" placeholder="Search your library…" oninput="_mlmLibraryDebounce(this.value)">
|
||||
</div>
|
||||
<div class="server-col-scroll" id="mlm-library-results"><p class="mlm-hint">Type to search</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mlm-existing-section">
|
||||
<div class="server-col-header mlm-matches-header">
|
||||
Existing Matches
|
||||
<span class="server-col-count" id="mlm-match-count"></span>
|
||||
</div>
|
||||
<div class="mlm-matches-wrap" id="mlm-matches-list"><p class="mlm-hint">Loading…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-modal-footer">
|
||||
<div class="playlist-modal-footer-left">
|
||||
<span id="mlm-status" class="mlm-status-msg"></span>
|
||||
</div>
|
||||
<div class="playlist-modal-footer-right">
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="_mlmClose()">Cancel</button>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-primary" id="mlm-save-btn" disabled onclick="_mlmSaveMatch()">Save Match</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
_mlmOverlay = overlay;
|
||||
_mlmSelectedSource = null;
|
||||
_mlmSelectedLibrary = null;
|
||||
_mlmUpdateSaveBtn();
|
||||
_mlmLoadMatches();
|
||||
|
||||
if (prefill) {
|
||||
const src = document.getElementById('mlm-source-search');
|
||||
if (src) { src.value = prefill; _mlmSourceSearch(prefill); }
|
||||
}
|
||||
}
|
||||
|
||||
function _mlmClose() {
|
||||
if (_mlmOverlay) { _mlmOverlay.remove(); _mlmOverlay = null; }
|
||||
_mlmSelectedSource = null;
|
||||
_mlmSelectedLibrary = null;
|
||||
}
|
||||
|
||||
function _mlmSourceDebounce(q) {
|
||||
clearTimeout(_mlmSourceTimer);
|
||||
_mlmSourceTimer = setTimeout(() => _mlmSourceSearch(q), 300);
|
||||
}
|
||||
function _mlmLibraryDebounce(q) {
|
||||
clearTimeout(_mlmLibraryTimer);
|
||||
_mlmLibraryTimer = setTimeout(() => _mlmLibrarySearch(q), 300);
|
||||
}
|
||||
|
||||
async function _mlmSourceSearch(q) {
|
||||
const el = document.getElementById('mlm-source-results');
|
||||
if (!el) return;
|
||||
if (!q.trim()) { el.innerHTML = '<p class="mlm-hint">Type to search</p>'; return; }
|
||||
el.innerHTML = '<p class="mlm-hint">Searching…</p>';
|
||||
try {
|
||||
const res = await fetch(`/api/manual-library-matches/source-search?q=${encodeURIComponent(q)}&limit=15`);
|
||||
const data = await res.json();
|
||||
_mlmRenderSourceResults(data.tracks || []);
|
||||
} catch (e) { el.innerHTML = '<p class="mlm-hint mlm-error">Search failed</p>'; }
|
||||
}
|
||||
|
||||
async function _mlmLibrarySearch(q) {
|
||||
const el = document.getElementById('mlm-library-results');
|
||||
if (!el) return;
|
||||
if (!q.trim()) { el.innerHTML = '<p class="mlm-hint">Type to search</p>'; return; }
|
||||
el.innerHTML = '<p class="mlm-hint">Searching…</p>';
|
||||
try {
|
||||
const res = await fetch(`/api/manual-library-matches/library-search?q=${encodeURIComponent(q)}&limit=15`);
|
||||
const data = await res.json();
|
||||
_mlmRenderLibraryResults(data.tracks || []);
|
||||
} catch (e) { el.innerHTML = '<p class="mlm-hint mlm-error">Search failed</p>'; }
|
||||
}
|
||||
|
||||
function _mlmEsc(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _mlmRenderSourceResults(tracks) {
|
||||
const el = document.getElementById('mlm-source-results');
|
||||
if (!el) return;
|
||||
if (!tracks.length) { el.innerHTML = '<p class="mlm-hint">No results</p>'; return; }
|
||||
el.innerHTML = tracks.map((t, i) => {
|
||||
const sel = _mlmSelectedSource && _mlmSelectedSource.source_track_id === t.source_track_id ? 'mlm-row-selected' : '';
|
||||
return `<div class="mlm-result-row ${sel}" data-idx="${i}" onclick="_mlmSelectSource(${i})">
|
||||
<div class="mlm-row-title">${_mlmEsc(t.title || '—')}</div>
|
||||
<div class="mlm-row-sub">${_mlmEsc(t.artist || '')}${t.album ? ' · ' + _mlmEsc(t.album) : ''}</div>
|
||||
<div class="mlm-row-ctx">${_mlmEsc(t.context || t.source || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
el._mlmTracks = tracks;
|
||||
}
|
||||
|
||||
function _mlmRenderLibraryResults(tracks) {
|
||||
const el = document.getElementById('mlm-library-results');
|
||||
if (!el) return;
|
||||
if (!tracks.length) { el.innerHTML = '<p class="mlm-hint">No results</p>'; return; }
|
||||
el.innerHTML = tracks.map((t, i) => {
|
||||
const sel = _mlmSelectedLibrary && _mlmSelectedLibrary.id === t.id ? 'mlm-row-selected' : '';
|
||||
const path = t.file_path ? t.file_path.split(/[/\\]/).pop() : '';
|
||||
return `<div class="mlm-result-row ${sel}" data-idx="${i}" onclick="_mlmSelectLibrary(${i})">
|
||||
<div class="mlm-row-title">${_mlmEsc(t.title || '—')}</div>
|
||||
<div class="mlm-row-sub">${_mlmEsc(t.artist_name || '')}${t.album_title ? ' · ' + _mlmEsc(t.album_title) : ''}</div>
|
||||
<div class="mlm-row-ctx">${_mlmEsc(path)}${t.bitrate ? ' · ' + t.bitrate + 'kbps' : ''}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
el._mlmTracks = tracks;
|
||||
}
|
||||
|
||||
function _mlmSelectSource(idx) {
|
||||
const el = document.getElementById('mlm-source-results');
|
||||
if (!el || !el._mlmTracks) return;
|
||||
_mlmSelectedSource = el._mlmTracks[idx];
|
||||
el.querySelectorAll('.mlm-result-row').forEach((r, i) => r.classList.toggle('mlm-row-selected', i === idx));
|
||||
_mlmUpdateSaveBtn();
|
||||
}
|
||||
|
||||
function _mlmSelectLibrary(idx) {
|
||||
const el = document.getElementById('mlm-library-results');
|
||||
if (!el || !el._mlmTracks) return;
|
||||
_mlmSelectedLibrary = el._mlmTracks[idx];
|
||||
el.querySelectorAll('.mlm-result-row').forEach((r, i) => r.classList.toggle('mlm-row-selected', i === idx));
|
||||
_mlmUpdateSaveBtn();
|
||||
}
|
||||
|
||||
function _mlmUpdateSaveBtn() {
|
||||
const btn = document.getElementById('mlm-save-btn');
|
||||
if (btn) btn.disabled = !(_mlmSelectedSource && _mlmSelectedLibrary);
|
||||
}
|
||||
|
||||
async function _mlmSaveMatch() {
|
||||
if (!_mlmSelectedSource || !_mlmSelectedLibrary) return;
|
||||
const status = document.getElementById('mlm-status');
|
||||
if (status) status.textContent = 'Saving…';
|
||||
try {
|
||||
const body = {
|
||||
source: _mlmSelectedSource.source,
|
||||
source_track_id: _mlmSelectedSource.source_track_id,
|
||||
library_track_id: _mlmSelectedLibrary.id,
|
||||
source_title: _mlmSelectedSource.title || '',
|
||||
source_artist: _mlmSelectedSource.artist || '',
|
||||
source_album: _mlmSelectedSource.album || '',
|
||||
source_context_json: '',
|
||||
server_source: '',
|
||||
};
|
||||
const res = await fetch('/api/manual-library-matches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (status) status.textContent = 'Saved!';
|
||||
_mlmSelectedSource = null;
|
||||
_mlmSelectedLibrary = null;
|
||||
_mlmUpdateSaveBtn();
|
||||
await _mlmLoadMatches();
|
||||
setTimeout(() => { if (status) status.textContent = ''; }, 2000);
|
||||
} else {
|
||||
if (status) status.textContent = 'Error: ' + (data.error || 'unknown');
|
||||
}
|
||||
} catch (e) {
|
||||
if (status) status.textContent = 'Network error';
|
||||
}
|
||||
}
|
||||
|
||||
async function _mlmLoadMatches() {
|
||||
const el = document.getElementById('mlm-matches-list');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('/api/manual-library-matches');
|
||||
const data = await res.json();
|
||||
const matches = data.matches || [];
|
||||
const countEl = document.getElementById('mlm-match-count');
|
||||
if (countEl) countEl.textContent = matches.length;
|
||||
if (!matches.length) {
|
||||
el.innerHTML = '<p class="mlm-hint">No matches saved yet</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="mlm-matches-table">
|
||||
<thead><tr><th>Source Track</th><th>Library Track</th><th>Source</th><th></th></tr></thead>
|
||||
<tbody>${matches.map(m => `<tr>
|
||||
<td><div class="mlm-row-title">${_mlmEsc(m.source_title || m.source_track_id)}</div><div class="mlm-row-sub">${_mlmEsc(m.source_artist || '')}</div></td>
|
||||
<td><div class="mlm-row-title">${_mlmEsc(m.library_title || String(m.library_track_id))}</div><div class="mlm-row-sub">${_mlmEsc(m.library_artist || '')}</div></td>
|
||||
<td><span class="mlm-source-badge">${_mlmEsc(m.source)}</span></td>
|
||||
<td><button class="mlm-remove-btn" onclick="_mlmDeleteMatch(${m.id})" title="Remove match">✕</button></td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="mlm-hint mlm-error">Failed to load matches</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function _mlmDeleteMatch(id) {
|
||||
try {
|
||||
await fetch(`/api/manual-library-matches/${id}`, { method: 'DELETE' });
|
||||
await _mlmLoadMatches();
|
||||
} catch (e) {
|
||||
if (typeof showToast === 'function') showToast('Failed to remove match', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =================================
|
||||
|
|
|
|||
|
|
@ -61332,3 +61332,183 @@ body.reduce-effects .dash-card::after {
|
|||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Manual Library Match modal ───────────────────────────────────────────── */
|
||||
|
||||
/* Modal body: override playlist-modal-body padding; flex column so panels + matches fill height */
|
||||
.mlm-modal-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Two-panel search area — mirrors .server-compare-columns visual language */
|
||||
.mlm-panels {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 280px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mlm-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.mlm-panel.source {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Search input lives between the column header and the scrollable results */
|
||||
.mlm-panel-search-wrap {
|
||||
padding: 10px 12px 4px 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.mlm-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.mlm-search:focus {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* Result rows live inside .server-col-scroll (reused directly) */
|
||||
.mlm-result-row {
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.mlm-result-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.mlm-result-row.mlm-row-selected {
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border-left: 3px solid rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
.mlm-row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #ffffff;
|
||||
}
|
||||
.mlm-row-sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.mlm-row-ctx {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.mlm-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-align: center;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.mlm-error { color: #ef4444; }
|
||||
|
||||
/* Status message in footer */
|
||||
.mlm-status-msg {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Existing matches section */
|
||||
.mlm-existing-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Extra class on server-col-header inside the matches section */
|
||||
.mlm-matches-header {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mlm-matches-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mlm-matches-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mlm-matches-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
padding: 8px 14px 6px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.mlm-matches-table td {
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mlm-matches-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mlm-matches-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.mlm-source-badge {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.mlm-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.mlm-remove-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue