commit
71cc6618b3
46 changed files with 3677 additions and 259 deletions
|
|
@ -44,7 +44,8 @@
|
|||
},
|
||||
"metadata_enhancement": {
|
||||
"enabled": true,
|
||||
"embed_album_art": true
|
||||
"embed_album_art": true,
|
||||
"single_to_album": false
|
||||
},
|
||||
"file_organization": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -662,7 +662,12 @@ class ConfigManager:
|
|||
# source whose art is smaller is skipped so the next source is
|
||||
# tried — stops a low-res Cover Art Archive upload from winning.
|
||||
# 0 disables the size gate.
|
||||
"min_art_size": 1000
|
||||
"min_art_size": 1000,
|
||||
# When a track matches a SINGLE release, look up the parent ALBUM
|
||||
# that contains it and tag it as that album, so it groups with its
|
||||
# album-mates and gets the album cover (not the single's). Off by
|
||||
# default — it's an extra per-import metadata lookup.
|
||||
"single_to_album": False
|
||||
},
|
||||
"musicbrainz": {
|
||||
"embed_tags": True
|
||||
|
|
|
|||
|
|
@ -660,8 +660,13 @@ class AutoImportWorker:
|
|||
auto_process = self._config_manager.get('auto_import.auto_process', True)
|
||||
|
||||
try:
|
||||
# Phase 3: Identify
|
||||
identification = self._identify_folder(candidate)
|
||||
# Phase 3: Identify.
|
||||
# Re-identify (#889): if the user designated this exact file's release in
|
||||
# the Re-identify modal, a hint short-circuits the guessing — we match
|
||||
# straight against the chosen album. No hint → byte-identical to before.
|
||||
rematch_hint, identification = self._resolve_rematch_hint(candidate)
|
||||
if identification is None:
|
||||
identification = self._identify_folder(candidate)
|
||||
if not identification:
|
||||
self._record_result(candidate, 'needs_identification', 0.0,
|
||||
error_message='Could not identify album from tags, folder name, or fingerprint')
|
||||
|
|
@ -690,7 +695,10 @@ class AutoImportWorker:
|
|||
high_conf_matches = [m for m in match_result.get('matches', []) if m['confidence'] >= 0.8]
|
||||
has_strong_individual_matches = len(high_conf_matches) > 0
|
||||
|
||||
if (confidence >= threshold or has_strong_individual_matches) and auto_process:
|
||||
# A re-identify is an explicit user choice — let it auto-process like a
|
||||
# strong match (still gated on the global auto_process preference).
|
||||
if (confidence >= threshold or has_strong_individual_matches
|
||||
or rematch_hint is not None) and auto_process:
|
||||
# Phase 5: Auto-process — insert an in-progress row
|
||||
# so the UI sees the import the moment it starts,
|
||||
# then update it with the final status when done.
|
||||
|
|
@ -709,6 +717,13 @@ class AutoImportWorker:
|
|||
confidence = max(confidence, effective_conf)
|
||||
if success:
|
||||
self._bump_stat('auto_processed')
|
||||
# Re-identify (#889): only NOW that the new home exists do we
|
||||
# consume the hint and (if replace was chosen) delete the old
|
||||
# row + file — so a failed import never loses the original. Pass
|
||||
# the landing paths so we never delete a file the re-import landed
|
||||
# at the SAME place (picking the release it's already in).
|
||||
if rematch_hint is not None:
|
||||
self._finalize_rematch_hint(rematch_hint, getattr(candidate, '_reid_final_paths', None))
|
||||
else:
|
||||
self._bump_stat('failed')
|
||||
|
||||
|
|
@ -1003,6 +1018,75 @@ class AutoImportWorker:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Re-identify hints (#889) ──
|
||||
|
||||
def _resolve_rematch_hint(self, candidate: 'FolderCandidate'):
|
||||
"""If this staged file carries a user-designated re-identify hint, return
|
||||
``(hint, identification)`` so matching skips the guessing tiers; otherwise
|
||||
``(None, None)`` and the caller falls back to normal identification.
|
||||
|
||||
Fail-safe: ANY error (no table, DB hiccup) returns ``(None, None)`` so a
|
||||
re-identify problem can never break ordinary auto-import. Only single-file
|
||||
candidates are eligible — a re-identify always stages exactly one track."""
|
||||
try:
|
||||
files = candidate.audio_files or []
|
||||
if len(files) != 1:
|
||||
return None, None
|
||||
from core.imports.rematch_hints import (
|
||||
build_identification_from_hint,
|
||||
find_hint_for_file,
|
||||
quick_file_signature,
|
||||
)
|
||||
file_path = files[0]
|
||||
sig = quick_file_signature(file_path)
|
||||
conn = self.database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
hint = find_hint_for_file(cursor, file_path, sig)
|
||||
finally:
|
||||
conn.close()
|
||||
if hint is None:
|
||||
return None, None
|
||||
logger.info("[Auto-Import] Re-identify hint for %s → %s '%s' (%s)",
|
||||
candidate.name, hint.album_type or 'release',
|
||||
hint.album_name or '?', hint.source)
|
||||
return hint, build_identification_from_hint(hint)
|
||||
except Exception as e:
|
||||
logger.debug("[Auto-Import] rematch-hint lookup skipped: %s", e)
|
||||
return None, None
|
||||
|
||||
def _finalize_rematch_hint(self, hint, new_paths=None) -> None:
|
||||
"""Post-success: delete the replaced library row + file (if the user chose
|
||||
replace) and consume the hint so it's single-use. ``new_paths`` are where the
|
||||
re-import landed — passed through so the same-home guard never deletes a file
|
||||
the import wrote at the old location. Best-effort — a cleanup failure is
|
||||
logged, never raised, since the re-import already succeeded."""
|
||||
try:
|
||||
from core.imports.rematch_hints import consume_hint, delete_replaced_track
|
||||
|
||||
def _resolve_old(stored):
|
||||
# The old row's path is a STORED path (Docker/media-server view) — map
|
||||
# it to a file this process can actually unlink, same as everywhere else.
|
||||
try:
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
return resolve_library_file_path(stored, config_manager=getattr(self, '_config_manager', None))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
conn = self.database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
removed = delete_replaced_track(cursor, hint.replace_track_id,
|
||||
resolve_fn=_resolve_old, new_paths=new_paths)
|
||||
consume_hint(cursor, hint.id)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if removed:
|
||||
logger.info("[Auto-Import] Re-identify replaced old track — removed %s", removed)
|
||||
except Exception as e:
|
||||
logger.warning("[Auto-Import] rematch-hint finalize failed (import still OK): %s", e)
|
||||
|
||||
# ── Identification ──
|
||||
|
||||
def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||
|
|
@ -1431,8 +1515,11 @@ class AutoImportWorker:
|
|||
|
||||
def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]:
|
||||
"""Match staging files to the identified album's tracklist."""
|
||||
# Singles: no album tracklist to match against — the file IS the match
|
||||
if candidate.is_single or identification.get('is_single'):
|
||||
# Singles: no album tracklist to match against — the file IS the match.
|
||||
# force_album_match (set by a re-identify hint) overrides this: even a lone
|
||||
# staged file is matched INTO the chosen album, so it inherits the album's
|
||||
# year / track number / art instead of the bare singles stub (#889).
|
||||
if not identification.get('force_album_match') and (candidate.is_single or identification.get('is_single')):
|
||||
conf = identification.get('identification_confidence', 0.7)
|
||||
track_data = {
|
||||
'name': identification.get('track_name', identification.get('album_name', '')),
|
||||
|
|
@ -1600,6 +1687,7 @@ class AutoImportWorker:
|
|||
|
||||
processed = 0
|
||||
errors = []
|
||||
reid_final_paths = [] # #889: where the pipeline landed each file (same-home guard)
|
||||
all_matches = list(match_result.get('matches', []))
|
||||
|
||||
# Album total duration — sum of every matched track's duration.
|
||||
|
|
@ -1780,6 +1868,11 @@ class AutoImportWorker:
|
|||
|
||||
self._process_callback(context_key, context, file_path)
|
||||
processed += 1
|
||||
# Capture where the pipeline actually landed the file (#889 same-home
|
||||
# guard) — the pipeline writes it back into the mutable context.
|
||||
_landed = context.get('_final_processed_path')
|
||||
if _landed:
|
||||
reid_final_paths.append(_landed)
|
||||
logger.info(f"[Auto-Import] Processed: {track_number}. {track_name}")
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1803,6 +1896,13 @@ class AutoImportWorker:
|
|||
except Exception as e:
|
||||
logger.debug("automation emit failed: %s", e)
|
||||
|
||||
# Stash landing paths on the candidate so _finalize_rematch_hint can avoid
|
||||
# deleting a file the re-import landed at the SAME place (#889).
|
||||
try:
|
||||
candidate._reid_final_paths = reid_final_paths
|
||||
except Exception as e:
|
||||
logger.debug("could not stash reid final paths: %s", e)
|
||||
|
||||
return processed > 0
|
||||
|
||||
# ── Database ──
|
||||
|
|
|
|||
98
core/imports/album_grouping.py
Normal file
98
core/imports/album_grouping.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Canonical album grouping for the SoulSync standalone import.
|
||||
|
||||
SoulSync grouped imported tracks into albums by the album NAME string
|
||||
(``_stable_soulsync_id("artist::album_name")``). That splits one release into
|
||||
several album rows whenever the name string drifts between imports (case,
|
||||
punctuation, ``(Deluxe Edition)`` suffixes, source-A-vs-B spelling), and every
|
||||
downstream tool (Library Re-tag, Cover-Art Filler) then dresses each split row
|
||||
in its own cover — so songs that belong to one album end up with different art
|
||||
(Sokhi).
|
||||
|
||||
This module is the pure, seam-testable heart of "group by canonical id, not
|
||||
name": when an imported track carries a metadata-source RELEASE id, prefer
|
||||
matching an existing album row by that id over the fragile name string, so the
|
||||
SAME release always lands in ONE album row regardless of how its name was typed.
|
||||
|
||||
Scope (deliberate): this unifies differently-named imports of the SAME release.
|
||||
It does NOT merge a track that genuinely matched a SINGLE release (a different
|
||||
release id) into its parent album — that needs single->album resolution upstream
|
||||
and is a separate change. New imports only; existing rows are left untouched.
|
||||
|
||||
Pure SQL-over-a-cursor; no app singletons, so it tests against an in-memory DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("imports.album_grouping")
|
||||
|
||||
# Album source-id columns this grouping may key on. An allowlist (not arbitrary
|
||||
# interpolation) — the column name IS spliced into SQL, so it must be a known,
|
||||
# trusted identifier. Mirrors get_library_source_id_columns()' 'album' values.
|
||||
ALLOWED_ALBUM_SOURCE_COLS = frozenset({
|
||||
"spotify_album_id",
|
||||
"itunes_album_id",
|
||||
"deezer_id",
|
||||
"soul_id",
|
||||
"discogs_id",
|
||||
"musicbrainz_release_id",
|
||||
})
|
||||
|
||||
|
||||
def find_existing_soulsync_album_id(
|
||||
cursor: Any,
|
||||
*,
|
||||
name_key_id: str,
|
||||
artist_id: str,
|
||||
album_name: str,
|
||||
album_source_col: Optional[str] = None,
|
||||
album_source_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the existing ``soulsync`` album row a track should join, or None
|
||||
(caller inserts a new row keyed by ``name_key_id``).
|
||||
|
||||
Match precedence:
|
||||
1. ``name_key_id`` — the exact prior stable-name-hash id (unchanged
|
||||
behaviour: a re-import with the identical name hits its own row).
|
||||
2. ``album_source_col == album_source_id`` — CANONICAL grouping: an
|
||||
existing row already carrying THIS release's source id, so a
|
||||
differently-named import of the same release unifies instead of
|
||||
splitting. Only when the column is allow-listed and the id is non-empty.
|
||||
3. ``(title, artist_id)`` — the legacy name match (kept so nothing that
|
||||
grouped before stops grouping now).
|
||||
"""
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'",
|
||||
(name_key_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
if album_source_col in ALLOWED_ALBUM_SOURCE_COLS and album_source_id:
|
||||
try:
|
||||
cursor.execute(
|
||||
f"SELECT id FROM albums WHERE {album_source_col} = ? "
|
||||
"AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_source_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
except Exception as exc:
|
||||
# That source has no dedicated album column on this DB (e.g. Deezer
|
||||
# doesn't split per-entity id columns) — fall through to the name
|
||||
# match rather than break the import. Mirrors the guarded source-id
|
||||
# UPDATE the caller already does on insert.
|
||||
logger.debug("album source-id lookup skipped (%s): %s", album_source_col, exc)
|
||||
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? "
|
||||
"AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_name, artist_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
|
@ -156,8 +156,11 @@ def score_file_against_track(
|
|||
score = 0.0
|
||||
|
||||
# Title similarity (TITLE_WEIGHT). Falls back to filename stem when
|
||||
# the file has no title tag.
|
||||
# the file has no title tag — strip a leading track-number prefix off that
|
||||
# stem (#890) so "01 - Sun It Rises" scores against "Sun It Rises".
|
||||
title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0]
|
||||
from core.imports.paths import strip_leading_track_number
|
||||
title = strip_leading_track_number(title)
|
||||
track_name = track.get('name', '')
|
||||
score += similarity(title, track_name) * TITLE_WEIGHT
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("imports.context")
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
|
@ -179,7 +183,12 @@ def get_import_clean_title(
|
|||
if not title:
|
||||
track_info = get_import_track_info(context)
|
||||
title = _first_value(track_info, "name", "title", default="")
|
||||
return str(title or default)
|
||||
title = str(title or default)
|
||||
# #890: strip a leading track-number prefix that leaked from a filename stem
|
||||
# (e.g. "01 - Sun It Rises" → "Sun It Rises") so it matches the canonical title.
|
||||
# Conservative — clean source titles ("7 Rings" etc.) pass through untouched.
|
||||
from core.imports.paths import strip_leading_track_number
|
||||
return strip_leading_track_number(title)
|
||||
|
||||
|
||||
def get_import_clean_album(
|
||||
|
|
@ -440,4 +449,86 @@ def detect_album_info_web(context, artist_context=None):
|
|||
force_album=True,
|
||||
)
|
||||
|
||||
return None
|
||||
# Last resort: the track matched a SINGLE with no usable album context —
|
||||
# look up the parent ALBUM that actually contains it (gated, fail-safe).
|
||||
return _resolve_single_to_parent_album(context, artist_context)
|
||||
|
||||
|
||||
def _resolve_single_to_parent_album(context, artist_context):
|
||||
"""A single-matched track -> a promoted album_info for its parent album, or
|
||||
None. GATED by ``metadata_enhancement.single_to_album`` (default OFF — it's a
|
||||
per-import metadata lookup, so it's opt-in). Fail-safe: any miss/error returns
|
||||
None so the track stays exactly as it was matched (never worse than today)."""
|
||||
try:
|
||||
from core.metadata.common import get_config_manager
|
||||
if not get_config_manager().get("metadata_enhancement.single_to_album", False):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
source = get_import_source(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
track_title = (track_info.get("name") or original_search.get("title") or "").strip()
|
||||
artist_name = (extract_artist_name(artist_context)
|
||||
or get_import_clean_artist(context, default="")).strip()
|
||||
if not source or not track_title or not artist_name:
|
||||
return None
|
||||
artist_id = str(get_import_source_ids(context).get("artist_id") or "")
|
||||
|
||||
from core.metadata.album_tracks import (
|
||||
get_artist_albums_for_source,
|
||||
get_artist_album_tracks,
|
||||
)
|
||||
from core.imports.single_to_album import resolve_single_to_album
|
||||
|
||||
def _acc(o, *ks):
|
||||
for k in ks:
|
||||
v = o.get(k) if isinstance(o, dict) else getattr(o, k, None)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
def fetch_candidates():
|
||||
albums = get_artist_albums_for_source(
|
||||
source, artist_id, artist_name=artist_name,
|
||||
album_type="album", limit=20) or []
|
||||
return [{"name": _acc(a, "name", "title"),
|
||||
"album_type": _acc(a, "album_type") or "album",
|
||||
"id": _acc(a, "id", "album_id")} for a in albums]
|
||||
|
||||
def fetch_tracks(alb):
|
||||
payload = get_artist_album_tracks(
|
||||
str(alb.get("id") or ""), artist_name=artist_name,
|
||||
album_name=alb.get("name") or "") or {}
|
||||
return [(_acc(t, "title", "name", "track_name") or "")
|
||||
for t in (payload.get("tracks") or [])]
|
||||
|
||||
album = resolve_single_to_album(
|
||||
track_title,
|
||||
fetch_album_candidates=fetch_candidates,
|
||||
fetch_album_tracks=fetch_tracks)
|
||||
if not album or not album.get("name"):
|
||||
return None
|
||||
logger.info("single->album: re-homed '%s' onto parent album '%s'",
|
||||
track_title, album["name"])
|
||||
promoted = build_import_album_info(
|
||||
context,
|
||||
album_info={
|
||||
"album_name": album["name"],
|
||||
"track_number": track_info.get("track_number"),
|
||||
"disc_number": track_info.get("disc_number"),
|
||||
"album_image_url": "",
|
||||
"confidence": 0.5,
|
||||
},
|
||||
force_album=True,
|
||||
)
|
||||
# build_import_album_info resolves album_name via get_import_clean_album,
|
||||
# which prefers original_search.album (the SINGLE's name); override it
|
||||
# with the resolved parent album so grouping + tags use the album.
|
||||
promoted["album_name"] = album["name"]
|
||||
return promoted
|
||||
except Exception as e:
|
||||
logger.debug("single->album resolution failed: %s", e)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -158,6 +158,33 @@ def clean_track_title(track_title: str, artist_name: str) -> str:
|
|||
return cleaned if cleaned else original
|
||||
|
||||
|
||||
# A leading track-number prefix is EITHER a zero-padded number (01, 04, 099 — no
|
||||
# real song title starts with one), OR a plain number followed by a real separator
|
||||
# AND a space ("3 - ", "12. "). Deliberately NOT a bare "number space word", so it
|
||||
# leaves "7 Rings", "99 Luftballons", "50 Ways to Leave Your Lover" and
|
||||
# "1-800-273-8255" untouched.
|
||||
_TRACK_NUM_PREFIX_RE = re.compile(r"^\s*(?:0\d{1,2}[\s._)\-]*|\d{1,3}\s*[._)\-]\s+)(?=\S)")
|
||||
|
||||
|
||||
def strip_leading_track_number(title: str) -> str:
|
||||
"""Conservatively remove a leading track-number prefix from a track title.
|
||||
|
||||
Fixes #890 — files named ``01 - Sun It Rises.flac`` whose stem leaks into the
|
||||
title as ``01 - Sun It Rises``, which then never matches the canonical
|
||||
``Sun It Rises`` (false "missing"). Only strips an unambiguous track-number
|
||||
prefix; a coincidental leading number that's part of the title is preserved, and
|
||||
it never reduces a title to empty or a bare number."""
|
||||
s = (title or "").strip()
|
||||
if not s:
|
||||
return title or ""
|
||||
stripped = _TRACK_NUM_PREFIX_RE.sub("", s, count=1).strip()
|
||||
# Keep the original if stripping left nothing real — empty, a bare number, or
|
||||
# only punctuation (e.g. "01 - " → "-"). A real title has a letter/digit.
|
||||
if stripped.isdigit() or not re.search(r"[^\W_]", stripped):
|
||||
return s
|
||||
return stripped
|
||||
|
||||
|
||||
|
||||
|
||||
def get_album_type_display(raw_type, track_count) -> str:
|
||||
|
|
|
|||
92
core/imports/rematch_apply.py
Normal file
92
core/imports/rematch_apply.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""#889 Phase 4/5: apply a re-identify — stage the library file + write the hint.
|
||||
|
||||
When the user confirms a release in the Re-identify modal, we:
|
||||
1. COPY (never move) the track's library file into the auto-import staging folder,
|
||||
so the original is untouched until the re-import succeeds,
|
||||
2. fingerprint the staged copy (rename-proof binding), and
|
||||
3. write a single-use hint carrying the chosen release's IDs (+ ``replace_track_id``
|
||||
when 'replace original' is ticked).
|
||||
|
||||
The auto-import worker then picks the staged file up, finds the hint, and re-imports
|
||||
it against the user-chosen release (Phase 2). The pieces here are split so the
|
||||
naming + hint construction are pure/unit-tested and the actual copy is injectable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.imports.paths import sanitize_filename
|
||||
from core.imports.rematch_hints import RematchHint, quick_file_signature
|
||||
|
||||
|
||||
def staged_destination(staging_dir: str, real_path: str, library_track_id: Any) -> str:
|
||||
"""Where the staged copy lands: a single loose file in the staging ROOT (so the
|
||||
worker treats it as a single-track candidate), named to keep the extension and
|
||||
be unique + traceable to the track it re-identifies. The filename is cosmetic —
|
||||
matching is driven by the hint, not the name."""
|
||||
base = os.path.basename(real_path)
|
||||
stem, ext = os.path.splitext(base)
|
||||
safe_stem = sanitize_filename(stem).strip() or "track"
|
||||
name = f"{safe_stem} [reid-{library_track_id}]{ext}"
|
||||
return os.path.join(staging_dir, name)
|
||||
|
||||
|
||||
def stage_file_for_reidentify(
|
||||
real_path: str,
|
||||
staging_dir: str,
|
||||
library_track_id: Any,
|
||||
*,
|
||||
copy_fn: Callable[[str, str], object] = shutil.copy2,
|
||||
signature_fn: Callable[[str], Optional[str]] = quick_file_signature,
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy the library file into staging and fingerprint the copy. Returns
|
||||
``{staged_path, content_hash}``. Raises ``FileNotFoundError`` if the source is
|
||||
gone (caller surfaces a clear error rather than writing a dangling hint)."""
|
||||
if not real_path or not os.path.isfile(real_path):
|
||||
raise FileNotFoundError(real_path or "(empty path)")
|
||||
os.makedirs(staging_dir, exist_ok=True)
|
||||
dest = staged_destination(staging_dir, real_path, library_track_id)
|
||||
copy_fn(real_path, dest)
|
||||
return {"staged_path": dest, "content_hash": signature_fn(dest)}
|
||||
|
||||
|
||||
def build_reidentify_hint(
|
||||
library_track_id: Any,
|
||||
hint_fields: Dict[str, Any],
|
||||
staged_path: str,
|
||||
content_hash: Optional[str],
|
||||
*,
|
||||
replace: bool,
|
||||
) -> RematchHint:
|
||||
"""Pure: assemble the RematchHint from the resolved release fields + staging
|
||||
info. ``replace_track_id`` is the library row to delete on success, but only
|
||||
when 'replace original' was ticked. ``exempt_dedup`` is always True — a
|
||||
re-identify is explicit and must bypass dedup-skip."""
|
||||
return RematchHint(
|
||||
staged_path=staged_path,
|
||||
content_hash=content_hash,
|
||||
source=hint_fields.get("source") or "",
|
||||
isrc=hint_fields.get("isrc"),
|
||||
track_id=hint_fields.get("track_id"),
|
||||
album_id=hint_fields.get("album_id"),
|
||||
artist_id=hint_fields.get("artist_id"),
|
||||
track_title=hint_fields.get("track_title"),
|
||||
album_name=hint_fields.get("album_name"),
|
||||
artist_name=hint_fields.get("artist_name"),
|
||||
album_type=hint_fields.get("album_type"),
|
||||
track_number=hint_fields.get("track_number"),
|
||||
disc_number=hint_fields.get("disc_number"),
|
||||
replace_track_id=(int(library_track_id) if replace and str(library_track_id).isdigit() else
|
||||
(library_track_id if replace else None)),
|
||||
exempt_dedup=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"staged_destination",
|
||||
"stage_file_for_reidentify",
|
||||
"build_reidentify_hint",
|
||||
]
|
||||
334
core/imports/rematch_hints.py
Normal file
334
core/imports/rematch_hints.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""Re-identify hints (#889) — a single-use, user-designated answer to "which
|
||||
release does this already-imported track belong to".
|
||||
|
||||
Flow: the user clicks *Re-identify* on a library track, searches a source, and
|
||||
picks the exact release (single / EP / album) it should live under. We write a
|
||||
**hint** here and stage the file for auto-import. The import flow then reads the
|
||||
hint at the very TOP of matching — before any fuzzy tier — builds the match from
|
||||
these exact IDs, and consumes the row. So the original ambiguity that mis-filed
|
||||
the track (which release?) is gone: the user already answered it.
|
||||
|
||||
Two safety properties live in the hint, not the import code:
|
||||
|
||||
- ``replace_track_id`` — the library row to delete AFTER the re-import lands (so a
|
||||
re-identify *replaces* rather than *duplicates*). Cleanup is deferred to success
|
||||
so a failed import can never lose the file.
|
||||
- ``exempt_dedup`` — always set: a re-identify is an explicit user action and must
|
||||
not be silently dropped by the quality dedup-skip (which would otherwise see the
|
||||
incoming file as a duplicate of the very row we're replacing).
|
||||
|
||||
This module is pure DB mechanics over an injected ``cursor`` (sqlite3-style,
|
||||
``?`` params) — no connection management, no app state — so the create / find /
|
||||
consume seam is unit-tested against an in-memory DB with no live metadata client.
|
||||
The binding is keyed on the staged path, with ``content_hash`` as a rename-proof
|
||||
fallback in case the staging watcher normalizes the filename on ingest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# Columns in INSERT/SELECT order — single source of truth so the dataclass, the
|
||||
# write, and the read can't drift apart.
|
||||
_FIELDS = (
|
||||
"staged_path",
|
||||
"content_hash",
|
||||
"source",
|
||||
"isrc",
|
||||
"track_id",
|
||||
"album_id",
|
||||
"artist_id",
|
||||
"track_title",
|
||||
"album_name",
|
||||
"artist_name",
|
||||
"album_type",
|
||||
"track_number",
|
||||
"disc_number",
|
||||
"replace_track_id",
|
||||
"exempt_dedup",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RematchHint:
|
||||
"""One user-designated re-identify answer. ``id``/``status`` are set by the DB."""
|
||||
staged_path: str
|
||||
source: str
|
||||
content_hash: Optional[str] = None
|
||||
isrc: Optional[str] = None
|
||||
track_id: Optional[str] = None
|
||||
album_id: Optional[str] = None
|
||||
artist_id: Optional[str] = None
|
||||
track_title: Optional[str] = None
|
||||
album_name: Optional[str] = None
|
||||
artist_name: Optional[str] = None
|
||||
album_type: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
replace_track_id: Optional[int] = None
|
||||
exempt_dedup: bool = True
|
||||
id: Optional[int] = None
|
||||
status: str = "pending"
|
||||
|
||||
def _values(self) -> tuple:
|
||||
return (
|
||||
self.staged_path,
|
||||
self.content_hash,
|
||||
self.source,
|
||||
self.isrc,
|
||||
self.track_id,
|
||||
self.album_id,
|
||||
self.artist_id,
|
||||
self.track_title,
|
||||
self.album_name,
|
||||
self.artist_name,
|
||||
self.album_type,
|
||||
self.track_number,
|
||||
self.disc_number,
|
||||
self.replace_track_id,
|
||||
1 if self.exempt_dedup else 0,
|
||||
)
|
||||
|
||||
|
||||
def _row_to_hint(row: Any) -> RematchHint:
|
||||
"""Map a sqlite3.Row (or any mapping/sequence-by-name) to a RematchHint."""
|
||||
def g(key, default=None):
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
return RematchHint(
|
||||
id=g("id"),
|
||||
staged_path=g("staged_path") or "",
|
||||
content_hash=g("content_hash"),
|
||||
source=g("source") or "",
|
||||
isrc=g("isrc"),
|
||||
track_id=g("track_id"),
|
||||
album_id=g("album_id"),
|
||||
artist_id=g("artist_id"),
|
||||
track_title=g("track_title"),
|
||||
album_name=g("album_name"),
|
||||
artist_name=g("artist_name"),
|
||||
album_type=g("album_type"),
|
||||
track_number=g("track_number"),
|
||||
disc_number=g("disc_number"),
|
||||
replace_track_id=g("replace_track_id"),
|
||||
exempt_dedup=bool(g("exempt_dedup", 1)),
|
||||
status=g("status") or "pending",
|
||||
)
|
||||
|
||||
|
||||
def create_hint(cursor: Any, hint: RematchHint) -> int:
|
||||
"""Insert a pending hint; return its new id. Caller owns commit."""
|
||||
placeholders = ", ".join("?" for _ in _FIELDS)
|
||||
cursor.execute(
|
||||
f"INSERT INTO rematch_hints ({', '.join(_FIELDS)}) VALUES ({placeholders})",
|
||||
hint._values(),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
hint.id = new_id
|
||||
return new_id
|
||||
|
||||
|
||||
def find_hint_for_file(
|
||||
cursor: Any,
|
||||
staged_path: str,
|
||||
content_hash: Optional[str] = None,
|
||||
) -> Optional[RematchHint]:
|
||||
"""Return the newest PENDING hint for a staged file, or ``None``.
|
||||
|
||||
Matched by exact ``staged_path`` first; if that misses and a ``content_hash``
|
||||
is given, fall back to it (covers a staging watcher that renamed the file on
|
||||
ingest). Only ``status='pending'`` rows are returned, so a consumed hint is
|
||||
never reused."""
|
||||
if staged_path:
|
||||
cursor.execute(
|
||||
"SELECT * FROM rematch_hints WHERE staged_path = ? AND status = 'pending' "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(staged_path,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is not None:
|
||||
return _row_to_hint(row)
|
||||
# Try by basename too — the watcher may move the file into a different dir.
|
||||
base = os.path.basename(staged_path)
|
||||
if base and base != staged_path:
|
||||
cursor.execute(
|
||||
"SELECT * FROM rematch_hints WHERE staged_path LIKE ? AND status = 'pending' "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
("%/" + base,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is not None:
|
||||
return _row_to_hint(row)
|
||||
|
||||
if content_hash:
|
||||
cursor.execute(
|
||||
"SELECT * FROM rematch_hints WHERE content_hash = ? AND status = 'pending' "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(content_hash,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is not None:
|
||||
return _row_to_hint(row)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def consume_hint(cursor: Any, hint_id: int) -> None:
|
||||
"""Mark a hint consumed (single-use). Caller owns commit."""
|
||||
cursor.execute(
|
||||
"UPDATE rematch_hints SET status = 'consumed', consumed_at = CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
(hint_id,),
|
||||
)
|
||||
|
||||
|
||||
def list_pending_hints(cursor: Any) -> list:
|
||||
"""All pending hints (newest first) — for a 'pending re-identify' view and
|
||||
orphan recovery when a staged file never imports."""
|
||||
cursor.execute("SELECT * FROM rematch_hints WHERE status = 'pending' ORDER BY id DESC")
|
||||
return [_row_to_hint(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
def build_identification_from_hint(hint: RematchHint) -> dict:
|
||||
"""Turn a hint into the ``identification`` dict the auto-import matcher expects,
|
||||
so a re-identify SKIPS the guessing tiers entirely and matches straight against
|
||||
the user-chosen release. Mirrors the shape `_identify_folder` returns (album_id
|
||||
/ source / track_number drive the album fetch + file→track match)."""
|
||||
return {
|
||||
"album_id": hint.album_id or None,
|
||||
"album_name": hint.album_name or hint.track_title or "",
|
||||
"artist_name": hint.artist_name or "",
|
||||
"artist_id": hint.artist_id or "",
|
||||
"track_name": hint.track_title or "",
|
||||
"track_id": hint.track_id or "",
|
||||
"image_url": "",
|
||||
"release_date": "",
|
||||
"track_number": hint.track_number or 1,
|
||||
"total_tracks": 1,
|
||||
"source": hint.source,
|
||||
"method": "rematch_hint",
|
||||
"identification_confidence": 1.0,
|
||||
# is_single reflects the CHOSEN release, but force_album_match makes the
|
||||
# matcher FETCH that release (even for a lone staged file) instead of taking
|
||||
# the singles fast-path — so the re-imported track gets the real album
|
||||
# metadata: year, the correct in-album track number, and the album art.
|
||||
"is_single": (str(hint.album_type or "").lower() == "single"),
|
||||
"force_album_match": True,
|
||||
"album_type": hint.album_type,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(path: Optional[str]) -> str:
|
||||
"""Canonical form of a path for same-file comparison (symlinks + case + sep)."""
|
||||
if not path:
|
||||
return ""
|
||||
try:
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
except OSError:
|
||||
return os.path.normcase(os.path.normpath(path))
|
||||
|
||||
|
||||
def delete_replaced_track(
|
||||
cursor: Any,
|
||||
replace_track_id: Any,
|
||||
*,
|
||||
unlink=os.remove,
|
||||
resolve_fn: Optional[Callable[[str], Optional[str]]] = None,
|
||||
new_paths: Optional[list] = None,
|
||||
) -> Optional[str]:
|
||||
"""Remove the OLD library row a re-identify replaces, and its file.
|
||||
|
||||
Called only AFTER the re-import has landed the track at its new home, so the
|
||||
original is never lost on failure. Safe by construction:
|
||||
|
||||
* **Same-home guard (CRITICAL):** if the re-import landed at the SAME file as the
|
||||
old one (``new_paths`` — the paths the import actually wrote), this is a no-op:
|
||||
we DON'T delete the row or the file, because that file IS the re-imported track.
|
||||
This is what stops "re-identify to the release it's already in" from deleting
|
||||
the file (the import reuses the same row, so deleting it would orphan the file).
|
||||
* the file is unlinked only if it still exists and **no other track row references
|
||||
it** (guards against yanking a file a different row legitimately points to).
|
||||
|
||||
Returns the path it removed, or ``None`` if there was nothing to do. ``unlink`` is
|
||||
injectable for tests. ``resolve_fn`` maps the STORED DB path to the file's actual
|
||||
on-disk location (the stored path may be a Docker/media-server view this process
|
||||
can't read literally — without it we'd delete the row but orphan the file)."""
|
||||
if not replace_track_id:
|
||||
return None
|
||||
cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (replace_track_id,))
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
old_path = (row["file_path"] if not isinstance(row, (tuple, list)) else row[0]) or ""
|
||||
if not old_path:
|
||||
cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,))
|
||||
return None
|
||||
|
||||
# Resolve the old stored path to its real on-disk location up front.
|
||||
real_path = old_path
|
||||
if resolve_fn is not None:
|
||||
try:
|
||||
real_path = resolve_fn(old_path) or old_path
|
||||
except Exception:
|
||||
real_path = old_path
|
||||
|
||||
# Same-home guard: if the re-import wrote to this very file, do NOTHING — the row
|
||||
# is the re-imported track's row and the file is its file. Deleting either would
|
||||
# be data loss (the "picked the same release" bug).
|
||||
if new_paths:
|
||||
landed = {_canonical(p) for p in new_paths if p}
|
||||
if _canonical(real_path) in landed or _canonical(old_path) in landed:
|
||||
return None
|
||||
|
||||
cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,))
|
||||
# Only unlink if no surviving row still points at this file (rows store the
|
||||
# stored path, so compare against the stored path, not the resolved one).
|
||||
cursor.execute("SELECT 1 FROM tracks WHERE file_path = ? LIMIT 1", (old_path,))
|
||||
if cursor.fetchone() is not None:
|
||||
return None
|
||||
try:
|
||||
if os.path.exists(real_path): # real_path resolved above
|
||||
unlink(real_path)
|
||||
return real_path
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def quick_file_signature(path: str, *, chunk: int = 65536) -> Optional[str]:
|
||||
"""A cheap, rename-proof content fingerprint: size + first/last chunk, hashed.
|
||||
|
||||
Audio files are large, so a full hash is wasteful when we only need to re-bind
|
||||
a hint to *this* file after a possible rename. Size + head + tail is plenty to
|
||||
distinguish staged files in practice. Returns ``None`` if the file can't be
|
||||
read (caller falls back to path-only binding)."""
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
h = hashlib.sha256()
|
||||
h.update(str(size).encode())
|
||||
with open(path, "rb") as f:
|
||||
h.update(f.read(chunk))
|
||||
if size > chunk:
|
||||
f.seek(max(0, size - chunk))
|
||||
h.update(f.read(chunk))
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RematchHint",
|
||||
"create_hint",
|
||||
"find_hint_for_file",
|
||||
"consume_hint",
|
||||
"list_pending_hints",
|
||||
"build_identification_from_hint",
|
||||
"delete_replaced_track",
|
||||
"quick_file_signature",
|
||||
]
|
||||
247
core/imports/rematch_search.py
Normal file
247
core/imports/rematch_search.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""#889 Phase 3: search a metadata source for the releases a track appears on.
|
||||
|
||||
The Re-identify modal lets the user search ANY configured source (tabs, defaulting
|
||||
to the active one) and shows the SAME song across its different collections —
|
||||
single / EP / album — so they can pick which release the track should be filed
|
||||
under. Two steps, deliberately split:
|
||||
|
||||
* ``search_release_candidates(source, query)`` — lightweight DISPLAY rows from the
|
||||
normal typed ``search_tracks`` (title, artist, release name, type badge, year,
|
||||
track count, art, ISRC, track_id). No album_id needed to draw the list.
|
||||
* ``resolve_hint_fields(source, track_id)`` — runs ONCE, on the row the user
|
||||
picks: ``get_track_details`` yields the album_id / isrc / track#/disc the hint
|
||||
needs. We don't pay that lookup for every search result, only the chosen one.
|
||||
|
||||
Pure normalization + injected client factory, so the search/normalize/resolve seam
|
||||
is unit-tested with a fake client and no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
def _get(obj: Any, key: str, default=None):
|
||||
"""Read ``key`` from either an object (attr) or a mapping (item)."""
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _year(release_date: Any) -> Optional[str]:
|
||||
s = str(release_date or "").strip()
|
||||
return s[:4] if len(s) >= 4 and s[:4].isdigit() else None
|
||||
|
||||
|
||||
def infer_release_type(album_type: Any, total_tracks: Any) -> str:
|
||||
"""Normalize a source's release type to one of album / ep / single / compilation.
|
||||
|
||||
Sources disagree: Spotify has no 'EP' — EPs come back as ``album_type='single'``
|
||||
with several tracks; MusicBrainz/Deezer label EPs properly. So when a 'single'
|
||||
carries more than a handful of tracks, call it an EP for the badge. The actual
|
||||
filing is unaffected — that's driven by the real album_id, not this label."""
|
||||
t = str(album_type or "").strip().lower()
|
||||
try:
|
||||
n = int(total_tracks) if total_tracks is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if t in ("compilation", "comp"):
|
||||
return "compilation"
|
||||
if t == "ep":
|
||||
return "ep"
|
||||
if t == "album":
|
||||
return "album" # an explicit album stays an album; only 'single' gets promoted to EP
|
||||
if t == "single":
|
||||
# 1–3 tracks → single; 4+ → almost always an EP in practice.
|
||||
return "ep" if n >= 4 else "single"
|
||||
# Unknown type: infer purely from track count.
|
||||
if n >= 7:
|
||||
return "album"
|
||||
if n >= 4:
|
||||
return "ep"
|
||||
if n >= 1:
|
||||
return "single"
|
||||
return t or "album"
|
||||
|
||||
|
||||
def normalize_search_result(result: Any, source: str) -> Optional[Dict[str, Any]]:
|
||||
"""One typed search Track (or raw dict) → a display row, or ``None`` if it has
|
||||
no usable id/title. ``album`` is just a name at search time; album_id is
|
||||
resolved later for the picked row only."""
|
||||
track_id = _get(result, "id") or _get(result, "track_id")
|
||||
title = _get(result, "name") or _get(result, "title")
|
||||
if not track_id or not title:
|
||||
return None
|
||||
|
||||
artists = _get(result, "artists")
|
||||
if isinstance(artists, list):
|
||||
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
|
||||
else:
|
||||
artist_name = str(artists or _get(result, "artist") or "")
|
||||
|
||||
album = _get(result, "album")
|
||||
album_name = album if isinstance(album, str) else (_get(album, "name") or "")
|
||||
raw_type = _get(result, "album_type")
|
||||
total = _get(result, "total_tracks")
|
||||
ext = _get(result, "external_ids") or {}
|
||||
isrc = _get(result, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"track_id": str(track_id),
|
||||
"track_title": str(title),
|
||||
"artist_name": artist_name,
|
||||
"album_name": str(album_name or ""),
|
||||
"album_type": infer_release_type(raw_type, total),
|
||||
"raw_album_type": str(raw_type or ""),
|
||||
"total_tracks": int(total) if isinstance(total, int) else None,
|
||||
"year": _year(_get(result, "release_date")),
|
||||
"image_url": _get(result, "image_url") or "",
|
||||
"isrc": isrc or None,
|
||||
}
|
||||
|
||||
|
||||
def search_release_candidates(
|
||||
source: str,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 25,
|
||||
client_factory: Optional[Callable[[str], Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search ``source`` for tracks matching ``query`` → normalized display rows.
|
||||
|
||||
Returns ``[]`` (never raises) when the source has no client or errors — the UI
|
||||
just shows an empty tab. Rows keep duplicate releases; the UI groups them."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
factory = client_factory or _default_client_factory
|
||||
try:
|
||||
client = factory(source)
|
||||
except Exception:
|
||||
client = None
|
||||
if client is None or not hasattr(client, "search_tracks"):
|
||||
return []
|
||||
try:
|
||||
results = client.search_tracks(query, limit=limit)
|
||||
except TypeError:
|
||||
results = client.search_tracks(query) # clients with no limit kwarg
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for r in results or []:
|
||||
row = normalize_search_result(r, source)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_hint_fields(
|
||||
source: str,
|
||||
track_id: str,
|
||||
*,
|
||||
client_factory: Optional[Callable[[str], Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve the picked track to the fields a hint needs (album_id critically,
|
||||
plus isrc / track# / disc# / album name+type). One lookup for one chosen row.
|
||||
Returns ``None`` if it can't be resolved (caller surfaces an error)."""
|
||||
factory = client_factory or _default_client_factory
|
||||
try:
|
||||
client = factory(source)
|
||||
except Exception:
|
||||
client = None
|
||||
if client is None or not hasattr(client, "get_track_details"):
|
||||
return None
|
||||
try:
|
||||
details = client.get_track_details(track_id)
|
||||
except Exception:
|
||||
return None
|
||||
if not details:
|
||||
return None
|
||||
|
||||
album = _get(details, "album") or {}
|
||||
album_id = _get(album, "id") if not isinstance(album, str) else None
|
||||
album_name = _get(album, "name") if not isinstance(album, str) else album
|
||||
album_type = _get(album, "album_type") or _get(details, "album_type")
|
||||
total = _get(album, "total_tracks") or _get(details, "total_tracks")
|
||||
|
||||
artists = _get(details, "artists") or []
|
||||
artist_id = None
|
||||
artist_name = ""
|
||||
if isinstance(artists, list) and artists:
|
||||
artist_id = _get(artists[0], "id")
|
||||
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
|
||||
|
||||
ext = _get(details, "external_ids") or {}
|
||||
isrc = _get(details, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
|
||||
|
||||
if not album_id:
|
||||
return None # without an album_id the import can't fetch the tracklist
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"track_id": str(track_id),
|
||||
"album_id": str(album_id),
|
||||
"artist_id": str(artist_id) if artist_id else None,
|
||||
"track_title": _get(details, "name") or _get(details, "title") or "",
|
||||
"album_name": str(album_name or ""),
|
||||
"artist_name": artist_name,
|
||||
"album_type": infer_release_type(album_type, total),
|
||||
"track_number": _get(details, "track_number"),
|
||||
"disc_number": _get(details, "disc_number") or 1,
|
||||
"isrc": isrc or None,
|
||||
}
|
||||
|
||||
|
||||
def _default_client_factory(source: str):
|
||||
from core.metadata.registry import get_client_for_source
|
||||
return get_client_for_source(source)
|
||||
|
||||
|
||||
def available_sources() -> List[Dict[str, Any]]:
|
||||
"""The source tabs for the modal: every metadata source with a live client,
|
||||
the primary one flagged ``active`` so the UI selects it by default."""
|
||||
from core.metadata.registry import (
|
||||
METADATA_SOURCE_PRIORITY,
|
||||
get_client_for_source,
|
||||
get_primary_source,
|
||||
)
|
||||
|
||||
try:
|
||||
primary = get_primary_source()
|
||||
except Exception:
|
||||
primary = None
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for src in METADATA_SOURCE_PRIORITY:
|
||||
if src in seen:
|
||||
continue
|
||||
seen.add(src)
|
||||
try:
|
||||
client = get_client_for_source(src)
|
||||
except Exception:
|
||||
client = None
|
||||
if client is None or not hasattr(client, "search_tracks"):
|
||||
continue
|
||||
out.append({
|
||||
"source": src,
|
||||
"label": src.replace("_", " ").title(),
|
||||
"active": src == primary,
|
||||
})
|
||||
# Guarantee the primary is selectable + first even if priority ordering missed it.
|
||||
if primary and not any(s["active"] for s in out):
|
||||
out.insert(0, {"source": primary, "label": primary.replace("_", " ").title(), "active": True})
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"infer_release_type",
|
||||
"normalize_search_result",
|
||||
"search_release_candidates",
|
||||
"resolve_hint_fields",
|
||||
"available_sources",
|
||||
]
|
||||
|
|
@ -565,19 +565,22 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
# ── Album row: same insert-or-fill-empty-fields shape ──
|
||||
album_source_col = source_columns.get("album")
|
||||
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'",
|
||||
(album_id,),
|
||||
# Group by CANONICAL release id when we have one (not just the name
|
||||
# string), so differently-named imports of the SAME release land in
|
||||
# one album row instead of splitting — which left the repair jobs
|
||||
# dressing each split row in its own cover art (Sokhi). Precedence:
|
||||
# name-hash id -> source release id -> (title, artist). Falls back to
|
||||
# the legacy name match, so nothing that grouped before stops now.
|
||||
from core.imports.album_grouping import find_existing_soulsync_album_id
|
||||
existing_album_id = find_existing_soulsync_album_id(
|
||||
cursor, name_key_id=album_id, artist_id=artist_id, album_name=album_name,
|
||||
album_source_col=album_source_col, album_source_id=album_source_id,
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_name, artist_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
album_id = row[0]
|
||||
if existing_album_id is not None:
|
||||
album_id = existing_album_id
|
||||
row = (album_id,)
|
||||
else:
|
||||
row = None
|
||||
|
||||
if row:
|
||||
_fill_empty_columns(
|
||||
|
|
|
|||
124
core/imports/single_to_album.py
Normal file
124
core/imports/single_to_album.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Single -> parent-album resolution.
|
||||
|
||||
When a track is matched to a SINGLE release (album_type 'single', the single's
|
||||
name usually equal to the track title), it carries the single's name + the
|
||||
single's source album id. The canonical grouping in
|
||||
[core/imports/album_grouping.py] then files it under a different album row than
|
||||
its album-mates, and the album-grouped repair jobs dress that row in the
|
||||
single's art — songs of one album end up with different covers (Sokhi).
|
||||
|
||||
This module re-homes such a track onto the ALBUM it actually belongs to, so it
|
||||
carries the album's name/id and groups with the rest of the album.
|
||||
|
||||
Design: the SELECTION is a pure, conservative function (no I/O), and the lookup
|
||||
loop takes INJECTED fetchers, so both are unit-testable without a live metadata
|
||||
client. CONSERVATIVE by intent — it only re-homes a track when a real
|
||||
``album``-type release's tracklist *contains that exact track*. It never
|
||||
promotes a genuine standalone single and never guesses, because a wrong
|
||||
promotion would mis-home a real single onto an album (the inverse bug).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
_WS = re.compile(r"\s+")
|
||||
# Trailing version qualifiers that differ between a single and its album cut but
|
||||
# don't change track identity (kept conservative — only the obvious ones).
|
||||
_QUALIFIER = re.compile(
|
||||
r"\s*[\(\[]\s*(album version|single version|radio edit|remaster(ed)?( \d{4})?)\s*[\)\]]\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _norm(s: Any) -> str:
|
||||
"""Lowercase, strip a trailing '(Album Version)'-style qualifier, collapse
|
||||
whitespace — so 'Song' matches 'Song (Album Version)'."""
|
||||
t = str(s or "").strip().lower()
|
||||
t = _QUALIFIER.sub("", t)
|
||||
return _WS.sub(" ", t).strip()
|
||||
|
||||
|
||||
def _get(obj: Any, *keys: str, default=None):
|
||||
for k in keys:
|
||||
if isinstance(obj, dict):
|
||||
if obj.get(k) is not None:
|
||||
return obj.get(k)
|
||||
else:
|
||||
v = getattr(obj, k, None)
|
||||
if v is not None:
|
||||
return v
|
||||
return default
|
||||
|
||||
|
||||
def select_parent_album(track_title: str, candidate_albums: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Pick the parent ALBUM for ``track_title`` from normalized candidates, or
|
||||
None. Each candidate is ``{name, album_type, tracks: [title, ...], ...}``.
|
||||
|
||||
Conservative rules — a candidate qualifies ONLY when:
|
||||
* it is an ``album`` release (never single / ep / compilation), and
|
||||
* its name is not just the track title (that IS the single), and
|
||||
* its tracklist contains the track by exact normalized title.
|
||||
Returns the FIRST qualifying candidate (caller passes them in priority
|
||||
order, so the result is deterministic).
|
||||
"""
|
||||
tgt = _norm(track_title)
|
||||
if not tgt:
|
||||
return None
|
||||
for alb in candidate_albums or []:
|
||||
if str(_get(alb, "album_type", default="album")).lower() != "album":
|
||||
continue
|
||||
if _norm(_get(alb, "name", "title", default="")) == tgt:
|
||||
continue
|
||||
tracks = _get(alb, "tracks", default=[]) or []
|
||||
if any(_norm(t) == tgt for t in tracks):
|
||||
return alb
|
||||
return None
|
||||
|
||||
|
||||
def resolve_single_to_album(
|
||||
track_title: str,
|
||||
*,
|
||||
fetch_album_candidates: Callable[[], List[Dict[str, Any]]],
|
||||
fetch_album_tracks: Callable[[Dict[str, Any]], List[str]],
|
||||
max_albums: int = 8,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find the parent album for a single-matched track. I/O is INJECTED so this
|
||||
is testable without a live client:
|
||||
* ``fetch_album_candidates()`` -> the artist's ALBUM-type releases (dicts
|
||||
with name/album_type/id/source), in priority order.
|
||||
* ``fetch_album_tracks(album)`` -> that album's track titles.
|
||||
Probes at most ``max_albums`` albums, lazily (stops at the first that
|
||||
contains the track). Fail-safe: any error / no confident match -> None
|
||||
(the track stays as it was matched). Returns the normalized winning album
|
||||
``{name, album_type, album_id, source, tracks}`` or None.
|
||||
"""
|
||||
if not _norm(track_title):
|
||||
return None
|
||||
try:
|
||||
albums = fetch_album_candidates() or []
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
probed = 0
|
||||
for alb in albums:
|
||||
if str(_get(alb, "album_type", default="album")).lower() != "album":
|
||||
continue
|
||||
if probed >= max_albums:
|
||||
break
|
||||
probed += 1
|
||||
try:
|
||||
tracks = fetch_album_tracks(alb) or []
|
||||
except Exception:
|
||||
continue
|
||||
normalized = {
|
||||
"name": _get(alb, "name", "title", default=""),
|
||||
"album_type": "album",
|
||||
"album_id": _get(alb, "id", "album_id"),
|
||||
"source": _get(alb, "source"),
|
||||
"tracks": list(tracks),
|
||||
}
|
||||
if select_parent_album(track_title, [normalized]):
|
||||
return normalized
|
||||
return None
|
||||
53
core/library/residual_files.py
Normal file
53
core/library/residual_files.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""What counts as a *residual* file — a leftover with no value once the audio it
|
||||
accompanied is gone: OS junk, cover/scan images, and lyric/metadata sidecars.
|
||||
|
||||
Single source of truth shared by:
|
||||
* the **Reorganize** cleanup, which strips these from a source dir after every
|
||||
track has moved out (so the empty-dir pruner can take the folder), and
|
||||
* the **Empty Folder Cleaner** job, which can optionally treat a folder holding
|
||||
ONLY residual files as removable (#891).
|
||||
|
||||
Defining "disposable" in one place keeps the two features agreeing on what a "dead
|
||||
folder" is. Pure predicates — no filesystem access — so they're unit-tested in
|
||||
isolation. The whitelist is deliberately conservative: anything NOT recognized here
|
||||
(a booklet ``.pdf``, a video, a ``.txt`` note) is treated as real content and kept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# OS / tooling junk.
|
||||
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
|
||||
# Cover art + booklet scans.
|
||||
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
|
||||
# Lyric / metadata / playlist sidecars that are worthless without their audio.
|
||||
SIDECAR_EXTS = {'.lrc', '.nfo', '.cue', '.m3u', '.m3u8'}
|
||||
|
||||
|
||||
def _ext(name: str) -> str:
|
||||
return os.path.splitext(name or '')[1].lower()
|
||||
|
||||
|
||||
def is_junk(name: str) -> bool:
|
||||
return (name or '').lower() in JUNK_FILES
|
||||
|
||||
|
||||
def is_image(name: str) -> bool:
|
||||
return _ext(name) in IMAGE_EXTS
|
||||
|
||||
|
||||
def is_sidecar(name: str) -> bool:
|
||||
return _ext(name) in SIDECAR_EXTS
|
||||
|
||||
|
||||
def is_disposable(name: str) -> bool:
|
||||
"""True if this file is junk, a cover/scan image, or a lyric/metadata sidecar —
|
||||
i.e. safe to delete from a folder that has no audio left."""
|
||||
return is_junk(name) or is_image(name) or is_sidecar(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'JUNK_FILES', 'IMAGE_EXTS', 'SIDECAR_EXTS',
|
||||
'is_junk', 'is_image', 'is_sidecar', 'is_disposable',
|
||||
]
|
||||
|
|
@ -1847,14 +1847,8 @@ def _prune_empty_album_dirs(artist_dir: str) -> None:
|
|||
# Sidecars that live alongside ONE audio file (same filename stem).
|
||||
_TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json')
|
||||
|
||||
# Sidecars that live at the ALBUM level (one per directory).
|
||||
_ALBUM_SIDECARS = (
|
||||
'cover.jpg', 'cover.jpeg', 'cover.png',
|
||||
'folder.jpg', 'folder.png',
|
||||
'front.jpg', 'front.png',
|
||||
'album.jpg', 'album.png',
|
||||
'artwork.jpg', 'artwork.png',
|
||||
)
|
||||
# Album-level leftovers (cover images, .lrc, etc.) are classified by the shared
|
||||
# `core.library.residual_files.is_disposable` predicate — see `_delete_album_sidecars`.
|
||||
|
||||
# Audio extensions used to decide whether a source directory still has
|
||||
# tracks the user might care about (i.e. a per-track failure left audio
|
||||
|
|
@ -1954,16 +1948,30 @@ def _delete_track_sidecars(audio_path: str) -> None:
|
|||
|
||||
|
||||
def _delete_album_sidecars(src_dir: str) -> None:
|
||||
"""Delete album-level sidecars (cover.jpg, folder.jpg, etc.) from
|
||||
`src_dir`. Used during end-of-run cleanup when no audio files remain
|
||||
in the directory. Best-effort — individual failures are debug-logged."""
|
||||
for name in _ALBUM_SIDECARS:
|
||||
sidecar = os.path.join(src_dir, name)
|
||||
if os.path.isfile(sidecar):
|
||||
"""Delete album-level *residual* files from ``src_dir`` — any cover/scan image,
|
||||
lyric/metadata sidecar (.lrc/.nfo/.cue/.m3u), or OS junk. Called during
|
||||
end-of-run cleanup ONLY when no audio remains in the directory, so everything
|
||||
here is leftover from the album that just moved (#891 — previously this only
|
||||
removed a fixed list of cover names, so ``back.jpg`` / ``disc.jpg`` / ``.webp``
|
||||
survived and kept the folder un-prunable).
|
||||
|
||||
Uses the shared ``is_disposable`` predicate so it agrees with the Empty Folder
|
||||
Cleaner on what's a dead leftover; anything unrecognized (a booklet ``.pdf``, a
|
||||
video) is deliberately LEFT. Best-effort — individual failures are debug-logged."""
|
||||
from core.library.residual_files import is_disposable
|
||||
try:
|
||||
entries = os.listdir(src_dir)
|
||||
except OSError:
|
||||
return
|
||||
for name in entries:
|
||||
if not is_disposable(name):
|
||||
continue
|
||||
full = os.path.join(src_dir, name)
|
||||
if os.path.isfile(full):
|
||||
try:
|
||||
os.remove(sidecar)
|
||||
os.remove(full)
|
||||
except OSError as e:
|
||||
logger.debug(f"[Reorganize] Couldn't remove album sidecar {sidecar}: {e}")
|
||||
logger.debug(f"[Reorganize] Couldn't remove residual file {full}: {e}")
|
||||
|
||||
|
||||
def _has_remaining_audio(directory: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -242,18 +242,25 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
|
|||
logger.warning("[Metadata Debug] Artist: %s", artist.get("name", "MISSING") if artist else "None")
|
||||
logger.warning("[Metadata Debug] Album info: %s", album_info.get("album_name", "MISSING") if album_info else "None")
|
||||
logger.error("[Metadata Debug] Traceback:\n%s", traceback.format_exc())
|
||||
# We cleared the file's art early; if the rewrite then crashed
|
||||
# before re-embedding, the on-disk file (already saved cleared at
|
||||
# the start) would be left art-less. Best-effort: put the original
|
||||
# art back and persist it so a mid-enrichment crash never destroys
|
||||
# the cover (#764). Guarded so a failure here can't mask the
|
||||
# original error.
|
||||
# The file was saved with tags CLEARED up front (so stale tags never
|
||||
# linger), then the failure-prone enrichment ran. By the time most
|
||||
# failures hit — the external source-id embed / cover-art fetch — the
|
||||
# core tags (album/artist/title/track from the matched context) are
|
||||
# already on the in-memory object but NOT yet on disk; the on-disk
|
||||
# file is still the cleared one. Persist the in-memory tags now (and
|
||||
# restore the original art too, #764) so a mid-enrichment crash leaves
|
||||
# a correctly-tagged file instead of an UNTAGGED one (Sokhi: tracks
|
||||
# landing in Rockbox's 'untagged' bucket after a 'processing failed').
|
||||
#
|
||||
# Previously this save was gated on there being original art to
|
||||
# restore, so an art-less file lost its tags entirely on any crash.
|
||||
# Guarded so a failure here can't mask the original error.
|
||||
try:
|
||||
if audio_file is not None and art_snapshot and restore_embedded_art(
|
||||
audio_file, symbols, art_snapshot
|
||||
):
|
||||
if audio_file is not None:
|
||||
if art_snapshot:
|
||||
restore_embedded_art(audio_file, symbols, art_snapshot)
|
||||
save_audio_file(audio_file, symbols)
|
||||
logger.info("Restored original cover art after enrichment error.")
|
||||
logger.info("Persisted core tags (and restored art) after enrichment error.")
|
||||
except Exception as restore_exc:
|
||||
logger.debug("Art restore after error failed: %s", restore_exc)
|
||||
logger.debug("Tag/art persist after error failed: %s", restore_exc)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -22,37 +22,36 @@ from __future__ import annotations
|
|||
import os
|
||||
from typing import Iterable, List
|
||||
|
||||
from core.library.residual_files import JUNK_FILES, is_disposable, is_junk # noqa: F401 — JUNK_FILES/is_junk re-exported
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("repair_jobs.empty_folder_cleaner")
|
||||
|
||||
# Files that don't count as real content — safe to delete along with the folder.
|
||||
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
|
||||
|
||||
|
||||
def is_junk(name: str) -> bool:
|
||||
return (name or '').lower() in JUNK_FILES
|
||||
|
||||
|
||||
def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str],
|
||||
*, ignore_junk: bool = True) -> bool:
|
||||
*, ignore_junk: bool = True, ignore_disposable: bool = False) -> bool:
|
||||
"""Pure: is a directory safe to remove?
|
||||
|
||||
Removable iff it has **no surviving subdirectories** and **no real files** —
|
||||
where "no real files" means literally empty, or (when ``ignore_junk``) only
|
||||
OS-junk files. ``surviving_subdirs`` is the list of child dirs that are NOT
|
||||
themselves being removed (i.e. still hold content).
|
||||
OS-junk files, or (when ``ignore_disposable`` — #891) only *residual* files:
|
||||
junk + cover/scan images + lyric/metadata sidecars. ``ignore_disposable`` is the
|
||||
broader opt-in that clears the cover.jpg-only folders a reorganize leaves behind.
|
||||
``surviving_subdirs`` is the list of child dirs that are NOT themselves being
|
||||
removed (i.e. still hold content).
|
||||
"""
|
||||
if list(surviving_subdirs):
|
||||
return False
|
||||
files = list(files)
|
||||
if not files:
|
||||
return True
|
||||
if not ignore_junk:
|
||||
return False
|
||||
return all(is_junk(f) for f in files)
|
||||
if ignore_disposable:
|
||||
return all(is_disposable(f) for f in files)
|
||||
if ignore_junk:
|
||||
return all(is_junk(f) for f in files)
|
||||
return False
|
||||
|
||||
|
||||
@register_job
|
||||
|
|
@ -65,15 +64,18 @@ class EmptyFolderCleanerJob(RepairJob):
|
|||
'relocations, and deletions — empty artist/album folders, or folders that '
|
||||
'hold only OS junk like .DS_Store / Thumbs.db.\n\n'
|
||||
'A finding is created for each. Applying one deletes the folder (after '
|
||||
're-checking it is still empty). Folders that contain any real file — a '
|
||||
'cover image, an audio track, anything — are never touched, the library '
|
||||
'root is never removed, and it cascades: a folder left empty once its '
|
||||
'empty children are removed is cleaned too.'
|
||||
're-checking it is still empty). Folders that contain any real file — an '
|
||||
'audio track, a booklet, anything not recognized as a leftover — are never '
|
||||
'touched, the library root is never removed, and it cascades: a folder left '
|
||||
'empty once its empty children are removed is cleaned too.\n\n'
|
||||
'Enable "Also remove image/sidecar-only folders" to clear the cover.jpg / '
|
||||
'.lrc leftovers a Library Reorganize leaves behind — folders whose only '
|
||||
'remaining files are cover/scan images or lyric/metadata sidecars.'
|
||||
)
|
||||
icon = 'repair-icon-folder'
|
||||
default_enabled = False
|
||||
default_interval_hours = 168 # weekly — empties accrue slowly
|
||||
default_settings = {'remove_junk_files': True}
|
||||
default_settings = {'remove_junk_files': True, 'remove_residual_files': False}
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
|
|
@ -86,10 +88,15 @@ class EmptyFolderCleanerJob(RepairJob):
|
|||
root = os.path.realpath(root)
|
||||
|
||||
ignore_junk = True
|
||||
ignore_disposable = False
|
||||
try:
|
||||
if context.config_manager:
|
||||
ignore_junk = bool(context.config_manager.get(
|
||||
'repair.jobs.empty_folder_cleaner.remove_junk_files', True))
|
||||
# #891: also clear folders left holding only images / .lrc / sidecars
|
||||
# (what a reorganize leaves behind). Opt-in — default off.
|
||||
ignore_disposable = bool(context.config_manager.get(
|
||||
'repair.jobs.empty_folder_cleaner.remove_residual_files', False))
|
||||
except Exception: # noqa: S110 — setting read is best-effort; defaults to True
|
||||
pass
|
||||
|
||||
|
|
@ -108,17 +115,29 @@ class EmptyFolderCleanerJob(RepairJob):
|
|||
|
||||
surviving = [d for d in dirnames
|
||||
if os.path.join(dirpath, d) not in flagged]
|
||||
if not dir_is_removable(filenames, surviving, ignore_junk=ignore_junk):
|
||||
if not dir_is_removable(filenames, surviving,
|
||||
ignore_junk=ignore_junk, ignore_disposable=ignore_disposable):
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
flagged.add(dirpath)
|
||||
junk = [f for f in filenames if is_junk(f)]
|
||||
# Files that will be swept along with the folder (junk always; images/
|
||||
# sidecars only when the residual option is on).
|
||||
purgeable = [f for f in filenames
|
||||
if is_junk(f) or (ignore_disposable and is_disposable(f))]
|
||||
residual = [f for f in purgeable if not is_junk(f)]
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
if context.report_progress:
|
||||
context.report_progress(log_line=f'Empty folder: {rel}', log_type='info')
|
||||
if context.create_finding:
|
||||
try:
|
||||
if residual:
|
||||
extra = f' (only {len(residual)} leftover image/sidecar file(s))'
|
||||
elif junk:
|
||||
extra = f' (only {len(junk)} junk file(s))'
|
||||
else:
|
||||
extra = ''
|
||||
inserted = context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='empty_folder',
|
||||
|
|
@ -127,13 +146,13 @@ class EmptyFolderCleanerJob(RepairJob):
|
|||
entity_id=dirpath,
|
||||
file_path=dirpath,
|
||||
title=f'Empty folder: {os.path.basename(dirpath) or rel}',
|
||||
description=(f'"{rel}" holds no music'
|
||||
+ (f' (only {len(junk)} junk file(s))' if junk else '')
|
||||
+ ' — safe to remove.'),
|
||||
description=(f'"{rel}" holds no music' + extra + ' — safe to remove.'),
|
||||
details={
|
||||
'folder_path': dirpath,
|
||||
'junk_files': junk,
|
||||
'purgeable_files': purgeable,
|
||||
'remove_junk': ignore_junk,
|
||||
'remove_disposable': ignore_disposable,
|
||||
})
|
||||
if inserted:
|
||||
result.findings_created += 1
|
||||
|
|
@ -158,12 +177,17 @@ class EmptyFolderCleanerJob(RepairJob):
|
|||
|
||||
|
||||
def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool,
|
||||
root: str, listdir, isdir, islink, remove_file, rmdir) -> dict:
|
||||
root: str, listdir, isdir, islink, remove_file, rmdir,
|
||||
remove_disposable: bool = False) -> dict:
|
||||
"""Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still
|
||||
removable, then deletes any junk + the folder. Effects injected for testing.
|
||||
removable, then deletes any purgeable leftovers + the folder. Effects injected
|
||||
for testing.
|
||||
|
||||
Returns ``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a
|
||||
symlink, a non-dir, or a folder that gained real content since the scan.
|
||||
With ``remove_disposable`` (#891) the re-check also treats cover images and
|
||||
lyric/metadata sidecars as removable, and sweeps them before rmdir. Returns
|
||||
``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a symlink, a
|
||||
non-dir, or a folder that gained REAL content (audio, a booklet, anything not
|
||||
recognized as residual) since the scan.
|
||||
"""
|
||||
if not folder_path or not isdir(folder_path):
|
||||
return {'removed': False, 'error': 'Folder no longer exists'}
|
||||
|
|
@ -172,19 +196,21 @@ def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk:
|
|||
if root and os.path.realpath(folder_path) == os.path.realpath(root):
|
||||
return {'removed': False, 'error': 'Refusing to remove the library root'}
|
||||
|
||||
# Re-check at apply time: only junk/empty now? (Anything else = leave it.)
|
||||
def _purgeable(e: str) -> bool:
|
||||
return (remove_junk and is_junk(e)) or (remove_disposable and is_disposable(e))
|
||||
|
||||
# Re-check at apply time: only purgeable leftovers now? (Anything else = leave it.)
|
||||
entries = list(listdir(folder_path))
|
||||
real_entries = [e for e in entries if not (remove_junk and is_junk(e))]
|
||||
real_entries = [e for e in entries if not _purgeable(e)]
|
||||
if real_entries:
|
||||
return {'removed': False, 'error': 'Folder is no longer empty — left untouched'}
|
||||
|
||||
if remove_junk:
|
||||
for j in entries:
|
||||
if is_junk(j):
|
||||
try:
|
||||
remove_file(os.path.join(folder_path, j))
|
||||
except Exception: # noqa: S110 — junk best-effort; rmdir below fails loudly if blocked
|
||||
pass
|
||||
for e in entries:
|
||||
if _purgeable(e):
|
||||
try:
|
||||
remove_file(os.path.join(folder_path, e))
|
||||
except Exception: # noqa: S110 — leftover best-effort; rmdir below fails loudly if blocked
|
||||
pass
|
||||
try:
|
||||
rmdir(folder_path)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import threading
|
|||
import time
|
||||
import uuid
|
||||
from difflib import SequenceMatcher
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from core.metadata_service import (
|
||||
|
|
@ -585,13 +585,29 @@ class RepairWorker:
|
|||
|
||||
logger.info("Repair worker thread finished")
|
||||
|
||||
@staticmethod
|
||||
def _hours_since(finished_at_iso: str, now_utc: datetime) -> float:
|
||||
"""Hours between a stored ``finished_at`` and ``now_utc``, both in UTC.
|
||||
|
||||
``finished_at`` is written by SQLite's CURRENT_TIMESTAMP, which is ALWAYS
|
||||
UTC (and naive). #885: the scheduler compared it against ``datetime.now()``
|
||||
(naive LOCAL), so the local↔UTC offset leaked into the elapsed time. For a
|
||||
zone AHEAD of UTC (Australia/Sydney = +11) every job looked ~11h stale and
|
||||
fired every poll; behind UTC (the Americas) it just waited too long. Parse
|
||||
the naive timestamp AS UTC and subtract a UTC ``now`` so scheduling is
|
||||
timezone-independent."""
|
||||
dt = datetime.fromisoformat(finished_at_iso)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return (now_utc - dt).total_seconds() / 3600
|
||||
|
||||
def _pick_next_job(self) -> Optional[str]:
|
||||
"""Pick the next job to run based on staleness priority.
|
||||
|
||||
Returns job_id of the stalest job whose interval has elapsed,
|
||||
or None if nothing is due.
|
||||
"""
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
best_job_id = None
|
||||
best_staleness = -1
|
||||
|
||||
|
|
@ -613,8 +629,7 @@ class RepairWorker:
|
|||
continue
|
||||
|
||||
try:
|
||||
last_finished = datetime.fromisoformat(last_run['finished_at'])
|
||||
elapsed_hours = (now - last_finished).total_seconds() / 3600
|
||||
elapsed_hours = self._hours_since(last_run['finished_at'], now)
|
||||
|
||||
if elapsed_hours < interval_hours:
|
||||
continue # Not due yet
|
||||
|
|
@ -1552,6 +1567,7 @@ class RepairWorker:
|
|||
resolved,
|
||||
junk_files=details.get('junk_files') or [],
|
||||
remove_junk=bool(details.get('remove_junk', True)),
|
||||
remove_disposable=bool(details.get('remove_disposable', False)),
|
||||
root=self.transfer_folder,
|
||||
listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
|
||||
remove_file=os.remove, rmdir=os.rmdir,
|
||||
|
|
|
|||
|
|
@ -426,8 +426,11 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
if f'.{file_ext}' not in audio_extensions:
|
||||
continue
|
||||
|
||||
quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
|
||||
|
||||
# .m4a is the usual AAC container — bucket it as 'aac' (the
|
||||
# quality filter treats AAC as an opt-in tier; off by default).
|
||||
quality = 'aac' if file_ext == 'm4a' else (
|
||||
file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
|
||||
|
||||
# Create TrackResult
|
||||
# Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms)
|
||||
raw_duration = file_data.get('length')
|
||||
|
|
@ -1147,7 +1150,9 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
ext = Path(filename).suffix.lower()
|
||||
if ext not in audio_extensions:
|
||||
continue
|
||||
quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
|
||||
_qext = ext.lstrip('.')
|
||||
quality = 'aac' if _qext == 'm4a' else (
|
||||
_qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
|
||||
raw_duration = file_data.get('length')
|
||||
duration_ms = raw_duration * 1000 if raw_duration else None
|
||||
results.append(TrackResult(
|
||||
|
|
@ -1957,6 +1962,7 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
'mp3_320': (1, 50),
|
||||
'mp3_256': (1, 40),
|
||||
'mp3_192': (1, 30),
|
||||
'aac': (1, 50),
|
||||
'other': (0, 500),
|
||||
}
|
||||
|
||||
|
|
@ -2040,6 +2046,7 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
'mp3_320': [],
|
||||
'mp3_256': [],
|
||||
'mp3_192': [],
|
||||
'aac': [],
|
||||
'other': []
|
||||
}
|
||||
|
||||
|
|
@ -2067,6 +2074,17 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
else:
|
||||
quality_buckets['other'].append(candidate)
|
||||
continue
|
||||
elif track_format in ('aac', 'm4a'):
|
||||
# Opt-in AAC tier. ADDITIVE: when AAC isn't enabled in the
|
||||
# profile (the default, and every profile that predates this),
|
||||
# route exactly where AAC went before — the 'other' bucket — so
|
||||
# behaviour is byte-identical. Only a user who turns AAC on lets
|
||||
# it become a first-class, selectable tier.
|
||||
aac_cfg = profile['qualities'].get('aac')
|
||||
if not (aac_cfg and aac_cfg.get('enabled')):
|
||||
quality_buckets['other'].append(candidate)
|
||||
continue
|
||||
quality_key = 'aac'
|
||||
else:
|
||||
quality_buckets['other'].append(candidate)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ class SpotifyWorker:
|
|||
# Current item being processed (for UI tooltip)
|
||||
self.current_item = None
|
||||
|
||||
# Whether the worker is serving via the no-creds Spotify Free source as of
|
||||
# its last loop iteration. Cached from the loop's _free_active() probe so
|
||||
# get_stats() can report it without an auth API call (#887: a no-auth user
|
||||
# whose enrichment runs on Free was shown "Not Authenticated").
|
||||
self._serving_via_free = False
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'matched': 0,
|
||||
|
|
@ -136,8 +142,14 @@ class SpotifyWorker:
|
|||
# real-API daily budget (the worker set _budget_exhausted_use_free —
|
||||
# a cheap attribute read). Lets the UI show "Running (Spotify Free)"
|
||||
# instead of a misleading "rate limited" / "daily limit reached".
|
||||
# #887: the loop's cached _free_active() result is the comprehensive
|
||||
# signal — it's True for a no-auth user enriching via Spotify Free by
|
||||
# default (prefer-free is on unless disabled), not just the rate-limit
|
||||
# / budget bridges. The extra terms stay as a fallback for the brief
|
||||
# window before the loop's first iteration sets the cache.
|
||||
using_free = bool(
|
||||
(rate_limited and self.client.is_spotify_metadata_available())
|
||||
getattr(self, '_serving_via_free', False)
|
||||
or (rate_limited and self.client.is_spotify_metadata_available())
|
||||
or getattr(self.client, '_budget_exhausted_use_free', False)
|
||||
)
|
||||
except Exception:
|
||||
|
|
@ -264,6 +276,9 @@ class SpotifyWorker:
|
|||
free_serving = self.client._free_active()
|
||||
except Exception:
|
||||
free_serving = False
|
||||
# Cache for get_stats() so the dashboard status reflects that the
|
||||
# worker IS enriching via Free even with no official auth (#887).
|
||||
self._serving_via_free = free_serving
|
||||
|
||||
# Daily budget guard — pause ONLY when the budget is spent AND we
|
||||
# can't serve via free (no free available). Otherwise free took over.
|
||||
|
|
|
|||
|
|
@ -193,9 +193,18 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool:
|
|||
string similarity ('Vol.4' vs 'Vol.4.5' = 0.97) and token-subset checks
|
||||
both wave these through, which hung volume 4.5's cover art on volume 4
|
||||
(Sokhi). Shared digits on both sides ('1989' vs '1989 (Deluxe)') are
|
||||
fine."""
|
||||
fine.
|
||||
|
||||
Tokenises on non-word runs but KEEPS word characters of every script, so a
|
||||
digit glued to a non-latin word stays its own digit-bearing token. Stripping
|
||||
to [a-z0-9] turned CJK into spaces, collapsing 'サウンドトラック2' to a bare
|
||||
'2' that a shared number elsewhere ('第2期' = season 2) already covered — so
|
||||
'Soundtrack' and 'Soundtrack2' both reduced to {'2'} and matched, hanging the
|
||||
wrong cover (Sokhi again)."""
|
||||
def _digit_tokens(text: str) -> frozenset:
|
||||
tokens = re.sub(r"[^a-z0-9]+", " ", (text or "").casefold()).split()
|
||||
# \W is Unicode-aware for str: CJK/kana count as word chars, so a digit
|
||||
# stays attached to its word instead of collapsing to a bare '2'.
|
||||
tokens = re.sub(r"\W+", " ", (text or "").casefold()).split()
|
||||
return frozenset(t for t in tokens if any(c.isdigit() for c in t))
|
||||
|
||||
return _digit_tokens(title_a) != _digit_tokens(title_b)
|
||||
|
|
|
|||
|
|
@ -198,7 +198,15 @@ class NZBGetAdapter:
|
|||
size=size_bytes,
|
||||
downloaded=downloaded_bytes,
|
||||
download_speed=speed,
|
||||
save_path=group.get('DestDir'),
|
||||
# A QUEUED group's DestDir is the in-progress intermediate dir
|
||||
# (NZBGet names it '<NZBName>.#<NZBID>' and empties/renames it once
|
||||
# the move completes). Never offer it as a final save_path — finalize
|
||||
# only from the HISTORY entry (real FinalDir/DestDir). Otherwise a
|
||||
# PP_FINISHED group (which maps to 'completed') would finalize on the
|
||||
# incomplete '....#2141' dir, which is then gone -> "No audio files
|
||||
# found in /…/incomplete/….#2141" (Swigs). Mirrors the SAB adapter
|
||||
# ignoring its incomplete_path.
|
||||
save_path=None,
|
||||
category=group.get('Category'),
|
||||
)
|
||||
|
||||
|
|
@ -217,7 +225,15 @@ class NZBGetAdapter:
|
|||
size=size_bytes,
|
||||
downloaded=size_bytes if not is_failed else 0,
|
||||
download_speed=0,
|
||||
save_path=entry.get('DestDir'),
|
||||
# Prefer FinalDir — the location after a post-processing script (or
|
||||
# NZBGet's own move) relocated the files; DestDir can still point at
|
||||
# the intermediate '….#NZBID' dir. Swigs: files landed in
|
||||
# /data/soulseek/… (FinalDir) while DestDir stayed /…/incomplete/….#2141.
|
||||
# Fall back to DestDir when FinalDir is empty (no PP move). Empty/
|
||||
# whitespace -> None so the plugin waits for a real path.
|
||||
save_path=(str(entry.get('FinalDir') or '').strip()
|
||||
or str(entry.get('DestDir') or '').strip()
|
||||
or None),
|
||||
category=entry.get('Category'),
|
||||
error=status_field if is_failed else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -750,6 +750,41 @@ class MusicDatabase:
|
|||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_status ON auto_import_history (status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_folder_hash ON auto_import_history (folder_hash)")
|
||||
|
||||
# Re-identify hints (#889) — a user-designated, single-use answer to "which
|
||||
# release does this track belong to". Written when the user picks a release in
|
||||
# the Re-identify modal and the file is staged for auto-import; the import flow
|
||||
# reads the hint at the TOP of matching (keyed by staged path, content_hash as a
|
||||
# rename-proof fallback), expedites the match to these exact IDs, then consumes
|
||||
# the row. `replace_track_id` (when set) is the library row to delete AFTER the
|
||||
# re-import lands; `exempt_dedup` is always 1 because a re-identify is an explicit
|
||||
# user action that must not be silently dropped by the quality dedup-skip.
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rematch_hints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
staged_path TEXT NOT NULL,
|
||||
content_hash TEXT,
|
||||
source TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
track_id TEXT,
|
||||
album_id TEXT,
|
||||
artist_id TEXT,
|
||||
track_title TEXT,
|
||||
album_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_type TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
replace_track_id INTEGER,
|
||||
exempt_dedup INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
consumed_at TIMESTAMP
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_staged_path ON rematch_hints (staged_path)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_content_hash ON rematch_hints (content_hash)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_status ON rematch_hints (status)")
|
||||
|
||||
# Sync history table — tracks the last 100 sync operations with cached context for re-trigger
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_history (
|
||||
|
|
@ -8676,6 +8711,15 @@ class MusicDatabase:
|
|||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
},
|
||||
# AAC (incl. .m4a): opt-in, OFF by default. Priority 1.5 sits it
|
||||
# above MP3 but below FLAC (AAC is more efficient than MP3); the
|
||||
# min_kbps gate keeps junk-bitrate AAC from beating a good MP3.
|
||||
"aac": {
|
||||
"enabled": False,
|
||||
"min_kbps": 128,
|
||||
"max_kbps": 400,
|
||||
"priority": 1.5
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
|
|
@ -8725,6 +8769,12 @@ class MusicDatabase:
|
|||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
},
|
||||
"aac": {
|
||||
"enabled": False,
|
||||
"min_kbps": 128,
|
||||
"max_kbps": 400,
|
||||
"priority": 1.5
|
||||
}
|
||||
},
|
||||
"fallback_enabled": False
|
||||
|
|
@ -8757,6 +8807,12 @@ class MusicDatabase:
|
|||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 4
|
||||
},
|
||||
"aac": {
|
||||
"enabled": False,
|
||||
"min_kbps": 128,
|
||||
"max_kbps": 400,
|
||||
"priority": 1.5
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
|
|
@ -8789,6 +8845,14 @@ class MusicDatabase:
|
|||
"min_kbps": 150,
|
||||
"max_kbps": 300,
|
||||
"priority": 3
|
||||
},
|
||||
# Space-saver favours small files, where AAC shines — but it
|
||||
# still ships OFF (opt-in). Priority 0.5 puts it above MP3.
|
||||
"aac": {
|
||||
"enabled": False,
|
||||
"min_kbps": 128,
|
||||
"max_kbps": 400,
|
||||
"priority": 0.5
|
||||
}
|
||||
},
|
||||
"fallback_enabled": True
|
||||
|
|
|
|||
|
|
@ -1,116 +1,37 @@
|
|||
# SoulSync 2.6.4 — Merge `dev` → `main`
|
||||
# soulsync 2.7.4 — `dev` → `main`
|
||||
|
||||
Patch release on top of 2.6.3. Headline:
|
||||
|
||||
- **#721 — Usenet album bundles stuck on "downloading release" when SAB History flips before storage lands.** Reported by @IamGroot60 against 2.6.3, validated on the `fix/usenet-bundle-save-path-handoff` branch, merged via PR #723.
|
||||
|
||||
Everything from 2.6.3 also rolls in unchanged (was bumped on dev but never tagged / published to main / docker, so this is the first time these changes reach users).
|
||||
patch release on top of 2.7.3. headline is **re-identify** — re-file an already-imported track under the right release without re-downloading it.
|
||||
|
||||
---
|
||||
|
||||
## 2.6.4 — the patch
|
||||
## what's new
|
||||
|
||||
### #721 — Usenet album bundle stuck on "downloading release" when SAB History flips before `storage` lands
|
||||
Follow-up to the 2.6.3 queue→history handoff fix (#706). 2.6.3 covered the gap where SAB removes a job from the queue before adding it to history. **2.6.4** covers a second-stage gap: SAB flips `status` to `Completed` in History a few seconds **before** its post-processing writes the final `storage` field.
|
||||
### re-identify a track (#889)
|
||||
filed a track under the wrong release (single vs ep vs album)? there's now a ⇄ button in the library Enhanced view that lets you fix it. search any configured source (tabs, defaults to your active one), see the same song across its single / ep / album with type badges, pick the right one, and soulsync re-files the file you already have under that release — correct year, in-album track number, and art. opt to replace the original entry or keep both.
|
||||
|
||||
Pre-fix: `poll_album_download` saw the first `Completed` read with `save_path=None` and bailed. The bundle plugin marked the batch failed, but the UI froze on the last `downloading progress=0.61` emit because the terminal `failed` emit never registered (renderer holds the last-known progress).
|
||||
built additively over 5 phases (hint store → import seam → multi-source search → modal → button), all riding the existing import pipeline so a no-hint import is byte-identical to before. and it can't lose your file: replace deletes the old entry only *after* the re-import lands, and never if you pick the release it's already in.
|
||||
|
||||
- **`poll_album_download`**: separate transient counter for "completed but no save_path." Tolerates up to `transient_miss_threshold` (default 5) consecutive reads in that state — gives SAB ~10s to land the path. When it arrives, return normally. When it doesn't, fail loudly with an explicit error pointing at the missing field.
|
||||
- **Sticky save_path**: earlier `downloading` reads with a non-empty `save_path` (qBit / Transmission set this from the start) remain cached. So torrent flows aren't affected by the retry path.
|
||||
- **SAB adapter (`_parse_history_slot`)**: widened the save_path fallback chain — `storage` → `path` → `download_path` → `dirname`. Covers SAB version differences (older builds populated `path`) and forks that expose `download_path` or `dirname`. Whitespace-only values skipped. `incomplete_path` intentionally NOT in the chain — it'd bypass the retry window and point at the in-progress staging dir.
|
||||
- **Diagnostic**: loud debug log when none of the known fields land, dumping the slot keys so we can grow `_HISTORY_SAVE_PATH_KEYS` if a fork ships a novel field name.
|
||||
### cleaner libraries & imports
|
||||
- **#890** — track titles no longer keep the "01 - " prefix from the filename when there's no embedded title tag (which made the real track read as a false "missing"). stripped conservatively so "7 Rings" / "1-800-273-8255" / "1979" are left alone.
|
||||
- **#891** — a Library Reorganize now sweeps the leftover cover.jpg / .lrc / sidecars from the old folder so it actually empties, plus an opt-in "Remove Residual Files" toggle on the Empty Folder Cleaner for the image-only folders you already have.
|
||||
- **Sokhi's batch** — same-album songs group under one canonical release id (no more split discographies / mixed cover art); a single can match its parent album; a mid-enrichment crash on an art-less file no longer leaves it untagged; and a sequel digit glued to a CJK title no longer matches the wrong album.
|
||||
|
||||
**9 new tests**:
|
||||
- `test_album_bundle.py` (3): late-save_path arrival recovers; threshold-exhausted fails cleanly; sticky save_path keeps torrent flows working.
|
||||
- `test_usenet_client_adapters.py` (6): each fallback field tier, whitespace-only skip, all-empty returns None, `incomplete_path` ignored.
|
||||
### quality & sources
|
||||
- **#886** — AAC (.m4a) as an opt-in soulseek quality tier, ranked above mp3 / below flac. off by default; existing profiles unchanged until you enable it.
|
||||
- **#887** — enrichment on Spotify Free now reads "Running (Spotify Free)" instead of wrongly showing "Not Authenticated".
|
||||
- **#884** — NZBGet imports from the finished location, not the incomplete "….#NZBID" folder.
|
||||
- **#885** — setting the timezone to Australia/Sydney no longer makes the cache-maintenance job loop every 5 seconds.
|
||||
|
||||
132 album-bundle + usenet tests pass. Strictly additive — zero impact on users whose SAB returns `storage` on the first Completed read.
|
||||
### polish
|
||||
- the artist-detail header no longer bleeds the blurred artist photo behind it.
|
||||
|
||||
---
|
||||
|
||||
## Everything else from 2.6.3 (carried forward)
|
||||
## tests
|
||||
strictly additive across the board — every new behavior is opt-in or gated so default flows are unchanged. ~100 new tests this cycle (re-identify seam, title-strip danger cases, the shared residual-file classifier, aac tier, tz scheduler, spotify-free status). full imports / matching / reorganize / auto-import suites green, ruff clean.
|
||||
|
||||
### Fixes
|
||||
|
||||
**#715 — Soulseek album downloads stuck on "failed" after slskd finished the release.**
|
||||
`core/soulseek_client._resolve_downloaded_album_file` probed 3 hard-coded candidate paths. On the common slskd config `directories.downloads.username = true`, files land at `<download_dir>/<username>/<filename>` — none of the 3 candidates carried a username segment, so every file looked locally missing and the bundle poll silently spun for ~30 minutes before marking the batch failed.
|
||||
|
||||
- Lifted the per-track flow's recursive walk-by-basename helper into `core/downloads/file_finder.py` (`find_completed_audio_file`). Bundle resolver now delegates to it. Default-slskd users see zero behavior change (3-candidate fast path preserved).
|
||||
- Bundle poll detects "slskd reports Completed but local file can't be resolved past a 45s grace window" → exits early with explicit log line pointing at the likely `soulseek.download_path` mismatch.
|
||||
- Misleading `"(0 tracks, quality=)"` log on the preflight-reuse path fixed.
|
||||
- **17 new tests** pin every slskd layout (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded, transfer-dir fallback, fuzzy variants).
|
||||
|
||||
**Auto-Sync ListenBrainz pipelines stuck on `Refreshing:` for 5+ minutes.**
|
||||
Refresh path ran `_maybe_discover` inline AND Phase 2 ran the same matching engine via `run_playlist_discovery_worker`. LB tracks discovered twice; refresh-side run blocked with zero progress emission. Also: LB manager only exposed `update_all_playlists` (refreshing one playlist re-pulled all 12+ cached playlists). Also: LB adapter had a silent `except Exception: pass` masking real API failures.
|
||||
|
||||
- Pipeline sets `skip_discovery=True` on refresh config; Phase 2 handles discovery with proper progress emits.
|
||||
- New `LBManager.refresh_playlist(mbid)` targeted refresh.
|
||||
- LB adapter logs exceptions with traceback at warning level + returns `None`.
|
||||
- **12 new tests**.
|
||||
|
||||
**Wishlist: harden Spotify backfill — poisoned `tn=1` can't mask a lean album.**
|
||||
Spotify-API backfill that hydrates `release_date` / `total_tracks` was coupled to the "track_number missing" branch, so a poisoned default-1 track_number short-circuited it. Lifted to `core/downloads/track_metadata_backfill.py` with split concerns — track-number resolution keeps its precedence chain; album hydration runs whenever `release_date` / `total_tracks` missing, independent of track_number. Single API call still serves both. Also `core/wishlist/routes.py:_build_track_data` no longer defaults `track_number=1` / `disc_number=1` / `total_tracks=1` / `release_date=''`. **24 new tests**.
|
||||
|
||||
**Wishlist: fix three regressions causing all imports to land as track 01.**
|
||||
Track→dict conversion in payload helpers dropped everything except `album.name`; Deezer-sourced discovery matches saved without `track_number`/`disc_number`; import pipeline only consulted `album_info.track_number` before falling to the filename. Track_number resolution chain lifted into `core/imports/track_number.py:resolve_track_number` with 18 unit tests.
|
||||
|
||||
**Wishlist: only engage album-bundle when several tracks from the same album are missing.**
|
||||
New `core/wishlist/album_grouping.py`. Bundle path only engages when an album has ≥2 missing tracks; single-track items take the cheaper per-track path. Configurable via `wishlist.album_bundle_min_tracks`.
|
||||
|
||||
**Wishlist: distinguish Queued from Analyzing batches in the UI.**
|
||||
|
||||
**Album-bundle staging: clean Soulseek copies + sweep orphans at startup.**
|
||||
Cleanup gate extended to include `soulseek` (was torrent/usenet only). New `sweep_orphan_album_bundle_staging` runs once at server boot. **12 new tests**.
|
||||
|
||||
**Usenet album poll: tolerate SAB queue→history handoff (#706).**
|
||||
|
||||
**Discogs: strip artist disambiguation suffixes everywhere (#634).**
|
||||
|
||||
**Library: Enhanced / Standard view toggle persists per browser.**
|
||||
|
||||
**Fix popup: manual matches survive Playlist Pipeline runs.**
|
||||
|
||||
**Fix popup: artist + track fields no longer surface unrelated covers.**
|
||||
|
||||
### UX overhauls
|
||||
|
||||
**Dashboard enrichment panel — equalizer-bar redesign.** 11 speedometer tiles → 11 vertical VU-meter equalizer bars in one symmetric flex row. Brand-logo avatar disc above each bar (Spotify/Apple Music/Deezer/Last.fm/Genius/MusicBrainz/AudioDB/Tidal/Qobuz/Discogs/Amazon with initial-letter CDN-fail fallback); peak-flash on cpm step-up; rolling counter; glass-surface reflection puddle. Last.fm circle-clipped; Tidal/Qobuz/Discogs/Amazon inverted to white silhouettes.
|
||||
|
||||
**Auto-Sync manager — full visual overhaul.** Selector-based override layer (zero JS/HTML changes). Every surface inside the modal restyled to match the dashboard's glassy / accent-radial aesthetic.
|
||||
|
||||
**Auto-Sync — weekly board cards now match the hourly board.** Same Run-now button, unschedule X, next-run countdown, health badge. Weekly cards now draggable between day columns.
|
||||
|
||||
**Auto-Sync sidebar — brand logo on each source-group header.**
|
||||
|
||||
**Sync page tabs — brand-logo chips with active label pill.** 14 tabs collapsed from cramped labeled pills to circular brand-logo chips; active tab swells into a pill with its label inline. `Link` variants (Spotify Link / Deezer Link / iTunes Link) carry a small chain-link badge bottom-right.
|
||||
|
||||
### Architectural lifts
|
||||
|
||||
**Unified Playlist Sources layer.** `PlaylistSource` ABC + registry in `core/playlists/sources/`. Refresh handler dropped from ~190 lines of if/elif to ~80 lines. ListenBrainz / Last.fm / SoulSync Discovery are now Sync-page tabs.
|
||||
|
||||
**Auto-Sync schedule types — weekday + time.** New Weekly Board tab on the Auto-Sync manager.
|
||||
|
||||
**iTunes / Apple Music link import.** New iTunes Link tab on the Sync page.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] 132 album-bundle + usenet tests pass (the new #721 path)
|
||||
- [x] 488 downloads tests pass (full suite)
|
||||
- [x] ~90 new unit tests across the cycle, including 9 new for #721
|
||||
- [x] Smoke: dashboard equalizer renders w/ brand logos, peak-flash on cpm increase
|
||||
- [x] Smoke: Auto-Sync manager renders glass overhaul, hourly + weekly cards both have action rows
|
||||
- [x] Smoke: Sync page tab strip renders as logo chips; active expands; Link variants show chain-link badge
|
||||
- [ ] Live: @IamGroot60 to re-test Forty Licks usenet bundle on dev (build with the #721 fix applied)
|
||||
- [ ] Live: Soulseek album download on a username-subdir slskd config completes cleanly (#715, user-validated post-merge)
|
||||
- [ ] Live: bundle staging dir cleaned on completion (user-validated post-merge)
|
||||
|
||||
---
|
||||
|
||||
## Post-merge checklist
|
||||
|
||||
- [ ] Tag `v2.6.4` on `main`
|
||||
- [ ] Trigger `docker-publish.yml` with `version_tag: 2.6.4` to push `boulderbadgedad/soulsync:2.6.4` + `ghcr.io/nezreka/soulsync:2.6.4` (default already updated)
|
||||
- [ ] Discord release announcement (auto-fired by the workflow)
|
||||
- [ ] Reply on #721 with the 2.6.4 release link
|
||||
## post-merge
|
||||
- [ ] tag `v2.7.4` on `main`
|
||||
- [ ] docker-publish with `version_tag: 2.7.4`
|
||||
- [ ] discord announce (auto-fired by the workflow)
|
||||
- [ ] reply on #889 / #890 / #891
|
||||
|
|
|
|||
106
tests/downloads/test_soulseek_aac_quality.py
Normal file
106
tests/downloads/test_soulseek_aac_quality.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""#886: AAC as an opt-in Soulseek quality tier.
|
||||
|
||||
The whole point is "purely additive": with AAC OFF (the default, and every
|
||||
profile that predates this), an AAC candidate must behave EXACTLY as before —
|
||||
it lands in the 'other' bucket, which the waterfall never returns, so it's
|
||||
dropped. Only a profile that explicitly enables AAC makes it a selectable tier,
|
||||
ranked above MP3 and below FLAC.
|
||||
|
||||
filter_results_by_quality_preference reads db.get_quality_profile() and walks the
|
||||
buckets; we stub the db + the quarantine sweep so it runs offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.soulseek_client import SoulseekClient
|
||||
from core.download_plugins.types import TrackResult
|
||||
|
||||
|
||||
def _client():
|
||||
c = SoulseekClient.__new__(SoulseekClient)
|
||||
c.base_url = 'http://localhost:5030'
|
||||
c.api_key = 'k'
|
||||
c.download_path = Path('./test_downloads')
|
||||
return c
|
||||
|
||||
|
||||
def _cand(quality, size_mb, bitrate=None):
|
||||
return TrackResult(
|
||||
username='peer', filename=f'A/B/01 - Song.{quality}',
|
||||
size=int(size_mb * 1024 * 1024), bitrate=bitrate, duration=None,
|
||||
quality=quality, free_upload_slots=1, upload_speed=1_000_000,
|
||||
queue_length=0, artist='A', title='Song', album='B', track_number=1)
|
||||
|
||||
|
||||
def _q(enabled_flac=True, enabled_mp3=True, aac=None):
|
||||
qualities = {
|
||||
'flac': {'enabled': enabled_flac, 'min_kbps': 500, 'max_kbps': 10000, 'priority': 1, 'bit_depth': 'any'},
|
||||
'mp3_320': {'enabled': enabled_mp3, 'min_kbps': 280, 'max_kbps': 500, 'priority': 2},
|
||||
}
|
||||
if aac is not None: # None => omit the tier entirely (pre-existing profile)
|
||||
qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5}
|
||||
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True}
|
||||
|
||||
|
||||
def _filter(candidates, profile):
|
||||
c = _client()
|
||||
fake_db = types.SimpleNamespace(get_quality_profile=lambda: profile)
|
||||
with patch('database.music_database.MusicDatabase', return_value=fake_db), \
|
||||
patch.object(SoulseekClient, '_drop_quarantined_sources', lambda self, r: r):
|
||||
return c.filter_results_by_quality_preference(candidates)
|
||||
|
||||
|
||||
# ── additive proof: AAC off == today (dropped) ────────────────────────────────
|
||||
def test_aac_dropped_when_tier_absent_pre_existing_profile():
|
||||
# A profile saved before this feature has no 'aac' key at all.
|
||||
out = _filter([_cand('aac', 5)], _q(aac=None))
|
||||
assert out == [] # AAC went to 'other' -> never returned, exactly as before
|
||||
|
||||
|
||||
def test_aac_dropped_when_tier_present_but_disabled():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=False))
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_flac_mp3_selection_unchanged_when_aac_absent():
|
||||
# The headline no-regression guard: a normal FLAC/MP3 mix is unaffected.
|
||||
flac, mp3 = _cand('flac', 30), _cand('mp3', 5, bitrate=320)
|
||||
out = _filter([mp3, flac], _q(aac=None))
|
||||
assert out and out[0].quality == 'flac' # FLAC still wins, as before
|
||||
|
||||
|
||||
# ── opt-in: AAC on makes it a real tier ───────────────────────────────────────
|
||||
def test_aac_selected_when_enabled():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=True))
|
||||
assert len(out) == 1 and out[0].quality == 'aac'
|
||||
|
||||
|
||||
def test_flac_beats_aac_when_both_present():
|
||||
flac, aac = _cand('flac', 30), _cand('aac', 5)
|
||||
out = _filter([aac, flac], _q(aac=True))
|
||||
assert out[0].quality == 'flac' # priority 1 < 1.5
|
||||
|
||||
|
||||
def test_aac_beats_mp3_when_both_present():
|
||||
mp3, aac = _cand('mp3', 5, bitrate=320), _cand('aac', 5)
|
||||
out = _filter([mp3, aac], _q(aac=True))
|
||||
assert out[0].quality == 'aac' # priority 1.5 < 2
|
||||
|
||||
|
||||
def test_default_and_presets_ship_aac_disabled_above_mp3():
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase.__new__(MusicDatabase) # no DB init
|
||||
profiles = [db._get_default_quality_profile()]
|
||||
profiles += [db.get_quality_preset(p) for p in ('audiophile', 'balanced', 'space_saver')]
|
||||
for prof in profiles:
|
||||
aac = prof['qualities']['aac']
|
||||
assert aac['enabled'] is False # opt-in everywhere
|
||||
# above MP3: lower priority number than the best MP3 tier present
|
||||
mp3_prios = [v['priority'] for k, v in prof['qualities'].items() if k.startswith('mp3')]
|
||||
assert aac['priority'] < min(mp3_prios)
|
||||
138
tests/imports/test_album_grouping.py
Normal file
138
tests/imports/test_album_grouping.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Seam tests for canonical album grouping (Sokhi: split album rows -> mixed
|
||||
cover art). Drives find_existing_soulsync_album_id against a real in-memory
|
||||
SQLite albums table — no app singletons, no I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.album_grouping import (
|
||||
find_existing_soulsync_album_id,
|
||||
ALLOWED_ALBUM_SOURCE_COLS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cur():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute(
|
||||
"""CREATE TABLE albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
artist_id TEXT,
|
||||
title TEXT,
|
||||
server_source TEXT,
|
||||
spotify_album_id TEXT,
|
||||
itunes_album_id TEXT,
|
||||
deezer_id TEXT,
|
||||
soul_id TEXT,
|
||||
discogs_id TEXT,
|
||||
musicbrainz_release_id TEXT
|
||||
)"""
|
||||
)
|
||||
yield conn.cursor()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _add(cur, *, id, title, artist_id="art1", server_source="soulsync", **source_ids):
|
||||
cols = ["id", "artist_id", "title", "server_source"] + list(source_ids)
|
||||
vals = [id, artist_id, title, server_source] + list(source_ids.values())
|
||||
cur.execute(
|
||||
f"INSERT INTO albums ({', '.join(cols)}) VALUES ({', '.join(['?'] * len(cols))})",
|
||||
vals,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_db_returns_none(cur):
|
||||
assert find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes",
|
||||
album_source_col="spotify_album_id", album_source_id="SP1") is None
|
||||
|
||||
|
||||
def test_exact_name_hash_id_wins_first(cur):
|
||||
_add(cur, id="nk", title="Parachutes")
|
||||
assert find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes") == "nk"
|
||||
|
||||
|
||||
def test_canonical_source_id_unifies_differently_named_imports(cur):
|
||||
# Existing row for release SP1 named "Parachutes". A second import of the
|
||||
# SAME release id but a drifted name must JOIN it, not split.
|
||||
_add(cur, id="existing", title="Parachutes", spotify_album_id="SP1")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="different_hash", artist_id="art1",
|
||||
album_name="Parachutes (Deluxe Edition)",
|
||||
album_source_col="spotify_album_id", album_source_id="SP1")
|
||||
assert got == "existing"
|
||||
|
||||
|
||||
def test_different_release_id_stays_separate(cur):
|
||||
# The single-vs-album case: a genuinely different release id must NOT merge
|
||||
# (documents the known limit — single->album resolution is a separate step).
|
||||
_add(cur, id="album_row", title="Parachutes", spotify_album_id="SP_ALBUM")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="single_hash", artist_id="art1", album_name="Yellow",
|
||||
album_source_col="spotify_album_id", album_source_id="SP_SINGLE")
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_legacy_name_match_still_groups_without_a_source_id(cur):
|
||||
_add(cur, id="byname", title="Parachutes")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="other_hash", artist_id="art1", album_name="parachutes",
|
||||
album_source_col=None, album_source_id=None)
|
||||
assert got == "byname" # case-insensitive title + artist
|
||||
|
||||
|
||||
def test_source_id_match_is_scoped_to_soulsync_rows(cur):
|
||||
_add(cur, id="plexrow", title="Parachutes", server_source="plex", spotify_album_id="SP1")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk", artist_id="art1", album_name="X",
|
||||
album_source_col="spotify_album_id", album_source_id="SP1")
|
||||
assert got is None # the matching row belongs to Plex, not soulsync
|
||||
|
||||
|
||||
def test_non_allowlisted_column_is_ignored(cur):
|
||||
# A column not on the allowlist must never be spliced into SQL.
|
||||
assert "title" not in ALLOWED_ALBUM_SOURCE_COLS
|
||||
_add(cur, id="row", title="Parachutes")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk", artist_id="art1", album_name="nope",
|
||||
album_source_col="title", album_source_id="Parachutes")
|
||||
assert got is None # 'title' ignored as a source col; name 'nope' doesn't match
|
||||
|
||||
|
||||
def test_empty_source_id_skips_canonical_match(cur):
|
||||
_add(cur, id="row", title="Parachutes", spotify_album_id="")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk", artist_id="art1", album_name="Other",
|
||||
album_source_col="spotify_album_id", album_source_id="")
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_missing_album_column_falls_through_not_raises(cur):
|
||||
# Some sources (Deezer) don't have a dedicated album id column on the albums
|
||||
# table; an allow-listed-but-absent column must NOT raise (it broke the whole
|
||||
# import once) — it falls through to the name match.
|
||||
cur.execute("CREATE TABLE albums_min (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
|
||||
cur.execute("INSERT INTO albums_min VALUES ('byname','art1','DZ Album','soulsync')")
|
||||
# Point the helper at a table missing deezer_id by aliasing via a fresh cursor.
|
||||
conn2 = sqlite3.connect(":memory:")
|
||||
conn2.execute("CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
|
||||
conn2.execute("INSERT INTO albums VALUES ('byname','art1','DZ Album','soulsync')")
|
||||
c2 = conn2.cursor()
|
||||
got = find_existing_soulsync_album_id(
|
||||
c2, name_key_id="nk", artist_id="art1", album_name="DZ Album",
|
||||
album_source_col="deezer_id", album_source_id="67890")
|
||||
conn2.close()
|
||||
assert got == "byname" # deezer_id column absent -> fell through to name match
|
||||
|
||||
|
||||
def test_musicbrainz_release_id_grouping(cur):
|
||||
_add(cur, id="mbrow", title="Album", musicbrainz_release_id="mb-123")
|
||||
got = find_existing_soulsync_album_id(
|
||||
cur, name_key_id="nk2", artist_id="art1", album_name="Album (Remaster)",
|
||||
album_source_col="musicbrainz_release_id", album_source_id="mb-123")
|
||||
assert got == "mbrow"
|
||||
71
tests/imports/test_rematch_apply.py
Normal file
71
tests/imports/test_rematch_apply.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""#889 Phase 4/5: apply a re-identify — stage the file (copy, not move) + build
|
||||
the hint. Locks down: the original is never touched, the staged name is unique +
|
||||
keeps the extension, the hint carries the chosen release, and replace_track_id is
|
||||
set ONLY when 'replace' is ticked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.rematch_apply import (
|
||||
build_reidentify_hint,
|
||||
stage_file_for_reidentify,
|
||||
staged_destination,
|
||||
)
|
||||
|
||||
_FIELDS = {
|
||||
"source": "spotify", "track_id": "trk_1", "album_id": "alb_album1",
|
||||
"artist_id": "art_1", "track_title": "Song", "album_name": "Album1",
|
||||
"artist_name": "Artist", "album_type": "album", "track_number": 5,
|
||||
"disc_number": 1, "isrc": "US1234567890",
|
||||
}
|
||||
|
||||
|
||||
def test_staged_destination_keeps_ext_and_is_traceable():
|
||||
dest = staged_destination("/staging", "/lib/EP1/05 - Song.flac", 42)
|
||||
assert dest.endswith(".flac")
|
||||
assert "[reid-42]" in dest # traceable to the track + unique per track
|
||||
assert dest.startswith("/staging/") # loose file in staging root → single candidate
|
||||
|
||||
|
||||
def test_stage_copies_not_moves(tmp_path: Path):
|
||||
src = tmp_path / "lib" / "EP1" / "05 - Song.flac"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b"audio-bytes")
|
||||
staging = tmp_path / "Staging"
|
||||
|
||||
out = stage_file_for_reidentify(str(src), str(staging), 42,
|
||||
signature_fn=lambda p: "sig123")
|
||||
staged = Path(out["staged_path"])
|
||||
assert staged.is_file() and staged.read_bytes() == b"audio-bytes"
|
||||
assert src.is_file() # ORIGINAL untouched (copy, never move)
|
||||
assert out["content_hash"] == "sig123"
|
||||
|
||||
|
||||
def test_stage_missing_source_raises(tmp_path: Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
stage_file_for_reidentify(str(tmp_path / "gone.flac"), str(tmp_path / "S"), 1)
|
||||
|
||||
|
||||
def test_build_hint_sets_replace_when_ticked():
|
||||
h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=True)
|
||||
assert h.replace_track_id == 42
|
||||
assert h.album_id == "alb_album1" and h.source == "spotify"
|
||||
assert h.track_number == 5 and h.isrc == "US1234567890"
|
||||
assert h.exempt_dedup is True
|
||||
assert h.staged_path == "/staging/x.flac" and h.content_hash == "sig"
|
||||
|
||||
|
||||
def test_build_hint_no_replace_when_unticked():
|
||||
h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=False)
|
||||
assert h.replace_track_id is None # keep original → no deletion
|
||||
assert h.exempt_dedup is True # still bypasses dedup-skip (explicit action)
|
||||
|
||||
|
||||
def test_build_hint_handles_non_numeric_track_id():
|
||||
# Jellyfin-style GUID track ids must still round-trip as replace target.
|
||||
h = build_reidentify_hint("abc-guid", _FIELDS, "/s/x.flac", None, replace=True)
|
||||
assert h.replace_track_id == "abc-guid"
|
||||
155
tests/imports/test_rematch_hints.py
Normal file
155
tests/imports/test_rematch_hints.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""#889 Phase 1: the re-identify hint store — create / find / consume.
|
||||
|
||||
The hint is the single-use, user-designated "which release" answer the import
|
||||
flow reads at the top of matching. These lock down: a hint round-trips, it's
|
||||
found by staged path, found by content_hash when the path missed (rename-proof),
|
||||
found by basename when the dir changed, consumed exactly once, and that a
|
||||
consumed hint is never handed back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.rematch_hints import (
|
||||
RematchHint,
|
||||
consume_hint,
|
||||
create_hint,
|
||||
find_hint_for_file,
|
||||
list_pending_hints,
|
||||
quick_file_signature,
|
||||
)
|
||||
|
||||
# The slice of the real schema this module touches (kept in sync with
|
||||
# database/music_database.py's rematch_hints CREATE).
|
||||
_SCHEMA = """
|
||||
CREATE TABLE rematch_hints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
staged_path TEXT NOT NULL,
|
||||
content_hash TEXT,
|
||||
source TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
track_id TEXT,
|
||||
album_id TEXT,
|
||||
artist_id TEXT,
|
||||
track_title TEXT,
|
||||
album_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_type TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
replace_track_id INTEGER,
|
||||
exempt_dedup INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
consumed_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cur():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript(_SCHEMA)
|
||||
yield conn.cursor()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _hint(**kw):
|
||||
base = dict(
|
||||
staged_path="/staging/Song.flac",
|
||||
source="spotify",
|
||||
isrc="USABC1234567",
|
||||
track_id="trk_1",
|
||||
album_id="alb_album1",
|
||||
artist_id="art_1",
|
||||
track_title="Song",
|
||||
album_name="Album1",
|
||||
artist_name="Artist",
|
||||
album_type="album",
|
||||
track_number=5,
|
||||
disc_number=1,
|
||||
replace_track_id=42,
|
||||
)
|
||||
base.update(kw)
|
||||
return RematchHint(**base)
|
||||
|
||||
|
||||
def test_create_and_find_by_path_roundtrips(cur):
|
||||
new_id = create_hint(cur, _hint())
|
||||
assert new_id > 0
|
||||
got = find_hint_for_file(cur, "/staging/Song.flac")
|
||||
assert got is not None
|
||||
assert got.id == new_id
|
||||
assert got.album_id == "alb_album1" and got.album_type == "album"
|
||||
assert got.isrc == "USABC1234567"
|
||||
assert got.track_number == 5 and got.disc_number == 1
|
||||
assert got.replace_track_id == 42
|
||||
assert got.exempt_dedup is True # always set for a user-designated re-identify
|
||||
assert got.status == "pending"
|
||||
|
||||
|
||||
def test_find_by_content_hash_when_path_missed(cur):
|
||||
create_hint(cur, _hint(content_hash="deadbeef"))
|
||||
# Watcher renamed/moved the file → path lookup misses, hash rescues it.
|
||||
assert find_hint_for_file(cur, "/totally/different.flac") is None
|
||||
got = find_hint_for_file(cur, "/totally/different.flac", content_hash="deadbeef")
|
||||
assert got is not None and got.album_name == "Album1"
|
||||
|
||||
|
||||
def test_find_by_basename_when_dir_changed(cur):
|
||||
create_hint(cur, _hint(staged_path="/staging/in/Song.flac"))
|
||||
# Same filename, different directory (watcher moved it deeper).
|
||||
got = find_hint_for_file(cur, "/staging/processing/Song.flac")
|
||||
assert got is not None and got.track_id == "trk_1"
|
||||
|
||||
|
||||
def test_consume_is_single_use(cur):
|
||||
new_id = create_hint(cur, _hint())
|
||||
assert find_hint_for_file(cur, "/staging/Song.flac") is not None
|
||||
consume_hint(cur, new_id)
|
||||
# Consumed → never handed back, by path or by hash.
|
||||
assert find_hint_for_file(cur, "/staging/Song.flac") is None
|
||||
assert find_hint_for_file(cur, "/x", content_hash=None) is None
|
||||
|
||||
|
||||
def test_list_pending_excludes_consumed(cur):
|
||||
a = create_hint(cur, _hint(staged_path="/staging/A.flac"))
|
||||
create_hint(cur, _hint(staged_path="/staging/B.flac"))
|
||||
assert len(list_pending_hints(cur)) == 2
|
||||
consume_hint(cur, a)
|
||||
pend = list_pending_hints(cur)
|
||||
assert len(pend) == 1 and pend[0].staged_path == "/staging/B.flac"
|
||||
|
||||
|
||||
def test_newest_pending_wins_on_duplicate_path(cur):
|
||||
create_hint(cur, _hint(album_id="alb_old"))
|
||||
create_hint(cur, _hint(album_id="alb_new")) # user re-picked for the same file
|
||||
got = find_hint_for_file(cur, "/staging/Song.flac")
|
||||
assert got.album_id == "alb_new"
|
||||
|
||||
|
||||
def test_exempt_dedup_false_roundtrips(cur):
|
||||
create_hint(cur, _hint(staged_path="/staging/Keep.flac", exempt_dedup=False))
|
||||
got = find_hint_for_file(cur, "/staging/Keep.flac")
|
||||
assert got.exempt_dedup is False
|
||||
|
||||
|
||||
# ── content fingerprint ───────────────────────────────────────────────────────
|
||||
def test_quick_file_signature_stable_and_distinct(tmp_path):
|
||||
a = tmp_path / "a.bin"
|
||||
b = tmp_path / "b.bin"
|
||||
a.write_bytes(b"hello world" * 1000)
|
||||
b.write_bytes(b"goodbye moon" * 1000)
|
||||
sig_a1 = quick_file_signature(str(a))
|
||||
sig_a2 = quick_file_signature(str(a))
|
||||
sig_b = quick_file_signature(str(b))
|
||||
assert sig_a1 and sig_a1 == sig_a2 # stable
|
||||
assert sig_a1 != sig_b # distinct content → distinct sig
|
||||
|
||||
|
||||
def test_quick_file_signature_missing_file_is_none():
|
||||
assert quick_file_signature("/no/such/file.flac") is None
|
||||
217
tests/imports/test_rematch_hints_seam.py
Normal file
217
tests/imports/test_rematch_hints_seam.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""#889 Phase 2: the import seam — a hint short-circuits identification, and the
|
||||
old library row is replaced only after the re-import succeeds.
|
||||
|
||||
Two layers:
|
||||
* pure helpers (build_identification_from_hint, delete_replaced_track) — exact
|
||||
mapping + safe replacement against an in-memory DB, injectable unlink.
|
||||
* the worker seam (_resolve_rematch_hint / _finalize_rematch_hint) — proves the
|
||||
NO-HINT path is untouched, the hint path returns a ready identification, the
|
||||
lookup is fail-safe, and finalize consumes + replaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from core.auto_import_worker import AutoImportWorker, FolderCandidate
|
||||
from core.imports.rematch_hints import (
|
||||
RematchHint,
|
||||
build_identification_from_hint,
|
||||
consume_hint,
|
||||
create_hint,
|
||||
delete_replaced_track,
|
||||
find_hint_for_file,
|
||||
)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE rematch_hints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, staged_path TEXT NOT NULL, content_hash TEXT,
|
||||
source TEXT NOT NULL, isrc TEXT, track_id TEXT, album_id TEXT, artist_id TEXT,
|
||||
track_title TEXT, album_name TEXT, artist_name TEXT, album_type TEXT,
|
||||
track_number INTEGER, disc_number INTEGER, replace_track_id INTEGER,
|
||||
exempt_dedup INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, consumed_at TIMESTAMP
|
||||
);
|
||||
CREATE TABLE tracks (
|
||||
id INTEGER PRIMARY KEY, album_id INTEGER, artist_id INTEGER, title TEXT,
|
||||
track_number INTEGER, file_path TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn():
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript(_SCHEMA)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _hint(**kw):
|
||||
base = dict(staged_path="/staging/Song.flac", source="spotify", album_id="alb_album1",
|
||||
artist_id="art_1", track_id="trk_1", track_title="Song", album_name="Album1",
|
||||
artist_name="Artist", album_type="album", track_number=5, disc_number=1)
|
||||
base.update(kw)
|
||||
return RematchHint(**base)
|
||||
|
||||
|
||||
# ── pure: identification mapping ──────────────────────────────────────────────
|
||||
def test_build_identification_maps_hint_fields():
|
||||
ident = build_identification_from_hint(_hint())
|
||||
assert ident["album_id"] == "alb_album1"
|
||||
assert ident["source"] == "spotify"
|
||||
assert ident["album_name"] == "Album1"
|
||||
assert ident["artist_id"] == "art_1"
|
||||
assert ident["track_number"] == 5
|
||||
assert ident["method"] == "rematch_hint"
|
||||
assert ident["identification_confidence"] == 1.0
|
||||
# album_type 'album' → not a single, and force_album_match makes the matcher
|
||||
# fetch the real album (year/track#/art) instead of the singles stub.
|
||||
assert ident["is_single"] is False
|
||||
assert ident["force_album_match"] is True
|
||||
|
||||
|
||||
def test_build_identification_single_release_still_forces_album_fetch():
|
||||
# Even a chosen SINGLE release is fetched (it has a year too); is_single flags
|
||||
# the type, force_album_match drives the album path regardless.
|
||||
ident = build_identification_from_hint(_hint(album_type="single"))
|
||||
assert ident["is_single"] is True
|
||||
assert ident["force_album_match"] is True
|
||||
|
||||
|
||||
# ── pure: safe replacement ────────────────────────────────────────────────────
|
||||
def test_delete_replaced_track_removes_row_and_file(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')")
|
||||
removed = []
|
||||
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p))
|
||||
assert out == "/lib/EP1/05 - Song.flac"
|
||||
assert removed == ["/lib/EP1/05 - Song.flac"] # file removed (we faked existence below)
|
||||
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
|
||||
assert cur.fetchone() is None # row gone
|
||||
|
||||
|
||||
def test_delete_replaced_track_keeps_file_if_another_row_points_at_it(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/shared.flac')")
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (8, '/lib/shared.flac')")
|
||||
removed = []
|
||||
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p))
|
||||
assert out is None and removed == [] # row 8 still references it → no unlink
|
||||
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
|
||||
assert cur.fetchone() is None # but row 7 still deleted
|
||||
|
||||
|
||||
def test_delete_replaced_track_noops_on_missing_id(conn):
|
||||
cur = conn.cursor()
|
||||
assert delete_replaced_track(cur, None) is None
|
||||
assert delete_replaced_track(cur, 999) is None # no such row
|
||||
|
||||
|
||||
def test_delete_replaced_track_same_home_is_noop(conn):
|
||||
# THE data-loss bug: re-identify to the release it's already in → the import
|
||||
# reuses the same file/row, so deleting it would orphan the file. Guard: no-op.
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/Album1/05 - Song.flac')")
|
||||
removed = []
|
||||
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
|
||||
new_paths=['/lib/Album1/05 - Song.flac'])
|
||||
assert out is None and removed == [] # NOTHING unlinked
|
||||
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
|
||||
assert cur.fetchone() is not None # row PRESERVED (it's the re-imported track)
|
||||
|
||||
|
||||
def test_delete_replaced_track_different_home_still_deletes(conn):
|
||||
# Genuinely re-homed (new path differs) → old row + file removed as intended.
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')")
|
||||
removed = []
|
||||
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
|
||||
new_paths=['/lib/Album1/05 - Song.flac'])
|
||||
assert out == '/lib/EP1/05 - Song.flac'
|
||||
assert removed == ['/lib/EP1/05 - Song.flac']
|
||||
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
|
||||
assert cur.fetchone() is None
|
||||
|
||||
|
||||
def test_delete_replaced_track_resolves_path_before_unlink(conn):
|
||||
# The stored path is a server/Docker view this process can't read literally;
|
||||
# resolve_fn maps it to the real file so we unlink the RIGHT path (not orphan it).
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/mnt/serverview/Song.flac')")
|
||||
removed = []
|
||||
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
|
||||
resolve_fn=lambda stored: '/real/local/Song.flac')
|
||||
assert out == '/real/local/Song.flac'
|
||||
assert removed == ['/real/local/Song.flac'] # unlinked the RESOLVED path
|
||||
|
||||
|
||||
# patch os.path.exists so the unlink branch is reachable without real files
|
||||
@pytest.fixture(autouse=True)
|
||||
def _exists(monkeypatch):
|
||||
monkeypatch.setattr("core.imports.rematch_hints.os.path.exists", lambda p: True)
|
||||
|
||||
|
||||
# ── worker seam ───────────────────────────────────────────────────────────────
|
||||
def _worker(conn):
|
||||
# Production hands out a FRESH connection per call (the worker closes it);
|
||||
# here we share one in-memory DB, so proxy close() to a no-op.
|
||||
w = AutoImportWorker.__new__(AutoImportWorker)
|
||||
proxy = types.SimpleNamespace(cursor=conn.cursor, commit=conn.commit, close=lambda: None)
|
||||
w.database = types.SimpleNamespace(_get_connection=lambda: proxy)
|
||||
return w
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_no_hint(conn):
|
||||
w = _worker(conn)
|
||||
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
|
||||
assert w._resolve_rematch_hint(cand) == (None, None) # untouched → normal identify
|
||||
|
||||
|
||||
def test_resolve_returns_identification_when_hinted(conn, monkeypatch):
|
||||
# don't hash a real file
|
||||
monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None)
|
||||
create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac"))
|
||||
conn.commit()
|
||||
w = _worker(conn)
|
||||
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
|
||||
hint, ident = w._resolve_rematch_hint(cand)
|
||||
assert hint is not None and hint.album_id == "alb_album1"
|
||||
assert ident["album_id"] == "alb_album1" and ident["method"] == "rematch_hint"
|
||||
|
||||
|
||||
def test_resolve_ignores_multi_file_candidates(conn):
|
||||
create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac"))
|
||||
conn.commit()
|
||||
w = _worker(conn)
|
||||
cand = FolderCandidate(path="/staging", name="Album",
|
||||
audio_files=["/staging/Song.flac", "/staging/Other.flac"])
|
||||
assert w._resolve_rematch_hint(cand) == (None, None) # re-identify is single-track only
|
||||
|
||||
|
||||
def test_resolve_is_failsafe_on_db_error():
|
||||
w = AutoImportWorker.__new__(AutoImportWorker)
|
||||
def _boom():
|
||||
raise RuntimeError("db down")
|
||||
w.database = types.SimpleNamespace(_get_connection=_boom)
|
||||
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
|
||||
assert w._resolve_rematch_hint(cand) == (None, None) # error never breaks auto-import
|
||||
|
||||
|
||||
def test_finalize_consumes_and_replaces(conn, monkeypatch):
|
||||
monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None)
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO tracks (id, file_path) VALUES (42, '/lib/EP1/05 - Song.flac')")
|
||||
hid = create_hint(cur, _hint(replace_track_id=42))
|
||||
conn.commit()
|
||||
w = _worker(conn)
|
||||
hint = find_hint_for_file(conn.cursor(), "/staging/Song.flac")
|
||||
w._finalize_rematch_hint(hint)
|
||||
# old row deleted, hint consumed
|
||||
cur.execute("SELECT 1 FROM tracks WHERE id = 42")
|
||||
assert cur.fetchone() is None
|
||||
assert find_hint_for_file(conn.cursor(), "/staging/Song.flac") is None # consumed
|
||||
121
tests/imports/test_rematch_search.py
Normal file
121
tests/imports/test_rematch_search.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""#889 Phase 3: re-identify search — normalize results across sources, infer the
|
||||
release-type badge, and resolve the picked row's album_id.
|
||||
|
||||
Locks down: same song surfaces as multiple rows (single/EP/album), the EP
|
||||
inference from a multi-track 'single', graceful empty on a dead source, and that
|
||||
resolve_hint_fields pulls album_id (and refuses a result without one).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
from core.imports.rematch_search import (
|
||||
available_sources,
|
||||
infer_release_type,
|
||||
normalize_search_result,
|
||||
resolve_hint_fields,
|
||||
search_release_candidates,
|
||||
)
|
||||
|
||||
|
||||
# typed-Track-ish object (mirrors core.metadata.types.Track attrs the modal reads)
|
||||
def _track(tid, title, album, album_type, total, isrc=None, year="2020"):
|
||||
return types.SimpleNamespace(
|
||||
id=tid, name=title, artists=["Artist"], album=album,
|
||||
album_type=album_type, total_tracks=total, release_date=year + "-01-01",
|
||||
image_url="http://img/" + tid, isrc=isrc, external_ids={},
|
||||
)
|
||||
|
||||
|
||||
# ── release-type inference ────────────────────────────────────────────────────
|
||||
def test_infer_album_stays_album():
|
||||
assert infer_release_type("album", 12) == "album"
|
||||
|
||||
|
||||
def test_infer_single_one_track_is_single():
|
||||
assert infer_release_type("single", 1) == "single"
|
||||
|
||||
|
||||
def test_infer_multitrack_single_promoted_to_ep():
|
||||
# Spotify labels EPs as album_type='single' — promote on track count.
|
||||
assert infer_release_type("single", 5) == "ep"
|
||||
|
||||
|
||||
def test_infer_compilation():
|
||||
assert infer_release_type("compilation", 40) == "compilation"
|
||||
|
||||
|
||||
def test_infer_unknown_falls_back_to_count():
|
||||
assert infer_release_type(None, 10) == "album"
|
||||
assert infer_release_type("", 4) == "ep"
|
||||
assert infer_release_type(None, 1) == "single"
|
||||
|
||||
|
||||
# ── normalization ─────────────────────────────────────────────────────────────
|
||||
def test_normalize_builds_display_row():
|
||||
row = normalize_search_result(_track("t1", "Song", "Album1", "album", 12, isrc="US1234567890"), "spotify")
|
||||
assert row["track_id"] == "t1"
|
||||
assert row["album_name"] == "Album1" and row["album_type"] == "album"
|
||||
assert row["artist_name"] == "Artist"
|
||||
assert row["year"] == "2020" and row["isrc"] == "US1234567890"
|
||||
|
||||
|
||||
def test_normalize_skips_result_without_id_or_title():
|
||||
assert normalize_search_result(types.SimpleNamespace(id="", name="X"), "spotify") is None
|
||||
assert normalize_search_result(types.SimpleNamespace(id="t", name=""), "spotify") is None
|
||||
|
||||
|
||||
def test_same_song_multiple_collections():
|
||||
"""The headline case: one song, three releases, three distinct rows + badges."""
|
||||
results = [
|
||||
_track("t_alb", "Song", "Album1", "album", 12),
|
||||
_track("t_ep", "Song", "EP1", "single", 5), # multi-track single → EP
|
||||
_track("t_sgl", "Song", "Song (Single)", "single", 1),
|
||||
]
|
||||
client = types.SimpleNamespace(search_tracks=lambda q, limit=25: results)
|
||||
rows = search_release_candidates("spotify", "Song", client_factory=lambda s: client)
|
||||
badges = {r["album_name"]: r["album_type"] for r in rows}
|
||||
assert badges == {"Album1": "album", "EP1": "ep", "Song (Single)": "single"}
|
||||
|
||||
|
||||
def test_search_empty_on_missing_client():
|
||||
assert search_release_candidates("spotify", "x", client_factory=lambda s: None) == []
|
||||
|
||||
|
||||
def test_search_empty_on_blank_query():
|
||||
called = []
|
||||
search_release_candidates("spotify", " ", client_factory=lambda s: called.append(1))
|
||||
assert called == [] # never even fetches a client for an empty query
|
||||
|
||||
|
||||
def test_search_swallows_client_error():
|
||||
def boom(q, limit=25):
|
||||
raise RuntimeError("rate limited")
|
||||
client = types.SimpleNamespace(search_tracks=boom)
|
||||
assert search_release_candidates("spotify", "x", client_factory=lambda s: client) == []
|
||||
|
||||
|
||||
# ── resolve on select ─────────────────────────────────────────────────────────
|
||||
def test_resolve_pulls_album_id_and_fields():
|
||||
details = {
|
||||
"name": "Song", "track_number": 5, "disc_number": 1, "isrc": "US1234567890",
|
||||
"album": {"id": "alb_album1", "name": "Album1", "album_type": "album", "total_tracks": 12},
|
||||
"artists": [{"id": "art_1", "name": "Artist"}],
|
||||
}
|
||||
client = types.SimpleNamespace(get_track_details=lambda tid: details)
|
||||
out = resolve_hint_fields("spotify", "t_alb", client_factory=lambda s: client)
|
||||
assert out["album_id"] == "alb_album1"
|
||||
assert out["artist_id"] == "art_1"
|
||||
assert out["track_number"] == 5 and out["disc_number"] == 1
|
||||
assert out["album_type"] == "album" and out["isrc"] == "US1234567890"
|
||||
|
||||
|
||||
def test_resolve_refuses_result_without_album_id():
|
||||
details = {"name": "Song", "album": {"name": "NoId Album"}} # no album id
|
||||
client = types.SimpleNamespace(get_track_details=lambda tid: details)
|
||||
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: client) is None
|
||||
|
||||
|
||||
def test_resolve_none_on_missing_client():
|
||||
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: None) is None
|
||||
206
tests/imports/test_single_to_album.py
Normal file
206
tests/imports/test_single_to_album.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Seam tests for single -> parent-album resolution (Sokhi: single-matched track
|
||||
splits from its album -> mixed cover art). The selector is pure; the resolver
|
||||
takes injected fetchers, so neither needs a live metadata client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.single_to_album import (
|
||||
select_parent_album,
|
||||
resolve_single_to_album,
|
||||
)
|
||||
from core.imports.context import detect_album_info_web
|
||||
|
||||
|
||||
# ── pure selector ─────────────────────────────────────────────────────────────
|
||||
def _alb(name, tracks, album_type="album", **extra):
|
||||
return {"name": name, "album_type": album_type, "tracks": tracks, **extra}
|
||||
|
||||
|
||||
def test_picks_album_that_contains_the_track():
|
||||
got = select_parent_album("Yellow", [
|
||||
_alb("Parachutes", ["Don't Panic", "Shiver", "Yellow", "Trouble"]),
|
||||
])
|
||||
assert got and got["name"] == "Parachutes"
|
||||
|
||||
|
||||
def test_returns_none_when_no_album_contains_the_track():
|
||||
assert select_parent_album("Yellow", [
|
||||
_alb("Some Other Album", ["Track A", "Track B"]),
|
||||
]) is None
|
||||
|
||||
|
||||
def test_never_promotes_onto_a_single_release():
|
||||
# The single's own release (album_type 'single', name == track) must be ignored.
|
||||
assert select_parent_album("Yellow", [
|
||||
_alb("Yellow", ["Yellow"], album_type="single"),
|
||||
]) is None
|
||||
|
||||
|
||||
def test_ignores_ep_and_compilation_types():
|
||||
assert select_parent_album("Yellow", [
|
||||
_alb("Yellow EP", ["Yellow", "Yellow (Live)"], album_type="ep"),
|
||||
_alb("Greatest Hits", ["Yellow", "Clocks"], album_type="compilation"),
|
||||
]) is None
|
||||
|
||||
|
||||
def test_skips_album_named_exactly_like_the_track():
|
||||
# An 'album' whose name IS the track title is the single dressed as an album;
|
||||
# don't treat it as the parent.
|
||||
assert select_parent_album("Yellow", [
|
||||
_alb("Yellow", ["Yellow"]),
|
||||
]) is None
|
||||
|
||||
|
||||
def test_matches_through_album_version_qualifier():
|
||||
got = select_parent_album("Yellow", [
|
||||
_alb("Parachutes", ["Shiver", "Yellow (Album Version)"]),
|
||||
])
|
||||
assert got and got["name"] == "Parachutes"
|
||||
|
||||
|
||||
def test_first_qualifying_candidate_wins_deterministically():
|
||||
got = select_parent_album("Yellow", [
|
||||
_alb("Parachutes", ["Yellow"]),
|
||||
_alb("Parachutes (Deluxe)", ["Yellow", "Bonus"]),
|
||||
])
|
||||
assert got["name"] == "Parachutes" # input order = priority
|
||||
|
||||
|
||||
def test_empty_title_returns_none():
|
||||
assert select_parent_album("", [_alb("Parachutes", ["Yellow"])]) is None
|
||||
|
||||
|
||||
# ── injected-I/O resolver ─────────────────────────────────────────────────────
|
||||
def test_resolver_finds_parent_album_lazily():
|
||||
calls = {"tracks": 0}
|
||||
albums = [
|
||||
{"name": "Single Yellow", "album_type": "single", "id": "s1"}, # skipped (not album)
|
||||
{"name": "Wrong Album", "album_type": "album", "id": "a1"},
|
||||
{"name": "Parachutes", "album_type": "album", "id": "a2"},
|
||||
]
|
||||
|
||||
def fetch_tracks(alb):
|
||||
calls["tracks"] += 1
|
||||
return {"a1": ["Other"], "a2": ["Yellow", "Shiver"]}.get(alb["id"], [])
|
||||
|
||||
got = resolve_single_to_album(
|
||||
"Yellow",
|
||||
fetch_album_candidates=lambda: albums,
|
||||
fetch_album_tracks=fetch_tracks,
|
||||
)
|
||||
assert got and got["name"] == "Parachutes" and got["album_id"] == "a2"
|
||||
assert calls["tracks"] == 2 # probed a1 then a2, stopped; never probed the single
|
||||
|
||||
|
||||
def test_resolver_returns_none_when_nothing_contains_track():
|
||||
got = resolve_single_to_album(
|
||||
"Yellow",
|
||||
fetch_album_candidates=lambda: [{"name": "X", "album_type": "album", "id": "a1"}],
|
||||
fetch_album_tracks=lambda alb: ["Nope"],
|
||||
)
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_resolver_is_failsafe_on_candidate_fetch_error():
|
||||
def boom():
|
||||
raise RuntimeError("api down")
|
||||
assert resolve_single_to_album(
|
||||
"Yellow", fetch_album_candidates=boom, fetch_album_tracks=lambda a: []) is None
|
||||
|
||||
|
||||
def test_resolver_is_failsafe_on_track_fetch_error():
|
||||
def boom(alb):
|
||||
raise RuntimeError("api down")
|
||||
got = resolve_single_to_album(
|
||||
"Yellow",
|
||||
fetch_album_candidates=lambda: [{"name": "Parachutes", "album_type": "album", "id": "a1"}],
|
||||
fetch_album_tracks=boom)
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_resolver_caps_albums_probed():
|
||||
albums = [{"name": f"A{i}", "album_type": "album", "id": str(i)} for i in range(20)]
|
||||
probed = {"n": 0}
|
||||
|
||||
def fetch_tracks(alb):
|
||||
probed["n"] += 1
|
||||
return ["nope"]
|
||||
|
||||
resolve_single_to_album(
|
||||
"Yellow",
|
||||
fetch_album_candidates=lambda: albums,
|
||||
fetch_album_tracks=fetch_tracks,
|
||||
max_albums=5)
|
||||
assert probed["n"] == 5 # never probes more than the cap
|
||||
|
||||
|
||||
# ── gated wiring through detect_album_info_web (config gate + client shapes) ───
|
||||
class _Cfg:
|
||||
def __init__(self, on):
|
||||
self._on = on
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key == "metadata_enhancement.single_to_album":
|
||||
return self._on
|
||||
return default
|
||||
|
||||
|
||||
_SINGLE_CTX = {
|
||||
"source": "spotify",
|
||||
"artist": {"id": "art1", "name": "Coldplay"},
|
||||
# album_type unset + name == track + total_tracks 1 -> is_album False, and the
|
||||
# existing best-effort skips (album name == track), so the glue is reached.
|
||||
"album": {"id": "s1", "name": "Yellow", "total_tracks": 1},
|
||||
"track_info": {"id": "t1", "name": "Yellow", "track_number": 7},
|
||||
"original_search_result": {"title": "Yellow", "album": "Yellow"},
|
||||
}
|
||||
|
||||
|
||||
def _patch_clients(monkeypatch, albums, tracks_by_id):
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source",
|
||||
lambda *a, **k: albums)
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_artist_album_tracks",
|
||||
lambda album_id, **k: {"tracks": tracks_by_id.get(album_id, [])})
|
||||
|
||||
|
||||
def test_glue_promotes_single_to_parent_album_when_enabled(monkeypatch):
|
||||
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
|
||||
_patch_clients(
|
||||
monkeypatch,
|
||||
albums=[{"name": "Yellow", "album_type": "single", "id": "s1"},
|
||||
{"name": "Parachutes", "album_type": "album", "id": "a2"}],
|
||||
tracks_by_id={"a2": [{"title": "Shiver"}, {"title": "Yellow"}]},
|
||||
)
|
||||
out = detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"})
|
||||
assert out and out["is_album"] is True
|
||||
assert out["album_name"] == "Parachutes"
|
||||
assert out["track_number"] == 7 # preserved
|
||||
|
||||
|
||||
def test_glue_disabled_by_default_returns_none(monkeypatch):
|
||||
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(False))
|
||||
# Even with clients that WOULD match, the flag off => no promotion.
|
||||
_patch_clients(monkeypatch,
|
||||
albums=[{"name": "Parachutes", "album_type": "album", "id": "a2"}],
|
||||
tracks_by_id={"a2": [{"title": "Yellow"}]})
|
||||
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
|
||||
|
||||
|
||||
def test_glue_no_match_returns_none(monkeypatch):
|
||||
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
|
||||
_patch_clients(monkeypatch,
|
||||
albums=[{"name": "Other Album", "album_type": "album", "id": "a9"}],
|
||||
tracks_by_id={"a9": [{"title": "Different Song"}]})
|
||||
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
|
||||
|
||||
|
||||
def test_glue_failsafe_when_client_raises(monkeypatch):
|
||||
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("spotify down")
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source", boom)
|
||||
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
|
||||
77
tests/imports/test_track_number_strip.py
Normal file
77
tests/imports/test_track_number_strip.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""#890: a leading track number leaking from a filename stem into the title
|
||||
("01 - Sun It Rises") makes the track never match the canonical "Sun It Rises",
|
||||
so it reads as a false "missing". strip_leading_track_number removes the prefix —
|
||||
conservatively, so titles that merely START with a number are left alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.context import get_import_clean_title
|
||||
from core.imports.paths import strip_leading_track_number
|
||||
|
||||
|
||||
# ── the bug: track-number prefixes get stripped ───────────────────────────────
|
||||
@pytest.mark.parametrize("dirty,clean", [
|
||||
("01 - Sun It Rises", "Sun It Rises"), # the screenshot
|
||||
("04 - Tiger Mountain Peasant Song", "Tiger Mountain Peasant Song"),
|
||||
("05 - Quiet Houses", "Quiet Houses"),
|
||||
("07 - Heard Them Stirring", "Heard Them Stirring"),
|
||||
("01 Sun It Rises", "Sun It Rises"), # zero-padded, no separator
|
||||
("3 - Title", "Title"), # plain number + separator + space
|
||||
("12. Some Song", "Some Song"), # dot separator
|
||||
("10 - Track Ten", "Track Ten"),
|
||||
("09) Closing Time", "Closing Time"), # paren separator
|
||||
(" 02 - Spaced Out ", "Spaced Out"), # messy whitespace
|
||||
])
|
||||
def test_strips_track_number_prefix(dirty, clean):
|
||||
assert strip_leading_track_number(dirty) == clean
|
||||
|
||||
|
||||
# ── the guard: titles that legitimately start with a number are UNTOUCHED ──────
|
||||
@pytest.mark.parametrize("title", [
|
||||
"7 Rings",
|
||||
"99 Luftballons",
|
||||
"50 Ways to Leave Your Lover",
|
||||
"1-800-273-8255", # number-with-dashes is part of the title
|
||||
"1979",
|
||||
"9 to 5",
|
||||
"4 Minutes",
|
||||
"8 Mile",
|
||||
"21 Guns",
|
||||
"24 Hour Party People", # no separator → not a track number
|
||||
"0 to 100",
|
||||
"Sun It Rises", # no leading number at all
|
||||
])
|
||||
def test_preserves_real_titles(title):
|
||||
assert strip_leading_track_number(title) == title
|
||||
|
||||
|
||||
# ── degenerate inputs ─────────────────────────────────────────────────────────
|
||||
def test_never_reduces_to_empty_or_bare_number():
|
||||
assert strip_leading_track_number("01") == "01" # bare number → keep
|
||||
assert strip_leading_track_number("01 - ") == "01 -" # nothing left → keep original (trimmed)
|
||||
assert strip_leading_track_number("") == ""
|
||||
assert strip_leading_track_number(None) == ""
|
||||
|
||||
|
||||
def test_only_strips_one_prefix():
|
||||
# A title that legitimately follows the number keeps its own leading number.
|
||||
assert strip_leading_track_number("01 - 24 Hour Party People") == "24 Hour Party People"
|
||||
|
||||
|
||||
# ── the chokepoint: every import path resolves its title through here ──────────
|
||||
def test_get_import_clean_title_strips_filename_leak():
|
||||
# original_search['title'] came from the filename stem (no embedded tag).
|
||||
ctx = {"original_search_result": {"title": "01 - Sun It Rises"}}
|
||||
assert get_import_clean_title(ctx) == "Sun It Rises"
|
||||
|
||||
|
||||
def test_get_import_clean_title_leaves_clean_source_title():
|
||||
ctx = {"original_search_result": {"title": "7 Rings"}}
|
||||
assert get_import_clean_title(ctx) == "7 Rings"
|
||||
|
||||
|
||||
def test_get_import_clean_title_default_untouched():
|
||||
assert get_import_clean_title({}, default="Unknown Track") == "Unknown Track"
|
||||
51
tests/library/test_residual_files.py
Normal file
51
tests/library/test_residual_files.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""#891: the shared 'residual file' classifier — junk + cover/scan images +
|
||||
lyric/metadata sidecars — used by both the Reorganize cleanup and the Empty
|
||||
Folder Cleaner, plus the reorganize sweep that uses it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from core.library.residual_files import (
|
||||
is_disposable,
|
||||
is_image,
|
||||
is_junk,
|
||||
is_sidecar,
|
||||
)
|
||||
|
||||
|
||||
def test_images_classified():
|
||||
for n in ('cover.jpg', 'Cover.JPEG', 'folder.png', 'back.webp', 'scan.tiff', 'art.gif'):
|
||||
assert is_image(n) and is_disposable(n)
|
||||
|
||||
|
||||
def test_sidecars_classified():
|
||||
for n in ('lyrics.lrc', 'album.nfo', 'disc.cue', 'playlist.m3u', 'x.m3u8'):
|
||||
assert is_sidecar(n) and is_disposable(n)
|
||||
|
||||
|
||||
def test_junk_classified():
|
||||
assert is_junk('.DS_Store') and is_disposable('Thumbs.db')
|
||||
|
||||
|
||||
def test_real_content_not_disposable():
|
||||
# Audio + anything unrecognized (booklet, video, a note) is real content.
|
||||
for n in ('song.flac', 'track.mp3', 'booklet.pdf', 'movie.mkv', 'readme.txt', 'data.json'):
|
||||
assert not is_disposable(n), n
|
||||
|
||||
|
||||
# ── the reorganize sweep that uses the predicate ──────────────────────────────
|
||||
def test_delete_album_sidecars_sweeps_all_residual_keeps_real(tmp_path: Path):
|
||||
from core.library_reorganize import _delete_album_sidecars
|
||||
|
||||
d = tmp_path / 'Old Album'
|
||||
d.mkdir()
|
||||
for n in ('cover.jpg', 'back.jpg', 'disc.png', 'lyrics.lrc', 'album.nfo', '.DS_Store'):
|
||||
(d / n).write_text('x')
|
||||
(d / 'booklet.pdf').write_text('keep') # unrecognized → must survive
|
||||
|
||||
_delete_album_sidecars(str(d))
|
||||
|
||||
survivors = {p.name for p in d.iterdir()}
|
||||
assert survivors == {'booklet.pdf'} # every residual swept, booklet kept
|
||||
|
|
@ -19,6 +19,12 @@ from core.musicbrainz_service import MusicBrainzService
|
|||
VOL4 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4"
|
||||
VOL45 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4.5"
|
||||
|
||||
# Sokhi #2: the sequel number is glued straight onto a CJK word ('…トラック2'),
|
||||
# with the SAME digit already present elsewhere ('第2期' = season 2). Stripping
|
||||
# to [a-z0-9] collapsed both titles to {'2'} and the wrong (cour-2) cover won.
|
||||
OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック"
|
||||
OST2 = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック2"
|
||||
|
||||
|
||||
def test_helper_volume_and_sequel_differ():
|
||||
assert numeric_tokens_differ(VOL4, VOL45)
|
||||
|
|
@ -26,11 +32,20 @@ def test_helper_volume_and_sequel_differ():
|
|||
assert numeric_tokens_differ("Now 99", "Now 100")
|
||||
|
||||
|
||||
def test_helper_cjk_trailing_sequel_digit_differs():
|
||||
# The trailing '2' must register as a difference even though '第2期' already
|
||||
# puts a '2' on both sides.
|
||||
assert numeric_tokens_differ(OST, OST2)
|
||||
assert numeric_tokens_differ(OST2, OST)
|
||||
|
||||
|
||||
def test_helper_shared_or_no_digits_match():
|
||||
assert not numeric_tokens_differ("1989", "1989 (Deluxe)")
|
||||
assert not numeric_tokens_differ(VOL4, VOL4)
|
||||
assert not numeric_tokens_differ("IGOR", "IGOR (Deluxe)")
|
||||
assert not numeric_tokens_differ("", "")
|
||||
# Same CJK album on both sides (incl. the shared 第2期) still matches.
|
||||
assert not numeric_tokens_differ(OST, OST)
|
||||
|
||||
|
||||
def _service_with_results(results):
|
||||
|
|
|
|||
|
|
@ -202,6 +202,19 @@ def test_album_matches_rejects_numeric_difference():
|
|||
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)")
|
||||
|
||||
|
||||
def test_album_matches_rejects_cjk_trailing_sequel_digit():
|
||||
"""Sokhi #2: the sequel '2' is glued straight onto a CJK word
|
||||
('…サウンドトラック2'), and '第2期' (season 2) already puts a '2' on both
|
||||
sides — so the digit-strip collapsed both to {'2'} and the cour-2
|
||||
soundtrack's cover hung on the base soundtrack."""
|
||||
ART = "藤澤慶昌"
|
||||
OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック"
|
||||
assert not art_lookup._album_matches(ART, OST, ART, OST + "2")
|
||||
assert not art_lookup._album_matches(ART, OST + "2", ART, OST)
|
||||
# The genuine base-album hit still matches (incl. its shared 第2期).
|
||||
assert art_lookup._album_matches(ART, OST, ART, OST)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_art_lookup — caching + guarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -36,6 +36,23 @@ def test_is_junk():
|
|||
assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg')
|
||||
|
||||
|
||||
# ── #891: residual (image / sidecar only) folders ───────────────────────────
|
||||
def test_image_only_dir_kept_by_default_removed_with_residual_opt():
|
||||
# Default (junk only): a cover.jpg keeps the folder (the conservative behavior).
|
||||
assert dir_is_removable(['cover.jpg'], []) is False
|
||||
# Opt-in: image/sidecar-only folders become removable.
|
||||
assert dir_is_removable(['cover.jpg'], [], ignore_disposable=True) is True
|
||||
assert dir_is_removable(['back.jpg', 'lyrics.lrc', '.DS_Store'], [], ignore_disposable=True) is True
|
||||
assert dir_is_removable(['folder.png', 'album.nfo'], [], ignore_disposable=True) is True
|
||||
|
||||
|
||||
def test_residual_opt_still_keeps_real_content():
|
||||
# Audio, or anything not recognized as a leftover (a booklet pdf), still blocks.
|
||||
assert dir_is_removable(['cover.jpg', 'song.flac'], [], ignore_disposable=True) is False
|
||||
assert dir_is_removable(['cover.jpg', 'booklet.pdf'], [], ignore_disposable=True) is False
|
||||
assert dir_is_removable([], ['Album'], ignore_disposable=True) is False # surviving subdir
|
||||
|
||||
|
||||
# ── apply re-check (real FS) ────────────────────────────────────────────────
|
||||
def _fx():
|
||||
return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
|
||||
|
|
@ -72,3 +89,34 @@ def test_apply_refuses_library_root(tmp_path):
|
|||
res = remove_empty_folder(str(root), junk_files=[], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is False and 'root' in res['error'].lower()
|
||||
assert root.exists()
|
||||
|
||||
|
||||
def test_apply_sweeps_residual_then_folder_when_enabled(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
d = root / 'Artist' / 'Old Album'; d.mkdir(parents=True)
|
||||
(d / 'cover.jpg').write_text('img')
|
||||
(d / 'back.jpg').write_text('img')
|
||||
(d / 'lyrics.lrc').write_text('la')
|
||||
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True,
|
||||
remove_disposable=True, root=str(root), **_fx())
|
||||
assert res['removed'] is True and not d.exists()
|
||||
|
||||
|
||||
def test_apply_without_residual_opt_leaves_image_folder(tmp_path):
|
||||
# The default apply (no residual opt) must NOT delete a cover.jpg folder.
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
d = root / 'HasCover'; d.mkdir()
|
||||
(d / 'cover.jpg').write_text('img')
|
||||
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is False and d.exists()
|
||||
|
||||
|
||||
def test_apply_residual_opt_still_refuses_real_content(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
d = root / 'Mixed'; d.mkdir()
|
||||
(d / 'cover.jpg').write_text('img')
|
||||
(d / 'booklet.pdf').write_text('pdf') # unrecognized → real content
|
||||
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True,
|
||||
remove_disposable=True, root=str(root), **_fx())
|
||||
assert res['removed'] is False and d.exists()
|
||||
assert (d / 'booklet.pdf').exists() and (d / 'cover.jpg').exists() # nothing deleted
|
||||
|
|
|
|||
100
tests/test_enrichment_tag_preservation.py
Normal file
100
tests/test_enrichment_tag_preservation.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Sokhi: tracks occasionally land 'untagged' after a processing failure.
|
||||
|
||||
enhance_file_metadata clears the file's tags and saves it UP FRONT (so stale
|
||||
tags never linger), then does the failure-prone enrichment (external source-id
|
||||
embed, cover-art fetch) and saves again at the end. The core tags
|
||||
(album/artist/title/track) come from the already-matched context and are written
|
||||
to the in-memory object BEFORE those external steps — but the on-disk file is
|
||||
still the cleared one until the final save.
|
||||
|
||||
The #764 fix made the error handler restore ART, but it gated the re-save on
|
||||
there being original art to restore. So a file with NO embedded art that hit a
|
||||
mid-enrichment crash had its in-memory core tags thrown away and was left on disk
|
||||
exactly as the up-front clear saved it: UNTAGGED.
|
||||
|
||||
These tests run the REAL enhance_file_metadata against a REAL art-less FLAC and
|
||||
assert the core tags survive a crash in the external step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mutagen")
|
||||
from mutagen.flac import FLAC # noqa: E402
|
||||
|
||||
import core.metadata.enrichment as enrichment # noqa: E402
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _make_flac_no_art(path):
|
||||
minimal = (
|
||||
b"fLaC"
|
||||
+ b"\x80\x00\x00\x22"
|
||||
+ b"\x00\x10\x00\x10"
|
||||
+ b"\x00\x00\x00\x00\x00\x00"
|
||||
+ b"\x0a\xc4\x42\xf0\x00\x00\x00\x00"
|
||||
+ b"\x00" * 16
|
||||
)
|
||||
with open(path, "wb") as f:
|
||||
f.write(minimal)
|
||||
FLAC(path).save() # valid FLAC, no tags, no pictures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flac_path():
|
||||
fd, path = tempfile.mkstemp(suffix=".flac")
|
||||
os.close(fd)
|
||||
_make_flac_no_art(path)
|
||||
yield path
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_CORE = {"title": "Yellow", "artist": "Coldplay", "album_artist": "Coldplay",
|
||||
"album": "Parachutes", "track_number": 1, "total_tracks": 9, "disc_number": 1}
|
||||
|
||||
|
||||
def _run(flac_path, *, metadata, embed_side_effect):
|
||||
with patch.object(enrichment, "get_config_manager", return_value=_Cfg()), \
|
||||
patch.object(enrichment, "strip_all_non_audio_tags"), \
|
||||
patch.object(enrichment, "extract_source_metadata", return_value=metadata), \
|
||||
patch.object(enrichment, "embed_source_ids"), \
|
||||
patch.object(enrichment, "verify_metadata_written", return_value=True), \
|
||||
patch.object(enrichment, "embed_album_art_metadata", side_effect=embed_side_effect):
|
||||
return enrichment.enhance_file_metadata(
|
||||
flac_path, context={}, artist={"name": "Coldplay"}, album_info={},
|
||||
)
|
||||
|
||||
|
||||
def test_core_tags_survive_when_art_step_raises_on_artless_file(flac_path):
|
||||
"""The regression: art-less file + a crash in the external art step must NOT
|
||||
leave the file untagged — the matched core tags must be on disk."""
|
||||
def boom(audio_file, metadata):
|
||||
raise RuntimeError("art backend exploded")
|
||||
|
||||
result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=boom)
|
||||
assert result is False # enrichment reported failure
|
||||
f = FLAC(flac_path)
|
||||
assert f.get("title") == ["Yellow"] # ...but core tags persisted
|
||||
assert f.get("artist") == ["Coldplay"]
|
||||
assert f.get("album") == ["Parachutes"] # the tag Rockbox buckets on
|
||||
assert f.get("tracknumber") == ["1/9"]
|
||||
|
||||
|
||||
def test_core_tags_written_on_happy_path_artless_file(flac_path):
|
||||
result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=lambda *a, **k: False)
|
||||
assert result is True
|
||||
f = FLAC(flac_path)
|
||||
assert f.get("album") == ["Parachutes"]
|
||||
assert f.get("artist") == ["Coldplay"]
|
||||
73
tests/test_repair_scheduler_tz.py
Normal file
73
tests/test_repair_scheduler_tz.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""#885: repair-job scheduling must be timezone-independent.
|
||||
|
||||
`finished_at` is written by SQLite's CURRENT_TIMESTAMP (always UTC), but the
|
||||
scheduler compared it against `datetime.now()` (naive LOCAL). With TZ=Australia/
|
||||
Sydney (UTC+11) every job looked ~11h stale and ran every poll; America/New_York
|
||||
(behind UTC) masked it. The fix parses finished_at as UTC and compares against a
|
||||
UTC now, so the machine timezone no longer leaks into elapsed time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core.repair_worker import RepairWorker
|
||||
|
||||
|
||||
# ── pure helper ───────────────────────────────────────────────────────────────
|
||||
def test_hours_since_treats_naive_timestamp_as_utc():
|
||||
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
|
||||
# SQLite CURRENT_TIMESTAMP style: UTC, no tz suffix.
|
||||
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_hours_since_handles_aware_timestamp():
|
||||
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
|
||||
assert RepairWorker._hours_since('2026-06-18T00:00:00+00:00', now) == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_hours_since_recent_is_near_zero():
|
||||
now = datetime(2026, 6, 18, 0, 0, 30, tzinfo=timezone.utc)
|
||||
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(30 / 3600, abs=1e-6)
|
||||
|
||||
|
||||
# ── the #885 repro: a just-run job is never due, regardless of timezone ────────
|
||||
def _set_tz(monkeypatch, tz):
|
||||
monkeypatch.setenv('TZ', tz)
|
||||
try:
|
||||
time.tzset()
|
||||
except AttributeError:
|
||||
pytest.skip('time.tzset() unavailable on this platform')
|
||||
|
||||
|
||||
def test_just_run_job_not_due_under_any_timezone(monkeypatch):
|
||||
w = RepairWorker.__new__(RepairWorker)
|
||||
w._jobs = {'cache_evictor': object()}
|
||||
monkeypatch.setattr(RepairWorker, 'get_job_config',
|
||||
lambda self, jid: {'enabled': True, 'interval_hours': 6})
|
||||
# Job finished "now" in UTC (exactly how CURRENT_TIMESTAMP records it).
|
||||
finished = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
monkeypatch.setattr(RepairWorker, '_get_last_run',
|
||||
lambda self, jid: {'finished_at': finished})
|
||||
|
||||
# Australia/Sydney is the exact repro; check the Americas + UTC too.
|
||||
for tz in ('Australia/Sydney', 'America/New_York', 'UTC'):
|
||||
_set_tz(monkeypatch, tz)
|
||||
assert w._pick_next_job() is None, f"just-run job wrongly due under TZ={tz}"
|
||||
|
||||
|
||||
def test_stale_job_is_still_picked_under_sydney(monkeypatch):
|
||||
# Sanity: a genuinely-overdue job IS picked (we didn't break due-detection).
|
||||
w = RepairWorker.__new__(RepairWorker)
|
||||
w._jobs = {'cache_evictor': object()}
|
||||
monkeypatch.setattr(RepairWorker, 'get_job_config',
|
||||
lambda self, jid: {'enabled': True, 'interval_hours': 6})
|
||||
# Finished ~10h ago in UTC.
|
||||
old = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
monkeypatch.setattr(RepairWorker, '_get_last_run',
|
||||
lambda self, jid: {'finished_at': old})
|
||||
_set_tz(monkeypatch, 'Australia/Sydney')
|
||||
assert w._pick_next_job() == 'cache_evictor'
|
||||
68
tests/test_spotify_worker_status.py
Normal file
68
tests/test_spotify_worker_status.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Issue #887: the Spotify enrichment worker's get_stats() must report
|
||||
``using_free`` when it's enriching via the no-creds Spotify Free source — even
|
||||
with no official auth — so the dashboard shows "Running (Spotify Free)" instead
|
||||
of a misleading "Not Authenticated".
|
||||
|
||||
Builds the worker via __new__ (bypassing the real SpotifyClient()) and stubs the
|
||||
db-querying helpers, so the get_stats() free/auth logic is tested in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
from core.spotify_worker import SpotifyWorker
|
||||
|
||||
|
||||
def _worker(*, serving_via_free, sp=None, rate_limited=False, budget_free=False):
|
||||
w = SpotifyWorker.__new__(SpotifyWorker)
|
||||
w.running = True
|
||||
w.paused = False
|
||||
w.thread = types.SimpleNamespace(is_alive=lambda: True)
|
||||
w.current_item = None
|
||||
w.stats = {'pending': 0, 'processed': 0}
|
||||
w._serving_via_free = serving_via_free
|
||||
w.client = types.SimpleNamespace(
|
||||
sp=sp,
|
||||
is_rate_limited=lambda: rate_limited,
|
||||
get_rate_limit_info=lambda: None,
|
||||
get_post_ban_cooldown_remaining=lambda: 0,
|
||||
is_spotify_metadata_available=lambda: True,
|
||||
_budget_exhausted_use_free=budget_free,
|
||||
)
|
||||
# db-backed helpers stubbed — we only exercise the auth/free reporting.
|
||||
w._count_pending_items = lambda: 100
|
||||
w._get_progress_breakdown = lambda: {}
|
||||
w._get_daily_budget_info = lambda: {'exhausted': False}
|
||||
return w
|
||||
|
||||
|
||||
def test_no_auth_but_serving_via_free_reports_using_free():
|
||||
# #887: no official auth (sp is None), worker enriching via Spotify Free.
|
||||
stats = _worker(serving_via_free=True, sp=None).get_stats()
|
||||
assert stats['authenticated'] is False # no official auth
|
||||
assert stats['using_free'] is True # ...but Free is carrying it
|
||||
|
||||
|
||||
def test_no_auth_and_not_serving_free_is_not_using_free():
|
||||
# Genuinely can't enrich (no auth, free not active) -> Not Authenticated stands.
|
||||
stats = _worker(serving_via_free=False, sp=None).get_stats()
|
||||
assert stats['authenticated'] is False
|
||||
assert stats['using_free'] is False
|
||||
|
||||
|
||||
def test_rate_limit_bridge_still_reports_using_free():
|
||||
# Pre-existing bridge path must still work (cache False, but rate-limited).
|
||||
stats = _worker(serving_via_free=False, sp=object(), rate_limited=True).get_stats()
|
||||
assert stats['using_free'] is True
|
||||
|
||||
|
||||
def test_budget_bridge_still_reports_using_free():
|
||||
stats = _worker(serving_via_free=False, sp=object(), budget_free=True).get_stats()
|
||||
assert stats['using_free'] is True
|
||||
|
||||
|
||||
def test_authed_and_not_on_free_reports_authenticated_not_free():
|
||||
stats = _worker(serving_via_free=False, sp=object()).get_stats()
|
||||
assert stats['authenticated'] is True
|
||||
assert stats['using_free'] is False
|
||||
|
|
@ -714,6 +714,67 @@ def test_nzbget_parse_group_computes_progress() -> None:
|
|||
assert status.download_speed == 500_000
|
||||
|
||||
|
||||
def test_nzbget_queue_group_never_offers_a_save_path() -> None:
|
||||
"""A queued group's DestDir is the in-progress '….#NZBID' dir, which is gone
|
||||
after the move — never expose it as a final save_path. Finalisation must come
|
||||
from the HISTORY entry. (Swigs: imported from /…/incomplete/….#2141.)"""
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_group({
|
||||
'NZBID': 2141, 'NZBName': 'xRepentancex-The.Sickness.Of.Eden',
|
||||
'Status': 'DOWNLOADING',
|
||||
'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141',
|
||||
'Category': 'soulsync',
|
||||
})
|
||||
assert status.save_path is None
|
||||
|
||||
|
||||
def test_nzbget_pp_finished_group_is_completed_but_has_no_path() -> None:
|
||||
"""PP_FINISHED maps to 'completed' but is still a QUEUE group on the
|
||||
in-progress dir — it must report no save_path so the plugin waits for the
|
||||
history entry instead of finalising on the incomplete folder."""
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_group({
|
||||
'NZBID': 2141, 'NZBName': 'Album', 'Status': 'PP_FINISHED',
|
||||
'DestDir': '/data/usenet/incomplete/Album.#2141', 'Category': 'soulsync',
|
||||
})
|
||||
assert status.state == 'completed'
|
||||
assert status.save_path is None
|
||||
|
||||
|
||||
def test_nzbget_history_prefers_finaldir_over_destdir() -> None:
|
||||
"""After a post-processing move, FinalDir is the real location; DestDir can
|
||||
still be the intermediate dir. Swigs' exact case."""
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_history({
|
||||
'NZBID': 2141, 'Name': 'xRepentancex-The.Sickness.Of.Eden',
|
||||
'Status': 'SUCCESS/ALL',
|
||||
'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141',
|
||||
'FinalDir': '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT',
|
||||
'Category': 'soulsync',
|
||||
})
|
||||
assert status.state == 'completed'
|
||||
assert status.save_path == '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT'
|
||||
|
||||
|
||||
def test_nzbget_history_falls_back_to_destdir_when_no_finaldir() -> None:
|
||||
"""No PP move -> FinalDir empty -> use DestDir (the final dest in that case)."""
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_history({
|
||||
'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/HEALTH',
|
||||
'DestDir': '/data/usenet/completed/Album', 'FinalDir': '', 'Category': 'c',
|
||||
})
|
||||
assert status.save_path == '/data/usenet/completed/Album'
|
||||
|
||||
|
||||
def test_nzbget_history_empty_dirs_yield_none() -> None:
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_history({
|
||||
'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/ALL',
|
||||
'DestDir': ' ', 'FinalDir': '', 'Category': 'c',
|
||||
})
|
||||
assert status.save_path is None
|
||||
|
||||
|
||||
def test_nzbget_remove_rejects_non_numeric_id() -> None:
|
||||
"""NZBGet IDs are ints; passing a string id like 'abc' must
|
||||
fail fast instead of corrupting the editqueue call."""
|
||||
|
|
|
|||
143
web_server.py
143
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
|||
|
||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||
_SOULSYNC_BASE_VERSION = "2.7.3"
|
||||
_SOULSYNC_BASE_VERSION = "2.7.4"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -10044,6 +10044,147 @@ def get_artist_enhanced_detail(artist_id):
|
|||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ── Re-identify an imported track (#889) ──
|
||||
@app.route('/api/reidentify/sources', methods=['GET'])
|
||||
def reidentify_sources():
|
||||
"""Source tabs for the Re-identify modal — every metadata source with a live
|
||||
client, the active one flagged so the UI selects it by default."""
|
||||
try:
|
||||
from core.imports.rematch_search import available_sources
|
||||
return jsonify({"success": True, "sources": available_sources()})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e), "sources": []}), 500
|
||||
|
||||
|
||||
@app.route('/api/reidentify/search', methods=['GET'])
|
||||
def reidentify_search():
|
||||
"""Search one metadata source for the releases a track appears on.
|
||||
|
||||
Query params: ``source`` (defaults to the active source), ``q`` (the query),
|
||||
``limit``. Returns display rows — the SAME song across single/EP/album, each
|
||||
with a type badge — for the user to pick. album_id is resolved later, only for
|
||||
the chosen row."""
|
||||
try:
|
||||
from core.imports.rematch_search import available_sources, search_release_candidates
|
||||
query = (request.args.get('q') or '').strip()
|
||||
if not query:
|
||||
return jsonify({"success": True, "results": []})
|
||||
source = (request.args.get('source') or '').strip()
|
||||
if not source:
|
||||
actives = [s for s in available_sources() if s.get('active')]
|
||||
source = actives[0]['source'] if actives else 'spotify'
|
||||
try:
|
||||
limit = max(1, min(50, int(request.args.get('limit', 25))))
|
||||
except (TypeError, ValueError):
|
||||
limit = 25
|
||||
rows = search_release_candidates(source, query, limit=limit)
|
||||
return jsonify({"success": True, "source": source, "results": rows})
|
||||
except Exception as e:
|
||||
logger.error(f"Re-identify search error: {e}")
|
||||
return jsonify({"success": False, "error": str(e), "results": []}), 500
|
||||
|
||||
|
||||
@app.route('/api/reidentify/apply', methods=['POST'])
|
||||
def reidentify_apply():
|
||||
"""Apply a re-identify: stage the track's library file + write a single-use hint
|
||||
so the auto-import worker re-files it under the chosen release (Phase 2).
|
||||
|
||||
Body: ``{library_track_id, source, track_id, replace}``. Admin-only (mutates the
|
||||
library). COPIES the file — the original is removed only after the re-import
|
||||
succeeds, and only when ``replace`` is true."""
|
||||
try:
|
||||
database = get_database()
|
||||
pid = get_current_profile_id()
|
||||
prof = database.get_profile(pid) if pid else None
|
||||
if not prof or not prof.get('is_admin'):
|
||||
return jsonify({"success": False, "error": "Admin only"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
library_track_id = data.get('library_track_id')
|
||||
source = (data.get('source') or '').strip()
|
||||
track_id = (data.get('track_id') or '').strip()
|
||||
replace = bool(data.get('replace', True))
|
||||
if not library_track_id or not source or not track_id:
|
||||
return jsonify({"success": False, "error": "library_track_id, source and track_id are required"}), 400
|
||||
|
||||
from core.imports.rematch_search import resolve_hint_fields
|
||||
from core.imports.rematch_apply import stage_file_for_reidentify, build_reidentify_hint
|
||||
from core.imports.rematch_hints import create_hint
|
||||
|
||||
# 1) Resolve the picked release → the IDs the hint needs (album_id critically).
|
||||
hint_fields = resolve_hint_fields(source, track_id)
|
||||
if not hint_fields:
|
||||
return jsonify({"success": False, "error": "Could not resolve the selected release (no album id)"}), 400
|
||||
|
||||
# 2) Locate the library file for this track.
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT file_path FROM tracks WHERE id = ?", (str(library_track_id),))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row or not row['file_path']:
|
||||
return jsonify({"success": False, "error": "Library track has no file on disk"}), 404
|
||||
stored_path = row['file_path']
|
||||
|
||||
# Resolve the stored DB path to a file THIS process can actually read, using
|
||||
# the SAME strong resolver the rest of the app uses (transfer/download/library/
|
||||
# Plex search + #833 confusable folding via find_on_disk).
|
||||
real_path = _resolve_library_file_path(stored_path)
|
||||
if not real_path:
|
||||
# On a miss, run the diagnostic variant purely to tell us (and the user)
|
||||
# what was tried — instead of failing on the raw, possibly-stale path.
|
||||
from core.library.path_resolver import resolve_library_file_path_with_diagnostic
|
||||
try:
|
||||
_plex = media_server_engine.client('plex') if media_server_engine else None
|
||||
except Exception:
|
||||
_plex = None
|
||||
_, attempt = resolve_library_file_path_with_diagnostic(
|
||||
stored_path, config_manager=config_manager, plex_client=_plex)
|
||||
searched = ", ".join(attempt.base_dirs_tried) or "(no library/transfer/download dirs configured)"
|
||||
logger.warning("[Re-identify] could not locate track %s file — stored=%s raw_exists=%s searched=[%s]",
|
||||
library_track_id, stored_path, attempt.raw_path_existed, searched)
|
||||
return jsonify({"success": False, "error": (
|
||||
f"SoulSync couldn't find this track's file on disk.\nStored path: {stored_path}\n"
|
||||
f"Searched: {searched}.\nIf the file lives on a media server SoulSync can't read directly "
|
||||
f"(or the stored path is stale), re-identify isn't available for it.")}), 404
|
||||
|
||||
# 3) Copy into staging + fingerprint the copy.
|
||||
staging_dir = docker_resolve_path(config_manager.get('import.staging_path', './Staging'))
|
||||
staged = stage_file_for_reidentify(real_path, staging_dir, library_track_id)
|
||||
|
||||
# 4) Persist the single-use hint.
|
||||
hint = build_reidentify_hint(library_track_id, hint_fields,
|
||||
staged['staged_path'], staged['content_hash'], replace=replace)
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
hint_id = create_hint(cur, hint)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 5) Nudge the worker so it doesn't wait for the next timer tick.
|
||||
try:
|
||||
if auto_import_worker is not None:
|
||||
auto_import_worker.trigger_scan()
|
||||
except Exception as _e:
|
||||
logger.debug("Re-identify: scan nudge failed (worker will catch it on its timer): %s", _e)
|
||||
|
||||
logger.info("[Re-identify] staged track %s → %s '%s' (%s), replace=%s",
|
||||
library_track_id, hint.album_type or 'release', hint.album_name or '?',
|
||||
source, replace)
|
||||
return jsonify({"success": True, "hint_id": hint_id, "staged_path": staged['staged_path'],
|
||||
"album_name": hint.album_name, "album_type": hint.album_type})
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({"success": False, "error": f"Source file not found: {e}"}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Re-identify apply error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/artist/<artist_id>/quality-analysis')
|
||||
def get_artist_quality_analysis(artist_id):
|
||||
"""Analyze track quality for an artist — returns tier classification for each track."""
|
||||
|
|
|
|||
|
|
@ -5135,6 +5135,37 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AAC Quality (opt-in; Soulseek/torrents — off by default) -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-aac-enabled"
|
||||
onchange="toggleQuality('aac')">
|
||||
<span class="quality-tier-name">AAC <span style="opacity:.6;font-weight:400;">(.m4a/.aac — Soulseek)</span></span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-aac">Priority: 1.5</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-aac">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min"
|
||||
id="aac-min" min="0" max="400" value="128" step="10"
|
||||
oninput="updateQualityRange('aac')">
|
||||
<input type="range" class="range-slider range-slider-max"
|
||||
id="aac-max" min="0" max="400" value="400" step="10"
|
||||
oninput="updateQualityRange('aac')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="aac-min-value">128 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="aac-max-value">400 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 320 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
|
|
@ -5730,6 +5761,13 @@
|
|||
<small class="settings-hint">Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.</small>
|
||||
<div class="hybrid-source-list" id="art-source-list"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="single-to-album-enabled">
|
||||
Match singles to their parent album
|
||||
</label>
|
||||
<small class="settings-hint">When a track matches a single release, look up the album that contains it and tag it as that album — so every song in an album gets the same (album) cover instead of some getting the single's art. Off by default: adds an extra metadata lookup per single-matched track.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="lrclib-enabled" checked>
|
||||
|
|
@ -7705,6 +7743,50 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Re-identify Track Modal (#889) -->
|
||||
<div class="modal-overlay hidden" id="reid-modal-overlay" onclick="if(event.target===this)closeReidentifyModal()">
|
||||
<div class="reid-modal" id="reid-modal">
|
||||
<div class="reid-hero">
|
||||
<div class="reid-hero-decor">
|
||||
<div class="reid-hero-bg" id="reid-hero-bg"></div>
|
||||
<div class="reid-hero-overlay"></div>
|
||||
</div>
|
||||
<span class="reid-close" onclick="closeReidentifyModal()">×</span>
|
||||
<div class="reid-hero-content">
|
||||
<div class="reid-hero-art" id="reid-hero-art"></div>
|
||||
<div class="reid-hero-meta">
|
||||
<div class="reid-hero-eyebrow">Re-identify track</div>
|
||||
<div class="reid-hero-title" id="reid-hero-title">Track</div>
|
||||
<div class="reid-hero-sub" id="reid-hero-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reid-tabs" id="reid-tabs"></div>
|
||||
|
||||
<div class="reid-search">
|
||||
<svg class="reid-search-icon" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/></svg>
|
||||
<input type="text" id="reid-search-input" class="reid-search-input"
|
||||
placeholder="Search for the track…" onkeydown="if(event.key==='Enter')runReidentifySearch()" />
|
||||
<button class="reid-search-btn" id="reid-search-btn" onclick="runReidentifySearch()">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="reid-results" id="reid-results"></div>
|
||||
|
||||
<div class="reid-footer">
|
||||
<label class="reid-replace" title="When on, the original file is deleted after the track is re-filed under the new release.">
|
||||
<input type="checkbox" id="reid-replace" checked />
|
||||
<span class="reid-replace-box"></span>
|
||||
<span class="reid-replace-text">Replace the original file <em>(recommended)</em></span>
|
||||
</label>
|
||||
<div class="reid-footer-actions">
|
||||
<button class="btn btn--secondary" onclick="closeReidentifyModal()">Cancel</button>
|
||||
<button class="btn btn--primary" id="reid-confirm-btn" disabled onclick="confirmReidentify()">Re-identify</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Watchlist Artist Config Modal -->
|
||||
<div class="modal-overlay hidden" id="watchlist-artist-config-modal-overlay">
|
||||
<div class="watchlist-artist-config-modal" id="watchlist-artist-config-modal">
|
||||
|
|
|
|||
|
|
@ -455,11 +455,14 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
|||
const button = document.getElementById('spotify-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const notAuthenticated = data.authenticated === false;
|
||||
const isRateLimited = data.rate_limited === true;
|
||||
// The real API is banned but the worker is still matching via the no-creds
|
||||
// Spotify Free source — treat it as running, not stuck (#798 bridge).
|
||||
// The real API is unauthed/banned but the worker is still matching via the
|
||||
// no-creds Spotify Free source — treat it as running, not stuck (#798/#887).
|
||||
const bridgingFree = data.using_free === true;
|
||||
// #887: a no-auth user whose enrichment runs on Spotify Free is NOT "not
|
||||
// authenticated" for status purposes — the worker IS enriching. Only flag
|
||||
// Not Authenticated when Free isn't carrying it.
|
||||
const notAuthenticated = data.authenticated === false && !bridgingFree;
|
||||
const rateLimitedStuck = isRateLimited && !bridgingFree;
|
||||
// Budget is a real-API cap; when bridging to free it no longer applies, so
|
||||
// only treat the budget as a stop when we're NOT serving via free (#798).
|
||||
|
|
|
|||
|
|
@ -3404,19 +3404,16 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
// Convention: keep only the CURRENT release here, plus a single brief
|
||||
// "Earlier versions" summary entry. Don't accumulate old per-version blocks.
|
||||
'2.7.3': [
|
||||
{ date: 'June 2026 — 2.7.3 release' },
|
||||
{ title: 'Quality Upgrade Finder', desc: 'a new findings-based job that scans your library for tracks you own in worse quality than is available and lets you upgrade them. matches by ISRC first, then album→track, then artist+title, with a direct track-ID tier, a dedup-skip, and a duration guard so it never swaps in the wrong song. replaces the old auto-acting Quality Scanner.', page: 'tools' },
|
||||
{ title: 'Tidal playlist discovery shows everything (#867)', desc: 'tidal playlist discovery used to cap at ~21 tracks — it now walks the whole playlist. and the discovery modal opens instantly in its "discovering" state instead of freezing the UI for ~10s on a pre-fetch.', page: 'sync' },
|
||||
{ title: 'Wishlist ignore-list (#874)', desc: 'remove a track from the wishlist or cancel an in-flight wishlist download and soulsync stops auto-re-adding it (it used to re-grab the same thing forever). softer than a blocklist — it expires after 30 days and never blocks a manual download. new "Ignored" view to see/undo it.', page: 'downloads' },
|
||||
{ title: 'Quarantine: group duplicates + auto-clear (#876)', desc: 'multiple failed attempts at the same song now collapse into one collapsible group; approve the good one and the other options auto-clear. plus the quarantine tab shows the correct count the moment you open it.', page: 'downloads' },
|
||||
{ title: 'Download Discography filters match Artist Detail (#877)', desc: 'the bulk-download modal now has the same Albums / EPs / Singles + Live / Compilations / Featured filters Artist Detail does — and the EPs toggle actually works now (it was always empty before).', page: 'artists' },
|
||||
{ title: 'Settings can no longer wipe your config (#879)', desc: 'if the settings page failed to load it used to fall back to blank defaults and then autosave the blanks over your real config. it now bails on a failed load and tells you to reload — your saved config is left untouched.', page: 'settings' },
|
||||
{ title: 'Tidal Favorites mirror grabs everything (#880)', desc: 'a transient tidal rate-limit (429) mid-sync used to truncate your Favorite Tracks mirror to ~98 of 500+. it retries the page with backoff now instead of stopping short.', page: 'sync' },
|
||||
{ title: 'Track numbers fixed (the "Track 01" bug)', desc: 'single tracks — especially deezer-sourced — imported as "01 - Title" regardless of their real album position, littering album folders with duplicate 01s. soulsync now recovers the real position from the downloaded file\'s own tag instead of guessing 1.', page: 'downloads' },
|
||||
{ title: 'More fixes', desc: 'deezer ARL stops appearing to "reset itself" — the saved token is tested directly, not a redacted mask (#870); an artist sharing a name with another no longer gets the wrong discography (disambiguated by the catalog you actually own, #868); a "Title - Remix" search now matches the base-titled track in your library; and a colon in a title (T:T) matches an underscore variant (T_T).', page: 'library' },
|
||||
{ title: 'Sidebar polish', desc: 'frosted-glass header blur, vertically-centered nav count badges, and the My-Accounts / Personal-Settings buttons are hidden for admins (who use the global app account anyway).', page: 'settings' },
|
||||
{ title: 'Earlier versions', desc: '2.7.2 added playlist-folder mirroring, server-playlist M3U export, follow-only watchlist, soundcloud-link + better youtube imports, and ReplayGain / Empty-Folder maintenance jobs. 2.7.1 added download verification (acoustid fingerprint-checks every download) + a review queue and closed the websocket login-bypass (#852). 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support. before that the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, and Library Re-tag.' },
|
||||
'2.7.4': [
|
||||
{ date: 'June 2026 — 2.7.4 release' },
|
||||
{ title: 'Re-identify a track (#889)', desc: 'filed a track under the wrong release? a new ⇄ button in the library Enhanced view lets you re-identify it — search any source (tabs, defaults to your active one), see the same song across its single / EP / album with type badges, pick the right one, and soulsync re-files the file you already have under that release with the correct year, in-album track number, and art. replace the original or keep both.', page: 'artists' },
|
||||
{ title: 'Track titles no longer keep the "01 - " (#890)', desc: 'files with no embedded title tag used to import as "01 - Song Title" (the filename stem) — which never matched the canonical "Song Title", so the real track showed as a false "missing". the number prefix is now stripped, conservatively, so titles like "7 Rings" or "1-800-273-8255" are left alone.', page: 'library' },
|
||||
{ title: 'Clear dead cover-art folders (#891)', desc: 'a Library Reorganize that moves an album now sweeps the leftover cover.jpg / .lrc / sidecars from the old folder so it actually empties. plus an opt-in "Remove Residual Files" toggle on the Empty Folder Cleaner clears the image-/sidecar-only folders you already have.', page: 'tools' },
|
||||
{ title: 'AAC as an opt-in quality tier (#886)', desc: 'soulseek downloads can now include AAC (.m4a) as a selectable quality tier, ranked above MP3 and below FLAC. purely additive — off by default; every existing profile behaves exactly as before until you enable it.', page: 'settings' },
|
||||
{ title: 'Spotify Free enrichment status (#887)', desc: 'if you run enrichment on Spotify Free (no spotify auth), the dashboard button now reads "Running (Spotify Free)" instead of wrongly showing "Not Authenticated".', page: 'dashboard' },
|
||||
{ title: 'Cleaner album imports (Sokhi)', desc: 'songs from the same album now group under one canonical release id (no more split discographies or mixed cover art), a single can be matched to its parent album, a mid-enrichment crash on an art-less file no longer leaves it untagged, and a sequel digit glued to a CJK title no longer matches the wrong album.', page: 'library' },
|
||||
{ title: 'More fixes', desc: 'NZBGet imports from the finished location instead of the incomplete "….#NZBID" folder (#884); setting your timezone to Australia/Sydney no longer makes the cache-maintenance job loop every 5 seconds (#885); and the artist-detail header no longer bleeds the blurred artist photo behind it.', page: 'downloads' },
|
||||
{ title: 'Earlier versions', desc: '2.7.3 added the Quality Upgrade Finder (find + upgrade tracks you own in worse quality than available), a wishlist ignore-list (#874), quarantine duplicate-grouping (#876), the "Track 01" position fix, and Tidal discovery/favorites fixes (#867, #880). 2.7.2 brought playlist-folder mirroring + server-playlist M3U export and ReplayGain / Empty-Folder maintenance jobs; 2.7.1 added download verification + a review queue and closed the websocket login-bypass (#852); 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support.' },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -3447,57 +3444,38 @@ const WHATS_NEW = {
|
|||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
title: "Quality Upgrade Finder",
|
||||
description: "a new findings-based job that scans your library for tracks you own in worse quality than is available, and lets you upgrade them. replaces the old auto-acting Quality Scanner.",
|
||||
title: "Re-identify a track (#889)",
|
||||
description: "filed a track under the wrong release? re-identify it from the library without re-downloading — soulsync re-files the file you already have under the release you pick.",
|
||||
features: [
|
||||
"matches by ISRC first, then album→track, then artist+title — using the IDs enrichment already embedded",
|
||||
"a direct track-ID tier for exact source matches",
|
||||
"dedup-skip so it won't re-find the same upgrade, and a duration guard so it never swaps in the wrong track",
|
||||
"a ⇄ button in the Enhanced library view opens a search across any configured source (tabs, defaults to your active one)",
|
||||
"see the same song across its single / EP / album, each with a type badge, and pick the right collection",
|
||||
"it re-files under that release with the correct year, in-album track number, and album art",
|
||||
"replace the original entry or keep both — and it can never delete the file if you pick the release it's already in",
|
||||
],
|
||||
usage_note: "Tools → Quality Upgrade Finder",
|
||||
usage_note: "Library → an artist → Enhanced view → ⇄ on a track",
|
||||
},
|
||||
{
|
||||
title: "Wishlist ignore-list (#874)",
|
||||
description: "remove a track from the wishlist, or cancel an in-flight wishlist download, and soulsync stops auto-re-adding it — it used to re-download the same release forever.",
|
||||
title: "Cleaner libraries & imports",
|
||||
description: "a batch of fixes that keep the library tidy and matchable.",
|
||||
features: [
|
||||
"softer than a blocklist: it expires after 30 days and never blocks a manual download",
|
||||
"a new \"Ignored\" view lets you see what's skipped and un-ignore anything",
|
||||
],
|
||||
usage_note: "Wishlist → Ignored",
|
||||
},
|
||||
{
|
||||
title: "Quarantine: group duplicates + auto-clear (#876)",
|
||||
description: "the quarantine workflow got a cleanup pass.",
|
||||
features: [
|
||||
"multiple failed attempts at the same song collapse into one collapsible group",
|
||||
"approve the good one and the other options auto-clear",
|
||||
"the quarantine tab shows the correct count the moment you open it (no more stale 0)",
|
||||
"#890 — track titles no longer keep the \"01 - \" from a filename (which caused false \"missing\" tracks); stripped conservatively so \"7 Rings\" / \"1-800-273-8255\" are left alone",
|
||||
"#891 — a reorganize now sweeps leftover cover.jpg / .lrc from the old folder, plus an opt-in \"Remove Residual Files\" toggle clears image-only folders you already have",
|
||||
"Sokhi — same-album songs group under one canonical release (no split discographies / mixed cover art); a single can match its parent album; a mid-enrichment crash no longer leaves a file untagged; a CJK-title sequel-digit no longer matches the wrong album",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tidal discovery & favorites (#867, #880)",
|
||||
description: "two tidal sync fixes.",
|
||||
title: "Quality & sources",
|
||||
description: "more control over downloads, plus a couple of source fixes.",
|
||||
features: [
|
||||
"#867 — playlist discovery now walks the whole playlist instead of capping at ~21 tracks, and the modal opens instantly instead of freezing the UI for ~10s",
|
||||
"#880 — a transient rate-limit (429) mid-sync no longer truncates your Favorite Tracks mirror to ~98 of 500+; it retries with backoff",
|
||||
"#886 — AAC (.m4a) as an opt-in soulseek quality tier, ranked above MP3 and below FLAC; off by default so nothing changes until you enable it",
|
||||
"#887 — enrichment on Spotify Free now reads \"Running (Spotify Free)\" instead of \"Not Authenticated\"",
|
||||
"#884 — NZBGet imports from the finished location, not the incomplete \"….#NZBID\" folder",
|
||||
"#885 — Australia/Sydney timezone no longer makes the cache-maintenance job loop every 5 seconds",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fixes this release",
|
||||
description: "a stack of issue fixes on top of 2.7.2.",
|
||||
features: [
|
||||
"the \"Track 01\" bug — single tracks (especially deezer) imported as \"01\" regardless of real album position; now recovered from the file's own tag",
|
||||
"#879 — a failed Settings load can no longer overwrite your saved config with blank defaults",
|
||||
"#877 — Download Discography filters now match Artist Detail (working EPs toggle + Live / Compilations / Featured)",
|
||||
"#870 — deezer ARL stops appearing to \"reset itself\" (the saved token is tested, not a redacted mask)",
|
||||
"#868 — an artist sharing a name with another no longer gets the wrong discography",
|
||||
"Find & Add matches a \"Title - Remix\" search to the base-titled library track; a colon (T:T) matches an underscore variant (T_T)",
|
||||
"sidebar polish: frosted-glass header, centered nav badges, admin-only cleanup",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Earlier in 2.7.2 / 2.7.1 / 2.7.0",
|
||||
description: "2.7.2 added playlist-folder mirroring, server-playlist M3U export, follow-only watchlist, soundcloud-link + better youtube imports, and ReplayGain / Empty-Folder maintenance jobs. 2.7.1 added download verification (acoustid fingerprint-checks every download against what you asked for) + an unverified review queue, and closed the websocket login-bypass (#852). 2.7.0 made multi-user real: per-profile streaming accounts (My Accounts), opt-in username/password login with recovery, and reverse-proxy support. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, and Library Re-tag.",
|
||||
title: "Earlier in 2.7.3 / 2.7.2 / 2.7.1 / 2.7.0",
|
||||
description: "2.7.3 added the Quality Upgrade Finder (find + upgrade tracks you own in worse quality than available), a wishlist ignore-list (#874), quarantine duplicate-grouping (#876), the \"Track 01\" position fix, and Tidal discovery/favorites fixes (#867, #880). 2.7.2 brought playlist-folder mirroring + server-playlist M3U export and ReplayGain / Empty-Folder maintenance jobs; 2.7.1 added download verification (acoustid checks every download) + a review queue and closed the websocket login-bypass (#852); 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support.",
|
||||
features: [],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4190,6 +4190,7 @@ function _buildTrackRow(track, album, admin) {
|
|||
actionsTd.innerHTML = `
|
||||
<div class="enhanced-track-actions-group">
|
||||
<button class="enhanced-source-info-btn" title="View download source info">ℹ</button>
|
||||
<button class="enhanced-reidentify-btn" title="Re-identify — file this track under a different release">⇄</button>
|
||||
<button class="enhanced-redownload-btn" title="Redownload this track">↻</button>
|
||||
<button class="enhanced-delete-btn" title="Delete track from library">✕</button>
|
||||
</div>
|
||||
|
|
@ -4387,6 +4388,15 @@ function _attachTableDelegation(table, album) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Re-identify button (admin) — #889
|
||||
if (target.closest('.enhanced-reidentify-btn')) {
|
||||
e.stopPropagation();
|
||||
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
||||
openReidentifyModal(track.id, track.title || 'Unknown', artistName,
|
||||
album.title || '', album.thumb_url || '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Redownload button (admin)
|
||||
if (target.closest('.enhanced-redownload-btn')) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -9174,3 +9184,198 @@ async function openArtistExportModal(initialScope) {
|
|||
|
||||
refresh();
|
||||
}
|
||||
|
||||
|
||||
// ==================== Re-identify Track Modal (#889) ====================
|
||||
// Lets an admin re-file an already-imported track under a different release
|
||||
// (single / EP / album). Searches any configured metadata source (tabs, default
|
||||
// active), and on confirm stages the file + writes a single-use hint the
|
||||
// auto-import worker consumes (see core/imports/rematch_*.py).
|
||||
|
||||
const reidState = { trackId: null, source: null, sources: [], rows: [], selected: null };
|
||||
|
||||
function openReidentifyModal(trackId, title, artist, albumTitle, imageUrl) {
|
||||
reidState.trackId = trackId;
|
||||
reidState.source = null;
|
||||
reidState.rows = [];
|
||||
reidState.selected = null;
|
||||
|
||||
const overlay = document.getElementById('reid-modal-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
// Hero
|
||||
document.getElementById('reid-hero-title').textContent = title || 'Track';
|
||||
const sub = document.getElementById('reid-hero-sub');
|
||||
sub.textContent = (artist || '') + (albumTitle ? ` · currently in “${albumTitle}”` : '');
|
||||
const art = document.getElementById('reid-hero-art');
|
||||
const bg = document.getElementById('reid-hero-bg');
|
||||
if (imageUrl) {
|
||||
art.style.backgroundImage = `url('${imageUrl}')`;
|
||||
art.classList.remove('empty');
|
||||
bg.style.backgroundImage = `url('${imageUrl}')`;
|
||||
} else {
|
||||
art.style.backgroundImage = '';
|
||||
art.classList.add('empty');
|
||||
bg.style.backgroundImage = '';
|
||||
}
|
||||
|
||||
document.getElementById('reid-search-input').value = `${title || ''} ${artist || ''}`.trim();
|
||||
document.getElementById('reid-replace').checked = true;
|
||||
_reidUpdateConfirm();
|
||||
_reidRenderState('idle');
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
_reidLoadTabs();
|
||||
}
|
||||
|
||||
function closeReidentifyModal() {
|
||||
const overlay = document.getElementById('reid-modal-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function _reidLoadTabs() {
|
||||
const tabsEl = document.getElementById('reid-tabs');
|
||||
tabsEl.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetch('/api/reidentify/sources');
|
||||
const data = await resp.json();
|
||||
reidState.sources = (data && data.sources) || [];
|
||||
} catch (_) {
|
||||
reidState.sources = [];
|
||||
}
|
||||
if (!reidState.sources.length) {
|
||||
tabsEl.innerHTML = '<span class="reid-tab active">No metadata sources available</span>';
|
||||
_reidRenderState('empty', 'No configured metadata source to search.');
|
||||
return;
|
||||
}
|
||||
const active = reidState.sources.find(s => s.active) || reidState.sources[0];
|
||||
reidState.source = active.source;
|
||||
reidState.sources.forEach(s => {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'reid-tab' + (s.source === reidState.source ? ' active' : '');
|
||||
tab.textContent = s.label || s.source;
|
||||
tab.onclick = () => _reidSelectTab(s.source);
|
||||
tabsEl.appendChild(tab);
|
||||
});
|
||||
runReidentifySearch(); // auto-search the active source on open
|
||||
}
|
||||
|
||||
function _reidSelectTab(source) {
|
||||
if (source === reidState.source) return;
|
||||
reidState.source = source;
|
||||
document.querySelectorAll('#reid-tabs .reid-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.textContent ===
|
||||
(reidState.sources.find(s => s.source === source) || {}).label);
|
||||
});
|
||||
runReidentifySearch();
|
||||
}
|
||||
|
||||
async function runReidentifySearch() {
|
||||
const query = (document.getElementById('reid-search-input').value || '').trim();
|
||||
if (!query || !reidState.source) return;
|
||||
reidState.selected = null;
|
||||
_reidUpdateConfirm();
|
||||
_reidRenderState('loading');
|
||||
try {
|
||||
const url = `/api/reidentify/search?source=${encodeURIComponent(reidState.source)}&q=${encodeURIComponent(query)}`;
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
reidState.rows = (data && data.results) || [];
|
||||
_reidRenderResults();
|
||||
} catch (e) {
|
||||
_reidRenderState('empty', 'Search failed. Try another source.');
|
||||
}
|
||||
}
|
||||
|
||||
function _reidRenderResults() {
|
||||
const el = document.getElementById('reid-results');
|
||||
if (!reidState.rows.length) {
|
||||
_reidRenderState('empty', 'No releases found. Try refining the search or another source tab.');
|
||||
return;
|
||||
}
|
||||
// ISRC-bearing rows first (provably the same recording), then the rest.
|
||||
const ranked = reidState.rows
|
||||
.map((r, i) => ({ r, i }))
|
||||
.sort((a, b) => (b.r.isrc ? 1 : 0) - (a.r.isrc ? 1 : 0));
|
||||
|
||||
el.innerHTML = '';
|
||||
ranked.forEach(({ r }, n) => {
|
||||
const badge = (r.album_type || 'album').toLowerCase();
|
||||
const bits = [];
|
||||
if (r.year) bits.push(r.year);
|
||||
if (r.total_tracks) bits.push(`${r.total_tracks} track${r.total_tracks === 1 ? '' : 's'}`);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'reid-result';
|
||||
row.style.animationDelay = `${Math.min(n * 0.03, 0.3)}s`;
|
||||
row.onclick = () => _reidSelectResult(r, row);
|
||||
row.innerHTML = `
|
||||
<div class="reid-result-art" ${r.image_url ? `style="background-image:url('${encodeURI(r.image_url)}')"` : ''}>
|
||||
${r.image_url ? '' : '<span>♪</span>'}
|
||||
</div>
|
||||
<div class="reid-result-info">
|
||||
<div class="reid-result-title">${escapeHtml(r.track_title || '')}</div>
|
||||
<div class="reid-result-release">${escapeHtml(r.album_name || 'Unknown release')}${r.artist_name ? ' · ' + escapeHtml(r.artist_name) : ''}</div>
|
||||
</div>
|
||||
<div class="reid-result-meta">
|
||||
<span class="reid-badge ${badge}">${escapeHtml(badge)}</span>
|
||||
${bits.length ? `<span class="reid-result-detail">${escapeHtml(bits.join(' · '))}</span>` : ''}
|
||||
<span class="reid-result-check"></span>
|
||||
</div>`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function _reidSelectResult(r, rowEl) {
|
||||
reidState.selected = r;
|
||||
document.querySelectorAll('#reid-results .reid-result').forEach(x => x.classList.remove('selected'));
|
||||
rowEl.classList.add('selected');
|
||||
_reidUpdateConfirm();
|
||||
}
|
||||
|
||||
function _reidUpdateConfirm() {
|
||||
const btn = document.getElementById('reid-confirm-btn');
|
||||
if (btn) btn.disabled = !reidState.selected;
|
||||
}
|
||||
|
||||
function _reidRenderState(kind, msg) {
|
||||
const el = document.getElementById('reid-results');
|
||||
if (!el) return;
|
||||
if (kind === 'loading') {
|
||||
el.innerHTML = '<div class="reid-state"><div class="reid-spinner"></div><p>Searching…</p></div>'
|
||||
+ '<div class="reid-skel"></div><div class="reid-skel"></div><div class="reid-skel"></div>';
|
||||
} else if (kind === 'empty') {
|
||||
el.innerHTML = `<div class="reid-state"><div class="reid-state-icon">🔍</div><p>${escapeHtml(msg || 'No results.')}</p></div>`;
|
||||
} else { // idle
|
||||
el.innerHTML = '<div class="reid-state"><div class="reid-state-icon">💿</div>'
|
||||
+ '<p>Pick the release this track should be filed under — the same song may appear on a single, an EP, and an album.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReidentify() {
|
||||
if (!reidState.selected || !reidState.trackId) return;
|
||||
const btn = document.getElementById('reid-confirm-btn');
|
||||
const replace = document.getElementById('reid-replace').checked;
|
||||
btn.disabled = true;
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Staging…';
|
||||
try {
|
||||
const resp = await fetch('/api/reidentify/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
library_track_id: reidState.trackId,
|
||||
source: reidState.selected.source,
|
||||
track_id: reidState.selected.track_id,
|
||||
replace: replace,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.success) throw new Error(data.error || 'Re-identify failed');
|
||||
showToast(`Re-filing under “${data.album_name || 'the chosen release'}” — it'll update after the next import pass.`, 'success');
|
||||
closeReidentifyModal();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Re-identify failed', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
|
||||
document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false;
|
||||
document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true;
|
||||
document.getElementById('single-to-album-enabled').checked = settings.metadata_enhancement?.single_to_album === true;
|
||||
document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
|
||||
document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true;
|
||||
document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0;
|
||||
|
|
@ -1933,7 +1934,7 @@ function populateQualityProfileUI(profile) {
|
|||
}
|
||||
|
||||
// Populate each quality tier
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
const qualities = ['flac', 'aac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
qualities.forEach(quality => {
|
||||
const config = profile.qualities[quality];
|
||||
if (config) {
|
||||
|
|
@ -2127,15 +2128,18 @@ function collectQualityProfileFromUI() {
|
|||
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true
|
||||
};
|
||||
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
const qualities = ['flac', 'aac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
|
||||
qualities.forEach((quality, index) => {
|
||||
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
|
||||
// Preserve priority from the currently loaded profile instead of using array order
|
||||
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1);
|
||||
// Preserve priority from the currently loaded profile instead of using array order.
|
||||
// AAC's default is 1.5 (above MP3, below FLAC) — not index+1 — so an upgraded
|
||||
// profile that never had an aac tier still ranks it correctly on first save.
|
||||
const _defaultPriority = quality === 'aac' ? 1.5 : (index + 1);
|
||||
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? _defaultPriority;
|
||||
|
||||
profile.qualities[quality] = {
|
||||
enabled: enabled,
|
||||
|
|
@ -3104,6 +3108,7 @@ async function saveSettings(quiet = false) {
|
|||
cover_art_download: document.getElementById('cover-art-download').checked,
|
||||
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
|
||||
album_art_order: getArtOrder(),
|
||||
single_to_album: document.getElementById('single-to-album-enabled').checked,
|
||||
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
|
||||
tags: {
|
||||
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
|
||||
|
|
|
|||
|
|
@ -59621,6 +59621,7 @@ tr:hover .enhanced-track-actions-group { opacity: 1; }
|
|||
.enhanced-track-actions-group.visible { opacity: 1; }
|
||||
|
||||
.enhanced-source-info-btn,
|
||||
.enhanced-reidentify-btn,
|
||||
.enhanced-redownload-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
width: 24px; height: 24px; border-radius: 5px;
|
||||
|
|
@ -59631,6 +59632,9 @@ tr:hover .enhanced-track-actions-group { opacity: 1; }
|
|||
.enhanced-source-info-btn { color: rgba(255,255,255,0.35); font-size: 13px; }
|
||||
.enhanced-source-info-btn:hover { color: rgba(100,181,246,0.9); background: rgba(100,181,246,0.1); }
|
||||
|
||||
.enhanced-reidentify-btn { color: rgba(255,255,255,0.35); font-size: 15px; }
|
||||
.enhanced-reidentify-btn:hover { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); }
|
||||
|
||||
.enhanced-redownload-btn { color: rgba(255,255,255,0.35); font-size: 15px; }
|
||||
.enhanced-redownload-btn:hover { color: var(--accent); background: rgba(var(--accent-rgb),0.1); }
|
||||
|
||||
|
|
@ -62563,6 +62567,10 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
|
|||
}
|
||||
|
||||
#artist-detail-page .artist-detail-hero-bg {
|
||||
/* Blurred artist-cover backdrop retired — Boulder didn't want the artist
|
||||
image bleeding behind the header. The element stays in the DOM (JS still
|
||||
sets its background-image) but is hidden; flip display back on to restore. */
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background-size: cover;
|
||||
|
|
@ -68350,3 +68358,201 @@ body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not
|
|||
}
|
||||
.wlx-opt input { accent-color: #7aa2f7; }
|
||||
.watchlist-export-btn .watchlist-all-icon { font-weight: 700; }
|
||||
|
||||
/* =========================================================================
|
||||
Re-identify Track Modal (#889)
|
||||
A focused, vibey chooser: "which release does this track belong to?"
|
||||
========================================================================= */
|
||||
#reid-modal-overlay { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||||
|
||||
.reid-modal {
|
||||
position: relative;
|
||||
width: min(760px, 94vw);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, #14161d 0%, #0e0f15 100%);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.18);
|
||||
box-shadow:
|
||||
0 30px 90px rgba(0, 0, 0, 0.6),
|
||||
0 0 80px rgba(var(--accent-rgb), 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
animation: reidPop 0.34s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes reidPop {
|
||||
from { opacity: 0; transform: translateY(18px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── Hero ──
|
||||
No overflow:hidden on .reid-hero itself (it was clipping the header content).
|
||||
Instead the decorative blurred bg + overlay live in .reid-hero-decor, an
|
||||
absolutely-positioned clip layer: it contains the blur (so it can't bleed into
|
||||
the tabs) and is pointer-events:none (so it can never steal clicks from the
|
||||
source tabs). The actual content is a SIBLING of the decor layer, so it's never
|
||||
clipped. */
|
||||
.reid-hero { position: relative; padding: 26px 28px; }
|
||||
.reid-hero-decor {
|
||||
position: absolute; inset: 0; z-index: 0;
|
||||
overflow: hidden; pointer-events: none;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.reid-hero-bg {
|
||||
position: absolute; inset: -30px;
|
||||
background-size: cover; background-position: center;
|
||||
filter: blur(46px) brightness(0.4) saturate(1.5);
|
||||
transform: scale(1.25); opacity: 0.9;
|
||||
transition: background-image 0.4s ease;
|
||||
}
|
||||
.reid-hero-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(135deg, rgba(10,11,16,0.55) 0%, rgba(10,11,16,0.8) 100%);
|
||||
}
|
||||
.reid-hero-content { position: relative; z-index: 2; display: flex; gap: 18px; align-items: center; }
|
||||
.reid-hero-art {
|
||||
width: 76px; height: 76px; flex: 0 0 76px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.05) center/cover no-repeat;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.reid-hero-art.empty { display: grid; place-items: center; }
|
||||
.reid-hero-art.empty::after { content: '♪'; font-size: 30px; color: rgba(var(--accent-rgb), 0.7); }
|
||||
.reid-hero-meta { min-width: 0; }
|
||||
.reid-hero-eyebrow {
|
||||
font: 600 11px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: rgb(var(--accent-light-rgb)); margin-bottom: 7px;
|
||||
}
|
||||
.reid-hero-title {
|
||||
font-size: 23px; font-weight: 800; color: #fff; line-height: 1.15;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.reid-hero-sub { margin-top: 4px; color: #aab; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.reid-close {
|
||||
position: absolute; top: 14px; right: 18px; z-index: 3;
|
||||
font-size: 26px; line-height: 1; color: #aab; cursor: pointer;
|
||||
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.reid-close:hover { color: #fff; background: rgba(255,255,255,0.1); transform: rotate(90deg); }
|
||||
|
||||
/* ── Source tabs ── */
|
||||
.reid-tabs {
|
||||
display: flex; gap: 7px; padding: 14px 28px 0; flex-wrap: wrap;
|
||||
}
|
||||
.reid-tab {
|
||||
padding: 7px 15px; border-radius: 999px; cursor: pointer;
|
||||
font-size: 12.5px; font-weight: 600; color: #9aa3bd;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||
transition: all 0.18s ease; white-space: nowrap;
|
||||
}
|
||||
.reid-tab:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.reid-tab.active {
|
||||
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
|
||||
border-color: transparent; box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.35);
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
.reid-search { display: flex; align-items: center; gap: 10px; padding: 16px 28px 12px; position: relative; }
|
||||
.reid-search-icon { position: absolute; left: 42px; color: #6b7390; pointer-events: none; }
|
||||
.reid-search-input {
|
||||
flex: 1; padding: 12px 14px 12px 40px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
color: #fff; font-size: 14px; transition: all 0.18s ease;
|
||||
}
|
||||
.reid-search-input:focus {
|
||||
outline: none; border-color: rgba(var(--accent-rgb), 0.5);
|
||||
background: rgba(255,255,255,0.06); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
.reid-search-btn {
|
||||
padding: 11px 20px; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: 13px;
|
||||
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
|
||||
border: none; transition: all 0.18s ease;
|
||||
}
|
||||
.reid-search-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||
|
||||
/* ── Results ── */
|
||||
.reid-results { flex: 1; overflow-y: auto; padding: 4px 22px 10px; min-height: 220px; }
|
||||
.reid-result {
|
||||
display: flex; align-items: center; gap: 14px; padding: 12px 14px; margin: 6px 0;
|
||||
border-radius: 14px; cursor: pointer;
|
||||
background: rgba(255,255,255,0.025); border: 1.5px solid transparent;
|
||||
transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
animation: reidRowIn 0.3s ease backwards;
|
||||
}
|
||||
@keyframes reidRowIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.reid-result:hover { background: rgba(255,255,255,0.05); transform: translateY(-2px); }
|
||||
.reid-result.selected {
|
||||
border-color: rgba(var(--accent-rgb), 0.65);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.18);
|
||||
}
|
||||
.reid-result-art {
|
||||
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 9px;
|
||||
background: rgba(255,255,255,0.06) center/cover no-repeat; border: 1px solid rgba(255,255,255,0.07);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.reid-result-art span { font-size: 20px; color: rgba(var(--accent-rgb), 0.6); }
|
||||
.reid-result-info { min-width: 0; flex: 1; }
|
||||
.reid-result-title { font-weight: 700; color: #fff; font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.reid-result-release { color: #9aa3bd; font-size: 12.5px; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.reid-result-meta { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
|
||||
.reid-result-detail { color: #6b7390; font-size: 11.5px; font-family: 'JetBrains Mono', ui-monospace, monospace; text-align: right; }
|
||||
.reid-result-check {
|
||||
width: 22px; height: 22px; border-radius: 50%; flex: 0 0 22px;
|
||||
border: 2px solid rgba(255,255,255,0.14); display: grid; place-items: center; transition: all 0.18s ease;
|
||||
}
|
||||
.reid-result.selected .reid-result-check {
|
||||
border-color: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
|
||||
}
|
||||
.reid-result.selected .reid-result-check::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
|
||||
|
||||
/* Type badges */
|
||||
.reid-badge {
|
||||
font: 700 10px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
padding: 4px 8px; border-radius: 6px; white-space: nowrap;
|
||||
}
|
||||
.reid-badge.album { color: #22ff6b; background: rgba(34,255,107,0.12); border: 1px solid rgba(34,255,107,0.25); }
|
||||
.reid-badge.ep { color: #5aa9ff; background: rgba(90,169,255,0.12); border: 1px solid rgba(90,169,255,0.25); }
|
||||
.reid-badge.single { color: #ffc24b; background: rgba(255,194,75,0.12); border: 1px solid rgba(255,194,75,0.25); }
|
||||
.reid-badge.compilation { color: #c98bff; background: rgba(201,139,255,0.12); border: 1px solid rgba(201,139,255,0.25); }
|
||||
|
||||
/* Idle / loading / empty */
|
||||
.reid-state { display: grid; place-items: center; gap: 14px; min-height: 200px; text-align: center; color: #6b7390; padding: 20px; }
|
||||
.reid-state .reid-state-icon { font-size: 38px; opacity: 0.5; }
|
||||
.reid-state p { font-size: 13.5px; max-width: 360px; }
|
||||
.reid-spinner {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
border: 3px solid rgba(var(--accent-rgb), 0.15); border-top-color: rgb(var(--accent-rgb));
|
||||
animation: reidSpin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes reidSpin { to { transform: rotate(360deg); } }
|
||||
.reid-skel {
|
||||
height: 76px; margin: 6px 0; border-radius: 14px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.07) 37%, rgba(255,255,255,0.03) 63%);
|
||||
background-size: 400% 100%; animation: reidShimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes reidShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
|
||||
|
||||
/* ── Footer ── */
|
||||
.reid-footer {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 16px 28px; border-top: 1px solid rgba(255,255,255,0.06);
|
||||
background: rgba(0,0,0,0.2); flex-wrap: wrap;
|
||||
}
|
||||
.reid-replace { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
|
||||
.reid-replace input { display: none; }
|
||||
.reid-replace-box {
|
||||
width: 20px; height: 20px; border-radius: 6px; flex: 0 0 20px;
|
||||
border: 2px solid rgba(255,255,255,0.18); transition: all 0.18s ease; display: grid; place-items: center;
|
||||
}
|
||||
.reid-replace input:checked + .reid-replace-box {
|
||||
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))); border-color: transparent;
|
||||
}
|
||||
.reid-replace input:checked + .reid-replace-box::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
|
||||
.reid-replace-text { color: #c2c9de; font-size: 13px; }
|
||||
.reid-replace-text em { color: #7f879e; font-style: normal; font-size: 11.5px; }
|
||||
.reid-footer-actions { display: flex; gap: 10px; }
|
||||
#reid-confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.4); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue