Add manual library track matching

This commit is contained in:
Broque Thomas 2026-05-17 20:27:05 -07:00
parent 4d882180bb
commit 42f4aa5eac
12 changed files with 1288 additions and 4 deletions

View file

@ -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")

View 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

View file

@ -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:

View file

@ -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]]:

View file

@ -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})

View 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)

View file

@ -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

View file

@ -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>

View file

@ -2478,6 +2478,7 @@ async function startMissingTracksProcess(playlistId) {
const requestBody = {
tracks: selectedTracks,
force_download_all: forceDownloadAll || isWingIt,
ignore_manual_matches: forceDownloadAll,
wing_it: isWingIt,
};

View file

@ -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' },

View file

@ -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()">&times;</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 &amp; sync history&hellip;" 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&hellip;" 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&hellip;</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&hellip;</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&hellip;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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">&#x2715;</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');
}
}
// =================================

View file

@ -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);
}