Merge remote-tracking branch 'nezreka/dev' into feature/best-quality-search-mode
# Conflicts: # core/hifi_client.py
This commit is contained in:
commit
63374b32f1
63 changed files with 4487 additions and 221 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.7.3)'
|
||||
description: 'Version tag (e.g. 2.7.6)'
|
||||
required: true
|
||||
default: '2.7.3'
|
||||
default: '2.7.6'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ __pycache__/
|
|||
|
||||
# User-specific files (auto-created by the app if missing)
|
||||
config/config.json
|
||||
config/youtube_cookies.txt
|
||||
database/music_library.db
|
||||
database/music_library.db-shm
|
||||
database/music_library.db-wal
|
||||
|
|
|
|||
|
|
@ -117,6 +117,48 @@ def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
|
|||
return 'track_position' in payload and 'contributors' in payload
|
||||
|
||||
|
||||
def resolve_album_track_positions(session, base_url, album_ids, cache=None, sleep_s=0.2):
|
||||
"""Build ``{str(track_id): track_position}`` for a set of Deezer album ids.
|
||||
|
||||
Deezer PLAYLIST and SEARCH track objects (and even the album object's embedded
|
||||
``tracks.data``) omit ``track_position`` — only ``/album/<id>/tracks`` and
|
||||
``/track/<id>`` carry it. So numbering playlist tracks by their playlist index
|
||||
silently poisons the real album track number, which then rides onto the
|
||||
downloaded file's tag. This resolves the authoritative position per album
|
||||
(cache-first, best-effort — a failed album just isn't in the map)."""
|
||||
import time as _time
|
||||
positions: Dict[str, int] = {}
|
||||
for aid in album_ids:
|
||||
aid = str(aid)
|
||||
at_list = None
|
||||
if cache:
|
||||
try:
|
||||
ct = cache.get_entity('deezer', 'album_tracks', aid)
|
||||
if ct and ct.get('data'):
|
||||
at_list = ct['data']
|
||||
except Exception: # noqa: BLE001 - cache is best-effort
|
||||
at_list = None
|
||||
if at_list is None:
|
||||
try:
|
||||
if sleep_s:
|
||||
_time.sleep(sleep_s) # respect Deezer rate limits
|
||||
r = session.get(f"{base_url}/album/{aid}/tracks", params={'limit': 500}, timeout=10)
|
||||
if getattr(r, 'ok', False):
|
||||
at_list = (r.json() or {}).get('data', [])
|
||||
if cache and at_list is not None:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album_tracks', aid, {'data': at_list})
|
||||
except Exception as _cache_err: # noqa: BLE001
|
||||
logger.debug("album_tracks cache store failed for %s: %s", aid, _cache_err)
|
||||
except Exception: # noqa: BLE001 - never let metadata resolution break the fetch
|
||||
at_list = None
|
||||
for at in (at_list or []):
|
||||
tp = at.get('track_position')
|
||||
if at.get('id') and tp:
|
||||
positions[str(at['id'])] = tp
|
||||
return positions
|
||||
|
||||
|
||||
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
||||
|
||||
@dataclass
|
||||
|
|
@ -1357,6 +1399,16 @@ class DeezerClient:
|
|||
|
||||
raw_tracks.extend(page_tracks)
|
||||
|
||||
# Real album track positions — playlist tracks don't carry track_position,
|
||||
# so numbering by playlist index would poison the downloaded file's tag.
|
||||
album_ids = {str(t.get('album', {}).get('id')) for t in raw_tracks if t.get('album', {}).get('id')}
|
||||
try:
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
_cache = get_metadata_cache()
|
||||
except Exception:
|
||||
_cache = None
|
||||
track_positions = resolve_album_track_positions(self.session, self.BASE_URL, album_ids, _cache)
|
||||
|
||||
# Normalize tracks
|
||||
tracks: List[Dict[str, Any]] = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
|
|
@ -1368,7 +1420,8 @@ class DeezerClient:
|
|||
'artists': [artist_name],
|
||||
'album': t.get('album', {}).get('title', ''),
|
||||
'duration_ms': t.get('duration', 0) * 1000,
|
||||
'track_number': i,
|
||||
# REAL album position; the playlist index is a last resort only.
|
||||
'track_number': track_positions.get(str(t.get('id'))) or i,
|
||||
})
|
||||
|
||||
result = {
|
||||
|
|
|
|||
|
|
@ -418,6 +418,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
if aid:
|
||||
album_ids.add(str(aid))
|
||||
album_release_dates = {}
|
||||
# Deezer PLAYLIST tracks do NOT carry `track_position` (only `/track/<id>`
|
||||
# and `/album/<id>/tracks` do), so numbering them by their playlist index
|
||||
# poisons the real album track number — which then rides into the wishlist
|
||||
# and onto the downloaded file's tag (e.g. 'Apologize' tagged track 1 instead
|
||||
# of 16). Resolve the REAL position from each album's track list (cache-first).
|
||||
track_positions: Dict[str, int] = {} # str(track_id) -> album track_position
|
||||
try:
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
cache = get_metadata_cache()
|
||||
|
|
@ -430,24 +436,32 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
cached = cache.get_entity('deezer', 'album', aid)
|
||||
if cached and cached.get('release_date'):
|
||||
album_release_dates[aid] = cached['release_date']
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("cache get_entity album release_date: %s", e)
|
||||
# Cache miss — fetch from API
|
||||
try:
|
||||
time.sleep(0.3) # Respect rate limits
|
||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
||||
if a_resp.ok:
|
||||
a_data = a_resp.json()
|
||||
album_release_dates[aid] = a_data.get('release_date', '')
|
||||
# Store in metadata cache for future use
|
||||
if cache:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album', aid, a_data)
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album release_date: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||
if aid not in album_release_dates:
|
||||
try:
|
||||
time.sleep(0.3) # Respect rate limits
|
||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
||||
if a_resp.ok:
|
||||
a_data = a_resp.json()
|
||||
album_release_dates[aid] = a_data.get('release_date', '')
|
||||
# Store in metadata cache for future use
|
||||
if cache:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album', aid, a_data)
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album release_date: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||
# Real album track positions (separate endpoint — playlist tracks AND the
|
||||
# album object's embedded tracks both omit track_position). Cache-first.
|
||||
try:
|
||||
from core.deezer_client import resolve_album_track_positions
|
||||
track_positions = resolve_album_track_positions(
|
||||
self._session, 'https://api.deezer.com', album_ids, cache)
|
||||
except Exception as e:
|
||||
logger.debug("resolve deezer album track positions: %s", e)
|
||||
|
||||
tracks = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
|
|
@ -468,7 +482,9 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
|||
'id': album_id,
|
||||
},
|
||||
'duration_ms': t.get('duration', 0) * 1000,
|
||||
'track_number': i,
|
||||
# REAL album position (resolved above); the playlist index is a last
|
||||
# resort only when the album lookup failed, never the default.
|
||||
'track_number': track_positions.get(str(t.get('id'))) or t.get('track_position') or i,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,32 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
|
|||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Durable manual library match (#787) — survives a library rescan (the
|
||||
# sync_match_cache above does not), so a user's Find & Add pairing keeps
|
||||
# sticking across auto-syncs instead of being re-matched from scratch (#895
|
||||
# follow-up). Self-heals a stale library id via the stored file path.
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.artists.map import get_current_profile_id
|
||||
m = db.find_manual_library_match_by_source_track_id(
|
||||
get_current_profile_id(), str(spotify_id), active_server)
|
||||
if m:
|
||||
lib_id = m.get('library_track_id')
|
||||
dt = db.get_track_by_id(lib_id) if lib_id is not None else None
|
||||
if not dt and m.get('library_file_path'):
|
||||
new_id = db.find_track_id_by_file_path(m['library_file_path'])
|
||||
dt = db.get_track_by_id(new_id) if new_id else None
|
||||
if dt:
|
||||
class DatabaseTrackDurable:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Durable manual match hit: '{original_title}' → {lib_id}")
|
||||
return DatabaseTrackDurable(dt), 1.0
|
||||
except Exception as e:
|
||||
logger.debug("durable manual match fast-path failed: %s", e)
|
||||
|
||||
# Try each artist
|
||||
for artist in spotify_track.artists:
|
||||
if isinstance(artist, str):
|
||||
|
|
|
|||
184
core/exports/export_sources.py
Normal file
184
core/exports/export_sources.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Wire the real cheapest-first sources for the export MBID waterfall (#903).
|
||||
|
||||
``mbid_resolver`` is the pure waterfall; this module supplies the real I/O behind each
|
||||
source and assembles the ``resolve_fn`` the export job uses:
|
||||
|
||||
1. **cache** — ``recording_mbid_cache`` (persistent (artist,title)->mbid).
|
||||
2. **DB** — a text-matched library track's ``tracks.musicbrainz_recording_id``.
|
||||
3. **file** — ``MUSICBRAINZ_RECORDING_ID`` tag of that track's file (when the DB row had
|
||||
no recording id but the file was tagged on import).
|
||||
4. **MusicBrainz** — live ``match_recording(track, artist)`` (rate-limited tail).
|
||||
|
||||
Every source is wrapped so any failure (missing table, unreadable file, MB timeout) returns
|
||||
None — the waterfall just falls through, the export never breaks. ``build_resolve_fn`` also
|
||||
writes a fresh non-cache hit back to the cache so the next export of the same song is free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.exports.mbid_resolver import (
|
||||
SRC_CACHE,
|
||||
SRC_DB,
|
||||
SRC_FILE,
|
||||
SRC_MUSICBRAINZ,
|
||||
normalize_key,
|
||||
resolve_recording_mbid,
|
||||
)
|
||||
|
||||
logger = get_logger("exports.export_sources")
|
||||
|
||||
|
||||
def _db_match(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Text-match a library track by (artist, title); return (recording_mbid, file_path).
|
||||
Either may be None. Fail-safe — any DB error returns (None, None)."""
|
||||
if not title:
|
||||
return (None, None)
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT t.musicbrainz_recording_id, t.file_path "
|
||||
"FROM tracks t JOIN artists a ON t.artist_id = a.id "
|
||||
"WHERE LOWER(t.title) = LOWER(?) AND LOWER(a.name) = LOWER(?) "
|
||||
"LIMIT 1",
|
||||
(title, artist),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return (None, None)
|
||||
mbid = row[0] if not hasattr(row, "keys") else row["musicbrainz_recording_id"]
|
||||
fpath = row[1] if not hasattr(row, "keys") else row["file_path"]
|
||||
return ((mbid or None), (fpath or None))
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(f"export db_match failed for '{artist} - {title}': {exc}")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def db_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Recording MBID stored on a matched library track (``musicbrainz_recording_id``)."""
|
||||
return _db_match(artist, title)[0]
|
||||
|
||||
|
||||
def file_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Recording MBID read from the matched track's file tag (set on import post-processing)."""
|
||||
_mbid, fpath = _db_match(artist, title)
|
||||
if not fpath:
|
||||
return None
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(fpath)
|
||||
if audio is None or not getattr(audio, "tags", None):
|
||||
return None
|
||||
tags = audio.tags
|
||||
# ID3 UFID (MusicBrainz), Vorbis/MP4 musicbrainz_trackid, etc.
|
||||
for key in ("UFID:http://musicbrainz.org", "musicbrainz_trackid",
|
||||
"MUSICBRAINZ_TRACKID", "----:com.apple.iTunes:MusicBrainz Track Id"):
|
||||
try:
|
||||
val = tags.get(key)
|
||||
except Exception:
|
||||
val = None
|
||||
if not val:
|
||||
continue
|
||||
if hasattr(val, "data"): # ID3 UFID frame
|
||||
val = val.data.decode("utf-8", "ignore")
|
||||
if isinstance(val, (list, tuple)):
|
||||
val = val[0] if val else ""
|
||||
if isinstance(val, bytes):
|
||||
val = val.decode("utf-8", "ignore")
|
||||
val = str(val).strip()
|
||||
if val:
|
||||
return val
|
||||
except Exception as exc:
|
||||
logger.debug(f"export file_recording_mbid failed for {fpath}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
_mb_service = None
|
||||
_mb_service_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_mb_service():
|
||||
"""Shared MusicBrainzService (client + cache + DB), created lazily so importing this
|
||||
module never triggers a DB/network connection on paths that don't export."""
|
||||
global _mb_service
|
||||
if _mb_service is None:
|
||||
with _mb_service_lock:
|
||||
if _mb_service is None:
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
from database.music_database import get_database
|
||||
_mb_service = MusicBrainzService(get_database())
|
||||
return _mb_service
|
||||
|
||||
|
||||
def musicbrainz_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||
"""Live MusicBrainz ``match_recording`` — the rate-limited tail."""
|
||||
if not title:
|
||||
return None
|
||||
try:
|
||||
svc = _get_mb_service()
|
||||
if not svc:
|
||||
return None
|
||||
result = svc.match_recording(title, artist)
|
||||
if result and result.get("mbid"):
|
||||
return result["mbid"]
|
||||
except Exception as exc:
|
||||
logger.debug(f"export musicbrainz_recording_mbid failed for '{artist} - {title}': {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def build_resolve_fn(
|
||||
*,
|
||||
db_fn: Callable[[str, str], Optional[str]] = db_recording_mbid,
|
||||
file_fn: Callable[[str, str], Optional[str]] = file_recording_mbid,
|
||||
mb_fn: Callable[[str, str], Optional[str]] = musicbrainz_recording_mbid,
|
||||
cache_lookup: Optional[Callable[[str], Optional[str]]] = None,
|
||||
cache_record: Optional[Callable[[str, str], bool]] = None,
|
||||
) -> Callable[[str, str], Tuple[Optional[str], Optional[str]]]:
|
||||
"""Assemble the export ``resolve_fn(artist, title) -> (mbid, source_label)``.
|
||||
|
||||
Runs cache -> DB -> file -> MusicBrainz, and writes a fresh (non-cache) hit back to the
|
||||
persistent cache. All sources are injectable so the wiring is unit-testable; defaults
|
||||
use the real cache module.
|
||||
"""
|
||||
if cache_lookup is None or cache_record is None:
|
||||
from core.exports import recording_mbid_cache as _cache
|
||||
cache_lookup = cache_lookup or _cache.lookup
|
||||
cache_record = cache_record or _cache.record
|
||||
|
||||
def resolve_fn(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
sources = [
|
||||
(SRC_CACHE, lambda a, t: cache_lookup(normalize_key(a, t))),
|
||||
(SRC_DB, db_fn),
|
||||
(SRC_FILE, file_fn),
|
||||
(SRC_MUSICBRAINZ, mb_fn),
|
||||
]
|
||||
mbid, label = resolve_recording_mbid(artist, title, sources)
|
||||
if mbid and label and label != SRC_CACHE:
|
||||
try:
|
||||
cache_record(normalize_key(artist, title), mbid)
|
||||
except Exception: # noqa: S110 — cache write is best-effort
|
||||
pass
|
||||
return (mbid, label)
|
||||
|
||||
return resolve_fn
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_resolve_fn",
|
||||
"db_recording_mbid",
|
||||
"file_recording_mbid",
|
||||
"musicbrainz_recording_mbid",
|
||||
]
|
||||
89
core/exports/jspf_export.py
Normal file
89
core/exports/jspf_export.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Build a JSPF playlist (ListenBrainz-compatible) from resolved SoulSync tracks.
|
||||
|
||||
ListenBrainz's ``POST /1/playlist/create`` requires JSPF where **every track carries a
|
||||
``identifier`` of ``https://musicbrainz.org/recording/<recording-mbid>``** — text-only
|
||||
entries (title/creator alone) are rejected. So a track can only be exported once we've
|
||||
resolved its MusicBrainz *recording* MBID (see ``mbid_resolver``); tracks without one are
|
||||
dropped here and surfaced to the user as "unmatched".
|
||||
|
||||
Pure + I/O-free: callers pass already-resolved track dicts, this returns the JSPF dict
|
||||
(and a small coverage summary). The same JSPF is used for both the downloadable ``.jspf``
|
||||
file and the direct create-playlist POST, so there's one source of truth for the shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
MB_RECORDING_PREFIX = "https://musicbrainz.org/recording/"
|
||||
|
||||
# A MusicBrainz MBID is a canonical UUID. Validate to avoid emitting garbage identifiers
|
||||
# that LB would reject (or, worse, that silently point nowhere).
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def is_valid_recording_mbid(mbid: Any) -> bool:
|
||||
"""True when ``mbid`` is a well-formed MusicBrainz UUID."""
|
||||
return bool(mbid) and isinstance(mbid, str) and bool(_UUID_RE.match(mbid.strip()))
|
||||
|
||||
|
||||
def _track_entry(track: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
"""Build one JSPF track entry, or None if the track has no valid recording MBID."""
|
||||
mbid = (track.get("recording_mbid") or "").strip() if isinstance(track.get("recording_mbid"), str) else ""
|
||||
if not is_valid_recording_mbid(mbid):
|
||||
return None
|
||||
entry: Dict[str, Any] = {"identifier": f"{MB_RECORDING_PREFIX}{mbid}"}
|
||||
# Optional, human-friendly fields — LB ignores them on create but they make the
|
||||
# downloaded .jspf readable and round-trippable.
|
||||
if track.get("title"):
|
||||
entry["title"] = str(track["title"])
|
||||
if track.get("artist"):
|
||||
entry["creator"] = str(track["artist"])
|
||||
if track.get("album"):
|
||||
entry["album"] = str(track["album"])
|
||||
return entry
|
||||
|
||||
|
||||
def build_jspf(
|
||||
title: str,
|
||||
tracks: List[Dict[str, Any]],
|
||||
*,
|
||||
creator: str = "",
|
||||
) -> Tuple[Dict[str, Any], Dict[str, int]]:
|
||||
"""Build a ListenBrainz-compatible JSPF dict from resolved tracks.
|
||||
|
||||
``tracks`` is an ordered list of dicts with ``recording_mbid`` (required to be
|
||||
included), plus optional ``title`` / ``artist`` / ``album``. Tracks without a valid
|
||||
recording MBID are skipped (LB rejects them).
|
||||
|
||||
Returns ``(jspf, summary)`` where ``jspf`` is ``{"playlist": {...}}`` and ``summary``
|
||||
is ``{"total", "included", "skipped"}`` for the coverage display.
|
||||
"""
|
||||
jspf_tracks: List[Dict[str, Any]] = []
|
||||
for t in tracks or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
entry = _track_entry(t)
|
||||
if entry is not None:
|
||||
jspf_tracks.append(entry)
|
||||
|
||||
playlist: Dict[str, Any] = {
|
||||
"title": (title or "SoulSync Export").strip() or "SoulSync Export",
|
||||
"track": jspf_tracks,
|
||||
}
|
||||
if creator:
|
||||
playlist["creator"] = str(creator)
|
||||
|
||||
total = sum(1 for t in (tracks or []) if isinstance(t, dict))
|
||||
summary = {
|
||||
"total": total,
|
||||
"included": len(jspf_tracks),
|
||||
"skipped": total - len(jspf_tracks),
|
||||
}
|
||||
return {"playlist": playlist}, summary
|
||||
|
||||
|
||||
__all__ = ["build_jspf", "is_valid_recording_mbid", "MB_RECORDING_PREFIX"]
|
||||
88
core/exports/mbid_resolver.py
Normal file
88
core/exports/mbid_resolver.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Resolve a playlist track's MusicBrainz *recording* MBID, cheapest source first.
|
||||
|
||||
A ListenBrainz playlist export needs each track's recording MBID (``jspf_export``). A
|
||||
SoulSync track can supply it from several places, in increasing cost:
|
||||
|
||||
1. **resolution cache** — a prior (artist,title)->mbid result (persistent; reused across
|
||||
playlists and runs, so the same song never costs twice).
|
||||
2. **library DB** — ``tracks.musicbrainz_recording_id`` (set by the MusicBrainz
|
||||
enrichment worker).
|
||||
3. **file tags** — ``MUSICBRAINZ_RECORDING_ID`` written into the audio file on import
|
||||
post-processing (catches tracks enriched at import but not via the worker).
|
||||
4. **MusicBrainz lookup** — a live ``match_recording(artist, title)`` (rate-limited
|
||||
~1 req/s; the slow tail — only hit when 1–3 miss).
|
||||
|
||||
This module is the **pure waterfall**: the caller passes ordered ``(label, fn)`` sources,
|
||||
each ``fn(artist, title) -> mbid | None``, and ``resolve_recording_mbid`` returns the
|
||||
first valid hit plus its label (for the live status / stats). The actual I/O (DB query,
|
||||
mutagen read, MB request, cache read/write) lives in the export job that wires the real
|
||||
sources — so this stays trivially unit-testable and short-circuits correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
# Source labels (also used in the live-status breakdown).
|
||||
SRC_CACHE = "cache"
|
||||
SRC_DB = "db"
|
||||
SRC_FILE = "file"
|
||||
SRC_MUSICBRAINZ = "musicbrainz"
|
||||
SRC_NONE = None
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
||||
)
|
||||
|
||||
Source = Tuple[str, Callable[[str, str], Optional[str]]]
|
||||
|
||||
|
||||
def _valid(mbid: Any) -> Optional[str]:
|
||||
"""Return the trimmed MBID if it's a well-formed UUID, else None."""
|
||||
if not isinstance(mbid, str):
|
||||
return None
|
||||
m = mbid.strip()
|
||||
return m if _UUID_RE.match(m) else None
|
||||
|
||||
|
||||
def normalize_key(artist: Any, title: Any) -> str:
|
||||
"""Stable cache key for an (artist, title) pair — lower, punctuation-stripped,
|
||||
whitespace-collapsed — so trivial variations share a cache entry."""
|
||||
def _n(v: Any) -> str:
|
||||
s = re.sub(r"[^\w\s]", "", str(v or "").lower())
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
return f"{_n(artist)}␟{_n(title)}"
|
||||
|
||||
|
||||
def resolve_recording_mbid(
|
||||
artist: str,
|
||||
title: str,
|
||||
sources: List[Source],
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Walk ``sources`` in order; return ``(mbid, label)`` of the first that yields a
|
||||
valid recording MBID, or ``(None, None)`` when every source misses.
|
||||
|
||||
Each source is ``(label, fn)`` and ``fn(artist, title)`` returns an MBID or None. A
|
||||
source that raises is treated as a miss (never aborts the waterfall) — so one flaky
|
||||
lookup (e.g. a MusicBrainz timeout) can't fail the whole export. Short-circuits: a
|
||||
later/expensive source isn't called once an earlier one hits.
|
||||
"""
|
||||
for label, fn in sources or []:
|
||||
try:
|
||||
mbid = _valid(fn(artist, title))
|
||||
except Exception:
|
||||
mbid = None
|
||||
if mbid:
|
||||
return (mbid, label)
|
||||
return (None, None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"resolve_recording_mbid",
|
||||
"normalize_key",
|
||||
"SRC_CACHE",
|
||||
"SRC_DB",
|
||||
"SRC_FILE",
|
||||
"SRC_MUSICBRAINZ",
|
||||
]
|
||||
92
core/exports/playlist_export.py
Normal file
92
core/exports/playlist_export.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Orchestrate resolving a playlist's tracks to recording MBIDs for export (#903).
|
||||
|
||||
This is the testable heart of the export job: walk the playlist's tracks, resolve each to a
|
||||
MusicBrainz recording MBID via an injected ``resolve_fn`` (which the job wires to the
|
||||
cache -> DB -> file -> MusicBrainz waterfall), dedup repeated songs within the run so they
|
||||
only cost one resolution, build the ordered "pseudo-playlist" of resolved tracks, and tally
|
||||
live stats (resolved / unmatched / per-source / deduped) for the on-card status display.
|
||||
|
||||
Pure: all I/O (DB, file reads, MusicBrainz, cache) is behind ``resolve_fn`` and the optional
|
||||
``on_progress`` callback, so the dedup + accounting logic is unit-testable without any
|
||||
network or database. The returned ``resolved`` list feeds straight into ``jspf_export``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.exports.mbid_resolver import normalize_key
|
||||
|
||||
# resolve_fn(artist, title) -> (recording_mbid|None, source_label|None)
|
||||
ResolveFn = Callable[[str, str], Tuple[Optional[str], Optional[str]]]
|
||||
ProgressFn = Callable[[int, int, Dict[str, Any]], None]
|
||||
|
||||
|
||||
def _field(track: Dict[str, Any], *names: str) -> str:
|
||||
"""First non-empty value among ``names`` (handles both playlist + LB-cache shapes)."""
|
||||
for n in names:
|
||||
v = track.get(n)
|
||||
if v:
|
||||
return str(v)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_playlist_tracks(
|
||||
tracks: List[Dict[str, Any]],
|
||||
resolve_fn: ResolveFn,
|
||||
*,
|
||||
on_progress: Optional[ProgressFn] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve every track to a recording MBID and build the export pseudo-playlist.
|
||||
|
||||
``tracks`` items may use ``artist``/``artist_name`` and ``title``/``track_name`` and
|
||||
``album``/``album_name`` (both the mirrored-playlist and LB-cache shapes are accepted).
|
||||
|
||||
Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is
|
||||
``{artist, title, album, recording_mbid}`` (recording_mbid is None when unmatched),
|
||||
in original order, and stats carries ``total, resolved, unmatched, deduped,
|
||||
by_source`` for the live display.
|
||||
"""
|
||||
total = len(tracks or [])
|
||||
memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
resolved: List[Dict[str, Any]] = []
|
||||
stats: Dict[str, Any] = {
|
||||
"total": total, "resolved": 0, "unmatched": 0, "deduped": 0, "by_source": {},
|
||||
}
|
||||
|
||||
for i, t in enumerate(tracks or []):
|
||||
if not isinstance(t, dict):
|
||||
t = {}
|
||||
artist = _field(t, "artist", "artist_name", "creator")
|
||||
title = _field(t, "title", "track_name", "name")
|
||||
album = _field(t, "album", "album_name", "release_name")
|
||||
key = normalize_key(artist, title)
|
||||
|
||||
if key in memo:
|
||||
mbid, source = memo[key]
|
||||
stats["deduped"] += 1
|
||||
fresh = False
|
||||
else:
|
||||
mbid, source = resolve_fn(artist, title)
|
||||
memo[key] = (mbid, source)
|
||||
fresh = True
|
||||
|
||||
resolved.append({"artist": artist, "title": title, "album": album, "recording_mbid": mbid})
|
||||
|
||||
if mbid:
|
||||
stats["resolved"] += 1
|
||||
if fresh and source:
|
||||
stats["by_source"][source] = stats["by_source"].get(source, 0) + 1
|
||||
else:
|
||||
stats["unmatched"] += 1
|
||||
|
||||
if on_progress is not None:
|
||||
try:
|
||||
on_progress(i + 1, total, stats)
|
||||
except Exception: # noqa: S110 — a progress-display error must never fail the export
|
||||
pass
|
||||
|
||||
return {"resolved": resolved, "stats": stats}
|
||||
|
||||
|
||||
__all__ = ["resolve_playlist_tracks"]
|
||||
126
core/exports/recording_mbid_cache.py
Normal file
126
core/exports/recording_mbid_cache.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Persistent (artist,title) -> MusicBrainz recording-MBID cache for playlist export.
|
||||
|
||||
The export waterfall (``core.exports.mbid_resolver``) ends in a live MusicBrainz lookup
|
||||
that's rate-limited to ~1 req/s — the slow tail of exporting a big playlist. Remembering a
|
||||
resolved recording MBID ONCE means the same song never costs a second lookup, across every
|
||||
future export and every playlist it appears in.
|
||||
|
||||
Mirrors ``core.metadata.album_mbid_cache`` exactly: a tiny SQLite table, lazy DB accessor,
|
||||
every function wrapped so any DB error degrades to a cache miss / no-op. If this module
|
||||
breaks, exports still work — they just re-resolve via the live waterfall like a cold cache.
|
||||
Key is the normalized ``track_key`` from ``mbid_resolver.normalize_key(artist, title)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("exports.recording_mbid_cache")
|
||||
|
||||
_db_factory_lock = threading.Lock()
|
||||
_db_factory = None
|
||||
|
||||
|
||||
def _get_database():
|
||||
"""Resolve the MusicDatabase singleton lazily; None on any failure (treated as miss)."""
|
||||
global _db_factory
|
||||
with _db_factory_lock:
|
||||
if _db_factory is None:
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
_db_factory = get_database
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache: could not load database module: {exc}")
|
||||
return None
|
||||
try:
|
||||
return _db_factory()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache: database accessor failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def lookup(track_key: str) -> Optional[str]:
|
||||
"""Read a cached recording MBID for ``track_key``; None on miss or any DB error."""
|
||||
if not track_key:
|
||||
return None
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return None
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT recording_mbid FROM mb_recording_cache WHERE track_key = ? LIMIT 1",
|
||||
(track_key,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return (row[0] if not hasattr(row, "keys") else row["recording_mbid"]) or None
|
||||
except Exception as exc:
|
||||
logger.debug(f"Recording-MBID cache lookup failed: {exc}")
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def record(track_key: str, recording_mbid: str) -> bool:
|
||||
"""Persist ``track_key`` -> ``recording_mbid`` (idempotent). False on any failure."""
|
||||
if not track_key or not recording_mbid:
|
||||
return False
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO mb_recording_cache "
|
||||
"(track_key, recording_mbid, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(track_key, recording_mbid),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Recording-MBID cache record failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
|
||||
|
||||
def clear_all() -> bool:
|
||||
"""Wipe the cache (tests / forced re-resolve)."""
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM mb_recording_cache")
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recording-MBID cache clear failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally cleanup
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["lookup", "record", "clear_all"]
|
||||
|
|
@ -165,6 +165,81 @@ def compute_new_default_pushes(all_defaults, offered, legacy_baseline, existing)
|
|||
return to_add, new_offered
|
||||
|
||||
|
||||
_EXTINF_RE = re.compile(r'#EXTINF:\s*([0-9]+(?:\.[0-9]+)?)')
|
||||
|
||||
|
||||
def sum_hls_segment_seconds(playlist_text: str) -> float:
|
||||
"""Total audio seconds an HLS media playlist actually provides — the sum of its
|
||||
``#EXTINF`` segment durations. This is the authoritative "how much audio is really
|
||||
here" signal: a PREVIEW manifest serves only ~30s of segments even though the track
|
||||
is full-length, so summing EXTINF catches it before we waste the download. Returns
|
||||
0.0 when the playlist has no EXTINF lines (master playlists, legacy manifests) — the
|
||||
caller treats 0 as 'unknown', never as 'preview'."""
|
||||
total = 0.0
|
||||
for m in _EXTINF_RE.finditer(playlist_text or ''):
|
||||
try:
|
||||
total += float(m.group(1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def is_short_audio(actual_seconds: float, expected_seconds: float, threshold: float = 0.8) -> bool:
|
||||
"""True when ``actual`` is meaningfully shorter than ``expected`` — i.e. a preview
|
||||
clip or a truncated/corrupt download. Conservative: returns False whenever either
|
||||
value is missing/zero (unknown ⇒ never reject), and only trips below ``threshold``
|
||||
of the expected length (previews are ~15% of full, so the margin is huge)."""
|
||||
try:
|
||||
a, e = float(actual_seconds or 0), float(expected_seconds or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if a <= 0 or e <= 0:
|
||||
return False
|
||||
return a < e * threshold
|
||||
|
||||
|
||||
def is_fake_lossless_bitrate(size_bytes, claimed_seconds, sample_rate, bits_per_sample,
|
||||
channels, min_ratio: float = 0.30) -> bool:
|
||||
"""True when a 'lossless' file's data is FAR too small for its claimed length — the
|
||||
fingerprint of a ~30s preview whose STREAMINFO/container was faked to the full
|
||||
duration (so every length header reads 'full' and only the bitrate gives it away).
|
||||
Real FLAC is ~40-75% of raw PCM; a preview padded to full length implies single-digit
|
||||
%. Conservative: 0 / bad inputs return False (never reject on unknowns)."""
|
||||
try:
|
||||
sz, secs = float(size_bytes or 0), float(claimed_seconds or 0)
|
||||
sr, bits, ch = int(sample_rate or 0), int(bits_per_sample or 0), int(channels or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if sz <= 0 or secs <= 0 or sr <= 0 or bits <= 0 or ch <= 0:
|
||||
return False
|
||||
return (sz * 8 / secs) < (sr * bits * ch) * min_ratio
|
||||
|
||||
|
||||
def parse_ffmpeg_time(stderr_text) -> float:
|
||||
"""The last ``time=HH:MM:SS.xx`` ffmpeg prints while decoding — the REAL decoded
|
||||
length (immune to a faked container/STREAMINFO duration). 0.0 if not found."""
|
||||
last = 0.0
|
||||
for m in re.finditer(r'time=(\d+):(\d+):(\d+(?:\.\d+)?)', stderr_text or ''):
|
||||
last = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
|
||||
return last
|
||||
|
||||
|
||||
def is_preview_download(real_seconds, reference_seconds, *, is_lossless, size_bytes,
|
||||
sample_rate, bits_per_sample, channels):
|
||||
"""Is a finished file a preview/truncated fake? Two independent signals, so it fires
|
||||
even when the fakery declares full length at every layer:
|
||||
1. DECODED length far below the reference (the ground truth, when a decoder ran);
|
||||
2. for lossless, an impossibly-low implied bitrate (no decoder needed).
|
||||
Returns ``(is_fake, reason)``."""
|
||||
if real_seconds and is_short_audio(real_seconds, reference_seconds):
|
||||
return True, "decoded %.0fs of %.0fs" % (real_seconds, reference_seconds)
|
||||
if is_lossless and is_fake_lossless_bitrate(size_bytes, reference_seconds, sample_rate,
|
||||
bits_per_sample, channels):
|
||||
kbps = (float(size_bytes) * 8 / reference_seconds / 1000) if reference_seconds else 0
|
||||
return True, "%.0fkbps lossless over %.0fs (far too low — a ~30s preview)" % (kbps, reference_seconds)
|
||||
return False, ""
|
||||
|
||||
|
||||
# Run the new-default push at most once per process.
|
||||
_pushed_new_defaults = False
|
||||
|
||||
|
|
@ -688,7 +763,7 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
logger.warning(f"Failed to parse HLS playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
media_text = playlist_text
|
||||
media_text = playlist_text # the playlist that actually carries the EXTINF segments
|
||||
if '#EXT-X-STREAM-INF' in playlist_text and segment_uris:
|
||||
playlist_uri = segment_uris[0]
|
||||
try:
|
||||
|
|
@ -729,6 +804,9 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
'extension': q_info['extension'],
|
||||
'codec': q_info['codec'],
|
||||
'quality': quality,
|
||||
# Real audio length the manifest provides (sum of EXTINF) — used to reject
|
||||
# preview manifests before downloading. 0.0 = unknown (don't reject).
|
||||
'manifest_duration': sum_hls_segment_seconds(media_text),
|
||||
}
|
||||
|
||||
def _get_legacy_track_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
|
|
@ -753,15 +831,57 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
'quality': quality,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _probe_audio_seconds(path) -> float:
|
||||
"""Real decoded audio length of a finished file, via mutagen (already a dep).
|
||||
0.0 on any failure — the caller treats 0 as 'unknown' and never rejects on it."""
|
||||
try:
|
||||
from mutagen import File as _MutagenFile
|
||||
mf = _MutagenFile(str(path))
|
||||
info = getattr(mf, 'info', None) if mf is not None else None
|
||||
if info is not None:
|
||||
return float(getattr(info, 'length', 0) or 0)
|
||||
except Exception as _probe_err: # noqa: BLE001
|
||||
logger.debug("mutagen audio-length probe failed for %s: %s", path, _probe_err)
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _find_ffmpeg():
|
||||
ff = shutil.which('ffmpeg')
|
||||
if ff:
|
||||
return ff
|
||||
cand = Path(__file__).parent.parent / 'tools' / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
||||
return str(cand) if cand.exists() else None
|
||||
|
||||
def _probe_real_seconds(self, path) -> float:
|
||||
"""REAL decoded audio length via ffmpeg — decodes the actual frames, so it sees
|
||||
through a faked STREAMINFO/container duration (a 30s preview claiming full
|
||||
length decodes to 30s). 0.0 if ffmpeg is unavailable or on error."""
|
||||
ff = self._find_ffmpeg()
|
||||
if not ff:
|
||||
return 0.0
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ff, '-hide_banner', '-nostdin', '-i', str(path), '-map', '0:a:0', '-f', 'null', '-'],
|
||||
capture_output=True, text=True, timeout=180)
|
||||
return parse_ffmpeg_time(proc.stderr)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _flac_props(path):
|
||||
"""(sample_rate, bits_per_sample, channels) for the bitrate sanity check, or None."""
|
||||
try:
|
||||
from mutagen.flac import FLAC
|
||||
si = FLAC(str(path)).info
|
||||
return (si.sample_rate, si.bits_per_sample, si.channels)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _demux_flac(self, input_path: Path, output_path: Path) -> None:
|
||||
ffmpeg = shutil.which('ffmpeg')
|
||||
ffmpeg = self._find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
tools_dir = Path(__file__).parent.parent / 'tools'
|
||||
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
||||
if ffmpeg_candidate.exists():
|
||||
ffmpeg = str(ffmpeg_candidate)
|
||||
else:
|
||||
raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.')
|
||||
raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
|
@ -883,13 +1003,18 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
|
||||
MIN_AUDIO_SIZE = 100 * 1024
|
||||
|
||||
# Real track duration — lets _get_hls_manifest reject 30s preview
|
||||
# manifests before we download them. Best-effort: 0 disables the check.
|
||||
# Expected track length, drives every preview/truncation guard here:
|
||||
# * _get_hls_manifest's pre-download is_preview_playlist check
|
||||
# * the pre-download is_short_audio manifest check
|
||||
# * the post-download is_preview_download faked-header decode check
|
||||
# Best-effort: a 0 here just disables the duration checks, never rejects.
|
||||
expected_s = 0.0
|
||||
try:
|
||||
_info = self.get_track_info(track_id) or {}
|
||||
expected_duration_s = float(_info.get('duration_s') or 0)
|
||||
info = self.get_track_info(track_id) or {}
|
||||
expected_s = float(info.get('duration_s') or 0)
|
||||
except Exception:
|
||||
expected_duration_s = 0
|
||||
expected_s = 0.0
|
||||
expected_duration_s = expected_s # alias for _get_hls_manifest's param name
|
||||
|
||||
for q_key in chain:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
|
|
@ -908,6 +1033,18 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
logger.warning(f"No HLS manifest at quality {q_key}, trying next")
|
||||
continue
|
||||
|
||||
# Preview guard #1 (pre-download): a preview manifest serves only ~30s of
|
||||
# segments for a full-length track. A preview means THIS SOURCE only has a
|
||||
# preview of the track — lower quality tiers are the SAME preview — so abort
|
||||
# HiFi entirely and let the orchestrator fall through to the next SOURCE
|
||||
# (soulseek/youtube/…), rather than landing a lower-tier preview.
|
||||
manifest_s = float(manifest_info.get('manifest_duration') or 0)
|
||||
if is_short_audio(manifest_s, expected_s):
|
||||
logger.warning(
|
||||
"HiFi has only a PREVIEW of '%s' (manifest %.0fs of %.0fs at %s) — "
|
||||
"failing HiFi so the next source is tried", display_name, manifest_s, expected_s, q_key)
|
||||
return None
|
||||
|
||||
extension = manifest_info['extension']
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||
out_filename = f"{safe_name}.{extension}"
|
||||
|
|
@ -987,6 +1124,31 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# Preview guard #2 (post-download): the real catch. HiFi previews fake the
|
||||
# FULL length in every header — manifest EXTINF, m4a moov, FLAC
|
||||
# total_samples — so only the DECODED audio (or, for lossless, the
|
||||
# bitrate) reveals the ~30s truth. Reference = the largest length any
|
||||
# header claims (so the file's own faked claim becomes the bar its real
|
||||
# audio must clear); is_preview_download decodes + bitrate-checks.
|
||||
ref_s = max(expected_s, self._probe_audio_seconds(out_path))
|
||||
real_s = self._probe_real_seconds(out_path)
|
||||
props = self._flac_props(out_path) if is_flac else None
|
||||
fake, why = is_preview_download(
|
||||
real_s, ref_s, is_lossless=is_flac, size_bytes=final_size,
|
||||
sample_rate=(props[0] if props else 0),
|
||||
bits_per_sample=(props[1] if props else 0),
|
||||
channels=(props[2] if props else 0))
|
||||
if fake:
|
||||
# A preview at this tier means the SOURCE only has a preview — every
|
||||
# lower tier is the same 30s clip (and the lossy ones dodge the
|
||||
# bitrate check). Abort HiFi so the orchestrator tries the next
|
||||
# SOURCE, instead of cascading down into an accepted lower-tier preview.
|
||||
logger.warning(
|
||||
"HiFi has only a PREVIEW of '%s' (%s at %s) — failing HiFi so the "
|
||||
"next source is tried", display_name, why, q_key)
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
logger.info(f"HiFi download complete ({q_key}): {out_path} "
|
||||
f"({final_size / (1024*1024):.1f} MB)")
|
||||
return str(out_path)
|
||||
|
|
|
|||
94
core/imports/album_position.py
Normal file
94
core/imports/album_position.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Resolve a track's position WITHIN its album's track list.
|
||||
|
||||
The bug this fixes: a track auto-downloaded from the playlist pipeline / wishlist /
|
||||
watchlist is identified as belonging to an album, but the per-track position is
|
||||
unknown — Deezer's search/track and MusicBrainz's recording lookups don't carry a
|
||||
track position (only their album endpoint does). ``detect_album_info_web`` then
|
||||
leaves ``track_number = None``, the import pipeline falls through to the default-1
|
||||
floor, and the file lands as ``01/1`` even though the album is known
|
||||
(``core/imports/context.py``). Verified live: e.g. Deezer says "Obelisk" is track
|
||||
9 of *The Grand Mirage*, but it was tagged 1/1.
|
||||
|
||||
This is the pure matcher: given the album's track list (fetched by the caller via
|
||||
``core.metadata.album_tracks.get_album_tracks_for_source`` — so this stays
|
||||
source-agnostic and I/O-free) plus the track's own identifiers, return its real
|
||||
``(track_number, disc_number)``. Match priority is by reliability:
|
||||
|
||||
1. **ISRC** — an exact recording identity; trusted immediately.
|
||||
2. **source track id** — exact within this album.
|
||||
3. **normalized title** — last resort.
|
||||
|
||||
Returns ``(None, None)`` on no confident match, so the caller keeps its existing
|
||||
behaviour (never worse than today).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
def _norm_title(value: Any) -> str:
|
||||
"""Lower, strip punctuation, collapse whitespace — for tolerant title match."""
|
||||
s = re.sub(r"[^\w\s]", "", str(value or "").lower())
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
def _pos_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return n if n >= 1 else None
|
||||
|
||||
|
||||
def resolve_track_position_in_album(
|
||||
album_tracks: List[dict],
|
||||
*,
|
||||
title: str = "",
|
||||
track_id: str = "",
|
||||
isrc: str = "",
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""Return ``(track_number, disc_number)`` for this track within ``album_tracks``,
|
||||
or ``(None, None)`` when no confident match is found.
|
||||
|
||||
``album_tracks`` is the list under ``get_album_tracks_for_source(...)['tracks']``
|
||||
— each entry has ``track_number`` / ``disc_number`` / ``id`` / ``name`` / ``isrc``.
|
||||
Entries without a valid positive ``track_number`` are skipped. Pure: no I/O.
|
||||
"""
|
||||
if not album_tracks:
|
||||
return (None, None)
|
||||
|
||||
want_isrc = str(isrc or "").strip().upper()
|
||||
want_id = str(track_id or "").strip()
|
||||
want_title = _norm_title(title)
|
||||
|
||||
by_id: Optional[Tuple[int, int]] = None
|
||||
by_title: Optional[Tuple[int, int]] = None
|
||||
|
||||
for t in album_tracks:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
tn = _pos_int(t.get("track_number"))
|
||||
if tn is None:
|
||||
continue
|
||||
dn = _pos_int(t.get("disc_number")) or 1
|
||||
|
||||
# 1) ISRC — exact recording. Win immediately.
|
||||
if want_isrc and str(t.get("isrc") or "").strip().upper() == want_isrc:
|
||||
return (tn, dn)
|
||||
# 2) source track id — exact within the album.
|
||||
if by_id is None and want_id and str(t.get("id") or "").strip() == want_id:
|
||||
by_id = (tn, dn)
|
||||
# 3) normalized title — last resort.
|
||||
if by_title is None and want_title and _norm_title(t.get("name")) == want_title:
|
||||
by_title = (tn, dn)
|
||||
|
||||
if by_id is not None:
|
||||
return by_id
|
||||
if by_title is not None:
|
||||
return by_title
|
||||
return (None, None)
|
||||
|
||||
|
||||
__all__ = ["resolve_track_position_in_album"]
|
||||
|
|
@ -433,16 +433,24 @@ def detect_album_info_web(context, artist_context=None):
|
|||
track_name.strip().lower(),
|
||||
artist_name.strip().lower(),
|
||||
}:
|
||||
_tn = track_info.get("track_number")
|
||||
_dn = track_info.get("disc_number")
|
||||
# The album is identified but discovery often doesn't carry the per-track
|
||||
# POSITION — Deezer's search/track and MusicBrainz's recording lookups omit
|
||||
# it (only their album endpoint has it). Without a position the pipeline
|
||||
# falls through to the default-1 floor and files an album track as 01/1
|
||||
# (e.g. Deezer says "Obelisk" is track 9 of The Grand Mirage). Resolve the
|
||||
# REAL position from the album's own track list when we have its id.
|
||||
# Fail-safe: leaves the numbers untouched on any miss, so behaviour is
|
||||
# never worse than the old preserve-None-and-fall-through.
|
||||
if _tn is None:
|
||||
_tn, _dn = _resolve_album_position_from_source(context, artist_context, _dn)
|
||||
return build_import_album_info(
|
||||
context,
|
||||
album_info={
|
||||
"album_name": album_name,
|
||||
# Preserve missing numbers as None so the import pipeline
|
||||
# can fall through to ``extract_track_number_from_filename``
|
||||
# at ``core/imports/pipeline.py:652`` instead of locking
|
||||
# to track/disc 01 for every wishlist re-attempt.
|
||||
"track_number": track_info.get("track_number"),
|
||||
"disc_number": track_info.get("disc_number"),
|
||||
"track_number": _tn,
|
||||
"disc_number": _dn,
|
||||
"album_image_url": album_ctx.get("image_url", ""),
|
||||
"confidence": 0.5,
|
||||
},
|
||||
|
|
@ -454,6 +462,48 @@ def detect_album_info_web(context, artist_context=None):
|
|||
return _resolve_single_to_parent_album(context, artist_context)
|
||||
|
||||
|
||||
def _resolve_album_position_from_source(context, artist_context, current_disc):
|
||||
"""Look up a track's real ``(track_number, disc_number)`` from its album's track
|
||||
list, for the case where the album is known but discovery didn't carry a
|
||||
position (Deezer/MusicBrainz search omit it).
|
||||
|
||||
Uses the SAME album id discovery already resolved (``get_import_source_ids`` →
|
||||
``album_id``), so it re-homes the track onto its own album with no re-search and
|
||||
no edition guessing. Matches by ISRC → source track id → title via the pure
|
||||
``core.imports.album_position`` seam. Returns ``(None, current_disc)`` on any
|
||||
miss/error so the caller falls back exactly as before — never worse than today.
|
||||
"""
|
||||
try:
|
||||
source = get_import_source(context)
|
||||
ids = get_import_source_ids(context)
|
||||
album_id = str(ids.get("album_id") or "")
|
||||
if not source or not album_id:
|
||||
return None, current_disc
|
||||
|
||||
from core.metadata.album_tracks import get_album_tracks_for_source
|
||||
payload = get_album_tracks_for_source(source, album_id) or {}
|
||||
tracks = payload.get("tracks") or []
|
||||
if not tracks:
|
||||
return None, current_disc
|
||||
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
title = (track_info.get("name") or original_search.get("title") or "").strip()
|
||||
isrc = str(track_info.get("isrc") or original_search.get("isrc") or "")
|
||||
|
||||
from core.imports.album_position import resolve_track_position_in_album
|
||||
tn, dn = resolve_track_position_in_album(
|
||||
tracks, title=title, track_id=str(ids.get("track_id") or ""), isrc=isrc)
|
||||
if tn is not None:
|
||||
logger.info("album-position: resolved '%s' to track %s/disc %s from album %s (%s)",
|
||||
title, tn, dn, album_id, source)
|
||||
return tn, (dn if dn is not None else current_disc)
|
||||
return None, current_disc
|
||||
except Exception as e:
|
||||
logger.debug("album-position resolution failed: %s", e)
|
||||
return None, current_disc
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -719,6 +719,16 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
album_info['clean_track_name'] = clean_track_name
|
||||
logger.info(f"[FIX] Updated album_info track_number to {track_number} for consistent metadata")
|
||||
|
||||
# Sync the disc number the SAME way (and via the SAME resolver) the embedded
|
||||
# tag will use — otherwise the "Disc N" folder is built from album_info's
|
||||
# original disc while the tag takes the per-track disc, so a disc-2/3 track
|
||||
# lands in the Disc 1 folder and every disc's tracks collapse together (Sokhi).
|
||||
from core.imports.track_number import resolve_disc_for_track
|
||||
_resolved_disc = resolve_disc_for_track(get_import_original_search(context), album_info)
|
||||
if album_info.get('disc_number') != _resolved_disc:
|
||||
logger.info(f"[FIX] Updated album_info disc_number to {_resolved_disc} for consistent metadata")
|
||||
album_info['disc_number'] = _resolved_disc
|
||||
|
||||
final_path, _ = build_final_path_for_track(context, artist_context, album_info, file_ext)
|
||||
logger.info(f"Resolved path: '{final_path}'")
|
||||
context['_final_processed_path'] = final_path
|
||||
|
|
|
|||
|
|
@ -159,3 +159,53 @@ def resolve_track_number(
|
|||
# value the pre-fix resolver would have used. A correctly-named file
|
||||
# with a stale/wrong embedded tag is therefore never regressed.
|
||||
return _coerce_positive(embedded_track_number)
|
||||
|
||||
|
||||
def normalize_disc_number(value) -> int:
|
||||
"""Coerce a disc value to a positive int, defaulting to 1.
|
||||
|
||||
Every track in a multi-disc album MUST carry a disc number, or Jellyfin/Plex
|
||||
leave the disc-less ones floating ungrouped above the disc sections (Sokhi's
|
||||
"tracks 3/9/15 at the top"). Upstream sources can hand back 0, None, '', or a
|
||||
non-numeric string for some tracks — especially when a track resolved to a
|
||||
different edition than its siblings — and the tag-writer only wrote the disc
|
||||
tag when it was truthy, so those tracks lost it entirely on the clear-then-
|
||||
rewrite. Flooring to >=1 here means a track is never written disc-less.
|
||||
"""
|
||||
try:
|
||||
n = int(value) # int, float, or clean int-string
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
n = int(float(str(value).strip())) # tolerate "2.0"
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return n if n >= 1 else 1
|
||||
|
||||
|
||||
def resolve_disc_for_track(original_search, album_info) -> int:
|
||||
"""The disc number for a track — resolved IDENTICALLY for the 'Disc N' folder
|
||||
(import pipeline) and the embedded tag (metadata.source), so the two can never
|
||||
disagree.
|
||||
|
||||
Sokhi: the pipeline synced the resolved track_number into album_info (so the
|
||||
folder matched the tag) but never did the same for disc — the folder used
|
||||
album_info's original disc (often 1) while the tag took the per-track disc
|
||||
(e.g. 2/3 from a MusicBrainz multi-medium release). Result: a disc-2/3 track
|
||||
landed in the Disc 1 folder, collapsing every disc's tracks into one folder.
|
||||
|
||||
Returns the first VALID positive disc — the per-track search's, else the album
|
||||
context's — else 1. A falsy/unknown (0/None/'') per-track disc falls through to
|
||||
the album rather than flooring early. Single source of truth so both call sites
|
||||
stay in lockstep."""
|
||||
for src in ((original_search or {}), (album_info or {})):
|
||||
raw = src.get("disc_number")
|
||||
try:
|
||||
n = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
n = int(float(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n >= 1:
|
||||
return n
|
||||
return 1
|
||||
|
|
|
|||
104
core/library/standalone_scan.py
Normal file
104
core/library/standalone_scan.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Decision logic for the SoulSync standalone Deep Scan's untracked → Staging move.
|
||||
|
||||
The standalone deep scan (``_run_soulsync_deep_scan`` in web_server) walks the
|
||||
Transfer folder, diffs it against the ``soulsync`` rows in the DB, and relocates
|
||||
every file it can't find a DB record for into Staging for auto-import. That's fine
|
||||
when Transfer is a scratch/landing area — files arrive, get moved, imported, and
|
||||
recorded, so a later scan only ever sees a few genuinely-new arrivals.
|
||||
|
||||
It is a DATA-LOSS trap when the DB is empty or out of sync with disk (a volume
|
||||
swap, a DB reset, external tag edits) while Transfer holds the user's real library:
|
||||
a path-only diff then flags the *entire* library as "untracked" and the scan
|
||||
relocates all of it (issue #904). The same failure mode the orphan detector and the
|
||||
media-server deep scan already guard against (``core.library.stale_guard``) — this
|
||||
path just never used the guard.
|
||||
|
||||
This module is the pure, testable decision: given the Transfer file set, the DB's
|
||||
known paths, and the user's "Transfer is my permanent library" preference, decide
|
||||
WHICH files are untracked and WHETHER it's safe to relocate them. The web layer does
|
||||
only the I/O (walk/move/delete) based on the returned plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Set
|
||||
|
||||
from core.library.stale_guard import (
|
||||
DEFAULT_MAX_ORPHAN_FRACTION,
|
||||
DEFAULT_MIN_ORPHANS,
|
||||
is_implausible_orphan_flood,
|
||||
)
|
||||
|
||||
# Block reason codes (web layer turns these into a user-facing warning).
|
||||
BLOCK_NONE = ""
|
||||
BLOCK_TRANSFER_PERMANENT = "transfer_permanent"
|
||||
BLOCK_DESYNC = "desync"
|
||||
|
||||
|
||||
def _norm(path: str) -> str:
|
||||
"""Normalize a path for cross-platform comparison (Windows vs Unix separators)."""
|
||||
return str(path).replace("\\", "/")
|
||||
|
||||
|
||||
def diff_untracked(transfer_files: Iterable[str], db_paths: Iterable[str]) -> Set[str]:
|
||||
"""Files present in ``transfer_files`` but with no matching ``db_paths`` record.
|
||||
|
||||
Comparison is separator-normalized, so a DB path stored with one separator style
|
||||
still matches the on-disk path. Pure — no I/O. Returns the original (un-normalized)
|
||||
transfer paths so the caller can act on the real filesystem entries.
|
||||
"""
|
||||
db_norm = {_norm(p) for p in db_paths if p}
|
||||
return {f for f in transfer_files if _norm(f) not in db_norm}
|
||||
|
||||
|
||||
def plan_standalone_deep_scan(
|
||||
transfer_files: Iterable[str],
|
||||
db_paths: Iterable[str],
|
||||
*,
|
||||
never_move: bool = False,
|
||||
min_untracked: int = DEFAULT_MIN_ORPHANS,
|
||||
max_fraction: float = DEFAULT_MAX_ORPHAN_FRACTION,
|
||||
) -> dict:
|
||||
"""Plan the untracked → Staging move for a standalone deep scan. Pure — no I/O.
|
||||
|
||||
Returns a dict:
|
||||
* ``untracked`` (set[str]) — Transfer files with no DB record.
|
||||
* ``move_blocked`` (bool) — True when the untracked files must NOT be relocated.
|
||||
* ``block_reason`` (str) — ``BLOCK_TRANSFER_PERMANENT`` / ``BLOCK_DESYNC`` / "".
|
||||
|
||||
The move is blocked when either:
|
||||
* ``never_move`` is set (the user marked Transfer as their permanent library), or
|
||||
* the untracked share is implausibly large (> ``min_untracked`` files AND
|
||||
> ``max_fraction`` of the folder) — the empty/desynced-DB signature, where a
|
||||
path-only diff would relocate the whole library. Below that floor a normal
|
||||
batch of new arrivals still moves as before.
|
||||
|
||||
``move_blocked`` is only ever True when there ARE untracked files; an empty scan
|
||||
or a clean library returns ``move_blocked=False`` with no reason.
|
||||
"""
|
||||
transfer_set = set(transfer_files) # concrete (handles generators) + dedups
|
||||
untracked = diff_untracked(transfer_set, db_paths)
|
||||
total = len(transfer_set)
|
||||
n_untracked = len(untracked)
|
||||
|
||||
if n_untracked == 0:
|
||||
return {"untracked": untracked, "move_blocked": False, "block_reason": BLOCK_NONE}
|
||||
|
||||
if never_move:
|
||||
return {"untracked": untracked, "move_blocked": True, "block_reason": BLOCK_TRANSFER_PERMANENT}
|
||||
|
||||
if is_implausible_orphan_flood(
|
||||
n_untracked, total, min_orphans=min_untracked, max_fraction=max_fraction
|
||||
):
|
||||
return {"untracked": untracked, "move_blocked": True, "block_reason": BLOCK_DESYNC}
|
||||
|
||||
return {"untracked": untracked, "move_blocked": False, "block_reason": BLOCK_NONE}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"diff_untracked",
|
||||
"plan_standalone_deep_scan",
|
||||
"BLOCK_NONE",
|
||||
"BLOCK_TRANSFER_PERMANENT",
|
||||
"BLOCK_DESYNC",
|
||||
]
|
||||
|
|
@ -158,6 +158,161 @@ class ListenBrainzClient:
|
|||
|
||||
return True
|
||||
|
||||
_MAX_TRACKS_PER_ADD = 100 # ListenBrainz MAX_RECORDINGS_PER_ADD
|
||||
_PLAYLIST_EXT = "https://musicbrainz.org/doc/jspf#playlist"
|
||||
|
||||
def _lb_headers(self) -> Dict:
|
||||
return {"Authorization": f"Token {self.token}", "Content-Type": "application/json"}
|
||||
|
||||
def _add_tracks_in_batches(self, playlist_mbid: str, tracks: List[Dict]) -> int:
|
||||
"""Add JSPF tracks to a playlist in <=100-track batches; return how many were added."""
|
||||
added = 0
|
||||
headers = self._lb_headers()
|
||||
for i in range(0, len(tracks or []), self._MAX_TRACKS_PER_ADD):
|
||||
batch = tracks[i:i + self._MAX_TRACKS_PER_ADD]
|
||||
try:
|
||||
r = self._make_request_with_retry(
|
||||
"POST", f"{self.base_url}/playlist/{playlist_mbid}/item/add",
|
||||
json={"playlist": {"track": batch}}, headers=headers,
|
||||
)
|
||||
if r and r.status_code in (200, 201):
|
||||
added += len(batch)
|
||||
else:
|
||||
logger.warning(f"ListenBrainz item/add batch failed: "
|
||||
f"{r.status_code if r else 'no response'}")
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz item/add error: {e}")
|
||||
return added
|
||||
|
||||
def get_playlist_track_count(self, playlist_mbid: str):
|
||||
"""Current track count of an LB playlist, or None if it can't be fetched (gone/404)."""
|
||||
try:
|
||||
r = self._make_request_with_retry(
|
||||
"GET", f"{self.base_url}/playlist/{playlist_mbid}",
|
||||
params={"fetch_metadata": "false"},
|
||||
headers={"Authorization": f"Token {self.token}"},
|
||||
)
|
||||
if r and r.status_code == 200:
|
||||
return len(((r.json() or {}).get("playlist") or {}).get("track", []))
|
||||
except Exception as e:
|
||||
logger.debug(f"ListenBrainz get playlist count failed: {e}")
|
||||
return None
|
||||
|
||||
def delete_playlist(self, playlist_mbid: str) -> bool:
|
||||
"""Delete an LB playlist. True on success."""
|
||||
try:
|
||||
r = self._make_request_with_retry(
|
||||
"POST", f"{self.base_url}/playlist/{playlist_mbid}/delete", headers=self._lb_headers()
|
||||
)
|
||||
return bool(r and r.status_code in (200, 201))
|
||||
except Exception as e:
|
||||
logger.error(f"ListenBrainz delete playlist error: {e}")
|
||||
return False
|
||||
|
||||
def create_playlist(self, title: str, tracks: List[Dict], public: bool = False) -> Dict:
|
||||
"""Create a NEW playlist on ListenBrainz and add its tracks (#903).
|
||||
|
||||
``tracks`` are JSPF track dicts — each MUST carry an ``identifier`` of the form
|
||||
``https://musicbrainz.org/recording/<mbid>`` (LB rejects text-only tracks). Creates
|
||||
an empty playlist for the MBID, then adds tracks in <=100 batches. Returns
|
||||
``{success, playlist_mbid, playlist_url, added, requested, error, updated}``. Never raises.
|
||||
"""
|
||||
result = {"success": False, "playlist_mbid": None, "playlist_url": None,
|
||||
"added": 0, "requested": len(tracks or []), "error": None, "updated": False}
|
||||
if not self.is_authenticated():
|
||||
result["error"] = "ListenBrainz not authenticated (no token/username)"
|
||||
return result
|
||||
|
||||
create_body = {"playlist": {
|
||||
"title": (title or "SoulSync Export").strip() or "SoulSync Export",
|
||||
"extension": {self._PLAYLIST_EXT: {"public": bool(public)}},
|
||||
}}
|
||||
try:
|
||||
resp = self._make_request_with_retry(
|
||||
"POST", f"{self.base_url}/playlist/create", json=create_body, headers=self._lb_headers()
|
||||
)
|
||||
except Exception as e:
|
||||
result["error"] = f"create request failed: {e}"
|
||||
return result
|
||||
if not resp or resp.status_code not in (200, 201):
|
||||
result["error"] = f"create returned {resp.status_code if resp else 'no response'}"
|
||||
return result
|
||||
try:
|
||||
playlist_mbid = (resp.json() or {}).get("playlist_mbid")
|
||||
except Exception:
|
||||
playlist_mbid = None
|
||||
if not playlist_mbid:
|
||||
result["error"] = "create succeeded but no playlist_mbid in response"
|
||||
return result
|
||||
|
||||
result["playlist_mbid"] = playlist_mbid
|
||||
result["playlist_url"] = f"https://listenbrainz.org/playlist/{playlist_mbid}"
|
||||
result["added"] = self._add_tracks_in_batches(playlist_mbid, tracks)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
def update_playlist(self, playlist_mbid: str, title: str, tracks: List[Dict], public: bool = False) -> Dict:
|
||||
"""Replace an existing LB playlist's contents IN PLACE (stable URL/MBID) (#903).
|
||||
|
||||
Verifies the playlist still exists, clears its current items, re-adds the new tracks,
|
||||
and updates the title. If the playlist is gone (deleted on LB), returns success=False
|
||||
with ``gone=True`` so the caller can fall back to creating a fresh one.
|
||||
Returns the same shape as ``create_playlist`` plus ``updated=True``.
|
||||
"""
|
||||
result = {"success": False, "playlist_mbid": playlist_mbid,
|
||||
"playlist_url": f"https://listenbrainz.org/playlist/{playlist_mbid}",
|
||||
"added": 0, "requested": len(tracks or []), "error": None,
|
||||
"updated": True, "gone": False}
|
||||
if not self.is_authenticated():
|
||||
result["error"] = "ListenBrainz not authenticated (no token/username)"
|
||||
return result
|
||||
|
||||
count = self.get_playlist_track_count(playlist_mbid)
|
||||
if count is None:
|
||||
result["error"] = "playlist not found on ListenBrainz"
|
||||
result["gone"] = True
|
||||
return result
|
||||
|
||||
headers = self._lb_headers()
|
||||
# Clear existing items (one range delete from the top).
|
||||
if count > 0:
|
||||
try:
|
||||
self._make_request_with_retry(
|
||||
"POST", f"{self.base_url}/playlist/{playlist_mbid}/item/delete",
|
||||
json={"index": 0, "count": count}, headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ListenBrainz item/delete (clear) failed: {e}")
|
||||
|
||||
result["added"] = self._add_tracks_in_batches(playlist_mbid, tracks)
|
||||
|
||||
# Refresh the title (best-effort — content already replaced).
|
||||
try:
|
||||
self._make_request_with_retry(
|
||||
"POST", f"{self.base_url}/playlist/edit/{playlist_mbid}",
|
||||
json={"playlist": {"title": (title or "SoulSync Export").strip() or "SoulSync Export"}},
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"ListenBrainz playlist title edit failed: {e}")
|
||||
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
def create_or_update_playlist(self, title: str, tracks: List[Dict],
|
||||
existing_mbid: str = None, public: bool = False) -> Dict:
|
||||
"""Update the existing LB playlist in place when we've pushed this one before, else
|
||||
create a fresh one — so re-exporting the same SoulSync playlist never duplicates it.
|
||||
Falls back to create if the remembered playlist was deleted on LB."""
|
||||
if existing_mbid:
|
||||
res = self.update_playlist(existing_mbid, title, tracks, public)
|
||||
if res.get("success"):
|
||||
return res
|
||||
# Remembered playlist gone/failed -> create a new one instead of erroring out.
|
||||
logger.info(f"ListenBrainz playlist {existing_mbid} unavailable for update "
|
||||
f"({res.get('error')}); creating a new one.")
|
||||
return self.create_playlist(title, tracks, public)
|
||||
|
||||
def get_playlists_created_for_user(self, count: int = 25, offset: int = 0) -> List[Dict]:
|
||||
"""
|
||||
Fetch playlists created FOR the user (recommendations, personalized playlists)
|
||||
|
|
|
|||
|
|
@ -438,10 +438,19 @@ def embed_album_art_metadata(audio_file, metadata: dict):
|
|||
|
||||
if not image_data:
|
||||
art_url = metadata.get("album_art_url")
|
||||
if not art_url:
|
||||
logger.warning("No album art URL available for embedding.")
|
||||
# Prefer the pinned release's OWN cover over a release-group / provider
|
||||
# representative (usually the standard edition); fall back to art_url
|
||||
# when the release has no art of its own. Keeps embedded art in sync
|
||||
# with cover.jpg (same preference + fetch).
|
||||
from core.metadata.caa_art import fetch_release_preferred_art
|
||||
image_data, mime_type, used_url = fetch_release_preferred_art(
|
||||
release_mbid, art_url, fetch_fn=_fetch_art_bytes)
|
||||
if not image_data:
|
||||
if not art_url and not release_mbid:
|
||||
logger.warning("No album art URL available for embedding.")
|
||||
return False
|
||||
image_data, mime_type = _fetch_art_bytes(art_url)
|
||||
if release_mbid and used_url and used_url != art_url:
|
||||
logger.info("Embedding release-specific art (edition match): %s", release_mbid)
|
||||
|
||||
if not image_data:
|
||||
logger.error("Failed to download album art data.")
|
||||
|
|
@ -560,13 +569,20 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None,
|
|||
art_url = images[0].get("url", "")
|
||||
if art_url:
|
||||
logger.info("Using cover art URL from album context")
|
||||
if not art_url:
|
||||
logger.warning("No cover art URL available for download.")
|
||||
# Prefer the pinned release's OWN cover over a release-group / provider
|
||||
# representative (which is usually the standard edition), falling back
|
||||
# to art_url when the release has no art of its own. Upgrades to the
|
||||
# source's highest resolution via _fetch_art_bytes (shared with the
|
||||
# tag-embed path so cover.jpg and embedded art match).
|
||||
from core.metadata.caa_art import fetch_release_preferred_art
|
||||
image_data, _, used_url = fetch_release_preferred_art(
|
||||
release_mbid, art_url, fetch_fn=_fetch_art_bytes)
|
||||
if not image_data:
|
||||
if not art_url and not release_mbid:
|
||||
logger.warning("No cover art URL available for download.")
|
||||
return
|
||||
# Upgrade to the source's highest resolution (Spotify master /
|
||||
# iTunes 3000 / Deezer 1900) with a one-level fallback — shared
|
||||
# with the tag-embed path so cover.jpg and embedded art match.
|
||||
image_data, _ = _fetch_art_bytes(art_url)
|
||||
if release_mbid and used_url and used_url != art_url:
|
||||
logger.info("Using release-specific cover.jpg (edition match): %s", release_mbid)
|
||||
|
||||
if not image_data:
|
||||
return
|
||||
|
|
|
|||
74
core/metadata/caa_art.py
Normal file
74
core/metadata/caa_art.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Cover Art Archive helper: prefer a pinned release's OWN cover over the
|
||||
release-group representative.
|
||||
|
||||
On the Cover Art Archive a release-group ``front`` is a single REPRESENTATIVE
|
||||
cover — CAA designates one release in the group to stand for the whole thing,
|
||||
which is almost always the standard / most-common edition. So when a download
|
||||
has pinned a SPECIFIC release (e.g. a "Gustave Edition" the user picked), using
|
||||
the release-group cover silently swaps in the standard art.
|
||||
|
||||
This helper tries the specific release's own ``/release/<mbid>/front`` first and
|
||||
only falls back to the caller's existing URL (a release-group representative or a
|
||||
provider cover) when the release has no art of its own — so it can only ever
|
||||
*improve* on today's behaviour, never strip a cover that was already showing.
|
||||
|
||||
Pure: the network fetch is injected, so the preference logic is unit-testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
COVER_ART_ARCHIVE_URL = "https://coverartarchive.org"
|
||||
|
||||
|
||||
def caa_front_url(mbid: Optional[str], scope: str = "release", size: int = 1200) -> Optional[str]:
|
||||
"""Build a Cover Art Archive front-cover URL, or None for a falsy mbid.
|
||||
``scope`` is 'release' (a specific edition) or 'release-group' (the group's
|
||||
representative). ``size`` selects the CDN thumbnail (e.g. 250/500/1200); 0
|
||||
requests the bare ``/front`` original."""
|
||||
if not mbid:
|
||||
return None
|
||||
if scope not in ("release", "release-group"):
|
||||
scope = "release"
|
||||
suffix = f"-{size}" if size else ""
|
||||
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front{suffix}"
|
||||
|
||||
|
||||
def fetch_release_preferred_art(
|
||||
release_mbid: Optional[str],
|
||||
fallback_url: Optional[str],
|
||||
*,
|
||||
fetch_fn: Callable[[str], Tuple[Optional[bytes], Optional[str]]],
|
||||
size: int = 1200,
|
||||
min_bytes: int = 0,
|
||||
) -> Tuple[Optional[bytes], Optional[str], Optional[str]]:
|
||||
"""Fetch the best cover, preferring the specific release's own art.
|
||||
|
||||
Tries ``/release/<release_mbid>/front`` first; on any miss (no such art,
|
||||
404, or smaller than ``min_bytes``) falls back to ``fallback_url`` (a
|
||||
release-group / provider cover). ``fetch_fn(url) -> (bytes|None, mime|None)``;
|
||||
it is expected to return ``(None, None)`` on a 404, which is how a release
|
||||
with no art of its own advances to the fallback. ``min_bytes`` defaults to 0
|
||||
(accept any non-empty image) to preserve the fallback path's prior behaviour;
|
||||
callers can raise it to reject placeholder/error images. Returns
|
||||
``(bytes|None, mime|None, url_used|None)``. Never raises for a missing cover —
|
||||
a failed candidate just advances to the next, so coverage never regresses."""
|
||||
candidates = []
|
||||
release_url = caa_front_url(release_mbid, "release", size) if release_mbid else None
|
||||
if release_url:
|
||||
candidates.append(release_url)
|
||||
if fallback_url and fallback_url not in candidates:
|
||||
candidates.append(fallback_url)
|
||||
|
||||
for url in candidates:
|
||||
try:
|
||||
data, mime = fetch_fn(url)
|
||||
except Exception:
|
||||
data, mime = None, None
|
||||
if data and len(data) > min_bytes:
|
||||
return data, mime, url
|
||||
return None, None, None
|
||||
|
||||
|
||||
__all__ = ["COVER_ART_ARCHIVE_URL", "caa_front_url", "fetch_release_preferred_art"]
|
||||
|
|
@ -131,6 +131,14 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
|
|||
track_num_str = format_track_number_tag(
|
||||
metadata.get('track_number'), metadata.get('total_tracks')
|
||||
)
|
||||
# Disc number is written UNCONDITIONALLY (floored to >=1), like the
|
||||
# track number above. The old code only wrote it when truthy, so a
|
||||
# track whose disc came back 0/None/'' (e.g. matched to a different
|
||||
# edition) lost its disc tag on the clear-then-rewrite and floated
|
||||
# ungrouped above the disc sections in Jellyfin/Plex (Sokhi).
|
||||
from core.imports.track_number import normalize_disc_number
|
||||
_disc_num = normalize_disc_number(metadata.get('disc_number'))
|
||||
disc_num_str = str(_disc_num)
|
||||
write_multi = cfg.get("metadata_enhancement.tags.write_multi_artist", False)
|
||||
artists_list = metadata.get("_artists_list", [])
|
||||
|
||||
|
|
@ -162,8 +170,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
|
|||
if metadata.get("genre"):
|
||||
audio_file.tags.add(symbols.TCON(encoding=3, text=[metadata["genre"]]))
|
||||
audio_file.tags.add(symbols.TRCK(encoding=3, text=[track_num_str]))
|
||||
if metadata.get("disc_number"):
|
||||
audio_file.tags.add(symbols.TPOS(encoding=3, text=[str(metadata["disc_number"])]))
|
||||
audio_file.tags.add(symbols.TPOS(encoding=3, text=[disc_num_str]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
if metadata.get("title"):
|
||||
audio_file["title"] = [metadata["title"]]
|
||||
|
|
@ -180,8 +187,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
|
|||
if metadata.get("genre"):
|
||||
audio_file["genre"] = [metadata["genre"]]
|
||||
audio_file["tracknumber"] = [track_num_str]
|
||||
if metadata.get("disc_number"):
|
||||
audio_file["discnumber"] = [str(metadata["disc_number"])]
|
||||
audio_file["discnumber"] = [disc_num_str]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
if metadata.get("title"):
|
||||
audio_file["\xa9nam"] = [metadata["title"]]
|
||||
|
|
@ -198,8 +204,7 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
|
|||
audio_file["trkn"] = [format_track_number_tuple(
|
||||
metadata.get("track_number"), metadata.get("total_tracks")
|
||||
)]
|
||||
if metadata.get("disc_number"):
|
||||
audio_file["disk"] = [(metadata["disc_number"], 0)]
|
||||
audio_file["disk"] = [(_disc_num, 0)]
|
||||
|
||||
embed_source_ids(audio_file, metadata, context, runtime=runtime)
|
||||
|
||||
|
|
|
|||
|
|
@ -1089,10 +1089,12 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
|
|||
metadata["track_number"] = 1
|
||||
metadata["total_tracks"] = 1
|
||||
|
||||
disc_num = original_search.get("disc_number")
|
||||
if disc_num is None and album_info:
|
||||
disc_num = album_info.get("disc_number")
|
||||
metadata["disc_number"] = disc_num if disc_num is not None else 1
|
||||
# Resolve via the SHARED resolver so the embedded tag and the "Disc N" folder
|
||||
# (computed in the import pipeline from album_info) can never disagree — same
|
||||
# function, same inputs. Floors to >=1 (a 0/''/non-numeric disc must not read
|
||||
# as disc-less and ungroup the track in Jellyfin/Plex).
|
||||
from core.imports.track_number import resolve_disc_for_track
|
||||
metadata["disc_number"] = resolve_disc_for_track(original_search, album_info)
|
||||
|
||||
if album_ctx and album_ctx.get("release_date"):
|
||||
release_date = _normalize_release_date_tag(album_ctx.get("release_date"))
|
||||
|
|
|
|||
94
core/playlists/item_naming.py
Normal file
94
core/playlists/item_naming.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Optional custom naming for the FILES inside an organize-by-playlist folder.
|
||||
|
||||
By default a playlist entry keeps the real library filename (the materialized
|
||||
folder is a view onto Artist/Album/track.ext). A user can opt into a flat
|
||||
filename template — e.g. ``$position - $artist - $title`` — so the folder sorts
|
||||
and plays the way they want (most commonly: in playlist order on a dumb DAP).
|
||||
|
||||
It is a **filename** template, never a path:
|
||||
- it may NOT contain a path separator (``/`` or ``\\``) — it names the file,
|
||||
not a folder tree, and
|
||||
- it MUST contain ``$title`` — so every file has a real, non-empty name.
|
||||
|
||||
Both rules are validated up front (so the Settings UI can reject a bad value
|
||||
with a reason) AND re-checked at apply time, where an invalid/empty template or
|
||||
an empty render falls back to the library filename. So a bad value can never
|
||||
produce a broken name — the worst case is "no change from today".
|
||||
|
||||
Pure logic: no DB, no config, no filesystem. The caller supplies the metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from core.imports.paths import sanitize_filename
|
||||
|
||||
# Tokens a user may use in the template (for docs / UI hints).
|
||||
PLAYLIST_ITEM_TOKENS = ("$position", "$artist", "$album", "$track", "$title")
|
||||
|
||||
|
||||
def validate_playlist_item_template(template: Optional[str]) -> Tuple[bool, str]:
|
||||
"""Return ``(ok, reason)``. An empty template is VALID and means "feature off"
|
||||
(keep the library filename). ``reason`` is '' when ok."""
|
||||
t = (template or "").strip()
|
||||
if not t:
|
||||
return True, "" # empty == disabled, not an error
|
||||
if "/" in t or "\\" in t:
|
||||
return False, ("Playlist file naming can't contain a folder separator "
|
||||
"( / or \\ ) — it names the file, not a path.")
|
||||
if "$title" not in t:
|
||||
return False, "Playlist file naming must include $title so every file has a name."
|
||||
return True, ""
|
||||
|
||||
|
||||
def render_playlist_item_name(
|
||||
template: Optional[str],
|
||||
*,
|
||||
title: str,
|
||||
artist: str = "",
|
||||
album: str = "",
|
||||
track: object = None,
|
||||
position: object = None,
|
||||
ext: str = "",
|
||||
fallback_name: str = "",
|
||||
) -> str:
|
||||
"""Render ``template`` to a sanitized filename WITH ``ext`` appended.
|
||||
|
||||
Falls back to ``fallback_name`` (the library filename) when the template is
|
||||
empty/invalid or renders to nothing after sanitizing — so the result is
|
||||
never broken. ``position`` is used verbatim (the caller pre-pads it for
|
||||
correct sorting); ``track`` is zero-padded to two digits when numeric."""
|
||||
ok, _ = validate_playlist_item_template(template)
|
||||
t = (template or "").strip()
|
||||
if not ok or not t:
|
||||
return fallback_name
|
||||
|
||||
pos_str = "" if position is None else str(position)
|
||||
if track is None:
|
||||
trk_str = ""
|
||||
else:
|
||||
try:
|
||||
trk_str = f"{int(track):02d}"
|
||||
except (TypeError, ValueError):
|
||||
trk_str = str(track)
|
||||
|
||||
# No token is a prefix of another, so replacement order is irrelevant.
|
||||
out = t
|
||||
out = out.replace("$position", pos_str)
|
||||
out = out.replace("$artist", str(artist or ""))
|
||||
out = out.replace("$album", str(album or ""))
|
||||
out = out.replace("$track", trk_str)
|
||||
out = out.replace("$title", str(title or ""))
|
||||
|
||||
out = sanitize_filename(out).strip()
|
||||
if not out:
|
||||
return fallback_name
|
||||
return out + (ext or "")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLAYLIST_ITEM_TOKENS",
|
||||
"validate_playlist_item_template",
|
||||
"render_playlist_item_name",
|
||||
]
|
||||
|
|
@ -72,16 +72,25 @@ def playlist_dir_for(playlists_root: str, playlist_name: str) -> str:
|
|||
return candidate
|
||||
|
||||
|
||||
def _desired_entries(playlist_dir: str, real_paths: Sequence[str]) -> "list[tuple[str, str]]":
|
||||
"""Map each real file to a flat destination inside ``playlist_dir``, preserving
|
||||
the source filename. On a basename collision between two *different* sources,
|
||||
disambiguate with a numeric suffix rather than silently overwriting."""
|
||||
def _desired_entries(
|
||||
playlist_dir: str,
|
||||
real_paths: Sequence[str],
|
||||
dest_names: Optional[Sequence[Optional[str]]] = None,
|
||||
) -> "list[tuple[str, str]]":
|
||||
"""Map each real file to a flat destination inside ``playlist_dir``.
|
||||
|
||||
By default the source filename is preserved. ``dest_names`` (parallel to
|
||||
``real_paths``) lets a caller override the name per entry — e.g. a custom
|
||||
playlist file-naming template; a falsy override falls back to the source
|
||||
basename. On a name collision between two *different* sources, disambiguate
|
||||
with a numeric suffix rather than silently overwriting."""
|
||||
entries: list[tuple[str, str]] = []
|
||||
used: dict[str, str] = {} # dest basename -> source real path
|
||||
for real in real_paths:
|
||||
for i, real in enumerate(real_paths):
|
||||
if not real:
|
||||
continue
|
||||
base = os.path.basename(real)
|
||||
override = dest_names[i] if (dest_names is not None and i < len(dest_names)) else None
|
||||
base = override or os.path.basename(real)
|
||||
name = base
|
||||
stem, ext = os.path.splitext(base)
|
||||
counter = 1
|
||||
|
|
@ -156,6 +165,7 @@ def rebuild_playlist_folder(
|
|||
real_paths: Sequence[str],
|
||||
mode: str = DEFAULT_MODE,
|
||||
*,
|
||||
dest_names: Optional[Sequence[Optional[str]]] = None,
|
||||
prune_stale: bool = True,
|
||||
symlink_fn: Callable[[str, str], None] = os.symlink,
|
||||
copy_fn: Callable[[str, str], object] = shutil.copy2,
|
||||
|
|
@ -163,13 +173,15 @@ def rebuild_playlist_folder(
|
|||
"""(Re)build ``playlists_root/<playlist_name>/`` so it contains exactly one
|
||||
entry per real file in ``real_paths`` — adding missing entries, leaving correct
|
||||
ones untouched, and (when ``prune_stale``) removing entries no longer present.
|
||||
``dest_names`` (parallel to ``real_paths``) optionally overrides each entry's
|
||||
filename (custom playlist naming); falsy entries keep the source basename.
|
||||
Idempotent and safe to re-run any time. Filesystem ops are injectable."""
|
||||
mode = normalize_mode(mode)
|
||||
pdir = playlist_dir_for(playlists_root, playlist_name)
|
||||
summary = RebuildSummary(playlist_dir=pdir, mode_requested=mode)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
|
||||
entries = _desired_entries(pdir, real_paths)
|
||||
entries = _desired_entries(pdir, real_paths, dest_names)
|
||||
keep = {dest for _real, dest in entries}
|
||||
|
||||
for real, dest in entries:
|
||||
|
|
|
|||
|
|
@ -155,11 +155,20 @@ def _rebuild_one_from_db(db, config_manager, playlist: dict):
|
|||
source IDs), resolving to disk, and rebuilding WITH prune. Because it's driven
|
||||
by current membership, a track that has LEFT the playlist drops out of the set
|
||||
and its symlink is pruned. Returns ``(playlist_name, RebuildSummary)``."""
|
||||
import os as _os
|
||||
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
from core.playlists.item_naming import render_playlist_item_name
|
||||
|
||||
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
|
||||
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
|
||||
real_paths: List[str] = []
|
||||
item_template = ((config_manager.get("file_organization.templates", {}) or {})
|
||||
.get("playlist_item", "") or "").strip()
|
||||
|
||||
# Resolve owned tracks to real paths IN PLAYLIST ORDER, keeping the metadata
|
||||
# so an optional custom filename template ($position/$artist/$title/...) can
|
||||
# be applied. $position is the playlist index, which is exactly this order.
|
||||
resolved: List[dict] = []
|
||||
seen = set()
|
||||
for t in (db.get_mirrored_playlist_tracks(playlist["id"]) or []):
|
||||
title = (t.get("track_name") or "").strip()
|
||||
|
|
@ -175,9 +184,32 @@ def _rebuild_one_from_db(db, config_manager, playlist: dict):
|
|||
real = resolve_library_file_path(getattr(db_track, "file_path", None), config_manager=config_manager)
|
||||
if real and real not in seen:
|
||||
seen.add(real)
|
||||
real_paths.append(real)
|
||||
resolved.append({
|
||||
"real": real,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": (t.get("album_name") or t.get("album") or "").strip(),
|
||||
"track": getattr(db_track, "track_number", None),
|
||||
})
|
||||
|
||||
real_paths: List[str] = [r["real"] for r in resolved]
|
||||
|
||||
dest_names = None
|
||||
if item_template:
|
||||
width = max(2, len(str(len(resolved)))) # zero-pad $position for correct sorting
|
||||
dest_names = [
|
||||
render_playlist_item_name(
|
||||
item_template,
|
||||
title=r["title"], artist=r["artist"], album=r["album"], track=r["track"],
|
||||
position=f"{i:0{width}d}",
|
||||
ext=_os.path.splitext(r["real"])[1],
|
||||
fallback_name=_os.path.basename(r["real"]),
|
||||
)
|
||||
for i, r in enumerate(resolved, start=1)
|
||||
]
|
||||
|
||||
name = playlist.get("name") or "Unnamed Playlist"
|
||||
return name, rebuild_playlist_folder(root, name, real_paths, mode) # prune_stale=True
|
||||
return name, rebuild_playlist_folder(root, name, real_paths, mode, dest_names=dest_names) # prune_stale=True
|
||||
|
||||
|
||||
def rebuild_organized_playlists_from_db(db, config_manager, *, profile_id: int = 1):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,35 @@ from urllib.parse import parse_qs, urlparse
|
|||
|
||||
_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$")
|
||||
|
||||
|
||||
def stable_source_track_id(track: Mapping, existing: Optional[str] = None) -> str:
|
||||
"""A stable per-track id for a mirrored-playlist track.
|
||||
|
||||
Spotify / YouTube / Deezer tracks carry a native id. File-import (CSV / M3U /
|
||||
TXT) and iTunes-only sources don't — they arrive with an empty
|
||||
``source_track_id``. The whole manual-match system (Find & Add ↔ sync) keys on
|
||||
``source_track_id``, and an empty key can neither be recorded (the persist is a
|
||||
no-op) nor looked up — so a manual match on a file-import track is silently
|
||||
dropped and the track re-appears as "extra" (#901).
|
||||
|
||||
When a native id is present it's used verbatim. Otherwise we derive a
|
||||
DETERMINISTIC id from the track's identity (artist|title|album, normalized) so
|
||||
the SAME song gets the SAME id across re-imports and discovery passes — which
|
||||
is exactly what the match lookup needs. Prefixed ``file:`` so it's recognizable
|
||||
and never collides with a real upstream id. Returns '' only when there's no
|
||||
usable identity at all (no title)."""
|
||||
native = (existing if existing is not None else track.get("source_track_id")) or ""
|
||||
native = str(native).strip()
|
||||
if native:
|
||||
return native
|
||||
title = str(track.get("track_name") or track.get("name") or "").strip().lower()
|
||||
if not title:
|
||||
return ""
|
||||
artist = str(track.get("artist_name") or track.get("artist") or "").strip().lower()
|
||||
album = str(track.get("album_name") or track.get("album") or "").strip().lower()
|
||||
digest = hashlib.md5(f"{artist}|{title}|{album}".encode("utf-8")).hexdigest()[:16]
|
||||
return f"file:{digest}"
|
||||
|
||||
# Synthetic batch playlist_id prefixes that wrap a mirrored_playlists PK.
|
||||
# Download/discovery flows build a batch playlist_id as f"{prefix}{pk}" — e.g.
|
||||
# auto_mirror_<pk> (core/automation/handlers/sync_playlist.py), youtube_mirrored_<pk>
|
||||
|
|
|
|||
|
|
@ -501,6 +501,10 @@ def add_album_track_to_wishlist(
|
|||
source_type=source_type,
|
||||
source_context=enhanced_source_context,
|
||||
profile_id=runtime.profile_id,
|
||||
# Explicit user click in the album modal — must bypass + clear the
|
||||
# ignore-list, even if the user previously cancelled this track
|
||||
# (otherwise the add is silently dropped — carlosjfcasero, #897).
|
||||
user_initiated=True,
|
||||
)
|
||||
|
||||
if success:
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ class WishlistService:
|
|||
source_type: str = "manual",
|
||||
source_context: Dict[str, Any] = None,
|
||||
profile_id: int = 1,
|
||||
user_initiated: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Directly add a track to the wishlist.
|
||||
|
|
@ -110,6 +111,8 @@ class WishlistService:
|
|||
source_type: Source type ('playlist', 'album', 'manual')
|
||||
source_context: Additional context information
|
||||
profile_id: Profile to add to
|
||||
user_initiated: True for an explicit user add — bypasses + clears the
|
||||
ignore-list while keeping the real source_type (#874/#897).
|
||||
"""
|
||||
if track_data is None:
|
||||
track_data = spotify_track_data
|
||||
|
|
@ -124,6 +127,7 @@ class WishlistService:
|
|||
source_type=source_type,
|
||||
source_info=source_context or {},
|
||||
profile_id=profile_id,
|
||||
user_initiated=user_initiated,
|
||||
)
|
||||
|
||||
def add_spotify_track_to_wishlist(
|
||||
|
|
|
|||
105
core/youtube_cookies.py
Normal file
105
core/youtube_cookies.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""YouTube cookie options for yt-dlp — a browser store *or* a pasted cookies.txt.
|
||||
|
||||
Settings → YouTube offers two ways to authenticate yt-dlp:
|
||||
|
||||
* a **browser dropdown** (Chrome/Firefox/…) → yt-dlp ``cookiesfrombrowser``, which
|
||||
reads a logged-in browser's cookie store *on the same machine as SoulSync*. Great
|
||||
for local installs, useless on a headless server / Docker box (no browser there).
|
||||
* a **"Paste cookies.txt"** mode → yt-dlp ``cookiefile``, a Netscape-format cookie
|
||||
file the user exports (e.g. with a "Get cookies.txt LOCALLY" extension) and pastes
|
||||
in. This is the only path that works for server/Docker users, and it's what makes
|
||||
*private* playlists — a user's "Liked Music" (``list=LM``) — actually visible.
|
||||
|
||||
This module centralises the precedence and the pasted-file validation so the live
|
||||
opts (:func:`build_youtube_cookie_opts`) and the settings-save write agree, and so
|
||||
the seam is unit-testable without I/O. The web layer owns *where* the file lives
|
||||
(next to ``config.json``); this module only decides the opts and validates content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
# Sentinel dropdown value meaning "use a pasted cookies.txt file" rather than a
|
||||
# browser name. Anything else non-empty is treated as a browser for cookiesfrombrowser.
|
||||
PASTE_MODE = "custom"
|
||||
|
||||
|
||||
def build_youtube_cookie_opts(
|
||||
mode: Any,
|
||||
cookiefile_path: str = "",
|
||||
*,
|
||||
cookiefile_exists: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the yt-dlp cookie options for a given Settings→YouTube ``mode``. Pure.
|
||||
|
||||
* ``mode == PASTE_MODE`` → ``{'cookiefile': path}`` when the file exists, else
|
||||
``{}`` (a stale/missing path must never become a broken cookiefile arg).
|
||||
* ``mode`` is any other non-empty string → ``{'cookiesfrombrowser': (mode,)}``.
|
||||
* ``mode`` falsy → ``{}`` (anonymous; public playlists only).
|
||||
|
||||
Precedence is structural: a browser name is never ``PASTE_MODE``, so the two
|
||||
cookie sources can't both be emitted. No I/O here — the caller passes
|
||||
``cookiefile_exists`` (the ``os.path.exists`` result) so this stays pure.
|
||||
"""
|
||||
m = str(mode or "").strip()
|
||||
if m == PASTE_MODE:
|
||||
if cookiefile_path and cookiefile_exists:
|
||||
return {"cookiefile": str(cookiefile_path)}
|
||||
return {}
|
||||
if m:
|
||||
return {"cookiesfrombrowser": (m,)}
|
||||
return {}
|
||||
|
||||
|
||||
def looks_like_cookiefile(content: Any) -> bool:
|
||||
"""True when ``content`` plausibly is a Netscape/Mozilla ``cookies.txt``.
|
||||
|
||||
Requires at least one real cookie row — a non-comment line with >= 6 TAB-separated
|
||||
fields (domain, flag, path, secure, expiry, name[, value]). The ``# Netscape HTTP
|
||||
Cookie File`` header alone is NOT enough: a header-only paste carries no auth and
|
||||
would silently save a useless file. This guards the save path so pasting junk (a
|
||||
URL, JSON, or just the header) is rejected up front instead of being written out
|
||||
and making yt-dlp raise mid-extraction.
|
||||
"""
|
||||
if not content or not isinstance(content, str):
|
||||
return False
|
||||
for raw in content.splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if len(line.split("\t")) >= 6:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_pasted_cookiefile(content: Any, dest_path: str) -> str:
|
||||
"""Validate + write a pasted ``cookies.txt`` to ``dest_path``.
|
||||
|
||||
Returns the written path on success, or ``""`` when the content is empty /
|
||||
doesn't look like a cookie file / can't be written — in which case the caller
|
||||
leaves any existing file untouched (a blank save must not wipe a saved cookie).
|
||||
Best-effort ``0600`` perms since the file holds live session secrets.
|
||||
"""
|
||||
if not looks_like_cookiefile(content):
|
||||
return ""
|
||||
try:
|
||||
text = content if content.endswith("\n") else content + "\n"
|
||||
with open(dest_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
try:
|
||||
os.chmod(dest_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return str(dest_path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PASTE_MODE",
|
||||
"build_youtube_cookie_opts",
|
||||
"looks_like_cookiefile",
|
||||
"write_pasted_cookiefile",
|
||||
]
|
||||
|
|
@ -993,6 +993,39 @@ class MusicDatabase:
|
|||
raise
|
||||
|
||||
self._init_manual_library_match_table()
|
||||
self._backfill_mirrored_track_source_ids()
|
||||
|
||||
def _backfill_mirrored_track_source_ids(self) -> int:
|
||||
"""One-time, idempotent: assign a stable source_track_id to mirrored tracks
|
||||
that have none (file-import / iTunes-only playlists imported before #901), so
|
||||
their existing Find & Add matches start sticking without a manual re-import.
|
||||
Only touches empty-id rows, so it's a no-op once they're filled."""
|
||||
from core.playlists.source_refs import stable_source_track_id
|
||||
updated = 0
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, track_name, artist_name, album_name
|
||||
FROM mirrored_playlist_tracks
|
||||
WHERE source_track_id IS NULL OR source_track_id = ''
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
for r in rows:
|
||||
sid = stable_source_track_id({
|
||||
'track_name': r['track_name'], 'artist_name': r['artist_name'],
|
||||
'album_name': r['album_name']})
|
||||
if sid:
|
||||
cursor.execute(
|
||||
"UPDATE mirrored_playlist_tracks SET source_track_id = ? WHERE id = ?",
|
||||
(sid, r['id']))
|
||||
updated += 1
|
||||
conn.commit()
|
||||
if updated:
|
||||
logger.info("Backfilled stable source_track_id on %d mirrored tracks (#901)", updated)
|
||||
except Exception as e:
|
||||
logger.error("mirrored track source_id backfill failed: %s", e)
|
||||
return updated
|
||||
|
||||
# Bump when the schema's generation meaningfully changes. Stamped into
|
||||
# PRAGMA user_version as a backstop indicator; nothing GATES on it yet.
|
||||
|
|
@ -2053,6 +2086,31 @@ class MusicDatabase:
|
|||
"ON mb_album_release_cache (release_mbid)"
|
||||
)
|
||||
|
||||
# Persistent (artist,title) -> recording MBID cache for playlist export (#903).
|
||||
# The MusicBrainz tail of the export waterfall is rate-limited (~1 req/s), so a
|
||||
# resolved recording MBID is remembered ONCE and reused for that song across every
|
||||
# future export and playlist. Additive: empty until the export feature writes it.
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mb_recording_cache (
|
||||
track_key TEXT PRIMARY KEY,
|
||||
recording_mbid TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Remember which external playlist a mirrored playlist was exported to, so a
|
||||
# re-export UPDATES it in place instead of creating a duplicate (#903). Keyed by
|
||||
# (mirrored playlist, target service) -> the target's playlist id (LB recording MBID).
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS playlist_export_targets (
|
||||
mirrored_playlist_id INTEGER NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
target_playlist_mbid TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (mirrored_playlist_id, target)
|
||||
)
|
||||
""")
|
||||
|
||||
# Discovery artist blacklist — artists users never want to see in discovery
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS discovery_artist_blacklist (
|
||||
|
|
@ -7289,6 +7347,18 @@ class MusicDatabase:
|
|||
variations.append(normalized_name.title())
|
||||
variations.append(normalized_name)
|
||||
|
||||
# Leading-"The" toggle — a leading "The" is noise for artist identity
|
||||
# ("The Black Eyed Peas" == "Black Eyed Peas"). Without this, a request for
|
||||
# one variant never fetches a library track filed under the other, so it
|
||||
# "fails to match" and re-downloads a duplicate. Search BOTH forms; the
|
||||
# confidence scorer still decides (50/50 title/artist), so this only widens
|
||||
# the candidate fetch — it can't merge genuinely different artists on its own.
|
||||
stripped = artist_name.strip()
|
||||
if stripped.lower().startswith("the ") and stripped[4:].strip():
|
||||
variations.append(stripped[4:].strip()) # "The Black Eyed Peas" -> "Black Eyed Peas"
|
||||
elif stripped:
|
||||
variations.append("The " + stripped) # "Black Eyed Peas" -> "The Black Eyed Peas"
|
||||
|
||||
# Add more aliases here in the future
|
||||
if "korn" in name_lower:
|
||||
if "KoЯn" not in variations:
|
||||
|
|
@ -8901,8 +8971,15 @@ class MusicDatabase:
|
|||
source_info: Dict[str, Any] = None,
|
||||
profile_id: int = 1,
|
||||
track_data: Dict[str, Any] = None,
|
||||
user_initiated: bool = False,
|
||||
) -> bool:
|
||||
"""Add a failed track to the wishlist for retry"""
|
||||
"""Add a failed track to the wishlist for retry.
|
||||
|
||||
``user_initiated`` marks an explicit user add (e.g. the library album
|
||||
"add to wishlist" modal). Like ``source_type == 'manual'`` it bypasses
|
||||
the ignore-list gate AND clears any stale ignore — but unlike changing
|
||||
``source_type`` it preserves the real provenance ('album'), which the
|
||||
wishlist categorisation (Albums vs Singles) relies on (#874/#897)."""
|
||||
try:
|
||||
if track_data is not None and spotify_track_data is None:
|
||||
spotify_track_data = track_data
|
||||
|
|
@ -8932,7 +9009,7 @@ class MusicDatabase:
|
|||
# clear any stale ignore so it sticks. Fail-open: any error here
|
||||
# must never block a legitimate wishlist add.
|
||||
try:
|
||||
if source_type == 'manual':
|
||||
if source_type == 'manual' or user_initiated:
|
||||
self.remove_from_wishlist_ignore(track_id, profile_id=profile_id)
|
||||
elif self.is_track_ignored(track_id, profile_id=profile_id):
|
||||
logger.info("Skipping wishlist add — track is on the ignore-list (#874): %s", track_id)
|
||||
|
|
@ -13925,16 +14002,20 @@ class MusicDatabase:
|
|||
logger.debug("Failed to preserve mirrored playlist extra_data: %s", e)
|
||||
|
||||
# Replace all tracks
|
||||
from core.playlists.source_refs import stable_source_track_id
|
||||
cursor.execute("DELETE FROM mirrored_playlist_tracks WHERE playlist_id=?", (playlist_id,))
|
||||
for i, t in enumerate(tracks):
|
||||
# File-import / iTunes-only tracks arrive with no native id; give
|
||||
# them a DETERMINISTIC one so a Find & Add manual match can be
|
||||
# recorded and found (it keys on source_track_id) instead of being
|
||||
# silently dropped and re-appearing as "extra" (#901).
|
||||
sid = stable_source_track_id(t)
|
||||
extra = t.get('extra_data')
|
||||
if extra and not isinstance(extra, str):
|
||||
extra = json.dumps(extra)
|
||||
# Restore preserved discovery data if the incoming track doesn't have its own
|
||||
if not extra:
|
||||
sid = t.get('source_track_id')
|
||||
if sid and sid in old_extra_map:
|
||||
extra = old_extra_map[sid]
|
||||
if not extra and sid and sid in old_extra_map:
|
||||
extra = old_extra_map[sid]
|
||||
cursor.execute("""
|
||||
INSERT INTO mirrored_playlist_tracks
|
||||
(playlist_id, position, track_name, artist_name, album_name, duration_ms, image_url, source_track_id, extra_data)
|
||||
|
|
@ -13943,7 +14024,7 @@ class MusicDatabase:
|
|||
playlist_id, i + 1,
|
||||
t.get('track_name', ''), t.get('artist_name', ''),
|
||||
t.get('album_name', ''), t.get('duration_ms', 0),
|
||||
t.get('image_url'), t.get('source_track_id'), extra
|
||||
t.get('image_url'), sid or None, extra
|
||||
))
|
||||
conn.commit()
|
||||
logger.info(f"Mirrored playlist '{name}' ({source}) with {len(tracks)} tracks")
|
||||
|
|
@ -14110,6 +14191,42 @@ class MusicDatabase:
|
|||
logger.error(f"Error updating custom_name for playlist {playlist_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_playlist_export_target(self, mirrored_playlist_id: int, target: str) -> Optional[str]:
|
||||
"""The external playlist id this mirror was last exported to (or None). #903."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT target_playlist_mbid FROM playlist_export_targets "
|
||||
"WHERE mirrored_playlist_id = ? AND target = ? LIMIT 1",
|
||||
(int(mirrored_playlist_id), target),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return (row[0] if not hasattr(row, "keys") else row["target_playlist_mbid"]) or None
|
||||
except Exception as e:
|
||||
logger.debug(f"get_playlist_export_target failed: {e}")
|
||||
return None
|
||||
|
||||
def set_playlist_export_target(self, mirrored_playlist_id: int, target: str, target_mbid: str) -> bool:
|
||||
"""Remember the external playlist id for this mirror (idempotent). #903."""
|
||||
if not target_mbid:
|
||||
return False
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO playlist_export_targets "
|
||||
"(mirrored_playlist_id, target, target_playlist_mbid, updated_at) "
|
||||
"VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
|
||||
(int(mirrored_playlist_id), target, target_mbid),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"set_playlist_export_target failed: {e}")
|
||||
return False
|
||||
|
||||
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
|
||||
"""Return all tracks for a mirrored playlist ordered by position."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,37 +1,41 @@
|
|||
# soulsync 2.7.4 — `dev` → `main`
|
||||
# soulsync 2.7.6 — `dev` → `main`
|
||||
|
||||
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.
|
||||
patch release on top of 2.7.5. the headline is going the *other* way with playlists — exporting them TO listenbrainz — plus youtube liked-music sync, a deep-scan data-loss guard, and a round of dashboard performance work.
|
||||
|
||||
---
|
||||
|
||||
## what's new
|
||||
|
||||
### 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.
|
||||
### export playlists to listenbrainz (#903)
|
||||
soulsync already pulls playlists IN from everywhere — now it can push one back OUT. every mirrored-playlist card gets a 📤 export button: pick **download .jspf** (a standard playlist file you can hand-upload anywhere) or **sync to listenbrainz** (creates the playlist straight on your LB account). each track is matched to its musicbrainz *recording* id via a cheapest-first waterfall — cache → your library (`musicbrainz_recording_id`) → the file's own tag → a live musicbrainz lookup — with the result cached so the same song never costs twice. live "matching N/M · X matched" status shows on the card, and re-syncing **updates the same LB playlist in place** instead of making duplicates. tracks that can't be resolved to an MBID are skipped (LB requires them) and counted so you see the coverage.
|
||||
|
||||
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.
|
||||
### youtube liked-music sync (#902)
|
||||
you can now sync your youtube music **Liked Music** playlist (`music.youtube.com/playlist?list=LM`). it's a private playlist, so it needs auth — and the existing "read a browser's cookies" option only works when the browser is on the same machine. added a **"paste cookies.txt"** option in Settings → YouTube so server/docker installs (and anyone on a browser like Zen that yt-dlp can't read) can supply their login from anywhere.
|
||||
|
||||
### 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.
|
||||
### deep scan won't relocate your library (#904)
|
||||
a standalone Deep Scan moves files it doesn't recognize into Staging for import. if the DB was empty/out of sync with disk (a volume swap, a DB reset, external tag edits), it treated your **entire** library as "unrecognized" and relocated all of it — one user lost ~1,500 tracks into Staging. now a guard refuses the move when the unrecognized share is implausibly large (the desync signature), leaves everything in place, and warns instead. plus a **"Transfer is my permanent library — never move files out"** toggle for people whose Transfer folder *is* their live library.
|
||||
|
||||
### 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.
|
||||
### dashboard performance
|
||||
a pass at the "soulsync makes my GPU work hard just sitting there" complaints. the sidebar sweep animates `transform` instead of `left` (no per-frame layout), particle glows are pre-rendered sprites instead of per-frame gradients, blur radii + redundant/invisible card shadows are trimmed, and low-power machines auto-drop to performance mode. all the visible effects stay; they're just cheaper to draw.
|
||||
|
||||
### polish
|
||||
- the artist-detail header no longer bleeds the blurred artist photo behind it.
|
||||
### fixes
|
||||
- **file-import manual matches stick (#901)** — a manual match on a file-imported playlist track is no longer forgotten on re-sync (the tracks now carry a stable id; existing ones are backfilled once).
|
||||
- **manual match heals a stale Plex key** — a Find & Add match whose stored Plex ratingKey went stale is now re-resolved against a live Plex search instead of silently breaking.
|
||||
- **multi-disc albums** — a track now files into the Disc folder that matches its own disc tag (no more disc-2 tracks landing in Disc 1), and a track is never written disc-less.
|
||||
- **auto-download track numbers** — a track auto-grabbed from the playlist pipeline / wishlist / watchlist now gets its real in-album position instead of being tagged `1/1`.
|
||||
|
||||
---
|
||||
|
||||
## a brief recap of what came before
|
||||
2.7.5 was a fix-heavy cycle — matching & artwork accuracy, the HiFi preview mess (#895) — plus M3U import (#893), ignore-list management (#897), and per-playlist file naming. 2.7.4 was re-identify; 2.7.3 the Quality Upgrade Finder + ignore-list (#874); 2.7.2 playlist-folder mirroring + M3U export; 2.7.1 download verification + a review queue (#852); 2.7.0 made multi-user real.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
additive + fail-safe — new behavior is opt-in or guarded, nothing existing rewired. new seam/regression suites across the listenbrainz export (JSPF build, the MBID waterfall + dedup, the persistent cache, the LB create/update-in-place client), the youtube cookie precedence, and the #904 deep-scan guard (incl. the 1,500-file regression). the listenbrainz push + update-in-place were also validated live against a real account. relevant suites green; `ruff check` clean on touched modules.
|
||||
|
||||
## post-merge
|
||||
- [ ] tag `v2.7.4` on `main`
|
||||
- [ ] docker-publish with `version_tag: 2.7.4`
|
||||
- [ ] tag `v2.7.6` on `main`
|
||||
- [ ] docker-publish with `version_tag: 2.7.6`
|
||||
- [ ] discord announce (auto-fired by the workflow)
|
||||
- [ ] reply on #889 / #890 / #891
|
||||
- [ ] reply on #902 / #903 / #904
|
||||
|
|
|
|||
|
|
@ -28,6 +28,71 @@ def _artist_name(artist) -> str:
|
|||
return name
|
||||
return str(artist) if artist is not None else ''
|
||||
|
||||
|
||||
def _plex_track_file(plex_track) -> str:
|
||||
"""Best-effort file path of a live Plex track object (media[0].parts[0].file)."""
|
||||
try:
|
||||
return plex_track.media[0].parts[0].file or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def reresolve_manual_match_live_plex(cache_db, media_client, m, *, profile_id,
|
||||
source_track_id, server_source):
|
||||
"""Re-resolve a manual match whose stored Plex ratingKey went stale.
|
||||
|
||||
Plex re-keys tracks on a metadata refresh/optimize, and the SoulSync DB id is
|
||||
itself that old ratingKey — so until a SoulSync rescan, BOTH the stored
|
||||
``library_track_id`` and the file-path self-heal land on the same dead key and
|
||||
``fetchItem`` 404s. The only live source of truth is Plex, so search it by the
|
||||
matched track's metadata, disambiguate by the stored file path (so the user's
|
||||
EXACT chosen track wins when several versions exist), heal the stored id, and
|
||||
return the current live Plex track. Best-effort: returns the live track or
|
||||
None, never raises — a manual match must never be dropped on a transient miss.
|
||||
"""
|
||||
try:
|
||||
title = (m.get('source_title') or '').strip()
|
||||
artist = (m.get('source_artist') or '').strip()
|
||||
file_path = (m.get('library_file_path') or '').strip()
|
||||
if not title:
|
||||
return None
|
||||
results = media_client.search_tracks(title, artist, limit=15) or []
|
||||
if not results:
|
||||
return None
|
||||
|
||||
import os as _os
|
||||
want_base = _os.path.basename(file_path.replace('\\', '/')) if file_path else ''
|
||||
chosen = None
|
||||
if want_base:
|
||||
for t in results:
|
||||
live = getattr(t, '_original_plex_track', None)
|
||||
p = _plex_track_file(live) if live is not None else ''
|
||||
if p and _os.path.basename(p.replace('\\', '/')) == want_base:
|
||||
chosen = t
|
||||
break
|
||||
if chosen is None:
|
||||
chosen = results[0] # search_tracks already ranks artist→title; best effort
|
||||
live = getattr(chosen, '_original_plex_track', None)
|
||||
if live is None or not hasattr(live, 'ratingKey'):
|
||||
return None
|
||||
|
||||
# Heal the stored id (+ the cache, done by the caller) so the next sync is
|
||||
# fast and doesn't repeat the live search.
|
||||
try:
|
||||
cache_db.save_manual_library_match(
|
||||
profile_id, m.get('source') or 'spotify', str(source_track_id), str(live.ratingKey),
|
||||
source_title=m.get('source_title'), source_artist=m.get('source_artist'),
|
||||
source_album=m.get('source_album'),
|
||||
server_source=server_source,
|
||||
library_file_path=file_path or _plex_track_file(live),
|
||||
)
|
||||
except Exception as _heal_err:
|
||||
logger.debug("manual-match heal (save) failed: %s", _heal_err)
|
||||
return live
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
playlist_name: str
|
||||
|
|
@ -599,38 +664,81 @@ class PlaylistSyncService:
|
|||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
# --- User-confirmed match fast-path (Find & Add / manual match) ---
|
||||
if spotify_id:
|
||||
cache_db = MusicDatabase()
|
||||
|
||||
def _materialize(server_track_id):
|
||||
"""Turn a stored library track id into the actual server item the
|
||||
sync needs (DB row for Jellyfin/Navidrome/SoulSync, Plex fetchItem)."""
|
||||
if server_track_id is None:
|
||||
return None
|
||||
dbt = cache_db.get_track_by_id(server_track_id)
|
||||
if not dbt:
|
||||
return None
|
||||
if server_type in ("jellyfin", "navidrome", "soulsync"):
|
||||
class DbTrackFromCache:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
return DbTrackFromCache(dbt)
|
||||
try:
|
||||
at = media_client.server.fetchItem(int(server_track_id))
|
||||
return at if (at and hasattr(at, 'ratingKey')) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# 1) Volatile sync_match_cache — fast, but wiped on every library rescan.
|
||||
try:
|
||||
cache_db = MusicDatabase()
|
||||
cached = cache_db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
server_track_id = cached['server_track_id']
|
||||
db_track_check = cache_db.get_track_by_id(server_track_id)
|
||||
if db_track_check:
|
||||
if server_type in ("jellyfin", "navidrome", "soulsync"):
|
||||
class DbTrackFromCache:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
actual_track = DbTrackFromCache(db_track_check)
|
||||
else:
|
||||
try:
|
||||
actual_track = media_client.server.fetchItem(int(server_track_id))
|
||||
if not (actual_track and hasattr(actual_track, 'ratingKey')):
|
||||
actual_track = None
|
||||
except Exception:
|
||||
actual_track = None
|
||||
|
||||
if actual_track:
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {server_track_id}")
|
||||
return actual_track, cached['confidence']
|
||||
|
||||
logger.debug(f"Sync cache stale for '{original_title}' — track {server_track_id} gone")
|
||||
actual_track = _materialize(cached['server_track_id'])
|
||||
if actual_track:
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return actual_track, cached['confidence']
|
||||
logger.debug(f"Sync cache stale for '{original_title}' — track {cached['server_track_id']} gone")
|
||||
except Exception as cache_err:
|
||||
logger.debug(f"Sync cache lookup error: {cache_err}")
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# 2) Durable manual library match (#787) — SURVIVES a rescan (the cache
|
||||
# above does not). Without this, a user's Find & Add pairing is
|
||||
# re-matched from scratch on the next auto-sync after a library scan,
|
||||
# so they have to Find & Add the same track again (#895 follow-up).
|
||||
# Self-heals a stale library id via the stored file path.
|
||||
try:
|
||||
from core.artists.map import get_current_profile_id
|
||||
_profile_id = get_current_profile_id()
|
||||
m = cache_db.find_manual_library_match_by_source_track_id(
|
||||
_profile_id, str(spotify_id), active_server)
|
||||
if m:
|
||||
actual_track = _materialize(m.get('library_track_id'))
|
||||
if not actual_track and m.get('library_file_path'):
|
||||
new_id = cache_db.find_track_id_by_file_path(m['library_file_path'])
|
||||
actual_track = _materialize(new_id)
|
||||
# Plex re-keys tracks on a metadata refresh, and the SoulSync
|
||||
# DB id IS that ratingKey — so both lookups above can land on
|
||||
# the same stale key and 404. Re-resolve against LIVE Plex by
|
||||
# the matched track's metadata so a manual match is NEVER
|
||||
# dropped or silently re-matched by the fuzzy path below.
|
||||
if not actual_track and server_type == "plex":
|
||||
actual_track = reresolve_manual_match_live_plex(
|
||||
cache_db, media_client, m,
|
||||
profile_id=_profile_id, source_track_id=spotify_id,
|
||||
server_source=active_server)
|
||||
if actual_track and spotify_id:
|
||||
try:
|
||||
cache_db.save_sync_match_cache(
|
||||
spotify_id, original_title, _artist_name(spotify_track.artists[0]) if spotify_track.artists else '',
|
||||
active_server, actual_track.ratingKey, getattr(actual_track, 'title', original_title), 1.0)
|
||||
except Exception as _cache_err:
|
||||
logger.debug("sync cache heal failed: %s", _cache_err)
|
||||
if actual_track:
|
||||
logger.info(f"Durable manual match honored for '{original_title}' → {getattr(actual_track, 'ratingKey', m.get('library_track_id'))}")
|
||||
return actual_track, 1.0
|
||||
except Exception as durable_err:
|
||||
logger.debug(f"Durable manual match lookup error: {durable_err}")
|
||||
# --- End match fast-path ---
|
||||
|
||||
# Try each artist (same as modal logic)
|
||||
for artist in spotify_track.artists:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
<Overview>Music discovery and automation platform. Find new music, curate playlists, sync libraries, and integrate with popular streaming services, Soulseek (slskd), and media servers.</Overview>
|
||||
<Category>MediaApp:Music</Category>
|
||||
<WebUI>http://[IP]:[PORT:8008]</WebUI>
|
||||
<TemplateURL>https://raw.githubusercontent.com/snuffomega/SoulSync_unraid/main/soulsync.xml</TemplateURL>
|
||||
<Icon>https://raw.githubusercontent.com/snuffomega/SoulSync_unraid/main/soulsync.png</Icon>
|
||||
<TemplateURL>https://raw.githubusercontent.com/Nezreka/SoulSync/main/templates/soulsync.xml</TemplateURL>
|
||||
<Icon>https://raw.githubusercontent.com/Nezreka/SoulSync/main/templates/soulsync.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
<Config Name="Database Volume" Target="/app/data" Default="/mnt/user/appdata/soulsync/data" Mode="rw" Description="Database storage (SQLite)" Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/soulsync/data</Config>
|
||||
<Config Name="Downloads" Target="/app/downloads" Default="/mnt/user/downloads/" Mode="rw" Description="Path to Soulseek (slskd) downloads folder — should match your slskd download path" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/downloads/</Config>
|
||||
<Config Name="Library/Transfer" Target="/app/Transfer" Default="/mnt/user/library/" Mode="rw" Description="Your music library folder for organized/transferred files" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/library/</Config>
|
||||
<Config Name="Music Videos" Target="/app/MusicVideos" Default="/mnt/user/media/music-videos/" Mode="rw" Description="Optional — folder for downloaded music videos. Point this at a share if you use the music-video feature, otherwise downloads land in an anonymous Docker volume. Set library.music_videos_path in the app to /app/MusicVideos." Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/media/music-videos/</Config>
|
||||
<Config Name="PUID" Target="PUID" Default="99" Mode="" Description="User ID for file permissions (default 99 = nobody on Unraid)" Type="Variable" Display="always" Required="false" Mask="false">99</Config>
|
||||
<Config Name="PGID" Target="PGID" Default="100" Mode="" Description="Group ID for file permissions (default 100 = users on Unraid)" Type="Variable" Display="always" Required="false" Mask="false">100</Config>
|
||||
<Config Name="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Timezone for log timestamps and scheduling (e.g., America/New_York)" Type="Variable" Display="always" Required="false" Mask="false">America/New_York</Config>
|
||||
|
|
|
|||
|
|
@ -60,3 +60,68 @@ def test_mirror_playlist_refresh_preserves_existing_description(tmp_path):
|
|||
assert refreshed_id == playlist_id
|
||||
playlist = db.get_mirrored_playlist(playlist_id)
|
||||
assert playlist["description"] == "https://open.spotify.com/playlist/abc"
|
||||
|
||||
|
||||
def test_file_import_tracks_get_a_stable_source_track_id(tmp_path):
|
||||
# #901: file-import tracks arrive with no source_track_id; mirror_playlist must
|
||||
# assign a deterministic one so a Find & Add manual match can key on it (and so
|
||||
# discovery extra_data survives a re-import).
|
||||
db = MusicDatabase(str(tmp_path / "music.db"))
|
||||
file_tracks = [
|
||||
{"track_name": "Slow Ride", "artist_name": "Foghat", "album_name": "Fool for the City"},
|
||||
{"track_name": "I Gotta Feeling", "artist_name": "The Black Eyed Peas"},
|
||||
]
|
||||
pid = db.mirror_playlist(source="file", source_playlist_id="myfile", name="From File",
|
||||
tracks=file_tracks, profile_id=1)
|
||||
rows = db.get_mirrored_playlist_tracks(pid)
|
||||
ids = [r["source_track_id"] for r in rows]
|
||||
assert all(i and i.startswith("file:") for i in ids) # no empty ids
|
||||
assert len(set(ids)) == 2 # distinct per song
|
||||
|
||||
# Re-import the SAME file → SAME ids (stable), so a recorded match still keys.
|
||||
db.mirror_playlist(source="file", source_playlist_id="myfile", name="From File",
|
||||
tracks=list(file_tracks), profile_id=1)
|
||||
rows2 = db.get_mirrored_playlist_tracks(pid)
|
||||
assert [r["source_track_id"] for r in rows2] == ids
|
||||
|
||||
|
||||
def test_native_ids_still_used_verbatim(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / "music.db"))
|
||||
pid = db.mirror_playlist(source="spotify", source_playlist_id="sp", name="Sp",
|
||||
tracks=[{"track_name": "S", "artist_name": "A", "source_track_id": "spotify123"}],
|
||||
profile_id=1)
|
||||
rows = db.get_mirrored_playlist_tracks(pid)
|
||||
assert rows[0]["source_track_id"] == "spotify123" # native id untouched
|
||||
|
||||
|
||||
def test_backfill_fills_existing_empty_ids_idempotently(tmp_path):
|
||||
# #901 backfill: a file-import playlist mirrored BEFORE the fix has empty-id rows.
|
||||
# The backfill assigns the SAME stable ids a fresh import would, so existing
|
||||
# Find & Add matches start working without a re-import.
|
||||
db = MusicDatabase(str(tmp_path / "music.db"))
|
||||
pid = db.mirror_playlist(source="file", source_playlist_id="old", name="Old",
|
||||
tracks=[{"track_name": "Slow Ride", "artist_name": "Foghat"}], profile_id=1)
|
||||
# simulate a pre-fix row: blank out the id
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("UPDATE mirrored_playlist_tracks SET source_track_id = '' WHERE playlist_id = ?", (pid,))
|
||||
conn.commit()
|
||||
|
||||
n = db._backfill_mirrored_track_source_ids()
|
||||
assert n == 1
|
||||
rows = db.get_mirrored_playlist_tracks(pid)
|
||||
from core.playlists.source_refs import stable_source_track_id
|
||||
assert rows[0]["source_track_id"] == stable_source_track_id(
|
||||
{"track_name": "Slow Ride", "artist_name": "Foghat"}) # same id a fresh import gives
|
||||
|
||||
# idempotent — second run touches nothing
|
||||
assert db._backfill_mirrored_track_source_ids() == 0
|
||||
|
||||
|
||||
def test_backfill_leaves_native_ids_untouched(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / "music.db"))
|
||||
pid = db.mirror_playlist(source="spotify", source_playlist_id="sp", name="Sp",
|
||||
tracks=[{"track_name": "S", "artist_name": "A", "source_track_id": "spotify123"}],
|
||||
profile_id=1)
|
||||
db._backfill_mirrored_track_source_ids()
|
||||
rows = db.get_mirrored_playlist_tracks(pid)
|
||||
assert rows[0]["source_track_id"] == "spotify123"
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ def test_matcher_signature_accepts_candidate_pool():
|
|||
def _run(track, **kw):
|
||||
fake_db = MagicMock()
|
||||
fake_db.read_sync_match_cache.return_value = None
|
||||
fake_db.find_manual_library_match_by_source_track_id.return_value = None
|
||||
fake_db.check_track_exists.return_value = (None, 0.0)
|
||||
fake_cm = MagicMock()
|
||||
fake_cm.get_active_media_server.return_value = "plex"
|
||||
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
|
||||
patch("config.settings.config_manager", fake_cm):
|
||||
patch("config.settings.config_manager", fake_cm), \
|
||||
patch("core.artists.map.get_current_profile_id", return_value=1):
|
||||
return asyncio.run(sync_mod._database_only_find_track(track, **kw))
|
||||
|
||||
|
||||
|
|
@ -42,10 +44,62 @@ def test_returns_match_when_db_has_it():
|
|||
track = SimpleNamespace(name="HUMBLE.", artists=["Kendrick Lamar"], id="sp2")
|
||||
fake_db = MagicMock()
|
||||
fake_db.read_sync_match_cache.return_value = None
|
||||
fake_db.find_manual_library_match_by_source_track_id.return_value = None
|
||||
fake_db.check_track_exists.return_value = (SimpleNamespace(id="t1", title="HUMBLE."), 0.95)
|
||||
fake_cm = MagicMock()
|
||||
fake_cm.get_active_media_server.return_value = "plex"
|
||||
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
|
||||
patch("config.settings.config_manager", fake_cm):
|
||||
patch("config.settings.config_manager", fake_cm), \
|
||||
patch("core.artists.map.get_current_profile_id", return_value=1):
|
||||
match, conf = asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
|
||||
assert conf == 0.95 and match.id == "t1"
|
||||
|
||||
|
||||
# ── durable manual match (#787) survives a rescan that wipes sync_match_cache ──
|
||||
# (#895 follow-up: Find & Add was forgotten on the next auto-sync after a library
|
||||
# scan, because the matcher only consulted the volatile cache.)
|
||||
|
||||
def _run_with_db(track, fake_db):
|
||||
fake_cm = MagicMock()
|
||||
fake_cm.get_active_media_server.return_value = "plex"
|
||||
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
|
||||
patch("config.settings.config_manager", fake_cm), \
|
||||
patch("core.artists.map.get_current_profile_id", return_value=1):
|
||||
return asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
|
||||
|
||||
|
||||
def test_durable_match_used_when_volatile_cache_is_empty():
|
||||
track = SimpleNamespace(name="Valió la Pena - Salsa Version", artists=["Marc Anthony"], id="sp16")
|
||||
dt = SimpleNamespace(id="t99", title="Valió la Pena (Salsa Version)")
|
||||
db = MagicMock()
|
||||
db.read_sync_match_cache.return_value = None # cache wiped by a rescan
|
||||
db.check_track_exists.return_value = (None, 0.0) # fuzzy would FAIL
|
||||
db.find_manual_library_match_by_source_track_id.return_value = {
|
||||
"library_track_id": "t99", "library_file_path": "/m/x.flac"}
|
||||
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "t99" else None
|
||||
match, conf = _run_with_db(track, db)
|
||||
assert conf == 1.0 and match.id == "t99" # manual pick honored, not re-matched
|
||||
|
||||
|
||||
def test_durable_match_self_heals_a_stale_library_id():
|
||||
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
|
||||
dt = SimpleNamespace(id="newid", title="X")
|
||||
db = MagicMock()
|
||||
db.read_sync_match_cache.return_value = None
|
||||
db.check_track_exists.return_value = (None, 0.0)
|
||||
db.find_manual_library_match_by_source_track_id.return_value = {
|
||||
"library_track_id": "staleid", "library_file_path": "/m/x.flac"}
|
||||
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "newid" else None # stale id misses
|
||||
db.find_track_id_by_file_path.return_value = "newid" # re-resolve via path
|
||||
match, conf = _run_with_db(track, db)
|
||||
assert conf == 1.0 and match.id == "newid"
|
||||
db.find_track_id_by_file_path.assert_called_once_with("/m/x.flac")
|
||||
|
||||
|
||||
def test_no_durable_match_falls_through_to_fuzzy():
|
||||
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
|
||||
db = MagicMock()
|
||||
db.read_sync_match_cache.return_value = None
|
||||
db.find_manual_library_match_by_source_track_id.return_value = None
|
||||
db.check_track_exists.return_value = (None, 0.0)
|
||||
assert _run_with_db(track, db) == (None, 0.0)
|
||||
|
|
|
|||
0
tests/exports/__init__.py
Normal file
0
tests/exports/__init__.py
Normal file
59
tests/exports/test_export_sources.py
Normal file
59
tests/exports/test_export_sources.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Export source wiring (#903): waterfall order + cache write-back.
|
||||
|
||||
build_resolve_fn assembles cache -> DB -> file -> MusicBrainz and writes a fresh
|
||||
(non-cache) hit back to the cache. Pins: a cache hit short-circuits everything and is
|
||||
NOT re-written; a DB/MB hit IS written back; misses fall through; the resolving label is
|
||||
returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.exports.export_sources import build_resolve_fn
|
||||
from core.exports.mbid_resolver import SRC_CACHE, SRC_DB, SRC_MUSICBRAINZ
|
||||
|
||||
MBID = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
|
||||
|
||||
|
||||
def _wire(db=None, file=None, mb=None, cache=None):
|
||||
recorded = {}
|
||||
store = dict(cache or {})
|
||||
fn = build_resolve_fn(
|
||||
db_fn=lambda a, t: (db or {}).get((a, t)),
|
||||
file_fn=lambda a, t: (file or {}).get((a, t)),
|
||||
mb_fn=lambda a, t: (mb or {}).get((a, t)),
|
||||
cache_lookup=lambda k: store.get(k),
|
||||
cache_record=lambda k, m: recorded.__setitem__(k, m) or True,
|
||||
)
|
||||
return fn, recorded
|
||||
|
||||
|
||||
def test_cache_hit_short_circuits_and_is_not_rewritten():
|
||||
from core.exports.mbid_resolver import normalize_key
|
||||
fn, recorded = _wire(
|
||||
cache={normalize_key("A", "T"): MBID},
|
||||
db={("A", "T"): "should-not-reach"},
|
||||
)
|
||||
mbid, label = fn("A", "T")
|
||||
assert (mbid, label) == (MBID, SRC_CACHE)
|
||||
assert recorded == {} # cache hit -> no write-back
|
||||
|
||||
|
||||
def test_db_hit_is_written_back_to_cache():
|
||||
from core.exports.mbid_resolver import normalize_key
|
||||
fn, recorded = _wire(db={("A", "T"): MBID})
|
||||
mbid, label = fn("A", "T")
|
||||
assert (mbid, label) == (MBID, SRC_DB)
|
||||
assert recorded == {normalize_key("A", "T"): MBID} # fresh hit cached for next time
|
||||
|
||||
|
||||
def test_falls_through_to_musicbrainz_and_caches():
|
||||
fn, recorded = _wire(db={}, file={}, mb={("A", "T"): MBID})
|
||||
mbid, label = fn("A", "T")
|
||||
assert (mbid, label) == (MBID, SRC_MUSICBRAINZ)
|
||||
assert list(recorded.values()) == [MBID]
|
||||
|
||||
|
||||
def test_all_miss_returns_none_and_no_write():
|
||||
fn, recorded = _wire()
|
||||
assert fn("A", "T") == (None, None)
|
||||
assert recorded == {}
|
||||
72
tests/exports/test_jspf_export.py
Normal file
72
tests/exports/test_jspf_export.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""JSPF builder for ListenBrainz playlist export (#903).
|
||||
|
||||
LB's create-playlist requires every track to carry a recording-MBID identifier; text-only
|
||||
tracks are rejected. These pin the shape (top-level {"playlist": {...}}, string identifier
|
||||
in the exact MB recording URL form), that tracks without a valid MBID are dropped, and that
|
||||
the coverage summary counts included vs skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.exports.jspf_export import MB_RECORDING_PREFIX, build_jspf, is_valid_recording_mbid
|
||||
|
||||
MBID_A = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
|
||||
MBID_B = "8f3471b5-7e6a-4c1f-9c1a-2b2b2b2b2b2b"
|
||||
|
||||
|
||||
def test_valid_mbid_check():
|
||||
assert is_valid_recording_mbid(MBID_A) is True
|
||||
assert is_valid_recording_mbid("not-a-uuid") is False
|
||||
assert is_valid_recording_mbid("") is False
|
||||
assert is_valid_recording_mbid(None) is False
|
||||
|
||||
|
||||
def test_top_level_shape_and_identifier_format():
|
||||
jspf, summary = build_jspf("My Playlist", [
|
||||
{"recording_mbid": MBID_A, "title": "Gold", "artist": "Spandau Ballet", "album": "True"},
|
||||
])
|
||||
assert set(jspf.keys()) == {"playlist"}
|
||||
pl = jspf["playlist"]
|
||||
assert pl["title"] == "My Playlist"
|
||||
assert len(pl["track"]) == 1
|
||||
t = pl["track"][0]
|
||||
# identifier is a STRING in the exact MB recording form (per the LB/JSPF spec)
|
||||
assert t["identifier"] == f"{MB_RECORDING_PREFIX}{MBID_A}"
|
||||
assert isinstance(t["identifier"], str)
|
||||
assert t["title"] == "Gold"
|
||||
assert t["creator"] == "Spandau Ballet" # artist -> creator
|
||||
assert t["album"] == "True"
|
||||
assert summary == {"total": 1, "included": 1, "skipped": 0}
|
||||
|
||||
|
||||
def test_tracks_without_valid_mbid_are_dropped():
|
||||
jspf, summary = build_jspf("P", [
|
||||
{"recording_mbid": MBID_A, "title": "Keep"},
|
||||
{"recording_mbid": "", "title": "No MBID"},
|
||||
{"recording_mbid": "garbage", "title": "Bad MBID"},
|
||||
{"title": "Missing key entirely"},
|
||||
])
|
||||
assert [t["title"] for t in jspf["playlist"]["track"]] == ["Keep"]
|
||||
assert summary == {"total": 4, "included": 1, "skipped": 3}
|
||||
|
||||
|
||||
def test_order_is_preserved():
|
||||
jspf, _ = build_jspf("P", [
|
||||
{"recording_mbid": MBID_A, "title": "first"},
|
||||
{"recording_mbid": MBID_B, "title": "second"},
|
||||
])
|
||||
assert [t["title"] for t in jspf["playlist"]["track"]] == ["first", "second"]
|
||||
|
||||
|
||||
def test_optional_fields_omitted_when_absent():
|
||||
jspf, _ = build_jspf("P", [{"recording_mbid": MBID_A}])
|
||||
t = jspf["playlist"]["track"][0]
|
||||
assert "title" not in t and "creator" not in t and "album" not in t
|
||||
|
||||
|
||||
def test_creator_and_title_defaults():
|
||||
jspf, summary = build_jspf("", [], creator="SoulSync")
|
||||
assert jspf["playlist"]["title"] == "SoulSync Export" # blank -> default
|
||||
assert jspf["playlist"]["creator"] == "SoulSync"
|
||||
assert jspf["playlist"]["track"] == []
|
||||
assert summary == {"total": 0, "included": 0, "skipped": 0}
|
||||
160
tests/exports/test_listenbrainz_create_playlist.py
Normal file
160
tests/exports/test_listenbrainz_create_playlist.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""ListenBrainz create_playlist client method (#903).
|
||||
|
||||
Pins the create -> batched item/add flow against a mocked network: large playlists are
|
||||
added in <=100 batches (LB's MAX_RECORDINGS_PER_ADD), the new playlist MBID/URL are
|
||||
returned, and failures are reported (never raised) so the export job can surface them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status, body=None):
|
||||
self.status_code = status
|
||||
self._body = body or {}
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _client():
|
||||
# token='' (not None) skips the network token-validation in __init__; then fake auth.
|
||||
c = ListenBrainzClient(token="")
|
||||
c.token = "tok"
|
||||
c.username = "user"
|
||||
return c
|
||||
|
||||
|
||||
def _tracks(n):
|
||||
return [{"identifier": f"https://musicbrainz.org/recording/{i:08d}-0000-0000-0000-000000000000"}
|
||||
for i in range(n)]
|
||||
|
||||
|
||||
def test_create_then_batched_add(monkeypatch):
|
||||
c = _client()
|
||||
calls = []
|
||||
|
||||
def fake_req(method, url, **kw):
|
||||
calls.append(url)
|
||||
if url.endswith("/playlist/create"):
|
||||
return _Resp(200, {"status": "ok", "playlist_mbid": "PL-MBID"})
|
||||
return _Resp(200, {"status": "ok"})
|
||||
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake_req)
|
||||
res = c.create_playlist("My Playlist", _tracks(250))
|
||||
|
||||
assert res["success"] is True
|
||||
assert res["playlist_mbid"] == "PL-MBID"
|
||||
assert res["playlist_url"] == "https://listenbrainz.org/playlist/PL-MBID"
|
||||
assert res["added"] == 250
|
||||
# 1 create + 3 add batches (100 + 100 + 50)
|
||||
assert sum(1 for u in calls if u.endswith("/playlist/create")) == 1
|
||||
assert sum(1 for u in calls if "/item/add" in u) == 3
|
||||
|
||||
|
||||
def test_not_authenticated_is_reported_not_raised():
|
||||
c = ListenBrainzClient(token="") # no username -> not authenticated
|
||||
res = c.create_playlist("x", _tracks(1))
|
||||
assert res["success"] is False
|
||||
assert "authenticated" in (res["error"] or "")
|
||||
|
||||
|
||||
def test_create_failure_reported(monkeypatch):
|
||||
c = _client()
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", lambda *a, **k: _Resp(400, {}))
|
||||
res = c.create_playlist("x", _tracks(5))
|
||||
assert res["success"] is False
|
||||
assert res["playlist_mbid"] is None
|
||||
assert "400" in (res["error"] or "")
|
||||
|
||||
|
||||
def test_create_ok_but_partial_add_failure(monkeypatch):
|
||||
c = _client()
|
||||
|
||||
def fake_req(method, url, **kw):
|
||||
if url.endswith("/playlist/create"):
|
||||
return _Resp(200, {"playlist_mbid": "PL"})
|
||||
return _Resp(500, {}) # every add fails
|
||||
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake_req)
|
||||
res = c.create_playlist("x", _tracks(10))
|
||||
# Playlist WAS created -> success True, but added=0 (honest partial report)
|
||||
assert res["success"] is True
|
||||
assert res["playlist_mbid"] == "PL"
|
||||
assert res["added"] == 0
|
||||
|
||||
|
||||
# ── update-in-place + create-or-update (#903 no-duplicate re-export) ──────────
|
||||
|
||||
def _route(existing_count=None):
|
||||
"""Build a fake _make_request_with_retry; records calls. existing_count=None -> GET 404."""
|
||||
calls = []
|
||||
def fake(method, url, **kw):
|
||||
calls.append((method, url, kw.get("json")))
|
||||
if method == "GET" and "/playlist/" in url:
|
||||
if existing_count is None:
|
||||
return _Resp(404, {})
|
||||
return _Resp(200, {"playlist": {"track": [{} for _ in range(existing_count)]}})
|
||||
if url.endswith("/playlist/create"):
|
||||
return _Resp(200, {"playlist_mbid": "NEW-MBID"})
|
||||
return _Resp(200, {}) # item/add, item/delete, edit, delete
|
||||
return fake, calls
|
||||
|
||||
|
||||
def test_update_playlist_clears_then_re_adds_same_mbid(monkeypatch):
|
||||
c = _client()
|
||||
fake, calls = _route(existing_count=3)
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake)
|
||||
res = c.update_playlist("EXIST-MBID", "New Title", _tracks(5))
|
||||
assert res["success"] is True and res["updated"] is True
|
||||
assert res["playlist_mbid"] == "EXIST-MBID" # stable URL/MBID
|
||||
assert res["added"] == 5
|
||||
urls = [u for _, u, _ in calls]
|
||||
assert any("/item/delete" in u for u in urls) # cleared existing
|
||||
assert any("/item/add" in u for u in urls) # re-added
|
||||
assert any("/playlist/edit/EXIST-MBID" in u for u in urls) # title refreshed
|
||||
|
||||
|
||||
def test_update_playlist_reports_gone(monkeypatch):
|
||||
c = _client()
|
||||
fake, _ = _route(existing_count=None) # GET 404 -> gone
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake)
|
||||
res = c.update_playlist("DEAD-MBID", "T", _tracks(2))
|
||||
assert res["success"] is False
|
||||
assert res["gone"] is True
|
||||
|
||||
|
||||
def test_create_or_update_updates_when_existing(monkeypatch):
|
||||
c = _client()
|
||||
fake, _ = _route(existing_count=2)
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake)
|
||||
res = c.create_or_update_playlist("T", _tracks(4), existing_mbid="EXIST")
|
||||
assert res["updated"] is True and res["playlist_mbid"] == "EXIST"
|
||||
|
||||
|
||||
def test_create_or_update_falls_back_to_create_when_gone(monkeypatch):
|
||||
c = _client()
|
||||
fake, _ = _route(existing_count=None) # remembered playlist deleted on LB
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake)
|
||||
res = c.create_or_update_playlist("T", _tracks(4), existing_mbid="DEAD")
|
||||
assert res["success"] is True
|
||||
assert res["updated"] is False # fell back to create
|
||||
assert res["playlist_mbid"] == "NEW-MBID"
|
||||
|
||||
|
||||
def test_create_or_update_creates_when_no_existing(monkeypatch):
|
||||
c = _client()
|
||||
fake, _ = _route()
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", fake)
|
||||
res = c.create_or_update_playlist("T", _tracks(3), existing_mbid=None)
|
||||
assert res["playlist_mbid"] == "NEW-MBID" and res["updated"] is False
|
||||
|
||||
|
||||
def test_delete_playlist(monkeypatch):
|
||||
c = _client()
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", lambda *a, **k: _Resp(200, {}))
|
||||
assert c.delete_playlist("MBID") is True
|
||||
monkeypatch.setattr(c, "_make_request_with_retry", lambda *a, **k: _Resp(404, {}))
|
||||
assert c.delete_playlist("MBID") is False
|
||||
75
tests/exports/test_mbid_resolver.py
Normal file
75
tests/exports/test_mbid_resolver.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""MBID resolution waterfall for playlist export (#903).
|
||||
|
||||
Pins: cheapest-source-first short-circuit (don't hit MusicBrainz when the DB has it),
|
||||
invalid MBIDs are treated as misses, a raising source doesn't abort the export, and the
|
||||
resolving source label is reported (for the live status breakdown).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.exports.mbid_resolver import (
|
||||
SRC_CACHE,
|
||||
SRC_DB,
|
||||
SRC_MUSICBRAINZ,
|
||||
normalize_key,
|
||||
resolve_recording_mbid,
|
||||
)
|
||||
|
||||
MBID = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
|
||||
MBID2 = "8f3471b5-7e6a-4c1f-9c1a-2b2b2b2b2b2b"
|
||||
|
||||
|
||||
def _src(label, value):
|
||||
return (label, lambda a, t: value)
|
||||
|
||||
|
||||
def test_returns_first_hit_with_label():
|
||||
mbid, label = resolve_recording_mbid("A", "T", [_src(SRC_DB, MBID), _src(SRC_MUSICBRAINZ, MBID2)])
|
||||
assert (mbid, label) == (MBID, SRC_DB)
|
||||
|
||||
|
||||
def test_short_circuits_expensive_sources():
|
||||
called = {"mb": False}
|
||||
def mb(a, t):
|
||||
called["mb"] = True
|
||||
return MBID2
|
||||
mbid, label = resolve_recording_mbid("A", "T", [_src(SRC_CACHE, MBID), (SRC_MUSICBRAINZ, mb)])
|
||||
assert (mbid, label) == (MBID, SRC_CACHE)
|
||||
assert called["mb"] is False # cache hit -> MusicBrainz never queried
|
||||
|
||||
|
||||
def test_falls_through_misses_to_later_source():
|
||||
mbid, label = resolve_recording_mbid("A", "T", [
|
||||
_src(SRC_CACHE, None),
|
||||
_src(SRC_DB, ""),
|
||||
_src(SRC_MUSICBRAINZ, MBID2),
|
||||
])
|
||||
assert (mbid, label) == (MBID2, SRC_MUSICBRAINZ)
|
||||
|
||||
|
||||
def test_invalid_mbid_is_a_miss():
|
||||
mbid, label = resolve_recording_mbid("A", "T", [
|
||||
_src(SRC_DB, "not-a-uuid"),
|
||||
_src(SRC_MUSICBRAINZ, MBID),
|
||||
])
|
||||
assert (mbid, label) == (MBID, SRC_MUSICBRAINZ)
|
||||
|
||||
|
||||
def test_raising_source_does_not_abort():
|
||||
def boom(a, t):
|
||||
raise RuntimeError("MusicBrainz timeout")
|
||||
mbid, label = resolve_recording_mbid("A", "T", [
|
||||
(SRC_DB, boom),
|
||||
_src(SRC_MUSICBRAINZ, MBID),
|
||||
])
|
||||
assert (mbid, label) == (MBID, SRC_MUSICBRAINZ)
|
||||
|
||||
|
||||
def test_all_miss_returns_none():
|
||||
assert resolve_recording_mbid("A", "T", [_src(SRC_DB, None), _src(SRC_MUSICBRAINZ, None)]) == (None, None)
|
||||
assert resolve_recording_mbid("A", "T", []) == (None, None)
|
||||
|
||||
|
||||
def test_normalize_key_is_stable_across_variations():
|
||||
assert normalize_key("The Beatles", "Hey Jude!") == normalize_key("the beatles", "hey jude")
|
||||
assert normalize_key("A", "X") != normalize_key("B", "X")
|
||||
74
tests/exports/test_playlist_export.py
Normal file
74
tests/exports/test_playlist_export.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Playlist export orchestrator (#903): dedup + stats + progress accounting.
|
||||
|
||||
Pins: repeated songs resolve once (deduped), per-source breakdown counts only fresh
|
||||
resolutions, unmatched tracks carry recording_mbid=None but stay in order, alternate
|
||||
field shapes (artist_name/track_name) are accepted, and a throwing progress callback
|
||||
never fails the export.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.exports.playlist_export import resolve_playlist_tracks
|
||||
|
||||
MBID = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
|
||||
MBID2 = "8f3471b5-7e6a-4c1f-9c1a-2b2b2b2b2b2b"
|
||||
|
||||
|
||||
def test_resolves_and_keeps_order_and_unmatched():
|
||||
table = {("A", "T1"): (MBID, "db"), ("A", "T2"): (None, None)}
|
||||
rf = lambda a, t: table.get((a, t), (None, None))
|
||||
out = resolve_playlist_tracks(
|
||||
[{"artist": "A", "title": "T1"}, {"artist": "A", "title": "T2"}], rf
|
||||
)
|
||||
res = out["resolved"]
|
||||
assert [r["title"] for r in res] == ["T1", "T2"]
|
||||
assert res[0]["recording_mbid"] == MBID
|
||||
assert res[1]["recording_mbid"] is None
|
||||
assert out["stats"]["resolved"] == 1
|
||||
assert out["stats"]["unmatched"] == 1
|
||||
assert out["stats"]["by_source"] == {"db": 1}
|
||||
|
||||
|
||||
def test_dedup_resolves_repeated_song_once():
|
||||
calls = {"n": 0}
|
||||
def rf(a, t):
|
||||
calls["n"] += 1
|
||||
return (MBID, "musicbrainz")
|
||||
tracks = [{"artist": "A", "title": "Song"}, {"artist": "a", "title": "song"}, # same (normalized)
|
||||
{"artist": "A", "title": "Song"}]
|
||||
out = resolve_playlist_tracks(tracks, rf)
|
||||
assert calls["n"] == 1 # resolve_fn called once for 3 identical
|
||||
assert out["stats"]["deduped"] == 2
|
||||
assert out["stats"]["resolved"] == 3 # all three tracks still get the mbid
|
||||
assert out["stats"]["by_source"] == {"musicbrainz": 1} # counted once (fresh only)
|
||||
assert all(r["recording_mbid"] == MBID for r in out["resolved"])
|
||||
|
||||
|
||||
def test_accepts_alternate_field_names():
|
||||
rf = lambda a, t: (MBID, "db") if (a, t) == ("Artist", "Track") else (None, None)
|
||||
out = resolve_playlist_tracks(
|
||||
[{"artist_name": "Artist", "track_name": "Track", "album_name": "Alb"}], rf
|
||||
)
|
||||
r = out["resolved"][0]
|
||||
assert r["artist"] == "Artist" and r["title"] == "Track" and r["album"] == "Alb"
|
||||
assert r["recording_mbid"] == MBID
|
||||
|
||||
|
||||
def test_progress_called_per_track_and_safe_when_throwing():
|
||||
seen = []
|
||||
def prog(done, total, stats):
|
||||
seen.append((done, total))
|
||||
raise RuntimeError("display blew up")
|
||||
out = resolve_playlist_tracks(
|
||||
[{"artist": "A", "title": "x"}, {"artist": "B", "title": "y"}],
|
||||
lambda a, t: (MBID, "db"),
|
||||
on_progress=prog,
|
||||
)
|
||||
assert seen == [(1, 2), (2, 2)] # called each track despite raising
|
||||
assert out["stats"]["resolved"] == 2 # export still completed
|
||||
|
||||
|
||||
def test_empty_playlist():
|
||||
out = resolve_playlist_tracks([], lambda a, t: (None, None))
|
||||
assert out["resolved"] == []
|
||||
assert out["stats"]["total"] == 0
|
||||
119
tests/imports/test_album_position.py
Normal file
119
tests/imports/test_album_position.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""A track auto-downloaded from the playlist pipeline / wishlist / watchlist is
|
||||
identified as belonging to an album, but Deezer's search/track and MusicBrainz's
|
||||
recording lookups don't carry a track POSITION — so detect_album_info_web left
|
||||
track_number=None, the pipeline floored it to 1, and album tracks landed as 01/1
|
||||
(verified live: Deezer says "Obelisk" is track 9 of The Grand Mirage, tagged 1/1).
|
||||
|
||||
The fix resolves the real position from the album's OWN track list. These pin the
|
||||
pure matcher and the (fail-safe) integration wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.album_position import resolve_track_position_in_album
|
||||
|
||||
|
||||
def _album_12():
|
||||
# a realistic 12-track album payload shape (get_album_tracks_for_source -> 'tracks')
|
||||
return [
|
||||
{"id": f"t{i}", "name": n, "track_number": i, "disc_number": 1,
|
||||
"isrc": f"ISRC{i:03d}"}
|
||||
for i, n in enumerate(
|
||||
["Intro", "Drift", "Mirage", "Haze", "Pulse", "Glow", "Echo", "Tide",
|
||||
"Obelisk", "Comet", "Dawn", "Outro"], start=1)
|
||||
]
|
||||
|
||||
|
||||
# ── pure matcher ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_resolves_position_by_title():
|
||||
tn, dn = resolve_track_position_in_album(_album_12(), title="Obelisk")
|
||||
assert (tn, dn) == (9, 1)
|
||||
|
||||
|
||||
def test_title_match_is_case_and_punctuation_insensitive():
|
||||
tracks = [{"id": "t1", "name": "Lueur Déclinante!!!", "track_number": 3, "disc_number": 1}]
|
||||
tn, _ = resolve_track_position_in_album(tracks, title="lueur déclinante")
|
||||
assert tn == 3
|
||||
|
||||
|
||||
def test_resolves_by_isrc_exactly():
|
||||
tn, dn = resolve_track_position_in_album(_album_12(), isrc="isrc009") # case-insensitive
|
||||
assert (tn, dn) == (9, 1)
|
||||
|
||||
|
||||
def test_resolves_by_track_id():
|
||||
tn, _ = resolve_track_position_in_album(_album_12(), track_id="t9")
|
||||
assert tn == 9
|
||||
|
||||
|
||||
def test_isrc_beats_id_beats_title_on_conflict():
|
||||
# craft a list where ISRC, id, and title each point at a DIFFERENT track
|
||||
tracks = [
|
||||
{"id": "byid", "name": "other", "track_number": 2, "disc_number": 1, "isrc": "X"},
|
||||
{"id": "z", "name": "WANT", "track_number": 3, "disc_number": 1, "isrc": "Y"},
|
||||
{"id": "z2", "name": "other2", "track_number": 4, "disc_number": 1, "isrc": "WANTISRC"},
|
||||
]
|
||||
# all three signals provided -> ISRC wins (track 4)
|
||||
tn, _ = resolve_track_position_in_album(tracks, title="WANT", track_id="byid", isrc="wantisrc")
|
||||
assert tn == 4
|
||||
# no ISRC -> id wins (track 2)
|
||||
tn, _ = resolve_track_position_in_album(tracks, title="WANT", track_id="byid")
|
||||
assert tn == 2
|
||||
# only title -> title wins (track 3)
|
||||
tn, _ = resolve_track_position_in_album(tracks, title="want")
|
||||
assert tn == 3
|
||||
|
||||
|
||||
def test_carries_disc_number():
|
||||
tracks = [{"id": "t1", "name": "B-Side", "track_number": 2, "disc_number": 2}]
|
||||
assert resolve_track_position_in_album(tracks, title="B-Side") == (2, 2)
|
||||
|
||||
|
||||
def test_no_match_returns_none():
|
||||
assert resolve_track_position_in_album(_album_12(), title="Not On This Album") == (None, None)
|
||||
assert resolve_track_position_in_album([], title="x") == (None, None)
|
||||
assert resolve_track_position_in_album(None, title="x") == (None, None)
|
||||
|
||||
|
||||
def test_skips_entries_without_a_valid_position():
|
||||
tracks = [
|
||||
{"id": "t1", "name": "Obelisk", "track_number": 0}, # 0 -> skip
|
||||
{"id": "t2", "name": "Obelisk", "track_number": None}, # None -> skip
|
||||
{"id": "t3", "name": "Obelisk", "track_number": "junk"}, # junk -> skip
|
||||
]
|
||||
assert resolve_track_position_in_album(tracks, title="Obelisk") == (None, None)
|
||||
|
||||
|
||||
# ── integration wrapper (fail-safe, real album lookup) ───────────────────────
|
||||
|
||||
def test_wrapper_resolves_from_source_album(monkeypatch):
|
||||
import core.imports.context as ctx
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_album_tracks_for_source",
|
||||
lambda source, album_id: {"tracks": _album_12()})
|
||||
context = {"source": "deezer",
|
||||
"track_info": {"id": "t9", "name": "Obelisk", "deezer_album_id": "232620572"}}
|
||||
tn, dn = ctx._resolve_album_position_from_source(context, {}, 1)
|
||||
assert tn == 9 and dn == 1
|
||||
|
||||
|
||||
def test_wrapper_is_failsafe_on_empty_or_missing(monkeypatch):
|
||||
import core.imports.context as ctx
|
||||
# no album id at all -> keeps current disc, no number
|
||||
assert ctx._resolve_album_position_from_source({"source": "deezer", "track_info": {}}, {}, 1) == (None, 1)
|
||||
# fetcher returns nothing -> fail-safe
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_album_tracks_for_source",
|
||||
lambda source, album_id: {"tracks": []})
|
||||
context = {"source": "deezer", "track_info": {"name": "Obelisk", "deezer_album_id": "x"}}
|
||||
assert ctx._resolve_album_position_from_source(context, {}, 2) == (None, 2)
|
||||
|
||||
|
||||
def test_wrapper_never_raises_on_fetch_error(monkeypatch):
|
||||
import core.imports.context as ctx
|
||||
def _boom(source, album_id):
|
||||
raise RuntimeError("deezer down")
|
||||
monkeypatch.setattr("core.metadata.album_tracks.get_album_tracks_for_source", _boom)
|
||||
context = {"source": "deezer", "track_info": {"name": "Obelisk", "deezer_album_id": "x"}}
|
||||
assert ctx._resolve_album_position_from_source(context, {}, 1) == (None, 1)
|
||||
69
tests/imports/test_normalize_disc_number.py
Normal file
69
tests/imports/test_normalize_disc_number.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Sokhi: some tracks in a multi-disc album got a null disc in Jellyfin and floated
|
||||
ungrouped above the disc sections. Root cause: the tag-writer only wrote the disc
|
||||
tag when disc_number was truthy, and upstream a 0 / None / '' (esp. when a track
|
||||
matched a different edition than its siblings) slipped through — so on the
|
||||
clear-then-rewrite those tracks lost their disc entirely. normalize_disc_number
|
||||
floors any value to >=1 so a track is never written disc-less."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.imports.track_number import normalize_disc_number
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
(1, 1), (2, 2), (4, 4),
|
||||
("1", 1), ("3", 3), (" 2 ", 2),
|
||||
(0, 1), ("0", 1), # the bug: 0 must floor to 1, not vanish
|
||||
(None, 1), ("", 1), (" ", 1),
|
||||
(-1, 1), ("-2", 1), # negatives floor to 1
|
||||
("abc", 1), ("1/4", 1), # non-numeric -> 1 (never raises)
|
||||
(2.0, 2), # float-ish via str()
|
||||
])
|
||||
def test_normalize_disc_number(value, expected):
|
||||
assert normalize_disc_number(value) == expected
|
||||
|
||||
|
||||
def test_valid_multidisc_values_preserved():
|
||||
# a real disc on a 4xLP must survive untouched
|
||||
for d in (1, 2, 3, 4):
|
||||
assert normalize_disc_number(d) == d
|
||||
|
||||
|
||||
# ── resolve_disc_for_track: the FOLDER and the TAG must use the same disc ──────
|
||||
|
||||
from core.imports.track_number import resolve_disc_for_track
|
||||
|
||||
|
||||
def test_resolve_disc_prefers_per_track_search_then_album():
|
||||
# per-track disc wins (this is the value the tag uses) — so the folder, which
|
||||
# now calls the SAME resolver with the SAME inputs, lands on the same disc.
|
||||
assert resolve_disc_for_track({"disc_number": 3}, {"disc_number": 1}) == 3
|
||||
# falls back to album context when the per-track search has none
|
||||
assert resolve_disc_for_track({}, {"disc_number": 2}) == 2
|
||||
assert resolve_disc_for_track({"disc_number": None}, {"disc_number": 2}) == 2
|
||||
# both missing -> floored default 1
|
||||
assert resolve_disc_for_track({}, {}) == 1
|
||||
assert resolve_disc_for_track(None, None) == 1
|
||||
|
||||
|
||||
def test_resolve_disc_floors_bad_values():
|
||||
assert resolve_disc_for_track({"disc_number": 0}, {"disc_number": 5}) == 5 # 0 is falsy -> fall to album
|
||||
assert resolve_disc_for_track({"disc_number": "2"}, {}) == 2
|
||||
assert resolve_disc_for_track({"disc_number": "junk"}, {}) == 1
|
||||
|
||||
|
||||
def test_folder_and_tag_resolve_identically():
|
||||
# the regression that matters: given the same (original_search, album_info),
|
||||
# source.py (tag) and the pipeline (folder) get the IDENTICAL disc.
|
||||
cases = [
|
||||
({"disc_number": 2}, {"disc_number": 1}), # Sokhi's case: per-track 2, album 1
|
||||
({"disc_number": 3}, {"disc_number": 1}),
|
||||
({}, {"disc_number": 1}),
|
||||
({"disc_number": 0}, {"disc_number": 1}),
|
||||
]
|
||||
for osrch, ainfo in cases:
|
||||
folder_disc = resolve_disc_for_track(osrch, ainfo) # what the pipeline writes to album_info
|
||||
tag_disc = resolve_disc_for_track(osrch, ainfo) # what source.py writes to the tag
|
||||
assert folder_disc == tag_disc
|
||||
115
tests/library/test_standalone_scan.py
Normal file
115
tests/library/test_standalone_scan.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Standalone Deep Scan planner — the #904 data-loss guard.
|
||||
|
||||
The scan relocates Transfer files the DB doesn't know about into Staging. With a
|
||||
path-only diff, an empty/desynced DB makes the WHOLE library look untracked and the
|
||||
scan moved all of it (reporter lost ~1,500 tracks into Staging). These pin the guard:
|
||||
a normal batch of new arrivals still moves; an implausibly large untracked share (the
|
||||
desync signature) or a 'permanent library' opt-out blocks the move instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.library.standalone_scan import (
|
||||
BLOCK_DESYNC,
|
||||
BLOCK_NONE,
|
||||
BLOCK_TRANSFER_PERMANENT,
|
||||
diff_untracked,
|
||||
plan_standalone_deep_scan,
|
||||
)
|
||||
|
||||
|
||||
def _files(prefix, n, start=0):
|
||||
return {f"{prefix}/track{i}.flac" for i in range(start, start + n)}
|
||||
|
||||
|
||||
# ── diff_untracked (pure path diff) ──────────────────────────────────────────
|
||||
|
||||
def test_diff_basic():
|
||||
transfer = {"/m/a.flac", "/m/b.flac", "/m/c.flac"}
|
||||
db = {"/m/a.flac", "/m/b.flac"}
|
||||
assert diff_untracked(transfer, db) == {"/m/c.flac"}
|
||||
|
||||
|
||||
def test_diff_is_separator_normalized():
|
||||
# DB stored a Windows-style path; the on-disk path uses forward slashes → still a match
|
||||
transfer = {"/m/Artist/x.flac"}
|
||||
db = {"\\m\\Artist\\x.flac"}
|
||||
assert diff_untracked(transfer, db) == set()
|
||||
|
||||
|
||||
def test_diff_all_untracked_when_db_empty():
|
||||
transfer = _files("/lib", 5)
|
||||
assert diff_untracked(transfer, set()) == transfer
|
||||
|
||||
|
||||
# ── plan: normal (move allowed) ──────────────────────────────────────────────
|
||||
|
||||
def test_clean_library_not_blocked():
|
||||
transfer = _files("/lib", 1000)
|
||||
plan = plan_standalone_deep_scan(transfer, transfer) # all known
|
||||
assert plan["untracked"] == set()
|
||||
assert plan["move_blocked"] is False
|
||||
assert plan["block_reason"] == BLOCK_NONE
|
||||
|
||||
|
||||
def test_normal_new_arrivals_move():
|
||||
# DB knows 990; 10 new files dropped in → small share, moves as before
|
||||
known = _files("/lib", 990)
|
||||
transfer = known | _files("/lib", 10, start=990)
|
||||
plan = plan_standalone_deep_scan(transfer, known)
|
||||
assert len(plan["untracked"]) == 10
|
||||
assert plan["move_blocked"] is False
|
||||
|
||||
|
||||
def test_small_fresh_import_under_floor_moves():
|
||||
# A tiny brand-new folder (under the absolute floor) isn't second-guessed
|
||||
transfer = _files("/lib", 5)
|
||||
plan = plan_standalone_deep_scan(transfer, set())
|
||||
assert len(plan["untracked"]) == 5
|
||||
assert plan["move_blocked"] is False
|
||||
|
||||
|
||||
# ── plan: the #904 guard (move blocked) ──────────────────────────────────────
|
||||
|
||||
def test_regression_904_empty_db_full_library_blocks():
|
||||
# Empty DB + a real 1,500-track library → 100% untracked → BLOCKED, nothing moved
|
||||
transfer = _files("/library", 1500)
|
||||
plan = plan_standalone_deep_scan(transfer, set())
|
||||
assert len(plan["untracked"]) == 1500
|
||||
assert plan["move_blocked"] is True
|
||||
assert plan["block_reason"] == BLOCK_DESYNC
|
||||
|
||||
|
||||
def test_majority_untracked_blocks():
|
||||
# 600 of 1000 unknown (60%, over the 50% line) → desync, blocked
|
||||
known = _files("/lib", 400)
|
||||
transfer = known | _files("/lib", 600, start=400)
|
||||
plan = plan_standalone_deep_scan(transfer, known)
|
||||
assert plan["move_blocked"] is True
|
||||
assert plan["block_reason"] == BLOCK_DESYNC
|
||||
|
||||
|
||||
def test_minority_untracked_just_under_threshold_moves():
|
||||
# 400 of 1000 unknown (40%, under 50%) → still treated as a batch, not a desync
|
||||
known = _files("/lib", 600)
|
||||
transfer = known | _files("/lib", 400, start=600)
|
||||
plan = plan_standalone_deep_scan(transfer, known)
|
||||
assert plan["move_blocked"] is False
|
||||
|
||||
|
||||
def test_never_move_blocks_even_small_sets():
|
||||
# Permanent-library opt-out: block regardless of fraction
|
||||
known = _files("/lib", 990)
|
||||
transfer = known | _files("/lib", 10, start=990)
|
||||
plan = plan_standalone_deep_scan(transfer, known, never_move=True)
|
||||
assert len(plan["untracked"]) == 10
|
||||
assert plan["move_blocked"] is True
|
||||
assert plan["block_reason"] == BLOCK_TRANSFER_PERMANENT
|
||||
|
||||
|
||||
def test_never_move_with_nothing_untracked_is_not_blocked():
|
||||
# Nothing to move → not a "blocked" outcome even with the toggle on
|
||||
transfer = _files("/lib", 100)
|
||||
plan = plan_standalone_deep_scan(transfer, transfer, never_move=True)
|
||||
assert plan["untracked"] == set()
|
||||
assert plan["move_blocked"] is False
|
||||
121
tests/playlists/test_playlist_item_naming.py
Normal file
121
tests/playlists/test_playlist_item_naming.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Custom file naming for organize-by-playlist folders.
|
||||
|
||||
The materialized playlist folder used to be stuck with the library filename.
|
||||
A user can now opt into a flat filename template (e.g. "$position - $artist -
|
||||
$title"). It's a FILENAME, not a path — validated so it can't make folders or
|
||||
broken names, and it falls back to the library filename on anything invalid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from core.playlists.item_naming import (
|
||||
render_playlist_item_name,
|
||||
validate_playlist_item_template,
|
||||
)
|
||||
from core.playlists.materialize import rebuild_playlist_folder
|
||||
|
||||
|
||||
# ── validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_empty_template_is_valid_means_off():
|
||||
assert validate_playlist_item_template("") == (True, "")
|
||||
assert validate_playlist_item_template(" ") == (True, "")
|
||||
assert validate_playlist_item_template(None) == (True, "")
|
||||
|
||||
|
||||
def test_slash_is_rejected_no_folder_structure():
|
||||
ok, why = validate_playlist_item_template("$artist/$title")
|
||||
assert ok is False and "separator" in why
|
||||
ok, why = validate_playlist_item_template("$artist\\$title")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_must_contain_title():
|
||||
ok, why = validate_playlist_item_template("$position - $artist")
|
||||
assert ok is False and "$title" in why
|
||||
|
||||
|
||||
def test_valid_flat_template_passes():
|
||||
assert validate_playlist_item_template("$position - $artist - $title") == (True, "")
|
||||
|
||||
|
||||
# ── rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_renders_tokens_and_keeps_extension():
|
||||
out = render_playlist_item_name(
|
||||
"$position - $artist - $title",
|
||||
title="One More Time", artist="Daft Punk", position="01", ext=".flac",
|
||||
fallback_name="x.flac")
|
||||
assert out == "01 - Daft Punk - One More Time.flac"
|
||||
|
||||
|
||||
def test_track_is_zero_padded_album_is_optional():
|
||||
out = render_playlist_item_name(
|
||||
"$track - $title", title="Genesis", track=5, ext=".mp3", fallback_name="x.mp3")
|
||||
assert out == "05 - Genesis.mp3"
|
||||
|
||||
|
||||
def test_invalid_template_falls_back_to_library_name():
|
||||
# slash / missing-title / empty all fall back — never a broken name
|
||||
for bad in ("$artist/$title", "$artist - $position", ""):
|
||||
assert render_playlist_item_name(
|
||||
bad, title="T", artist="A", ext=".flac", fallback_name="orig.flac") == "orig.flac"
|
||||
|
||||
|
||||
def test_garbage_title_still_yields_a_safe_name_not_broken():
|
||||
# a title made of separators is sanitized to a safe (ugly) name with the
|
||||
# extension intact — never a broken name and never a path.
|
||||
out = render_playlist_item_name(
|
||||
"$title", title="/////", ext=".flac", fallback_name="orig.flac")
|
||||
assert out.endswith(".flac") and "/" not in out and "\\" not in out
|
||||
|
||||
|
||||
def test_rendered_name_can_never_contain_a_separator():
|
||||
out = render_playlist_item_name(
|
||||
"$artist - $title", title="AC/DC Song", artist="AC/DC", ext=".flac", fallback_name="x.flac")
|
||||
assert "/" not in out and "\\" not in out
|
||||
|
||||
|
||||
# ── end-to-end through the real folder builder ──────────────────────────────
|
||||
|
||||
def _touch(p):
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "wb") as f:
|
||||
f.write(b"\x00")
|
||||
|
||||
|
||||
def test_rebuild_uses_dest_names_when_given(tmp_path):
|
||||
lib = tmp_path / "lib"
|
||||
a = str(lib / "Artist A" / "05 - Song A.flac")
|
||||
b = str(lib / "Artist B" / "02 - Song B.flac")
|
||||
_touch(a); _touch(b)
|
||||
root = str(tmp_path / "Playlists")
|
||||
summary = rebuild_playlist_folder(
|
||||
root, "My Mix", [a, b], "copy",
|
||||
dest_names=["01 - Artist A - Song A.flac", "02 - Artist B - Song B.flac"])
|
||||
got = sorted(os.listdir(summary.playlist_dir))
|
||||
assert got == ["01 - Artist A - Song A.flac", "02 - Artist B - Song B.flac"]
|
||||
|
||||
|
||||
def test_rebuild_without_dest_names_keeps_basename(tmp_path):
|
||||
# back-compat: default behavior unchanged
|
||||
a = str(tmp_path / "lib" / "05 - Song A.flac")
|
||||
_touch(a)
|
||||
summary = rebuild_playlist_folder(str(tmp_path / "PL"), "Mix", [a], "copy")
|
||||
assert os.listdir(summary.playlist_dir) == ["05 - Song A.flac"]
|
||||
|
||||
|
||||
def test_rebuild_disambiguates_colliding_dest_names(tmp_path):
|
||||
# two different sources, same templated name (e.g. template "$title" + dup title)
|
||||
a = str(tmp_path / "lib" / "a" / "x.flac")
|
||||
b = str(tmp_path / "lib" / "b" / "y.flac")
|
||||
_touch(a); _touch(b)
|
||||
summary = rebuild_playlist_folder(
|
||||
str(tmp_path / "PL"), "Mix", [a, b], "copy",
|
||||
dest_names=["Song.flac", "Song.flac"])
|
||||
got = sorted(os.listdir(summary.playlist_dir))
|
||||
assert got == ["Song (2).flac", "Song.flac"]
|
||||
45
tests/playlists/test_stable_source_track_id.py
Normal file
45
tests/playlists/test_stable_source_track_id.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""#901: a manual match (Find & Add) on a file-import playlist track was silently
|
||||
dropped and the track re-appeared as "extra". Root cause: file-import / iTunes-only
|
||||
tracks arrive with an EMPTY source_track_id, and the whole manual-match system keys
|
||||
on it — an empty key can't be persisted (no-op) or looked up. Fix: derive a stable
|
||||
deterministic id from the track's identity so matches stick like they do for
|
||||
Spotify/YouTube (which carry native ids).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.playlists.source_refs import stable_source_track_id
|
||||
|
||||
|
||||
def test_native_id_is_used_verbatim():
|
||||
assert stable_source_track_id({"source_track_id": "2fdfsGuqb6SBX5ocoBWHUd"}) == "2fdfsGuqb6SBX5ocoBWHUd"
|
||||
# explicit existing wins over the dict
|
||||
assert stable_source_track_id({"source_track_id": "x"}, existing="y") == "y"
|
||||
|
||||
|
||||
def test_file_track_gets_a_deterministic_prefixed_id():
|
||||
t = {"track_name": "Slow Ride", "artist_name": "Foghat", "album_name": "Fool for the City"}
|
||||
a = stable_source_track_id(t)
|
||||
assert a.startswith("file:") and len(a) == len("file:") + 16
|
||||
# SAME song → SAME id across calls/re-imports (what the match lookup needs)
|
||||
assert stable_source_track_id(dict(t)) == a
|
||||
|
||||
|
||||
def test_identity_is_case_and_field_insensitive_but_distinguishes_songs():
|
||||
base = {"track_name": "Slow Ride", "artist_name": "Foghat", "album_name": "Fool for the City"}
|
||||
same = {"name": "slow ride", "artist": "FOGHAT", "album": "fool for the city"} # alt field names + case
|
||||
assert stable_source_track_id(base) == stable_source_track_id(same)
|
||||
# a different song gets a different id
|
||||
assert stable_source_track_id(base) != stable_source_track_id(
|
||||
{"track_name": "I Just Want to Make Love to You", "artist_name": "Foghat"})
|
||||
|
||||
|
||||
def test_empty_id_when_no_title():
|
||||
assert stable_source_track_id({"artist_name": "Foghat"}) == ""
|
||||
assert stable_source_track_id({}) == ""
|
||||
|
||||
|
||||
def test_never_collides_with_a_real_upstream_id():
|
||||
# the file: prefix keeps synthetic ids out of the spotify/youtube id space
|
||||
fid = stable_source_track_id({"track_name": "x", "artist_name": "y"})
|
||||
assert ":" in fid and not fid.replace("file:", "").startswith("file")
|
||||
108
tests/sync/test_reresolve_live_plex.py
Normal file
108
tests/sync/test_reresolve_live_plex.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Plex re-keys tracks on a metadata refresh, so a durable manual match's stored
|
||||
ratingKey (and the SoulSync DB id, which IS that ratingKey) can both go stale at
|
||||
once — every DB-side lookup lands on the same dead key and fetchItem 404s, so the
|
||||
manually-matched track gets dropped on sync (wolf39us). The fix re-resolves the
|
||||
match against LIVE Plex by the matched track's metadata, disambiguated by the
|
||||
stored file path so the user's EXACT chosen track wins, and heals the stored id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.sync_service import reresolve_manual_match_live_plex
|
||||
|
||||
|
||||
class _PlexTrack:
|
||||
def __init__(self, rating_key, file):
|
||||
self.ratingKey = rating_key
|
||||
self.title = f"track-{rating_key}"
|
||||
|
||||
class _Part:
|
||||
def __init__(self, f): self.file = f
|
||||
class _Media:
|
||||
def __init__(self, f): self.parts = [_Part(f)]
|
||||
self.media = [_Media(file)]
|
||||
|
||||
|
||||
class _TrackInfo:
|
||||
def __init__(self, plex_track):
|
||||
self._original_plex_track = plex_track
|
||||
|
||||
|
||||
class _MediaClient:
|
||||
def __init__(self, results):
|
||||
self._results = results
|
||||
self.calls = []
|
||||
def search_tracks(self, title, artist, limit=15):
|
||||
self.calls.append((title, artist))
|
||||
return self._results
|
||||
|
||||
|
||||
class _CacheDb:
|
||||
def __init__(self):
|
||||
self.healed = []
|
||||
def save_manual_library_match(self, profile_id, source, source_track_id, library_track_id, **meta):
|
||||
self.healed.append({"id": library_track_id, "source_track_id": source_track_id, **meta})
|
||||
return True
|
||||
|
||||
|
||||
_MATCH = {
|
||||
"source": "spotify", "source_title": "It's the End of the World",
|
||||
"source_artist": "R.E.M.", "source_album": "Document",
|
||||
"library_file_path": "/music/REM/Document/05 - Its the End.flac",
|
||||
"library_track_id": 39161, # stale
|
||||
}
|
||||
|
||||
|
||||
def test_picks_the_track_matching_the_stored_file_path():
|
||||
# Two live candidates (a different version + the real one); the stored file
|
||||
# path must select the user's exact track, with its CURRENT ratingKey.
|
||||
wrong = _TrackInfo(_PlexTrack(50001, "/music/REM/Live/05 - Its the End (Live).flac"))
|
||||
right = _TrackInfo(_PlexTrack(39167, "/music/REM/Document/05 - Its the End.flac"))
|
||||
mc = _MediaClient([wrong, right])
|
||||
db = _CacheDb()
|
||||
live = reresolve_manual_match_live_plex(
|
||||
db, mc, _MATCH, profile_id=1, source_track_id="sp1", server_source="plex")
|
||||
assert live is not None and live.ratingKey == 39167 # current key, not stale 39161
|
||||
assert mc.calls == [("It's the End of the World", "R.E.M.")]
|
||||
# healed the stored id to the fresh ratingKey
|
||||
assert db.healed and db.healed[0]["id"] == "39167"
|
||||
assert db.healed[0]["source_track_id"] == "sp1"
|
||||
|
||||
|
||||
def test_basename_match_handles_server_vs_local_path():
|
||||
# stored path is a local path; the Plex part.file is a container path — same basename.
|
||||
m = dict(_MATCH, library_file_path="D:\\Music\\REM\\05 - Its the End.flac")
|
||||
right = _TrackInfo(_PlexTrack(39167, "/data/Music/REM/05 - Its the End.flac"))
|
||||
live = reresolve_manual_match_live_plex(
|
||||
_CacheDb(), _MediaClient([right]), m, profile_id=1, source_track_id="sp1", server_source="plex")
|
||||
assert live.ratingKey == 39167
|
||||
|
||||
|
||||
def test_no_file_match_falls_back_to_top_result():
|
||||
only = _TrackInfo(_PlexTrack(40000, "/music/somewhere/else.flac"))
|
||||
live = reresolve_manual_match_live_plex(
|
||||
_CacheDb(), _MediaClient([only]), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex")
|
||||
assert live.ratingKey == 40000 # never drop a manual match — best-effort top hit
|
||||
|
||||
|
||||
def test_no_results_returns_none_and_does_not_heal():
|
||||
db = _CacheDb()
|
||||
assert reresolve_manual_match_live_plex(
|
||||
db, _MediaClient([]), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") is None
|
||||
assert db.healed == []
|
||||
|
||||
|
||||
def test_missing_title_returns_none():
|
||||
m = dict(_MATCH, source_title="")
|
||||
assert reresolve_manual_match_live_plex(
|
||||
_CacheDb(), _MediaClient([_TrackInfo(_PlexTrack(1, "/x.flac"))]), m,
|
||||
profile_id=1, source_track_id="sp1", server_source="plex") is None
|
||||
|
||||
|
||||
def test_never_raises_on_a_broken_media_client():
|
||||
class _Boom:
|
||||
def search_tracks(self, *a, **k):
|
||||
raise RuntimeError("plex down")
|
||||
# a transient Plex error must not bubble — the caller falls through, not crash.
|
||||
assert reresolve_manual_match_live_plex(
|
||||
_CacheDb(), _Boom(), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") is None
|
||||
57
tests/sync/test_sync_durable_match.py
Normal file
57
tests/sync/test_sync_durable_match.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""The REAL playlist sync matcher (PlaylistSyncService._find_track_in_media_server)
|
||||
must honor a durable Find & Add / manual match when the volatile sync_match_cache
|
||||
has been wiped by a library rescan — otherwise the manual pick is re-matched from
|
||||
scratch on the next auto-sync (#895 follow-up). Jellyfin server-type avoids Plex
|
||||
fetchItem mocking; the durable block is server-agnostic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from services.sync_service import PlaylistSyncService
|
||||
|
||||
|
||||
def _service():
|
||||
svc = PlaylistSyncService(spotify_client=MagicMock(), download_orchestrator=MagicMock(),
|
||||
media_server_engine=MagicMock())
|
||||
client = MagicMock(); client.is_connected.return_value = True
|
||||
svc._get_active_media_client = lambda: (client, "jellyfin")
|
||||
svc._cancelled = False
|
||||
return svc
|
||||
|
||||
|
||||
def _run(svc, db):
|
||||
cm = MagicMock(); cm.get_active_media_server.return_value = "jellyfin"
|
||||
track = SimpleNamespace(name="Valió la Pena - Salsa Version", artists=["Marc Anthony"], id="sp16")
|
||||
with patch("database.music_database.MusicDatabase", return_value=db), \
|
||||
patch("config.settings.config_manager", cm), \
|
||||
patch("core.artists.map.get_current_profile_id", return_value=1):
|
||||
return asyncio.run(svc._find_track_in_media_server(track, candidate_pool={}))
|
||||
|
||||
|
||||
def test_durable_match_used_when_volatile_cache_wiped():
|
||||
dt = SimpleNamespace(id="t99", title="Valió la Pena (Salsa Version)")
|
||||
db = MagicMock()
|
||||
db.read_sync_match_cache.return_value = None # rescan wiped the cache
|
||||
db.check_track_exists.return_value = (None, 0.0) # fuzzy would FAIL
|
||||
db.find_manual_library_match_by_source_track_id.return_value = {
|
||||
"library_track_id": "t99", "library_file_path": "/m/x.flac"}
|
||||
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "t99" else None
|
||||
match, conf = _run(svc=_service(), db=db)
|
||||
assert conf == 1.0 and match.id == "t99" # manual pick honored across the rescan
|
||||
|
||||
|
||||
def test_durable_match_self_heals_stale_library_id():
|
||||
dt = SimpleNamespace(id="newid", title="X")
|
||||
db = MagicMock()
|
||||
db.read_sync_match_cache.return_value = None
|
||||
db.check_track_exists.return_value = (None, 0.0)
|
||||
db.find_manual_library_match_by_source_track_id.return_value = {
|
||||
"library_track_id": "staleid", "library_file_path": "/m/x.flac"}
|
||||
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "newid" else None # stale id gone
|
||||
db.find_track_id_by_file_path.return_value = "newid"
|
||||
match, conf = _run(svc=_service(), db=db)
|
||||
assert conf == 1.0 and match.id == "newid"
|
||||
db.find_track_id_by_file_path.assert_called_once_with("/m/x.flac")
|
||||
90
tests/test_artist_the_prefix_variations.py
Normal file
90
tests/test_artist_the_prefix_variations.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Leading-"The" duplicate fix.
|
||||
|
||||
A user wanted "I Gotta Feeling" by "The Black Eyed Peas" but owned it under
|
||||
"Black Eyed Peas" (or vice-versa). The dedup gate (check_track_exists) fetches
|
||||
candidates via _get_artist_variations(), which had no "The" toggle — so the
|
||||
owned track was never fetched, the request "failed to match", and a duplicate
|
||||
was downloaded. The toggle widens the fetch to both forms; the scorer still
|
||||
decides, so it can't merge genuinely different artists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
def _variations(name):
|
||||
db = object.__new__(MusicDatabase) # no DB / network init needed
|
||||
return db._get_artist_variations(name)
|
||||
|
||||
|
||||
def test_leading_the_is_stripped_to_search_the_bare_form():
|
||||
v = _variations("The Black Eyed Peas")
|
||||
assert "Black Eyed Peas" in v # owned-bare form now gets fetched
|
||||
assert "The Black Eyed Peas" in v # original kept
|
||||
|
||||
|
||||
def test_bare_name_also_searches_the_the_prefixed_form():
|
||||
v = _variations("Black Eyed Peas")
|
||||
assert "The The Black Eyed Peas" not in v # no double-"The"
|
||||
assert "The Black Eyed Peas" in v # the "The"-variant gets fetched
|
||||
assert "Black Eyed Peas" in v
|
||||
|
||||
|
||||
def test_the_band_named_just_the_never_produces_an_empty_search():
|
||||
# "The" alone must not collapse to an empty artist search (which would match
|
||||
# the entire library). Adding "The The" is harmless — the scorer still gates.
|
||||
v = _variations("The")
|
||||
assert "" not in v
|
||||
assert "The" in v
|
||||
|
||||
|
||||
def test_a_leading_the_word_is_required_not_a_mid_word_the():
|
||||
# "Theory of a Deadman" starts with "The" but not the WORD "The" — it must not
|
||||
# be stripped mid-word. (It does still get the harmless "The "-prefixed widen.)
|
||||
v = _variations("Theory of a Deadman")
|
||||
assert "ory of a Deadman" not in v # NOT mangled mid-word
|
||||
assert "Theory of a Deadman" in v
|
||||
|
||||
|
||||
def test_the_toggle_lands_the_match_through_the_real_scorer():
|
||||
# End-to-end on the confidence scorer: requesting one variant against the
|
||||
# other owned variant must clear the 0.8 dedup threshold (50/50 title/artist
|
||||
# → 1.0*0.5 + 0.882*0.5 = 0.94), so it's recognized as already owned.
|
||||
db = object.__new__(MusicDatabase)
|
||||
|
||||
class _Track:
|
||||
title = "I Gotta Feeling"
|
||||
artist_name = "Black Eyed Peas"
|
||||
track_artist = "Black Eyed Peas"
|
||||
album = "The E.N.D."
|
||||
|
||||
conf = db._calculate_track_confidence("I Gotta Feeling", "The Black Eyed Peas", _Track())
|
||||
assert conf >= 0.8, conf
|
||||
|
||||
# …and the reverse direction too.
|
||||
class _Track2:
|
||||
title = "I Gotta Feeling"
|
||||
artist_name = "The Black Eyed Peas"
|
||||
track_artist = "The Black Eyed Peas"
|
||||
album = "The E.N.D."
|
||||
|
||||
conf2 = db._calculate_track_confidence("I Gotta Feeling", "Black Eyed Peas", _Track2())
|
||||
assert conf2 >= 0.8, conf2
|
||||
|
||||
|
||||
def test_toggle_does_not_falsely_merge_different_the_artists():
|
||||
# "The Police" and "Police" are arguably the same band, but "The Weeknd" vs
|
||||
# "Weeknd" etc. — the toggle only WIDENS the fetch; the scorer still gates.
|
||||
# A clearly different artist must not score as a match just because both
|
||||
# share no "The". (Title differs too — this is the real safety net.)
|
||||
db = object.__new__(MusicDatabase)
|
||||
|
||||
class _Other:
|
||||
title = "Some Other Song"
|
||||
artist_name = "Black Eyed Peas"
|
||||
track_artist = "Black Eyed Peas"
|
||||
album = "Whatever"
|
||||
|
||||
conf = db._calculate_track_confidence("I Gotta Feeling", "The Killers", _Other())
|
||||
assert conf < 0.8, conf
|
||||
85
tests/test_caa_release_art.py
Normal file
85
tests/test_caa_release_art.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Special-edition cover art: prefer the pinned release's OWN cover over the
|
||||
release-group representative.
|
||||
|
||||
A MusicBrainz release-group 'front' on the Cover Art Archive is a single
|
||||
representative cover (usually the standard edition), so a special edition (e.g.
|
||||
"Gustave Edition") was getting the standard art. The download/embed art paths now
|
||||
try the specific release's own cover first and fall back to the group/provider
|
||||
URL only when the release has none — so coverage never regresses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.metadata.caa_art import caa_front_url, fetch_release_preferred_art
|
||||
|
||||
|
||||
def test_caa_front_url_scopes_and_size():
|
||||
assert caa_front_url("abc", "release") == "https://coverartarchive.org/release/abc/front-1200"
|
||||
assert caa_front_url("rg", "release-group") == "https://coverartarchive.org/release-group/rg/front-1200"
|
||||
assert caa_front_url("abc", "release", size=250).endswith("/front-250")
|
||||
assert caa_front_url("abc", "release", size=0).endswith("/abc/front")
|
||||
assert caa_front_url("", "release") is None
|
||||
assert caa_front_url(None) is None
|
||||
# unknown scope coerces to release
|
||||
assert "/release/x/" in caa_front_url("x", "bogus")
|
||||
|
||||
|
||||
def _fetcher(table):
|
||||
"""table: {url: bytes|None}. Returns (bytes, mime) or (None, None)."""
|
||||
calls = []
|
||||
def fetch(url):
|
||||
calls.append(url)
|
||||
data = table.get(url)
|
||||
return (data, "image/jpeg") if data else (None, None)
|
||||
fetch.calls = calls
|
||||
return fetch
|
||||
|
||||
|
||||
def test_prefers_release_specific_art_over_fallback():
|
||||
rel = "https://coverartarchive.org/release/REL/front-1200"
|
||||
fb = "https://provider.example/standard.jpg"
|
||||
fetch = _fetcher({rel: b"X" * 5000, fb: b"Y" * 5000})
|
||||
data, mime, used = fetch_release_preferred_art("REL", fb, fetch_fn=fetch)
|
||||
assert data == b"X" * 5000 and used == rel
|
||||
assert fetch.calls[0] == rel # release tried FIRST
|
||||
|
||||
|
||||
def test_falls_back_when_release_has_no_own_art():
|
||||
rel = "https://coverartarchive.org/release/REL/front-1200"
|
||||
fb = "https://provider.example/standard.jpg"
|
||||
fetch = _fetcher({rel: None, fb: b"Y" * 5000}) # release 404s
|
||||
data, _, used = fetch_release_preferred_art("REL", fb, fetch_fn=fetch)
|
||||
assert data == b"Y" * 5000 and used == fb # never regresses: keeps the old cover
|
||||
assert fetch.calls == [rel, fb] # tried release, then fell back
|
||||
|
||||
|
||||
def test_no_release_mbid_uses_fallback_directly():
|
||||
fb = "https://provider.example/standard.jpg"
|
||||
fetch = _fetcher({fb: b"Y" * 5000})
|
||||
data, _, used = fetch_release_preferred_art(None, fb, fetch_fn=fetch)
|
||||
assert data and used == fb
|
||||
assert fetch.calls == [fb] # no wasted release lookup
|
||||
|
||||
|
||||
def test_tiny_image_is_treated_as_a_miss():
|
||||
rel = "https://coverartarchive.org/release/REL/front-1200"
|
||||
fb = "https://provider.example/standard.jpg"
|
||||
fetch = _fetcher({rel: b"tiny", fb: b"Y" * 5000}) # release art under min_bytes
|
||||
data, _, used = fetch_release_preferred_art("REL", fb, fetch_fn=fetch, min_bytes=1000)
|
||||
assert used == fb
|
||||
|
||||
|
||||
def test_nothing_available_returns_none():
|
||||
fetch = _fetcher({})
|
||||
assert fetch_release_preferred_art("REL", None, fetch_fn=fetch) == (None, None, None)
|
||||
assert fetch_release_preferred_art(None, None, fetch_fn=fetch) == (None, None, None)
|
||||
|
||||
|
||||
def test_fetch_exception_is_treated_as_miss_not_fatal():
|
||||
fb = "https://provider.example/standard.jpg"
|
||||
def fetch(url):
|
||||
if "release" in url:
|
||||
raise RuntimeError("network boom")
|
||||
return b"Y" * 5000, "image/jpeg"
|
||||
data, _, used = fetch_release_preferred_art("REL", fb, fetch_fn=fetch)
|
||||
assert data == b"Y" * 5000 and used == fb # exception on release → fell back safely
|
||||
60
tests/test_deezer_track_positions.py
Normal file
60
tests/test_deezer_track_positions.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Deezer playlist tracks must carry the REAL album track_position, not their
|
||||
playlist index — otherwise the downloaded file is tagged with the wrong track
|
||||
number (e.g. 'Apologize' from Shock Value tagged track 1 instead of 16)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.deezer_client import resolve_album_track_positions
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, data, ok=True):
|
||||
self._d, self.ok = data, ok
|
||||
|
||||
def json(self):
|
||||
return self._d
|
||||
|
||||
|
||||
class _Session:
|
||||
"""Fake requests session returning /album/<id>/tracks payloads."""
|
||||
|
||||
def __init__(self, by_album, fail_for=()):
|
||||
self.by_album, self.fail_for, self.calls = by_album, set(fail_for), []
|
||||
|
||||
def get(self, url, params=None, timeout=None):
|
||||
aid = url.rstrip('/').split('/')[-2] # …/album/<aid>/tracks
|
||||
self.calls.append(aid)
|
||||
if aid in self.fail_for:
|
||||
return _Resp(None, ok=False)
|
||||
return _Resp({'data': self.by_album.get(aid, [])})
|
||||
|
||||
|
||||
def test_maps_track_id_to_real_album_position():
|
||||
sess = _Session({'119606': [
|
||||
{'id': 100, 'track_position': 16}, {'id': 101, 'track_position': 2}]})
|
||||
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'119606'}, sleep_s=0)
|
||||
assert pos == {'100': 16, '101': 2} # real positions, not 1/2 enumerate
|
||||
|
||||
|
||||
def test_cache_first_skips_the_network():
|
||||
class _Cache:
|
||||
def __init__(self): self.stored = {}
|
||||
def get_entity(self, src, kind, aid):
|
||||
return {'data': [{'id': 7, 'track_position': 9}]} if kind == 'album_tracks' else None
|
||||
def store_entity(self, *a, **k): pass
|
||||
sess = _Session({})
|
||||
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'42'}, cache=_Cache(), sleep_s=0)
|
||||
assert pos == {'7': 9} and sess.calls == [] # served from cache, no HTTP
|
||||
|
||||
|
||||
def test_failed_album_is_simply_absent_not_fatal():
|
||||
sess = _Session({'1': [{'id': 5, 'track_position': 3}]}, fail_for={'2'})
|
||||
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'1', '2'}, sleep_s=0)
|
||||
assert pos == {'5': 3} # album 2 failed → just missing
|
||||
|
||||
|
||||
def test_zero_position_is_ignored():
|
||||
# Deezer sometimes returns 0/None for odd entries — don't poison the map with them
|
||||
sess = _Session({'1': [{'id': 5, 'track_position': 0}, {'id': 6}, {'id': 7, 'track_position': 4}]})
|
||||
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'1'}, sleep_s=0)
|
||||
assert pos == {'7': 4}
|
||||
218
tests/test_hifi_preview_guard.py
Normal file
218
tests/test_hifi_preview_guard.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""HiFi sometimes serves a PREVIEW manifest (~30s of segments) for a full-length
|
||||
track, which slipped through the old 100KB-only size floor. The duration guards
|
||||
catch it: a preview manifest is way shorter than the real track length."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.hifi_client import sum_hls_segment_seconds, is_short_audio
|
||||
|
||||
|
||||
_FULL = """#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:10.0,
|
||||
seg0.mp4
|
||||
#EXTINF:10.0,
|
||||
seg1.mp4
|
||||
#EXTINF:9.5,
|
||||
seg2.mp4
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
_PREVIEW = """#EXTM3U
|
||||
#EXTINF:15.0,
|
||||
p0.mp4
|
||||
#EXTINF:15.0,
|
||||
p1.mp4
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
|
||||
def test_sums_extinf_segment_durations():
|
||||
assert sum_hls_segment_seconds(_FULL) == 29.5 # 10 + 10 + 9.5
|
||||
assert sum_hls_segment_seconds(_PREVIEW) == 30.0 # the preview's true length
|
||||
|
||||
|
||||
def test_no_extinf_is_unknown_zero():
|
||||
assert sum_hls_segment_seconds("#EXTM3U\nseg.mp4\n") == 0.0
|
||||
assert sum_hls_segment_seconds("") == 0.0
|
||||
|
||||
|
||||
def test_preview_is_flagged_short_against_full_track():
|
||||
# Save Your Tears ~215s; a 30s preview manifest is obviously short
|
||||
assert is_short_audio(30.0, 215.0) is True
|
||||
|
||||
|
||||
def test_full_length_download_is_not_flagged():
|
||||
assert is_short_audio(213.0, 215.0) is False # ~1% trim → fine
|
||||
assert is_short_audio(215.0, 215.0) is False
|
||||
|
||||
|
||||
def test_unknown_durations_never_reject():
|
||||
assert is_short_audio(0, 215) is False # couldn't probe → don't reject
|
||||
assert is_short_audio(30, 0) is False # expected unknown → don't reject
|
||||
assert is_short_audio(0, 0) is False
|
||||
|
||||
|
||||
def test_legitimately_short_track_is_kept():
|
||||
# a real 40s interlude: actual ≈ expected → not a preview
|
||||
assert is_short_audio(40.0, 41.0) is False
|
||||
|
||||
|
||||
def test_threshold_boundary():
|
||||
assert is_short_audio(79, 100) is True # below 80%
|
||||
assert is_short_audio(85, 100) is False # above 80%
|
||||
|
||||
|
||||
# ── integration: the guards actually wire into _download_sync ────────────────
|
||||
import pytest
|
||||
import core.hifi_client as hc
|
||||
|
||||
|
||||
class _Cfg:
|
||||
"""Stub config so _download_sync just takes its defaults (no DB)."""
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _bare_client(tmp_path):
|
||||
c = object.__new__(hc.HiFiClient) # skip __init__ (DB / network)
|
||||
c.download_path = tmp_path
|
||||
c._engine = None
|
||||
c.shutdown_check = None
|
||||
return c
|
||||
|
||||
|
||||
def test_download_sync_skips_preview_manifests_and_never_downloads(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hc, 'config_manager', _Cfg())
|
||||
c = _bare_client(tmp_path)
|
||||
c.get_track_info = lambda tid: {'duration_s': 215} # real track length
|
||||
tiers = []
|
||||
c._get_hls_manifest = lambda tid, quality='lossless': (
|
||||
tiers.append(quality) or
|
||||
{'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
|
||||
'manifest_duration': 30.0}) # preview at EVERY tier
|
||||
c._download_segment_with_retry = lambda url: pytest.fail("downloaded a preview segment!")
|
||||
|
||||
result = c._download_sync('dl1', 12345, 'The Weeknd - Save Your Tears')
|
||||
assert result is None # → orchestrator falls back
|
||||
# A preview means the SOURCE only has a preview — every lower tier is the same clip,
|
||||
# so it must ABORT on the first preview, not cascade down into a lower-tier preview.
|
||||
assert tiers == ['lossless']
|
||||
|
||||
|
||||
def test_download_sync_proceeds_past_the_gate_for_a_full_manifest(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hc, 'config_manager', _Cfg())
|
||||
c = _bare_client(tmp_path)
|
||||
c.get_track_info = lambda tid: {'duration_s': 215}
|
||||
c._get_hls_manifest = lambda tid, quality='lossless': {
|
||||
'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
|
||||
'manifest_duration': 215.0} # full length → must NOT skip
|
||||
seg_calls = []
|
||||
|
||||
def _seg(url):
|
||||
seg_calls.append(url)
|
||||
raise RuntimeError("stop after the gate")
|
||||
c._download_segment_with_retry = _seg
|
||||
|
||||
c._download_sync('dl1', 12345, 'x')
|
||||
assert seg_calls # it got PAST the preview gate to download
|
||||
|
||||
|
||||
def test_download_sync_aborts_on_a_faked_full_length_file_no_tier_cascade(tmp_path, monkeypatch):
|
||||
# The real #895 case: manifest + container claim FULL length, but the finished file
|
||||
# decodes to 30s. It must abort HiFi (return None) on the first tier — NOT drop to the
|
||||
# lossy 'high' tier (the same 30s preview, which dodges the bitrate check).
|
||||
monkeypatch.setattr(hc, 'config_manager', _Cfg())
|
||||
c = _bare_client(tmp_path)
|
||||
c.get_track_info = lambda tid: {'duration_s': 215}
|
||||
tiers = []
|
||||
c._get_hls_manifest = lambda tid, quality='lossless': (
|
||||
tiers.append(quality) or
|
||||
{'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac', 'manifest_duration': 215.0})
|
||||
c._download_segment_with_retry = lambda url: b'\x00' * 200_000 # > MIN_AUDIO_SIZE
|
||||
c._demux_flac = lambda i, o: o.write_bytes(b'\x00' * 200_000) # produce the 'flac'
|
||||
c._probe_real_seconds = lambda p: 30.0 # decodes to 30s
|
||||
c._probe_audio_seconds = lambda p: 215.0 # faked container claim
|
||||
c._flac_props = lambda p: (44100, 16, 2)
|
||||
|
||||
result = c._download_sync('dl1', 12345, 'The Weeknd - Save Your Tears')
|
||||
assert result is None and tiers == ['lossless'] # aborted, did NOT try 'high'
|
||||
|
||||
|
||||
def test_download_sync_does_not_reject_when_track_length_unknown(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hc, 'config_manager', _Cfg())
|
||||
c = _bare_client(tmp_path)
|
||||
c.get_track_info = lambda tid: {'duration_s': 0} # expected unknown
|
||||
c._get_hls_manifest = lambda tid, quality='lossless': {
|
||||
'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
|
||||
'manifest_duration': 30.0} # short, but expected is unknown
|
||||
seg_calls = []
|
||||
c._download_segment_with_retry = lambda url: (seg_calls.append(url), (_ for _ in ()).throw(RuntimeError("stop")))[0]
|
||||
|
||||
c._download_sync('dl1', 12345, 'x')
|
||||
assert seg_calls # unknown length → no rejection, proceeds
|
||||
|
||||
|
||||
# ── faked-header previews: claim full length everywhere, only ~30s of real audio ──
|
||||
# (real numbers measured from issue #895's files: every "lossless" FLAC was a 30s
|
||||
# preview with STREAMINFO total_samples faked to the full length.)
|
||||
from core.hifi_client import is_fake_lossless_bitrate, is_preview_download, parse_ffmpeg_time
|
||||
|
||||
|
||||
def test_real_issue895_files_are_all_flagged_by_bitrate():
|
||||
# (size_bytes, claimed_seconds) for the actual files — 16-bit/44.1kHz stereo FLAC.
|
||||
samples = [
|
||||
(4_080_000, 216), # Save Your Tears (151 kbps claimed)
|
||||
(6_770_000, 150), # I Ain't Worried (362 kbps — the highest, nearest the line)
|
||||
(2_240_000, 326), # Lose Yourself (55 kbps)
|
||||
(4_190_000, 285), # The Real Slim Shady
|
||||
(4_910_000, 170), # APT
|
||||
]
|
||||
for size, secs in samples:
|
||||
assert is_fake_lossless_bitrate(size, secs, 44100, 16, 2) is True, (size, secs)
|
||||
|
||||
|
||||
def test_real_full_lossless_is_not_flagged():
|
||||
# a genuine 16/44.1 lossless track is ~700-1100 kbps → well above the floor
|
||||
assert is_fake_lossless_bitrate(25_000_000, 216, 44100, 16, 2) is False # ~926 kbps
|
||||
assert is_fake_lossless_bitrate(12_000_000, 216, 44100, 16, 2) is False # ~444 kbps, still real
|
||||
|
||||
|
||||
def test_bitrate_check_is_conservative_on_unknowns():
|
||||
assert is_fake_lossless_bitrate(0, 216, 44100, 16, 2) is False
|
||||
assert is_fake_lossless_bitrate(4_080_000, 0, 44100, 16, 2) is False
|
||||
assert is_fake_lossless_bitrate(4_080_000, 216, 0, 0, 0) is False
|
||||
|
||||
|
||||
def test_is_preview_download_decode_path():
|
||||
# decoded 30s of a claimed 216s → fake, regardless of bitrate
|
||||
fake, why = is_preview_download(30.0, 216.0, is_lossless=False, size_bytes=99_000_000,
|
||||
sample_rate=44100, bits_per_sample=16, channels=2)
|
||||
assert fake and "decoded 30s of 216s" in why
|
||||
|
||||
|
||||
def test_is_preview_download_bitrate_path_when_no_decoder():
|
||||
# real_seconds=0 (no ffmpeg) → fall back to the lossless bitrate check
|
||||
fake, why = is_preview_download(0.0, 216.0, is_lossless=True, size_bytes=4_080_000,
|
||||
sample_rate=44100, bits_per_sample=16, channels=2)
|
||||
assert fake and "kbps lossless" in why
|
||||
|
||||
|
||||
def test_is_preview_download_passes_a_real_file():
|
||||
fake, _ = is_preview_download(214.0, 216.0, is_lossless=True, size_bytes=25_000_000,
|
||||
sample_rate=44100, bits_per_sample=16, channels=2)
|
||||
assert fake is False
|
||||
|
||||
|
||||
def test_is_preview_download_lossy_no_decoder_is_not_flagged():
|
||||
# a lossy tier (mp3/m4a) with no decode info → can't bitrate-check → don't reject
|
||||
fake, _ = is_preview_download(0.0, 216.0, is_lossless=False, size_bytes=2_000_000,
|
||||
sample_rate=44100, bits_per_sample=16, channels=2)
|
||||
assert fake is False
|
||||
|
||||
|
||||
def test_parse_ffmpeg_time_reads_the_last_progress_line():
|
||||
stderr = "frame= ... time=00:00:12.34 bitrate=...\nframe= ... time=00:00:30.05 bitrate=..."
|
||||
assert abs(parse_ffmpeg_time(stderr) - 30.05) < 0.01
|
||||
assert parse_ffmpeg_time("no time here") == 0.0
|
||||
|
|
@ -59,8 +59,12 @@ class _RebuildDB:
|
|||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, root, mode="symlink"):
|
||||
self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode}
|
||||
def __init__(self, root, mode="symlink", item_template=""):
|
||||
self._d = {
|
||||
"playlists.materialize_path": root,
|
||||
"playlists.materialize_mode": mode,
|
||||
"file_organization.templates": {"playlist_item": item_template},
|
||||
}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._d.get(key, default)
|
||||
|
|
@ -266,3 +270,30 @@ def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path):
|
|||
assert name == "Mix" and s.linked == 1 # only A owned; Gone skipped
|
||||
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
|
||||
assert not (tmp_path / "Playlists" / "Off").exists()
|
||||
|
||||
|
||||
def test_playlist_item_template_renames_entries(tmp_path: Path):
|
||||
"""The custom-naming opt-in: a configured playlist_item template renames the
|
||||
files INSIDE the playlist folder (real library file untouched), with $position
|
||||
coming straight from playlist order."""
|
||||
a = (tmp_path / "Music" / "Artist X" / "07 - A.flac")
|
||||
a.parent.mkdir(parents=True, exist_ok=True)
|
||||
a.write_bytes(b"audio")
|
||||
db = _RebuildDB({"A": str(a)})
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy", item_template="$position - $title")
|
||||
results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
|
||||
assert results[0][0] == "Mix"
|
||||
mix = tmp_path / "Playlists" / "Mix"
|
||||
assert sorted(p.name for p in mix.iterdir()) == ["01 - A.flac"] # templated, NOT "07 - A.flac"
|
||||
|
||||
|
||||
def test_empty_playlist_item_template_keeps_library_filename(tmp_path: Path):
|
||||
"""Back-compat: with no template configured, entries keep the library filename."""
|
||||
a = (tmp_path / "Music" / "Artist X" / "07 - A.flac")
|
||||
a.parent.mkdir(parents=True, exist_ok=True)
|
||||
a.write_bytes(b"audio")
|
||||
db = _RebuildDB({"A": str(a)})
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy") # item_template="" (default)
|
||||
rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
|
||||
mix = tmp_path / "Playlists" / "Mix"
|
||||
assert sorted(p.name for p in mix.iterdir()) == ["07 - A.flac"] # unchanged
|
||||
|
|
|
|||
100
tests/test_youtube_cookies.py
Normal file
100
tests/test_youtube_cookies.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Settings → YouTube cookie options: browser store vs a pasted cookies.txt.
|
||||
|
||||
#902: syncing a YouTube *Music* "Liked Music" playlist (list=LM) needs auth, and on
|
||||
a server/Docker box there's no local browser for cookiesfrombrowser to read — so we
|
||||
let users paste a cookies.txt (yt-dlp cookiefile). These pin the precedence (so the
|
||||
two cookie sources can never both be emitted), the paste validation (junk must not be
|
||||
written out and break yt-dlp), and the fail-safe write (a blank save never wipes a
|
||||
saved file).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.youtube_cookies import (
|
||||
PASTE_MODE,
|
||||
build_youtube_cookie_opts,
|
||||
looks_like_cookiefile,
|
||||
write_pasted_cookiefile,
|
||||
)
|
||||
|
||||
NETSCAPE = (
|
||||
"# Netscape HTTP Cookie File\n"
|
||||
".youtube.com\tTRUE\t/\tTRUE\t1999999999\tLOGIN_INFO\tsecretvalue\n"
|
||||
".youtube.com\tTRUE\t/\tTRUE\t1999999999\tSID\tanother\n"
|
||||
)
|
||||
|
||||
|
||||
# ── precedence (pure opts) ──────────────────────────────────────────────────
|
||||
|
||||
def test_empty_mode_is_anonymous():
|
||||
assert build_youtube_cookie_opts("") == {}
|
||||
assert build_youtube_cookie_opts(None) == {}
|
||||
|
||||
|
||||
def test_browser_mode_uses_cookiesfrombrowser():
|
||||
assert build_youtube_cookie_opts("firefox") == {"cookiesfrombrowser": ("firefox",)}
|
||||
|
||||
|
||||
def test_paste_mode_uses_cookiefile_when_present():
|
||||
opts = build_youtube_cookie_opts(PASTE_MODE, "/cfg/youtube_cookies.txt", cookiefile_exists=True)
|
||||
assert opts == {"cookiefile": "/cfg/youtube_cookies.txt"}
|
||||
|
||||
|
||||
def test_paste_mode_without_a_real_file_is_anonymous_not_broken():
|
||||
# stale/missing path must NOT become a cookiefile arg yt-dlp would choke on
|
||||
assert build_youtube_cookie_opts(PASTE_MODE, "/cfg/gone.txt", cookiefile_exists=False) == {}
|
||||
assert build_youtube_cookie_opts(PASTE_MODE, "", cookiefile_exists=True) == {}
|
||||
|
||||
|
||||
def test_sources_are_mutually_exclusive():
|
||||
# a browser name is never PASTE_MODE, so cookiefile + cookiesfrombrowser can't co-occur
|
||||
for mode in ("chrome", "firefox", PASTE_MODE, ""):
|
||||
opts = build_youtube_cookie_opts(mode, "/x.txt", cookiefile_exists=True)
|
||||
assert not ("cookiefile" in opts and "cookiesfrombrowser" in opts)
|
||||
|
||||
|
||||
# ── paste validation ────────────────────────────────────────────────────────
|
||||
|
||||
def test_accepts_netscape_header_and_cookie_rows():
|
||||
assert looks_like_cookiefile(NETSCAPE) is True
|
||||
# no header but a valid tab-separated cookie row still counts
|
||||
assert looks_like_cookiefile(".youtube.com\tTRUE\t/\tTRUE\t123\tSID\tv") is True
|
||||
|
||||
|
||||
def test_rejects_junk_paste():
|
||||
assert looks_like_cookiefile("") is False
|
||||
assert looks_like_cookiefile(" ") is False
|
||||
assert looks_like_cookiefile(None) is False
|
||||
assert looks_like_cookiefile("https://music.youtube.com/playlist?list=LM") is False
|
||||
assert looks_like_cookiefile('{"cookies": []}') is False
|
||||
assert looks_like_cookiefile("# Netscape HTTP Cookie File\n# only comments\n") is False
|
||||
|
||||
|
||||
# ── fail-safe write ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_write_persists_valid_cookiefile(tmp_path):
|
||||
dest = tmp_path / "youtube_cookies.txt"
|
||||
out = write_pasted_cookiefile(NETSCAPE, str(dest))
|
||||
assert out == str(dest)
|
||||
assert dest.read_text().startswith("# Netscape HTTP Cookie File")
|
||||
|
||||
|
||||
def test_write_appends_trailing_newline(tmp_path):
|
||||
dest = tmp_path / "c.txt"
|
||||
write_pasted_cookiefile(NETSCAPE.rstrip("\n"), str(dest))
|
||||
assert dest.read_text().endswith("\n")
|
||||
|
||||
|
||||
def test_write_refuses_junk_and_leaves_no_file(tmp_path):
|
||||
dest = tmp_path / "c.txt"
|
||||
assert write_pasted_cookiefile("not a cookie file", str(dest)) == ""
|
||||
assert not dest.exists()
|
||||
|
||||
|
||||
def test_write_refuses_junk_without_clobbering_existing(tmp_path):
|
||||
# a blank/garbage save must NOT wipe a previously-saved cookie file
|
||||
dest = tmp_path / "c.txt"
|
||||
write_pasted_cookiefile(NETSCAPE, str(dest))
|
||||
before = dest.read_text()
|
||||
assert write_pasted_cookiefile("", str(dest)) == ""
|
||||
assert dest.read_text() == before
|
||||
|
|
@ -146,6 +146,26 @@ def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
|
|||
assert db.is_track_ignored("t1") is False
|
||||
|
||||
|
||||
def test_user_initiated_add_bypasses_and_clears_keeping_source_type(db):
|
||||
# #897 / carlosjfcasero: a user manually adds an album track they had
|
||||
# previously cancelled. It must bypass the gate AND clear the ignore — but
|
||||
# WITHOUT pretending to be source_type='manual' (the album modal sends
|
||||
# source_type='album', which the Albums/Singles categorisation relies on,
|
||||
# and which an automatic path like repair_worker also legitimately uses).
|
||||
track = _track("t7")
|
||||
db.add_to_wishlist_ignore("t7", "Owned Song", "Owned Artist", REASON_CANCELLED)
|
||||
# An automatic 'album' add (e.g. repair_worker) is still correctly blocked.
|
||||
assert db.add_to_wishlist(track, source_type="album") is False
|
||||
assert db.is_track_ignored("t7") is True
|
||||
# The explicit user click (user_initiated) goes through and clears the ignore,
|
||||
# while the stored source_type stays 'album'.
|
||||
assert db.add_to_wishlist(track, source_type="album", user_initiated=True) is True
|
||||
assert db.is_track_ignored("t7") is False
|
||||
# Provenance preserved: the stored row is still source_type='album', NOT 'manual'.
|
||||
row = next(r for r in db.get_wishlist_tracks() if str(r.get("spotify_track_id")) == "t7")
|
||||
assert row.get("source_type") == "album"
|
||||
|
||||
|
||||
def test_gate_failopen_when_ignore_table_errors(db, monkeypatch):
|
||||
# If the ignore check raises, the add must still succeed (never block).
|
||||
monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
|
|
|
|||
209
web_server.py
209
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.4"
|
||||
_SOULSYNC_BASE_VERSION = "2.7.6"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -3126,6 +3126,22 @@ def handle_settings():
|
|||
f"in Manage Profiles first.",
|
||||
"members_without_password": _stranded}), 400
|
||||
|
||||
# YouTube pasted cookies.txt (server/Docker path): pull it out BEFORE the
|
||||
# generic persist so the raw cookie blob never lands in config.json — it's
|
||||
# secret + bulky. We validate up front and store only a file path.
|
||||
_yt_in = new_settings.get('youtube')
|
||||
_yt_paste = _yt_in.pop('cookies_paste', None) if isinstance(_yt_in, dict) else None
|
||||
if _yt_paste is not None and str(_yt_paste).strip():
|
||||
from core.youtube_cookies import looks_like_cookiefile, write_pasted_cookiefile
|
||||
if not looks_like_cookiefile(_yt_paste):
|
||||
return jsonify({"success": False,
|
||||
"error": "That doesn't look like a cookies.txt file. Export it "
|
||||
"with a 'Get cookies.txt LOCALLY' browser extension and "
|
||||
"paste the whole file."}), 400
|
||||
_cookie_path = str(config_manager.config_path.parent / "youtube_cookies.txt")
|
||||
if write_pasted_cookiefile(_yt_paste, _cookie_path):
|
||||
config_manager.set('youtube.cookies_file', _cookie_path)
|
||||
|
||||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
|
|
@ -14980,15 +14996,20 @@ def clean_youtube_artist(artist_string):
|
|||
|
||||
def _youtube_cookie_opts():
|
||||
"""yt-dlp cookie options matching the rest of the app (Settings → YouTube).
|
||||
Per-video extraction needs these to get past YouTube's bot checks."""
|
||||
opts = {}
|
||||
|
||||
Per-video extraction needs these to get past YouTube's bot checks, and private
|
||||
playlists (a user's "Liked Music", list=LM) need them to be visible at all. The
|
||||
dropdown is either a browser name (cookiesfrombrowser, local installs) or the
|
||||
PASTE_MODE sentinel, in which case we point yt-dlp at the pasted cookies.txt that
|
||||
server/Docker users supply. Precedence + emptiness live in core.youtube_cookies."""
|
||||
from core.youtube_cookies import build_youtube_cookie_opts
|
||||
try:
|
||||
cb = config_manager.get('youtube.cookies_browser', '')
|
||||
if cb:
|
||||
opts['cookiesfrombrowser'] = (cb,)
|
||||
mode = config_manager.get('youtube.cookies_browser', '')
|
||||
path = config_manager.get('youtube.cookies_file', '')
|
||||
exists = bool(path) and os.path.exists(path)
|
||||
return build_youtube_cookie_opts(mode, path, cookiefile_exists=exists)
|
||||
except Exception: # noqa: S110 - cookie config is best-effort; resolve still works without it
|
||||
pass
|
||||
return opts
|
||||
return {}
|
||||
|
||||
|
||||
def _fetch_youtube_video_artist(video_id, cookie_opts):
|
||||
|
|
@ -16431,16 +16452,40 @@ def _run_soulsync_deep_scan():
|
|||
|
||||
logger.info(f"[SoulSync Deep Scan] {len(db_paths)} tracks in soulsync DB")
|
||||
|
||||
# Phase 3: Find untracked files (in Transfer but not in DB)
|
||||
untracked = transfer_files - db_paths
|
||||
# Also check with normalized paths (Windows vs Unix separators)
|
||||
if untracked:
|
||||
db_paths_normalized = {p.replace('\\', '/') for p in db_paths}
|
||||
untracked = {f for f in untracked if f.replace('\\', '/') not in db_paths_normalized}
|
||||
# Phase 3: Plan the untracked → Staging move, with the data-loss guard (#904).
|
||||
# A path-only diff treats EVERY file the DB doesn't know about as "a new arrival
|
||||
# to relocate". When the DB is empty/out of sync with disk (volume swap, DB reset,
|
||||
# external tag edits) but Transfer holds the real library, that flags the whole
|
||||
# library as untracked and relocates all of it. The planner refuses the move when
|
||||
# the untracked share is implausibly large (the desync signature) or when the user
|
||||
# marked Transfer as their permanent library — leaving files in place and warning.
|
||||
from core.library.standalone_scan import (
|
||||
plan_standalone_deep_scan, BLOCK_TRANSFER_PERMANENT, BLOCK_DESYNC,
|
||||
)
|
||||
never_move = bool(config_manager.get('import.transfer_is_permanent', False))
|
||||
plan = plan_standalone_deep_scan(transfer_files, db_paths, never_move=never_move)
|
||||
untracked = plan['untracked']
|
||||
move_blocked = plan['move_blocked']
|
||||
block_reason = plan['block_reason']
|
||||
|
||||
# Phase 4: Move untracked files to Staging for auto-import
|
||||
# Phase 4: Move untracked files to Staging for auto-import — unless guarded.
|
||||
moved_count = 0
|
||||
if untracked and os.path.isdir(staging_path):
|
||||
blocked_count = 0
|
||||
if untracked and move_blocked:
|
||||
blocked_count = len(untracked)
|
||||
if block_reason == BLOCK_TRANSFER_PERMANENT:
|
||||
warn = (f"Deep scan: {blocked_count} file(s) in Transfer aren't in the database, "
|
||||
f"but Transfer is marked your permanent library — nothing was moved.")
|
||||
else: # BLOCK_DESYNC
|
||||
pct = round(100 * blocked_count / max(1, len(transfer_files)))
|
||||
warn = (f"Deep scan STOPPED to protect your library: {blocked_count} of "
|
||||
f"{len(transfer_files)} files in Transfer ({pct}%) aren't in the database. "
|
||||
f"That usually means the database is out of sync with disk, not that you "
|
||||
f"have {blocked_count} new files — so NOTHING was moved. Re-sync/import "
|
||||
f"before scanning, or enable 'Transfer is my permanent library'.")
|
||||
logger.warning(f"[SoulSync Deep Scan] {warn}")
|
||||
add_activity_item("", "SoulSync Deep Scan — move blocked", warn, "Now")
|
||||
elif untracked and os.path.isdir(staging_path):
|
||||
_db_update_phase_callback('moving_untracked')
|
||||
for file_path in untracked:
|
||||
try:
|
||||
|
|
@ -16472,6 +16517,23 @@ def _run_soulsync_deep_scan():
|
|||
stale_track_ids.append(db_path)
|
||||
stale_count += 1
|
||||
|
||||
# Guard the deletes the same way as the move (#904): if a desync blocked the
|
||||
# move, the DB<->disk mapping is unreliable, so os.path.exists may be lying for
|
||||
# every file — don't delete rows. Also independently skip when the stale share
|
||||
# is implausibly large (storage unreachable / remount), mirroring the orphan guard.
|
||||
from core.library.stale_guard import is_implausible_stale_removal
|
||||
if move_blocked and block_reason == BLOCK_DESYNC:
|
||||
if stale_track_ids:
|
||||
logger.warning(f"[SoulSync Deep Scan] Skipping removal of {stale_count} 'stale' "
|
||||
f"records — move was blocked for desync, mapping is unreliable.")
|
||||
stale_track_ids = []
|
||||
stale_count = 0
|
||||
elif is_implausible_stale_removal(stale_count, len(db_paths)):
|
||||
logger.warning(f"[SoulSync Deep Scan] Skipping removal of {stale_count}/{len(db_paths)} "
|
||||
f"'stale' records — implausibly large share, storage likely unreachable.")
|
||||
stale_track_ids = []
|
||||
stale_count = 0
|
||||
|
||||
# Remove stale records
|
||||
if stale_track_ids:
|
||||
try:
|
||||
|
|
@ -16504,9 +16566,11 @@ def _run_soulsync_deep_scan():
|
|||
summary = f"Deep scan complete: {len(transfer_files)} files scanned"
|
||||
if moved_count > 0:
|
||||
summary += f", {moved_count} untracked files moved to Staging"
|
||||
if blocked_count > 0:
|
||||
summary += f", {blocked_count} untracked files LEFT IN PLACE (move blocked — see warning)"
|
||||
if stale_count > 0:
|
||||
summary += f", {stale_count} stale records removed"
|
||||
if moved_count == 0 and stale_count == 0:
|
||||
if moved_count == 0 and blocked_count == 0 and stale_count == 0:
|
||||
summary += " — library is clean"
|
||||
|
||||
logger.info(f"[SoulSync Deep Scan] {summary}")
|
||||
|
|
@ -27178,6 +27242,117 @@ def export_watchlist():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ── Playlist export to ListenBrainz / JSPF (#903) ────────────────────────────
|
||||
# Resolve each mirrored-playlist track to a MusicBrainz recording MBID (cache -> DB ->
|
||||
# file tag -> live MB), build a JSPF, and either hand it back as a download or create the
|
||||
# playlist directly on ListenBrainz. Runs in a background thread with live status the
|
||||
# mirrored-playlist card polls. Entirely additive — new routes + an in-memory job registry.
|
||||
_playlist_export_jobs = {}
|
||||
_playlist_export_jobs_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_playlist_export(job_id, playlist_id, title, mode):
|
||||
job = _playlist_export_jobs[job_id]
|
||||
try:
|
||||
from core.exports.export_sources import build_resolve_fn
|
||||
from core.exports.playlist_export import resolve_playlist_tracks
|
||||
from core.exports.jspf_export import build_jspf
|
||||
|
||||
db = get_database()
|
||||
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
|
||||
job['total'] = len(tracks)
|
||||
job['phase'] = 'resolving'
|
||||
|
||||
resolve_fn = build_resolve_fn()
|
||||
|
||||
def on_progress(done, total, stats):
|
||||
job['done'] = done
|
||||
job['stats'] = dict(stats)
|
||||
|
||||
out = resolve_playlist_tracks(tracks, resolve_fn, on_progress=on_progress)
|
||||
jspf, summary = build_jspf(title, out['resolved'], creator='SoulSync')
|
||||
job['summary'] = summary
|
||||
job['jspf'] = jspf
|
||||
job['stats'] = out['stats']
|
||||
|
||||
if mode == 'push':
|
||||
job['phase'] = 'pushing'
|
||||
from core.listenbrainz_client import ListenBrainzClient
|
||||
client = ListenBrainzClient()
|
||||
# Re-export updates the same LB playlist in place instead of duplicating it (#903).
|
||||
existing = db.get_playlist_export_target(int(playlist_id), 'listenbrainz')
|
||||
res = client.create_or_update_playlist(title, jspf['playlist']['track'], existing_mbid=existing)
|
||||
job['push'] = res
|
||||
if res.get('success'):
|
||||
if res.get('playlist_mbid'):
|
||||
db.set_playlist_export_target(int(playlist_id), 'listenbrainz', res['playlist_mbid'])
|
||||
job['phase'] = 'done'
|
||||
else:
|
||||
job['phase'] = 'error'
|
||||
job['error'] = res.get('error') or 'ListenBrainz push failed'
|
||||
else:
|
||||
job['phase'] = 'done'
|
||||
except Exception as e:
|
||||
logger.error(f"[Playlist Export] job {job_id} failed: {e}")
|
||||
job['phase'] = 'error'
|
||||
job['error'] = str(e)
|
||||
|
||||
|
||||
@app.route('/api/playlists/<playlist_id>/export/listenbrainz', methods=['POST'])
|
||||
def start_playlist_export_listenbrainz(playlist_id):
|
||||
"""Start a background export of a mirrored playlist to ListenBrainz/JSPF.
|
||||
|
||||
Body: {"mode": "download"|"push"} (default "download"). Returns {job_id} to poll."""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
mode = 'push' if body.get('mode') == 'push' else 'download'
|
||||
db = get_database()
|
||||
meta = db.get_mirrored_playlist(int(playlist_id)) or {}
|
||||
title = (meta.get('name') or meta.get('title') or 'SoulSync Export').strip() or 'SoulSync Export'
|
||||
|
||||
import uuid
|
||||
job_id = uuid.uuid4().hex
|
||||
with _playlist_export_jobs_lock:
|
||||
_playlist_export_jobs[job_id] = {
|
||||
'job_id': job_id, 'playlist_id': str(playlist_id), 'title': title,
|
||||
'mode': mode, 'phase': 'starting', 'done': 0, 'total': 0,
|
||||
'stats': {}, 'error': None,
|
||||
}
|
||||
t = threading.Thread(target=_run_playlist_export, args=(job_id, playlist_id, title, mode), daemon=True)
|
||||
t.start()
|
||||
return jsonify({"success": True, "job_id": job_id})
|
||||
except Exception as e:
|
||||
logger.error(f"Playlist export start failed: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/playlists/export/status/<job_id>', methods=['GET'])
|
||||
def playlist_export_status(job_id):
|
||||
"""Live status for an export job (polled by the mirrored-playlist card)."""
|
||||
job = _playlist_export_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({"success": False, "error": "unknown job"}), 404
|
||||
# Don't ship the full JSPF on every poll — just status + coverage.
|
||||
out = {k: v for k, v in job.items() if k != 'jspf'}
|
||||
out['has_download'] = bool(job.get('jspf'))
|
||||
return jsonify({"success": True, "job": out})
|
||||
|
||||
|
||||
@app.route('/api/playlists/export/download/<job_id>', methods=['GET'])
|
||||
def playlist_export_download(job_id):
|
||||
"""Download a completed export's JSPF file."""
|
||||
job = _playlist_export_jobs.get(job_id)
|
||||
if not job or not job.get('jspf'):
|
||||
return jsonify({"success": False, "error": "no export available"}), 404
|
||||
import json as _json
|
||||
safe = re.sub(r'[^\w\-]+', '_', (job.get('title') or 'playlist')).strip('_') or 'playlist'
|
||||
return Response(
|
||||
_json.dumps(job['jspf'], indent=2, ensure_ascii=False),
|
||||
mimetype='application/jspf+json',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe}.jspf"'},
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/library/artists/export', methods=['GET'])
|
||||
def export_library_artists():
|
||||
"""Export the WHOLE library artist roster (name + every source id/url we have,
|
||||
|
|
|
|||
|
|
@ -1917,8 +1917,8 @@
|
|||
<div class="import-file-zone-icon">📄</div>
|
||||
<div class="import-file-zone-title">Drop your file here</div>
|
||||
<div class="import-file-zone-subtitle">or click to browse</div>
|
||||
<div class="import-file-zone-formats">Supported: CSV, TSV, TXT</div>
|
||||
<input type="file" id="import-file-input" accept=".csv,.tsv,.txt" style="display:none">
|
||||
<div class="import-file-zone-formats">Supported: CSV, TSV, TXT, M3U, M3U8</div>
|
||||
<input type="file" id="import-file-input" accept=".csv,.tsv,.txt,.m3u,.m3u8" style="display:none">
|
||||
</div>
|
||||
<div class="import-file-format-hints">
|
||||
<div class="import-file-hint">
|
||||
|
|
@ -1929,6 +1929,10 @@
|
|||
<span class="import-file-hint-label">TXT</span>
|
||||
<span class="import-file-hint-text">One track per line (e.g. Artist - Title). Format and separator can be adjusted after upload.</span>
|
||||
</div>
|
||||
<div class="import-file-hint">
|
||||
<span class="import-file-hint-label">M3U / M3U8</span>
|
||||
<span class="import-file-hint-text">Standard playlist files. Artist, title and duration are read automatically from #EXTINF lines (or the file name for simple playlists).</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -4969,10 +4973,24 @@
|
|||
<option value="brave">Brave</option>
|
||||
<option value="opera">Opera</option>
|
||||
<option value="safari">Safari</option>
|
||||
<option value="custom">Paste cookies.txt (server / Docker)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
If YouTube shows "Sign in to confirm you're not a bot", select your browser here.
|
||||
SoulSync will use your browser's YouTube cookies to authenticate.
|
||||
SoulSync will use your browser's YouTube cookies to authenticate. Reading a browser
|
||||
only works when that browser is on the same machine as SoulSync — on a
|
||||
server/Docker box, choose <strong>Paste cookies.txt</strong> instead.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="youtube-cookies-paste-group" style="display:none;">
|
||||
<label>Paste cookies.txt:</label>
|
||||
<textarea id="youtube-cookies-paste" class="form-input" rows="6"
|
||||
placeholder="# Netscape HTTP Cookie File .youtube.com	TRUE	/	TRUE	...	NAME	VALUE"></textarea>
|
||||
<div class="setting-help-text">
|
||||
For server/Docker installs with no local browser. Export your YouTube cookies
|
||||
with a "Get cookies.txt LOCALLY" browser extension and paste the whole file.
|
||||
Required for private playlists like your <strong>Liked Music</strong>.
|
||||
Stored on the server and they expire periodically — re-paste if YouTube stops working.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
@ -5458,6 +5476,13 @@
|
|||
<small class="settings-hint">Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only). Use ${var} to append text</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Playlist File Naming:</label>
|
||||
<input type="text" id="template-playlist-item"
|
||||
placeholder="Leave empty to keep the library filename">
|
||||
<small class="settings-hint">Renames the files inside each "Organize by Playlist" folder (your real library files are never touched). <strong>Filename only — no folders.</strong> Variables: $position (playlist order), $artist, $album, $track, $title. Must include $title and cannot contain "/". Example: <code>$position - $artist - $title</code> → <code>01 - Daft Punk - One More Time.flac</code>. Empty = keep the original library filename.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Music Video Path Template:</label>
|
||||
<input type="text" id="template-video-path"
|
||||
|
|
@ -6292,6 +6317,21 @@
|
|||
that folder's name (the cause of the "soulsync" mass-mislabel). With it off, the
|
||||
metadata-identified artist is always kept.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="import-transfer-permanent">
|
||||
Transfer is my permanent library — never move files out
|
||||
</label>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
Off by default. Turn this <strong>on</strong> if your Transfer folder is your
|
||||
real, permanent library (served by Navidrome/Plex/etc.) rather than a scratch
|
||||
drop area. A Deep Scan then never relocates files it doesn't recognize out to
|
||||
Staging — it leaves them in place and just reports them. Even with this off, a
|
||||
Deep Scan now refuses to move files when the database looks badly out of sync
|
||||
with disk, as a safety guard against wiping a library (issue #904).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end Import body -->
|
||||
|
|
@ -7179,6 +7219,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="wishlist-page-header-right">
|
||||
<button class="btn btn--secondary" onclick="openWishlistIgnoreModal()" title="Tracks you removed or cancelled — auto-skipped until they expire. Un-ignore to allow auto-download again.">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
Ignored
|
||||
</button>
|
||||
<button class="btn btn--secondary" onclick="cleanupWishlistOverview()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/></svg>
|
||||
Cleanup
|
||||
|
|
|
|||
|
|
@ -714,9 +714,10 @@ const DOCS_SECTIONS = [
|
|||
</div>
|
||||
<div class="docs-subsection" id="sync-import-file">
|
||||
<h3 class="docs-subsection-title">Import from File</h3>
|
||||
<p class="docs-text">Import track lists from <strong>CSV, TSV, or plain text files</strong>. Drag and drop a file or click to browse. SoulSync parses the file, lets you preview and map columns, then creates a mirrored playlist for discovery and download.</p>
|
||||
<p class="docs-text">Import track lists from <strong>CSV, TSV, M3U/M3U8, or plain text files</strong>. Drag and drop a file or click to browse. SoulSync parses the file, lets you preview and map columns, then creates a mirrored playlist for discovery and download.</p>
|
||||
<ul class="docs-list">
|
||||
<li><strong>CSV/TSV</strong>: Auto-detects columns; map Artist, Title, and Album from dropdowns</li>
|
||||
<li><strong>M3U/M3U8</strong>: Read automatically — artist, title and duration come from <code>#EXTINF</code> lines (or the file name for simple playlists). Round-trips with SoulSync's own M3U export</li>
|
||||
<li><strong>Text files</strong>: One track per line; choose Artist-Title or Title-Artist order and separator (dash, tab, pipe, etc.)</li>
|
||||
<li>Preview parsed tracks before importing</li>
|
||||
<li>Name your playlist and it becomes a mirrored playlist for sync</li>
|
||||
|
|
@ -1308,7 +1309,7 @@ const DOCS_SECTIONS = [
|
|||
</div>
|
||||
<div class="docs-subsection" id="imp-textfile">
|
||||
<h3 class="docs-subsection-title">Import from Text File</h3>
|
||||
<p class="docs-text">Import track lists from <strong>CSV</strong>, <strong>TSV</strong>, or <strong>TXT</strong> files. Upload a file with columns for artist, album, and track title:</p>
|
||||
<p class="docs-text">Import track lists from <strong>CSV</strong>, <strong>TSV</strong>, <strong>TXT</strong>, or <strong>M3U/M3U8</strong> files. Upload a file with columns for artist, album, and track title (M3U playlists are read automatically):</p>
|
||||
<ol class="docs-steps">
|
||||
<li>Click <strong>Import from File</strong> and select your text file</li>
|
||||
<li>Choose the <strong>separator</strong> (comma, tab, or pipe)</li>
|
||||
|
|
|
|||
|
|
@ -722,8 +722,8 @@ const HELPER_CONTENT = {
|
|||
},
|
||||
'.sync-tab-button[data-tab="import-file"]': {
|
||||
title: 'Import from File',
|
||||
description: 'Import track lists from CSV, TSV, or plain text files. Drag and drop or browse for a file, map columns, then create a playlist for sync.',
|
||||
tips: ['Supports CSV, TSV, and plain text (one track per line)', 'Column mapping for CSV/TSV files', 'Creates a mirrored playlist for persistent state'],
|
||||
description: 'Import track lists from CSV, TSV, M3U/M3U8, or plain text files. Drag and drop or browse for a file, map columns, then create a playlist for sync.',
|
||||
tips: ['Supports CSV, TSV, M3U/M3U8, and plain text (one track per line)', 'M3U/M3U8 is read automatically (artist, title, duration from #EXTINF)', 'Column mapping for CSV/TSV files', 'Creates a mirrored playlist for persistent state'],
|
||||
docsId: 'sync-import-file'
|
||||
},
|
||||
'.sync-tab-button[data-tab="mirrored"]': {
|
||||
|
|
@ -3404,16 +3404,17 @@ 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.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.' },
|
||||
'2.7.6': [
|
||||
{ date: 'June 2026 — 2.7.6 release' },
|
||||
{ title: 'Export playlists to ListenBrainz (#903)', desc: 'soulsync already pulls playlists IN — now it can push one OUT. every mirrored-playlist card gets a 📤 export button: download a standard .jspf file, or sync the playlist straight to your ListenBrainz account. each track is matched to its musicbrainz recording id (cache → your library → file tag → live musicbrainz), with live "matching N/M" status on the card. re-syncing updates the SAME LB playlist in place instead of duplicating it.', page: 'sync' },
|
||||
{ title: 'YouTube Liked Music sync (#902)', desc: 'you can now sync your youtube music "Liked Music" playlist (music.youtube.com/playlist?list=LM). it\'s private, so it needs auth — added a "paste cookies.txt" option in Settings → YouTube so server/docker installs (or browsers yt-dlp can\'t read, like Zen) can supply their login from anywhere.', page: 'settings' },
|
||||
{ title: 'Deep Scan won\'t relocate your library (#904)', desc: 'a standalone Deep Scan moves unrecognized files into Staging. if the DB was empty/out of sync with disk, it treated your ENTIRE library as unrecognized and relocated all of it. now a guard refuses the move when the unrecognized share is implausibly large, leaves files in place, and warns — plus a "Transfer is my permanent library — never move files out" toggle.', page: 'settings' },
|
||||
{ title: 'Dashboard performance', desc: 'a pass at "soulsync works my GPU hard just sitting there". the sidebar sweep animates transform not left, particle glows are cached sprites, blur radii + redundant/invisible card shadows are trimmed, and low-power machines auto-drop to performance mode. the effects all stay — they\'re just cheaper to draw.', page: 'dashboard' },
|
||||
{ title: 'File-import manual matches stick (#901)', desc: 'a manual match on a file-imported playlist track is no longer forgotten on re-sync — the tracks now carry a stable id (existing ones backfilled once), so your pick survives.', page: 'sync' },
|
||||
{ title: 'Manual match heals a stale Plex key', desc: 'a Find & Add match whose stored Plex ratingKey went stale is now re-resolved against a live Plex search instead of silently breaking on the next sync.', page: 'sync' },
|
||||
{ title: 'Multi-disc albums', desc: 'a track now files into the Disc folder that matches its own disc tag (no more disc-2 tracks landing in Disc 1), and a track is never written disc-less.', page: 'downloads' },
|
||||
{ title: 'Auto-download track numbers', desc: 'a track auto-grabbed from the playlist pipeline / wishlist / watchlist now gets its real in-album position instead of being tagged 1/1.', page: 'downloads' },
|
||||
{ title: 'Earlier versions', desc: '2.7.5 was a fix-heavy cycle — matching & artwork accuracy, the HiFi preview mess (#895), M3U import (#893), ignore-list management (#897), per-playlist file naming. 2.7.4 added re-identify; 2.7.3 the Quality Upgrade Finder + ignore-list (#874); 2.7.2 playlist-folder mirroring + M3U export; 2.7.1 download verification + a review queue (#852); 2.7.0 made multi-user real.' },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -3444,38 +3445,54 @@ const WHATS_NEW = {
|
|||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
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.",
|
||||
title: "Export playlists to ListenBrainz (#903)",
|
||||
description: "soulsync already pulls playlists IN from everywhere — now it can push one back OUT.",
|
||||
features: [
|
||||
"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",
|
||||
"📤 export button on every mirrored-playlist card — download a standard .jspf file, or sync the playlist straight onto your ListenBrainz account",
|
||||
"each track is matched to its musicbrainz recording id via a cheapest-first waterfall — cache → your library → the file's own tag → a live musicbrainz lookup — and the result is cached so the same song never costs twice",
|
||||
"live \"matching N/M · X matched\" status on the card, and re-syncing updates the SAME LB playlist in place instead of making duplicates",
|
||||
],
|
||||
usage_note: "Library → an artist → Enhanced view → ⇄ on a track",
|
||||
usage_note: "find it on the 📤 button when you hover a mirrored playlist card. tracks that can't be resolved to a musicbrainz id are skipped (LB requires them) and counted, so you see the coverage.",
|
||||
},
|
||||
{
|
||||
title: "Cleaner libraries & imports",
|
||||
description: "a batch of fixes that keep the library tidy and matchable.",
|
||||
title: "YouTube Liked Music sync (#902)",
|
||||
description: "sync your youtube music \"Liked Music\" playlist (music.youtube.com/playlist?list=LM).",
|
||||
features: [
|
||||
"#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",
|
||||
"it's a private playlist, so it needs auth — the existing browser-cookie option only works when the browser is on the same machine as soulsync",
|
||||
"added a \"paste cookies.txt\" option in Settings → YouTube so server/docker installs (or browsers yt-dlp can't read, like Zen) can supply their login from anywhere",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Quality & sources",
|
||||
description: "more control over downloads, plus a couple of source fixes.",
|
||||
title: "Deep Scan won't relocate your library (#904)",
|
||||
description: "a standalone Deep Scan moves unrecognized files into Staging — which went very wrong when the DB was out of sync with disk.",
|
||||
features: [
|
||||
"#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",
|
||||
"if the DB was empty/desynced, a scan treated your ENTIRE library as unrecognized and relocated all of it (one user lost ~1,500 tracks into Staging)",
|
||||
"now a guard refuses the move when the unrecognized share is implausibly large (the desync signature), leaves everything in place, and warns instead",
|
||||
"new \"Transfer is my permanent library — never move files out\" toggle for people whose Transfer folder IS their live library",
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
title: "Dashboard performance + fixes",
|
||||
description: "lighter dashboard, and a handful of matching/import fixes.",
|
||||
features: [
|
||||
"dashboard — the sidebar sweep animates transform not left, particle glows are cached sprites, blur/shadow excess is trimmed, and low-power machines auto-drop to performance mode (the effects all stay, just cheaper to draw)",
|
||||
"#901 — a manual match on a file-imported playlist track now survives re-sync (stable ids, existing ones backfilled)",
|
||||
"a Find & Add match whose stored Plex key went stale is re-resolved against a live Plex search instead of breaking",
|
||||
"multi-disc albums file each track in the Disc folder matching its tag (no disc-less / wrong-disc tracks); auto-grabbed tracks get their real in-album number instead of 1/1",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Earlier in 2.7.5",
|
||||
description: "a fix-heavy cycle — matching & artwork accuracy plus a few quality-of-life features.",
|
||||
features: [
|
||||
"special-edition cover art (a \"Gustave Edition\" uses its OWN cover), deezer track numbers, and the \"The\" duplicate fix",
|
||||
"HiFi 30-second previews disguised as full songs are caught and rejected (#895)",
|
||||
"import M3U / M3U8 playlists (#893), name files inside playlist folders, find & add remembered, ignore-list management (#897), Unraid template fixes (#899)",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Earlier in 2.7.4 / 2.7.3 / 2.7.2 / 2.7.1 / 2.7.0",
|
||||
description: "2.7.4 added re-identify (re-file an imported track under the right release without re-downloading) plus library/import cleanups (#889/#890/#891). 2.7.3 added the Quality Upgrade Finder and the wishlist ignore-list (#874); 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: [],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -213,6 +213,47 @@ function applyReduceEffects(enabled) {
|
|||
|
||||
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
|
||||
(function () {
|
||||
// Auto performance mode on likely-weak hardware. Only acts when this device has
|
||||
// NO stored preference yet (null) — so it runs at most once and never overrides
|
||||
// a choice the user (or a prior auto-run) made. Device-scoped via localStorage on
|
||||
// purpose: a weak laptop shouldn't flip the server setting for the user's other
|
||||
// machines. Mobile already disables these effects elsewhere, so skip it here.
|
||||
if (localStorage.getItem('soulsync-reduce-effects') === null) {
|
||||
const ua = navigator.userAgent || '';
|
||||
const isMobile = window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(ua);
|
||||
const cores = navigator.hardwareConcurrency || 0; // widely supported
|
||||
const mem = navigator.deviceMemory || 0; // Chromium only; 0 elsewhere
|
||||
// Conservative — avoid flagging capable machines: <=2 cores, or <=2GB, or a
|
||||
// low-mid box that's low on BOTH (<=4 cores AND <=4GB). A 4-core/8GB laptop
|
||||
// (mem>4) is NOT flagged; Firefox/Safari (mem unknown) only trip on <=2 cores.
|
||||
const weak = !isMobile && (
|
||||
(cores > 0 && cores <= 2) ||
|
||||
(mem > 0 && mem <= 2) ||
|
||||
(cores > 0 && cores <= 4 && mem > 0 && mem <= 4)
|
||||
);
|
||||
if (weak) {
|
||||
localStorage.setItem('soulsync-reduce-effects', '1');
|
||||
window._autoPerfModeApplied = true; // show the explainer toast once the UI is up
|
||||
}
|
||||
}
|
||||
|
||||
if (window._autoPerfModeApplied) {
|
||||
// Toast lives in downloads.js (loaded separately) — retry until it's defined.
|
||||
const fireToast = (tries) => {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Performance mode is on — this looks like a lower-power device. ' +
|
||||
'Turn effects back on in Settings → Appearance.', 'info');
|
||||
} else if (tries < 40) {
|
||||
setTimeout(() => fireToast(tries + 1), 250);
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => fireToast(0));
|
||||
} else {
|
||||
fireToast(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
|
||||
document.body.classList.add('reduce-effects');
|
||||
window._reduceEffectsActive = true;
|
||||
|
|
|
|||
|
|
@ -101,6 +101,27 @@
|
|||
// Each preset: { count, init(p, i), update(p, time, i), draw(p, ctx, accent, time),
|
||||
// optional: initExtras(), drawGlobal(ctx, particles, accent, time, extras) }
|
||||
|
||||
// Pre-rendered glow sprite. A dashboard particle's glow is a radial gradient
|
||||
// (its accent colour -> transparent) of a SIZE that's fixed for the particle's
|
||||
// life — the only thing changing each frame is the pulse ALPHA. Since canvas
|
||||
// multiplies fillStyle/image alpha by ctx.globalAlpha, we can bake the gradient
|
||||
// ONCE into an offscreen canvas at full alpha and drawImage it each frame with
|
||||
// globalAlpha = pulse, instead of building a fresh createRadialGradient + arc-fill
|
||||
// for all 50 particles every frame. Output is pixel-identical; just far cheaper.
|
||||
function buildGlowSprite(col, glowSize) {
|
||||
const s = Math.max(1, Math.ceil(glowSize));
|
||||
const c = document.createElement('canvas');
|
||||
c.width = s * 2;
|
||||
c.height = s * 2;
|
||||
const g = c.getContext('2d');
|
||||
const grad = g.createRadialGradient(s, s, 0, s, s, s);
|
||||
grad.addColorStop(0, `rgba(${col}, 1)`);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, s * 2, s * 2);
|
||||
return c;
|
||||
}
|
||||
|
||||
const PRESETS = {
|
||||
|
||||
// ── DASHBOARD — network nodes, connections, data packets, shooting stars ──
|
||||
|
|
@ -130,16 +151,20 @@
|
|||
const pulse = 0.5 + 0.5 * Math.sin(time * 1.5 + p.phase);
|
||||
const col = shiftAccent(accent, p.hueShift);
|
||||
|
||||
// Glow — hubs get bigger, brighter glow
|
||||
// Glow — hubs get bigger, brighter glow. Cached sprite blit (alpha at
|
||||
// blit time); pixel-identical to the old per-frame radial gradient.
|
||||
const glowSize = p.isHub ? p.radius * 6 : p.radius * 4;
|
||||
const glowAlpha = p.isHub ? (0.18 + pulse * 0.12) : (0.10 + pulse * 0.07);
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||
glow.addColorStop(0, `rgba(${col}, ${glowAlpha})`);
|
||||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fill();
|
||||
if (!p._glowCanvas || p._glowCol !== col || p._glowR !== glowSize) {
|
||||
p._glowCanvas = buildGlowSprite(col, glowSize); // rebuilt only on accent change
|
||||
p._glowCol = col;
|
||||
p._glowR = glowSize;
|
||||
}
|
||||
// Multiply by the incoming globalAlpha so transition fades match exactly.
|
||||
const _glowPrevAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = _glowPrevAlpha * glowAlpha;
|
||||
ctx.drawImage(p._glowCanvas, p.x - glowSize, p.y - glowSize, glowSize * 2, glowSize * 2);
|
||||
ctx.globalAlpha = _glowPrevAlpha;
|
||||
|
||||
// Core
|
||||
const coreAlpha = p.isHub ? (0.5 + pulse * 0.3) : (0.3 + pulse * 0.2);
|
||||
|
|
|
|||
|
|
@ -1215,6 +1215,25 @@ async function loadSettingsData() {
|
|||
// Populate YouTube settings
|
||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||
document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3;
|
||||
// Show the cookies.txt paste box only in "custom" mode. We never echo the
|
||||
// stored cookie back to the UI (it's secret + lives in a file, not config);
|
||||
// if one is already saved, say so via placeholder so a blank save won't wipe it.
|
||||
const _ytCookieSel = document.getElementById('youtube-cookies-browser');
|
||||
const _ytPasteBox = document.getElementById('youtube-cookies-paste');
|
||||
const _ytPasteGroup = document.getElementById('youtube-cookies-paste-group');
|
||||
if (_ytCookieSel && _ytPasteGroup) {
|
||||
const _toggleYtPaste = () => {
|
||||
_ytPasteGroup.style.display = _ytCookieSel.value === 'custom' ? '' : 'none';
|
||||
};
|
||||
if (_ytPasteBox && settings.youtube?.cookies_file) {
|
||||
_ytPasteBox.placeholder = 'A cookies.txt is saved. Paste again to replace it, or leave blank to keep it.';
|
||||
}
|
||||
_toggleYtPaste();
|
||||
if (!_ytCookieSel.dataset.pasteToggleBound) {
|
||||
_ytCookieSel.addEventListener('change', _toggleYtPaste);
|
||||
_ytCookieSel.dataset.pasteToggleBound = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on download source mode
|
||||
updateDownloadSourceUI();
|
||||
|
|
@ -1266,6 +1285,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title';
|
||||
document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title';
|
||||
document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title';
|
||||
document.getElementById('template-playlist-item').value = settings.file_organization?.templates?.playlist_item || '';
|
||||
document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video';
|
||||
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
|
||||
document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first';
|
||||
|
|
@ -1315,6 +1335,8 @@ async function loadSettingsData() {
|
|||
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
|
||||
const _folderArtistEl = document.getElementById('import-folder-artist-override');
|
||||
if (_folderArtistEl) _folderArtistEl.checked = settings.import?.folder_artist_override === true;
|
||||
const _transferPermEl = document.getElementById('import-transfer-permanent');
|
||||
if (_transferPermEl) _transferPermEl.checked = settings.import?.transfer_is_permanent === true;
|
||||
|
||||
// Populate M3U Export settings
|
||||
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
|
||||
|
|
@ -1362,8 +1384,14 @@ async function loadSettingsData() {
|
|||
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
|
||||
applyWorkerOrbsSetting(workerOrbsEnabled);
|
||||
|
||||
// Reduce effects toggle
|
||||
const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false
|
||||
// Reduce effects toggle. This flag is device-scoped: localStorage (set by the
|
||||
// live toggle and by weak-hardware auto-detect) is the source of truth for THIS
|
||||
// machine; the server value is only the cross-device default used when this
|
||||
// device has never chosen. Prefer localStorage when present so opening Settings
|
||||
// doesn't clobber an auto-enabled (or manually-set) per-device choice.
|
||||
const serverReduce = settings.ui_appearance?.reduce_effects === true; // default false
|
||||
const localReduce = localStorage.getItem('soulsync-reduce-effects'); // '1' | '0' | null
|
||||
const reduceEffects = localReduce !== null ? (localReduce === '1') : serverReduce;
|
||||
const reduceCheckbox = document.getElementById('reduce-effects-enabled');
|
||||
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
|
||||
applyReduceEffects(reduceEffects);
|
||||
|
|
@ -2907,6 +2935,21 @@ async function saveSettings(quiet = false) {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate the optional "Playlist File Naming" template before saving: it's a
|
||||
// filename (no path separator) and must include $title — mirrors the server-side
|
||||
// rule so a broken value can't be stored. Empty = feature off (allowed).
|
||||
const _plItemTpl = (document.getElementById('template-playlist-item')?.value || '').trim();
|
||||
if (_plItemTpl) {
|
||||
if (_plItemTpl.includes('/') || _plItemTpl.includes('\\')) {
|
||||
showToast('Playlist File Naming can\'t contain a folder separator ( / or \\ ) — it names the file, not a path.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!_plItemTpl.includes('$title')) {
|
||||
showToast('Playlist File Naming must include $title so every file has a name.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
active_media_server: activeServer,
|
||||
spotify: {
|
||||
|
|
@ -3105,6 +3148,7 @@ async function saveSettings(quiet = false) {
|
|||
album_path: document.getElementById('template-album-path').value,
|
||||
single_path: document.getElementById('template-single-path').value,
|
||||
playlist_path: document.getElementById('template-playlist-path').value,
|
||||
playlist_item: document.getElementById('template-playlist-item').value,
|
||||
video_path: document.getElementById('template-video-path').value
|
||||
}
|
||||
},
|
||||
|
|
@ -3139,6 +3183,7 @@ async function saveSettings(quiet = false) {
|
|||
quality_filter_enabled: document.getElementById('import-quality-filter-enabled')?.checked !== false,
|
||||
replace_lower_quality: document.getElementById('import-replace-lower-quality').checked,
|
||||
folder_artist_override: document.getElementById('import-folder-artist-override')?.checked === true,
|
||||
transfer_is_permanent: document.getElementById('import-transfer-permanent')?.checked === true,
|
||||
staging_path: document.getElementById('staging-path').value || './Staging'
|
||||
},
|
||||
playlists: {
|
||||
|
|
@ -3171,6 +3216,9 @@ async function saveSettings(quiet = false) {
|
|||
youtube: {
|
||||
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
||||
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
|
||||
// Raw cookies.txt blob — backend validates, writes it to a file, and stores
|
||||
// only the path (never echoed back). Blank = keep any already-saved file.
|
||||
cookies_paste: document.getElementById('youtube-cookies-paste')?.value || '',
|
||||
},
|
||||
security: {
|
||||
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ function _initImportFileTab() {
|
|||
|
||||
function _importFileRead(file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (!['csv', 'tsv', 'txt'].includes(ext)) {
|
||||
showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error');
|
||||
if (!['csv', 'tsv', 'txt', 'm3u', 'm3u8'].includes(ext)) {
|
||||
showToast('Unsupported file type. Use CSV, TSV, TXT, M3U, or M3U8.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,8 @@ function _importFileRead(file) {
|
|||
reader.onload = (e) => {
|
||||
_importFileState.rawText = e.target.result;
|
||||
_importFileState.fileName = file.name;
|
||||
_importFileState.fileType = (ext === 'txt') ? 'text' : 'csv';
|
||||
_importFileState.fileType = (ext === 'm3u' || ext === 'm3u8') ? 'm3u'
|
||||
: (ext === 'txt') ? 'text' : 'csv';
|
||||
_importFileParseAndPreview();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
|
@ -103,6 +104,66 @@ function _importFileParseCsv(text, delimiter) {
|
|||
return { headers, rows };
|
||||
}
|
||||
|
||||
// Parse an M3U / M3U8 playlist into track objects. Handles both the simple form
|
||||
// (one media path per line) and the extended form (#EXTINF:<secs>,<artist> - <title>
|
||||
// followed by the path). SoulSync's own export is extended M3U and replaces the path
|
||||
// of an un-located track with a "# MISSING: ..." comment — those are exactly the
|
||||
// tracks a user imports to go match/download, so a pending #EXTINF is flushed even
|
||||
// when no path line follows it. Returns { tracks, playlistName }.
|
||||
function _importFileParseM3u(text) {
|
||||
const lines = (text || '').split(/\r?\n/);
|
||||
const tracks = [];
|
||||
let playlistName = '';
|
||||
let pending = null; // { duration_ms, artist, title } from the last #EXTINF
|
||||
|
||||
function splitArtistTitle(s) {
|
||||
const i = s.indexOf(' - ');
|
||||
return i !== -1
|
||||
? { artist: s.slice(0, i).trim(), title: s.slice(i + 3).trim() }
|
||||
: { artist: '', title: s.trim() };
|
||||
}
|
||||
function pushTrack(artist, title, duration_ms) {
|
||||
if (!title && !artist) return;
|
||||
tracks.push({ track_name: title || '', artist_name: artist || '', album_name: '', duration_ms: duration_ms || 0 });
|
||||
}
|
||||
function flushPending() {
|
||||
if (pending) { pushTrack(pending.artist, pending.title, pending.duration_ms); pending = null; }
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (line.charAt(0) === '#') {
|
||||
if (/^#EXTINF:/i.test(line)) {
|
||||
flushPending(); // a prior #EXTINF whose path was missing still counts
|
||||
const rest = line.slice(8); // after "#EXTINF:"
|
||||
const comma = rest.indexOf(',');
|
||||
const secs = parseFloat(comma === -1 ? rest : rest.slice(0, comma));
|
||||
const meta = comma === -1 ? '' : rest.slice(comma + 1).trim();
|
||||
const { artist, title } = splitArtistTitle(meta);
|
||||
pending = { duration_ms: (!isNaN(secs) && secs > 0) ? Math.round(secs * 1000) : 0, artist, title };
|
||||
} else if (/^#PLAYLIST:/i.test(line)) {
|
||||
playlistName = line.slice(line.indexOf(':') + 1).trim();
|
||||
}
|
||||
// ignore #EXTM3U, #GENERATED, "# MISSING:", #EXTALB, and other directives
|
||||
continue;
|
||||
}
|
||||
// A non-# line is a media path/URL — the entry for the pending #EXTINF, if any.
|
||||
if (pending && (pending.title || pending.artist)) {
|
||||
pushTrack(pending.artist, pending.title, pending.duration_ms);
|
||||
pending = null;
|
||||
} else {
|
||||
// Simple M3U with no #EXTINF: derive artist/title from the file name.
|
||||
pending = null;
|
||||
const base = (line.split(/[\\/]/).pop() || line).replace(/\.[^.]+$/, '');
|
||||
const { artist, title } = splitArtistTitle(base);
|
||||
pushTrack(artist, title, 0);
|
||||
}
|
||||
}
|
||||
flushPending(); // trailing #EXTINF with no following path
|
||||
return { tracks, playlistName };
|
||||
}
|
||||
|
||||
function _importFileAutoMapColumns(headers) {
|
||||
const map = {};
|
||||
const lowerHeaders = headers.map(h => h.toLowerCase().trim());
|
||||
|
|
@ -139,7 +200,14 @@ function _importFileParseAndPreview() {
|
|||
const state = _importFileState;
|
||||
const text = state.rawText;
|
||||
|
||||
if (state.fileType === 'text') {
|
||||
if (state.fileType === 'm3u') {
|
||||
// M3U/M3U8 is self-describing — no column mapping or order/separator needed.
|
||||
const { tracks, playlistName } = _importFileParseM3u(text);
|
||||
state.rows = tracks; // already track objects
|
||||
state.headers = [];
|
||||
state.columnMap = {};
|
||||
state.m3uPlaylistName = playlistName || '';
|
||||
} else if (state.fileType === 'text') {
|
||||
// Plain text: one track per line
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
||||
state.rows = lines;
|
||||
|
|
@ -163,7 +231,15 @@ function _importFileBuildTracks() {
|
|||
const state = _importFileState;
|
||||
state.parsedTracks = [];
|
||||
|
||||
if (state.fileType === 'text') {
|
||||
if (state.fileType === 'm3u') {
|
||||
// Tracks were already parsed by _importFileParseM3u; just copy them through.
|
||||
state.parsedTracks = state.rows.map(t => ({
|
||||
track_name: t.track_name || '',
|
||||
artist_name: t.artist_name || '',
|
||||
album_name: t.album_name || '',
|
||||
duration_ms: t.duration_ms || 0
|
||||
}));
|
||||
} else if (state.fileType === 'text') {
|
||||
const orderEl = document.getElementById('import-file-text-order');
|
||||
const sepEl = document.getElementById('import-file-text-separator');
|
||||
const order = orderEl ? orderEl.value : 'artist-title';
|
||||
|
|
@ -249,10 +325,12 @@ function _importFileRenderPreview() {
|
|||
_importFileRenderColumnMapping();
|
||||
}
|
||||
|
||||
// Pre-fill playlist name from filename (strip extension)
|
||||
// Pre-fill playlist name: prefer the M3U's own #PLAYLIST: directive, else the filename.
|
||||
const nameInput = document.getElementById('import-file-playlist-name');
|
||||
if (nameInput && !nameInput.value) {
|
||||
nameInput.value = state.fileName.replace(/\.[^.]+$/, '');
|
||||
nameInput.value = (state.fileType === 'm3u' && state.m3uPlaylistName)
|
||||
? state.m3uPlaylistName
|
||||
: state.fileName.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
// Update button state
|
||||
const btn = document.getElementById('import-file-import-btn');
|
||||
|
|
@ -523,6 +601,7 @@ function renderMirroredCard(p, container) {
|
|||
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
|
||||
<button class="mirrored-card-rename" onclick="event.stopPropagation(); editMirroredCustomName(${p.id}, '${_escJs(p.name)}', '${_escJs(p.custom_name || '')}')" title="Rename (changes the name shown here and used when syncing)">✏️</button>
|
||||
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button>
|
||||
<button class="mirrored-card-export" onclick="event.stopPropagation(); exportMirroredPlaylist(${p.id}, '${_escJs(p.display_name || p.name)}')" title="Export to ListenBrainz / JSPF">📤</button>
|
||||
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror">✕</button>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
|
|
@ -566,6 +645,110 @@ function renderMirroredCard(p, container) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Playlist export to ListenBrainz / JSPF (#903) ───────────────────────────
|
||||
// Export button on the mirrored-playlist card -> pick a destination -> background job
|
||||
// resolves each track to a MusicBrainz recording MBID (cache/DB/file/MusicBrainz) and either
|
||||
// downloads the .jspf or pushes the playlist straight to ListenBrainz, with live status on the card.
|
||||
function exportMirroredPlaylist(playlistId, name) {
|
||||
const existing = document.getElementById('pl-export-modal');
|
||||
if (existing) existing.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pl-export-modal';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);';
|
||||
overlay.innerHTML = `
|
||||
<div style="width:420px;max-width:92vw;background:linear-gradient(135deg,rgba(26,26,30,0.98),rgba(18,18,22,0.99));border:1px solid rgba(var(--accent-rgb),0.25);border-radius:18px;padding:22px;box-shadow:0 18px 50px rgba(0,0,0,0.55);">
|
||||
<div style="font-size:16px;font-weight:700;color:#fff;margin-bottom:4px;">Export playlist</div>
|
||||
<div style="font-size:13px;color:rgba(255,255,255,0.55);margin-bottom:18px;">${_esc(name)} → ListenBrainz</div>
|
||||
<button class="pl-export-choice" data-mode="push" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(var(--accent-rgb),0.35);background:rgba(var(--accent-rgb),0.12);color:#fff;cursor:pointer;">
|
||||
<div style="font-weight:600;">Sync to ListenBrainz</div>
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create the playlist directly on your ListenBrainz account (needs your LB token).</div>
|
||||
</button>
|
||||
<button class="pl-export-choice" data-mode="download" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
|
||||
<div style="font-weight:600;">Download .jspf file</div>
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Save a JSPF playlist you can upload to ListenBrainz manually.</div>
|
||||
</button>
|
||||
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Each track is matched to its MusicBrainz recording ID (cached → library → file tag → MusicBrainz). Tracks without an ID can't be included — you'll see how many matched.</div>
|
||||
<div style="text-align:right;margin-top:14px;"><button onclick="document.getElementById('pl-export-modal').remove()" style="background:none;border:none;color:rgba(255,255,255,0.5);cursor:pointer;font-size:13px;">Cancel</button></div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
overlay.querySelectorAll('.pl-export-choice').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.mode;
|
||||
overlay.remove();
|
||||
_startPlaylistExport(playlistId, mode, name);
|
||||
});
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function _startPlaylistExport(playlistId, mode, name) {
|
||||
_setExportStatus(playlistId, `<span style="color:#a78bfa;">Starting export…</span>`);
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${playlistId}/export/listenbrainz`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.success || !data.job_id) {
|
||||
_setExportStatus(playlistId, `<span style="color:#ef4444;">Export failed to start</span>`);
|
||||
return;
|
||||
}
|
||||
_pollPlaylistExport(data.job_id, playlistId, mode, name);
|
||||
} catch (e) {
|
||||
_setExportStatus(playlistId, `<span style="color:#ef4444;">Export error</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
async function _pollPlaylistExport(jobId, playlistId, mode, name) {
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/export/status/${jobId}`);
|
||||
const data = await resp.json();
|
||||
const job = data.job || {};
|
||||
const st = job.stats || {};
|
||||
if (job.phase === 'resolving') {
|
||||
const pct = job.total ? Math.round(100 * (job.done || 0) / job.total) : 0;
|
||||
_setExportStatus(playlistId, `<span style="color:#38bdf8;">Matching ${job.done || 0}/${job.total || 0} (${pct}%)${st.resolved != null ? ` · ${st.resolved} matched` : ''}</span>`);
|
||||
} else if (job.phase === 'pushing') {
|
||||
_setExportStatus(playlistId, `<span style="color:#a78bfa;">Pushing to ListenBrainz…</span>`);
|
||||
} else if (job.phase === 'done') {
|
||||
const sum = job.summary || {};
|
||||
const cov = `${sum.included || 0}/${sum.total || 0} matched${sum.skipped ? ` · ${sum.skipped} unmatched` : ''}`;
|
||||
if (mode === 'download') {
|
||||
window.location = `/api/playlists/export/download/${jobId}`;
|
||||
_setExportStatus(playlistId, `<span style="color:#22c55e;">Downloaded · ${cov}</span>`, 8000);
|
||||
} else {
|
||||
const url = (job.push && job.push.playlist_url) || '';
|
||||
_setExportStatus(playlistId, `<span style="color:#22c55e;">Synced to ListenBrainz · ${cov}</span>` + (url ? ` <a href="${url}" target="_blank" style="color:#38bdf8;">view</a>` : ''), 12000);
|
||||
if (typeof showToast === 'function') showToast(`Playlist synced to ListenBrainz (${cov})`, 'success');
|
||||
}
|
||||
return;
|
||||
} else if (job.phase === 'error') {
|
||||
_setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(job.error || 'Export failed')}</span>`, 10000);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 1000);
|
||||
} catch (e) {
|
||||
setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function _setExportStatus(playlistId, html, autoHideMs) {
|
||||
// Inject into the card's existing .card-meta line (same approach as the pipeline phase
|
||||
// indicator) so the status sits inline and never disrupts the card's flex row.
|
||||
const card = document.getElementById(`mirrored-card-${playlistId}`);
|
||||
if (!card) return;
|
||||
const meta = card.querySelector('.card-meta');
|
||||
if (!meta) return;
|
||||
let span = meta.querySelector('.export-status-span');
|
||||
if (!span) {
|
||||
span = document.createElement('span');
|
||||
span.className = 'export-status-span';
|
||||
meta.appendChild(span);
|
||||
}
|
||||
span.innerHTML = html;
|
||||
if (autoHideMs) setTimeout(() => { if (span && span.parentNode) span.remove(); }, autoHideMs);
|
||||
}
|
||||
|
||||
function updateMirroredCardPhase(urlHash, phase) {
|
||||
// Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement)
|
||||
const state = youtubePlaylistStates[urlHash];
|
||||
|
|
|
|||
|
|
@ -82,11 +82,11 @@ body {
|
|||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-bottom-right-radius: 24px;
|
||||
|
||||
/* Soft floating shadow with inner glow */
|
||||
/* Soft floating shadow with inner glow — dropped the 0 4px 16px duplicate and the
|
||||
0 0 60px accent glow (60px blur at 6% opacity: nearly invisible but a costly
|
||||
shadow pass). One outer + the two insets read the same. */
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2),
|
||||
0 0 60px rgba(var(--accent-rgb), 0.06),
|
||||
0 8px 30px rgba(0, 0, 0, 0.34),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
|
|
@ -108,7 +108,10 @@ body {
|
|||
position: fixed;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
/* The radial-gradient already fades to transparent at 70%, so a smaller blur
|
||||
still reads as a soft aura — but shrinks the composited (orb + feather)
|
||||
bounding box and the blur work. 40px -> 28px: ~imperceptible, cheaper fill. */
|
||||
filter: blur(28px);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
|
|
@ -307,11 +310,9 @@ body.reduce-effects .sidebar::after {
|
|||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Above the nav (the sidebar gives its children z-index:1) so nav items
|
||||
scrolling up sit BEHIND the header — i.e. in its backdrop, where the
|
||||
blur below can actually act on them. Without this they paint in front
|
||||
and backdrop-filter has nothing to blur. */
|
||||
z-index: 2;
|
||||
/* Same stacking level as the nav (z-index:1) — sits in the normal sidebar
|
||||
flow rather than forced above everything. */
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
|
@ -319,8 +320,11 @@ body.reduce-effects .sidebar::after {
|
|||
accent-gradient top now read as a soft blur instead of bleeding through
|
||||
sharply. (Only visible where the background is translucent — the opaque
|
||||
base toward the bottom still stops any bleed.) */
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.3);
|
||||
/* Backdrop blur is re-evaluated whenever content drifts behind it (the
|
||||
ambient orbs do). Cost scales with radius — 28px -> 18px keeps the frosted
|
||||
read while cutting the per-frame blur work by ~a third. */
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
|
||||
/* Subtle inner glow */
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
|
|
@ -342,14 +346,20 @@ body.reduce-effects .sidebar::after {
|
|||
rgba(var(--accent-rgb), 0.06) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
/* Animate transform (compositor-only) instead of `left` (which forced a
|
||||
layout recalc every frame). The element width is 60% of the header, so
|
||||
translateX(400%) = 240% of header width — the same travel `left:-100%`
|
||||
→ `left:140%` produced. Pixel-identical path, no per-frame layout. */
|
||||
transform: translateX(0);
|
||||
will-change: transform, opacity;
|
||||
animation: sidebar-header-sweep 8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes sidebar-header-sweep {
|
||||
0%, 100% { left: -100%; opacity: 0; }
|
||||
0%, 100% { transform: translateX(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
50% { left: 140%; opacity: 1; }
|
||||
50% { transform: translateX(400%); opacity: 1; }
|
||||
60% { opacity: 0; }
|
||||
}
|
||||
|
||||
|
|
@ -8390,10 +8400,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin: 20px;
|
||||
/* Soft floating shadow */
|
||||
/* Soft floating shadow — one outer layer instead of two stacked (this is a
|
||||
near-fullscreen element, so each shadow pass is expensive); the single
|
||||
slightly-stronger blur reads the same. */
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2),
|
||||
0 8px 28px rgba(0, 0, 0, 0.34),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
|
|
@ -9049,8 +9060,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
.service-card {
|
||||
background: rgba(18, 18, 22, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
/* backdrop-filter dropped: the 90% opaque bg made blur(12px) invisible (x3 cards,
|
||||
pure GPU waste). Bumped to 0.97 so the now-unblurred sliver is imperceptible. */
|
||||
background: rgba(18, 18, 22, 0.97);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 20px 22px;
|
||||
|
|
@ -9821,8 +9833,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
.stat-card-dashboard {
|
||||
background: rgba(18, 18, 22, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
/* backdrop-filter dropped: 90% opaque bg made blur(12px) invisible (x3, GPU waste);
|
||||
0.97 keeps it looking solid. */
|
||||
background: rgba(18, 18, 22, 0.97);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 20px;
|
||||
|
|
@ -10228,9 +10241,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.activity-feed-container {
|
||||
/* Premium glassmorphic foundation */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(26, 26, 26, 0.95) 0%,
|
||||
rgba(18, 18, 18, 0.98) 100%);
|
||||
backdrop-filter: blur(12px) saturate(1.1);
|
||||
rgba(26, 26, 26, 0.97) 0%,
|
||||
rgba(18, 18, 18, 0.99) 100%);
|
||||
/* backdrop-filter dropped: 95-99% opaque bg already hid the blur (GPU waste). */
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
|
|
@ -14672,6 +14685,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.mirrored-card-delete,
|
||||
.mirrored-card-clear,
|
||||
.mirrored-card-link,
|
||||
.mirrored-card-export,
|
||||
.mirrored-card-rename {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
|
@ -14693,11 +14707,25 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.mirrored-playlist-card:hover .mirrored-card-delete,
|
||||
.mirrored-playlist-card:hover .mirrored-card-clear,
|
||||
.mirrored-playlist-card:hover .mirrored-card-link,
|
||||
.mirrored-playlist-card:hover .mirrored-card-export,
|
||||
.mirrored-playlist-card:hover .mirrored-card-rename,
|
||||
.mirrored-playlist-card:hover .mirrored-card-pipeline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mirrored-card-export:hover {
|
||||
color: rgba(var(--accent-rgb), 1);
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Live export status, injected inline into the card's .card-meta line. */
|
||||
.export-status-span {
|
||||
font-size: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mirrored-card-delete:hover {
|
||||
color: #ff4444;
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
|
|
|
|||
Loading…
Reference in a new issue