mirror of
https://github.com/dbv111m/musicgrabber.git
synced 2026-10-19 01:31:29 -05:00
India's biggest streaming service, asked directly through the api.php its own web player uses. Most tracks are 320 kbps AAC (saved as .m4a), some 160, and results declare which, so the quality filter and upgrade scanner know what they're getting. Stream links arrive DES-encrypted with a key that has been public for years, hence pycryptodome in the image. Found via Hayasaka, which reaches the same catalogue through someone's proxy; we skip the middleman so there is nothing to vanish. Catch: JioSaavn filters search by IP, so outside India the Western catalogue has holes. The Indian catalogue, which the other sources are thin on, is the point. Wired in wherever its siblings live: registry, download routing (.m4a staging), health probe, URL allowlist, quality tiers, provenance, settings toggle (on by default), and the frontend badge, stats, quips and preview. Assembled by one human and one AI who now know far too much about DES.
1438 lines
62 KiB
Python
1438 lines
62 KiB
Python
"""Track upgrades - Phase 1: the library scan.
|
|
|
|
Lidarr-style "hold out for a better copy" feature. This module owns the cheap,
|
|
network-free half: walk the library, work out each file's quality tier, and flag
|
|
the ones sitting below the quality the user already downloads at. The actual
|
|
searching and file-swapping (Phase 2) lives elsewhere; the scan never touches a
|
|
file or hits the network.
|
|
|
|
Ground rules (see docs/upgrades-design.md):
|
|
- The filesystem is the source of truth, and the scan still self-corrects; it no
|
|
longer opens every file to prove it. Size and modification time gate the
|
|
expensive work, and the persistent library index answers for files it has
|
|
already read. Anything that genuinely changes a file moves one of those, so a
|
|
track the user upgraded themselves is noticed on the next pass. A replacement
|
|
that preserves both size and timestamp to the byte is not, and has earned it.
|
|
- Only files carrying our SOURCE tag are eligible. No tag = the user's own file =
|
|
hands off. The tag is written at download time by metadata.apply_metadata_to_file.
|
|
- Target quality is derived from the existing download/convert settings, never a
|
|
separate knob, so there is no contradiction and no re-upgrade loop.
|
|
- Albums are deliberately out of scope; we only walk Singles + playlist folders.
|
|
"""
|
|
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import hashlib
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import library_index
|
|
from audio_probe import probe_file
|
|
from constants import (
|
|
UPGRADE_SCAN_INTERVAL_HOURS,
|
|
UPGRADE_SCAN_WRITE_BATCH,
|
|
UPGRADE_SEARCH_TTL_SECONDS,
|
|
UPGRADE_MATCH_FLOOR,
|
|
TIER_LOSSY_128, TIER_LOSSY_192, TIER_LOSSY_256, TIER_LOSSY_320, TIER_LOSSLESS,
|
|
kbps_to_tier as _kbps_to_tier,
|
|
SEARCH_SLOT_WAIT_AUTOMATED,
|
|
)
|
|
|
|
# These constants have long been part of this module's public import surface;
|
|
# tests and callers import them from ``upgrades`` alongside the tier helpers.
|
|
__all__ = [
|
|
"TIER_LOSSY_128",
|
|
"TIER_LOSSY_192",
|
|
"TIER_LOSSY_256",
|
|
"TIER_LOSSY_320",
|
|
"TIER_LOSSLESS",
|
|
]
|
|
from db import db_conn
|
|
from settings import (
|
|
get_setting_bool,
|
|
get_setting_int,
|
|
get_setting,
|
|
get_singles_dir,
|
|
get_playlists_dir,
|
|
)
|
|
from utils import (
|
|
spawn_daemon_thread,
|
|
iter_library_audio_files,
|
|
library_tag_index_in_progress,
|
|
)
|
|
|
|
_LOSSLESS_CODECS = {"flac", "alac", "wav", "wave", "pcm", "ape", "wavpack", "tak"}
|
|
|
|
# Extensions we bother scanning. webm is skipped: yt-dlp owns its tags and we
|
|
# can't reliably stamp/read a SOURCE on it anyway.
|
|
_AUDIO_EXTS = {".flac", ".mp3", ".m4a", ".mp4", ".opus", ".ogg", ".oga", ".wav", ".aac"}
|
|
|
|
# Rough VBR-quality-preset -> effective average kbps. Only the common LAME
|
|
# presets matter; anything exotic falls back to a conservative middle value.
|
|
_VBR_KBPS = {"v0": 245, "v1": 225, "v2": 190, "v3": 175, "v4": 165, "v5": 150}
|
|
|
|
|
|
def tier_of(codec: str | None, bitrate_kbps: int) -> int:
|
|
"""Quality tier for an actual file given its codec and average bitrate."""
|
|
if codec and codec.lower() in _LOSSLESS_CODECS:
|
|
return TIER_LOSSLESS
|
|
return _kbps_to_tier(bitrate_kbps or 0)
|
|
|
|
|
|
def _digits_to_int(s: str) -> int:
|
|
"""Largest number in a string, else 0. Largest (not first) so codec-name digits
|
|
don't win: 'FLAC (from MP3 320kbps)' -> 320, not the 3 in 'MP3'."""
|
|
import re
|
|
nums = [int(n) for n in re.findall(r"\d+", s or "")]
|
|
return max(nums) if nums else 0
|
|
|
|
|
|
def effective_tier(
|
|
codec: str | None,
|
|
bitrate_kbps: int,
|
|
source_quality: str | None,
|
|
source_codec: str | None = None,
|
|
source_bitrate_kbps: int = 0,
|
|
) -> int:
|
|
"""True quality tier, seeing through a lossless container around a lossy source.
|
|
|
|
A YouTube grab converted to FLAC is a FLAC *file* but 130kbps *audio*. We stamp the
|
|
honest origin into structured SOURCE_CODEC / SOURCE_BITRATE tags and keep the older
|
|
SOURCE_QUALITY label for display and backwards compatibility. Structured provenance
|
|
wins when present; parsing a human-readable label is only a fallback for older files.
|
|
|
|
Quality can never exceed either side of a transcode. MP3 128 -> Opus 256 is still a
|
|
128-tier recording, while FLAC -> Opus 256 is a 256-tier recording. This is what lets
|
|
the most common real upgrade (lossy-sourced -> proper lossless) be detected without
|
|
mistaking one genuinely lossless wrapper for an upgrade over another.
|
|
"""
|
|
sq = source_quality or ""
|
|
is_lossless_container = bool(codec and codec.lower() in _LOSSLESS_CODECS)
|
|
|
|
origin_codec = (source_codec or "").strip().lower()
|
|
origin_kbps = int(source_bitrate_kbps or 0)
|
|
has_structured_origin = bool(origin_codec)
|
|
|
|
if not has_structured_origin and "from" in sq.lower():
|
|
origin = re.search(r"\bfrom\s+([a-z0-9_]+)", sq, re.I)
|
|
origin_codec = origin.group(1).lower() if origin else ""
|
|
origin_part = re.split(r"\bfrom\b", sq, maxsplit=1, flags=re.I)[-1]
|
|
origin_kbps = _digits_to_int(origin_part)
|
|
|
|
if origin_codec and origin_codec not in _LOSSLESS_CODECS:
|
|
origin_tier = _kbps_to_tier(origin_kbps) if origin_kbps else TIER_LOSSY_192
|
|
if is_lossless_container:
|
|
return origin_tier
|
|
|
|
# A lossy-to-lossy conversion is capped by both the input and output.
|
|
stored_kbps = bitrate_kbps or _digits_to_int(
|
|
re.split(r"\bfrom\b", sq, maxsplit=1, flags=re.I)[0]
|
|
)
|
|
return min(origin_tier, _kbps_to_tier(stored_kbps)) if stored_kbps else origin_tier
|
|
|
|
if is_lossless_container:
|
|
return TIER_LOSSLESS
|
|
|
|
# Lossy: trust the real bitrate, but mutagen reports 0 for some codecs (Opus),
|
|
# so fall back to the kbps recorded in SOURCE_QUALITY.
|
|
kbps = bitrate_kbps or _digits_to_int(sq)
|
|
return _kbps_to_tier(kbps)
|
|
|
|
|
|
def _setting_to_kbps(value: str) -> int | None:
|
|
"""Map a bitrate setting value to effective kbps. None means lossless."""
|
|
if not value:
|
|
return None
|
|
v = value.strip().lower().rstrip("k")
|
|
if v in ("lossless", "0", ""):
|
|
return None
|
|
if v in _VBR_KBPS:
|
|
return _VBR_KBPS[v]
|
|
try:
|
|
return int(v)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def target_tier(user_id: str | None = None) -> int:
|
|
"""Derive the upgrade target tier from the user's download/convert settings.
|
|
|
|
No separate setting: this IS "the quality I already download at". Convert-to-FLAC
|
|
or a lossless format means lossless; otherwise the chosen codec's bitrate setting
|
|
decides the lossy tier.
|
|
"""
|
|
fmt = (get_setting("audio_format", "opus", user_id=user_id) or "opus").strip().lower()
|
|
if fmt in ("flac", "wav", "alac"):
|
|
# alac is lossless; alac_bitrate is effectively always "lossless"
|
|
if fmt != "alac":
|
|
return TIER_LOSSLESS
|
|
if _setting_to_kbps(get_setting("alac_bitrate", "lossless", user_id=user_id)) is None:
|
|
return TIER_LOSSLESS
|
|
|
|
if fmt == "mp3":
|
|
kbps = _setting_to_kbps(get_setting("mp3_bitrate", "v2", user_id=user_id))
|
|
elif fmt == "opus":
|
|
kbps = _setting_to_kbps(get_setting("opus_bitrate", "256k", user_id=user_id))
|
|
else:
|
|
# aac / m4a-lossy and anything else: assume a sensible high lossy target
|
|
kbps = 256
|
|
|
|
if kbps is None:
|
|
return TIER_LOSSLESS
|
|
return _kbps_to_tier(kbps)
|
|
|
|
|
|
def upgrade_output_profile(user_id: str | None = None) -> dict:
|
|
"""Describe how an accepted upgrade will actually be stored.
|
|
|
|
Search quality describes the candidate *before* MusicGrabber handles it. The
|
|
upgrade download then follows the normal default-conversion path, so a lossless
|
|
candidate may quite deliberately land as Opus, MP3, or AAC. Keep this server-side
|
|
so the Watched UI reports the same settings the worker will use.
|
|
"""
|
|
conversion_enabled = get_setting_bool(
|
|
"default_convert_audio", False, user_id=user_id
|
|
)
|
|
if not conversion_enabled:
|
|
return {
|
|
"upgrade_conversion_enabled": False,
|
|
"upgrade_output_format": None,
|
|
"upgrade_output_label": "Keep source format",
|
|
}
|
|
|
|
fmt = (get_setting("audio_format", "opus", user_id=user_id) or "opus").strip().lower()
|
|
if fmt == "flac":
|
|
label = "FLAC lossless"
|
|
elif fmt == "alac":
|
|
bitrate = get_setting("alac_bitrate", "lossless", user_id=user_id)
|
|
label = "ALAC lossless" if _setting_to_kbps(bitrate) is None else f"AAC {bitrate.rstrip('kK')} kbps (.m4a)"
|
|
elif fmt == "mp3":
|
|
bitrate = (get_setting("mp3_bitrate", "v2", user_id=user_id) or "v2").lower()
|
|
presets = {"v0": "MP3 V0 (~245 kbps)", "v2": "MP3 V2 (~190 kbps)"}
|
|
label = presets.get(bitrate, f"MP3 {bitrate.rstrip('kK')} kbps")
|
|
else:
|
|
fmt = "opus"
|
|
bitrate = get_setting("opus_bitrate", "256k", user_id=user_id) or "256k"
|
|
label = f"Opus {bitrate.rstrip('kK')} kbps"
|
|
|
|
return {
|
|
"upgrade_conversion_enabled": True,
|
|
"upgrade_output_format": fmt,
|
|
"upgrade_output_label": label,
|
|
}
|
|
|
|
|
|
def content_sha256(path: Path) -> str:
|
|
"""Strong file identity used to detect replacements at an unchanged path."""
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _looks_unchanged(cached, st) -> bool:
|
|
"""Whether a cached row still matches the file on disk, cheaply.
|
|
|
|
Size plus mtime, and nothing that requires opening the file. A row missing
|
|
either value, or missing the codec we would need to re-rank it without a
|
|
fresh probe, is treated as changed so the deep pass picks it up.
|
|
|
|
This will not notice a byte-for-byte replacement that carefully preserves
|
|
both size and timestamp. Anything that rewrites a file in the normal course
|
|
of events bumps its mtime; anything that goes out of its way not to has
|
|
earned the right to be missed until something else disturbs the file.
|
|
"""
|
|
if cached is None:
|
|
return False
|
|
if cached["mtime"] is None or cached["file_size"] is None:
|
|
return False
|
|
if not cached["codec"]:
|
|
return False
|
|
return cached["file_size"] == st.st_size and cached["mtime"] == st.st_mtime
|
|
|
|
|
|
def _norm_user(user_id: str | None) -> str:
|
|
return user_id or ""
|
|
|
|
|
|
def _iter_audio_files(directory: Path):
|
|
"""Yield `(path, stat)` for the user's real audio files, minus the NAS bins.
|
|
|
|
Delegates to the shared library walker so trash/recycle folders and symlinks
|
|
are skipped here exactly as they are everywhere else; offering to "upgrade"
|
|
a file the user deleted three weeks ago is not the kind of helpful we want.
|
|
The walker hands back the stat it already took, which is the size and mtime
|
|
this scan needs anyway.
|
|
"""
|
|
if not directory:
|
|
return
|
|
yield from iter_library_audio_files(directory, _AUDIO_EXTS)
|
|
|
|
|
|
def _wait_for_duplicate_scan_priority(user_id: str | None = None) -> bool:
|
|
"""Yield upgrade scanning to duplicate checks and honour its off switch.
|
|
|
|
The upgrade pass is optional background housekeeping. A download deciding
|
|
whether a track already exists is not, so there is no merit in making both
|
|
trawl the same NAS at once. Returning False tells the caller that upgrades
|
|
were switched off while it waited.
|
|
"""
|
|
while get_setting_bool("enable_track_upgrades", False, user_id=user_id):
|
|
if not library_tag_index_in_progress(user_id):
|
|
return True
|
|
time.sleep(0.25)
|
|
return False
|
|
|
|
|
|
def scan_user_library(user_id: str | None = None) -> dict:
|
|
"""Scan one user's eligible library and refresh their upgrade_candidates rows.
|
|
|
|
Returns a small summary dict. Network-free, never modifies audio files.
|
|
"""
|
|
if not get_setting_bool("enable_track_upgrades", False, user_id=user_id):
|
|
return {"scanned": 0, "eligible": 0, "below_target": 0,
|
|
"skipped_unchanged": 0, "from_index": 0}
|
|
|
|
uid = _norm_user(user_id)
|
|
tgt = target_tier(user_id)
|
|
|
|
dirs = [get_singles_dir(user_id)]
|
|
pl = get_playlists_dir(user_id)
|
|
if pl:
|
|
dirs.append(pl)
|
|
|
|
# The interval scan used to be deliberately deep, re-probing and re-hashing
|
|
# every file on the theory that someone might replace one while preserving
|
|
# its path and timestamps. Correct, and ruinously expensive: it pulled the
|
|
# entire library across the mount every single day to discover that nothing
|
|
# had happened. Size and mtime now gate the expensive work. Every organiser
|
|
# that rewrites a file bumps its mtime, so the deep pass is reserved for
|
|
# files that actually look different.
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM upgrade_candidates WHERE user_id = ?",
|
|
(uid,),
|
|
).fetchall()
|
|
cache = {r["path"]: r for r in rows}
|
|
cache_by_file_id = {r["file_id"]: r for r in rows if r["file_id"]}
|
|
|
|
seen: set[str] = set()
|
|
seen_ids: set[int] = set()
|
|
scanned = eligible = below = skipped = from_index = 0
|
|
|
|
# Row writes are queued and committed in batches. SQLite in WAL mode fsyncs
|
|
# on every commit, and this loop used to commit once per file, so a library
|
|
# that had not changed since yesterday still cost one fsync per track to say
|
|
# so. A batch that dies mid-flush loses only that batch, and the next scan
|
|
# rebuilds it from the disk anyway, which is a bargain at the price.
|
|
pending: list[tuple[str, tuple, bool]] = []
|
|
|
|
def flush_pending():
|
|
"""Commit everything queued so far, collecting new row IDs as we go."""
|
|
if not pending:
|
|
return
|
|
with db_conn() as conn:
|
|
for sql, params, capture_id in pending:
|
|
cur = conn.execute(sql, params)
|
|
if capture_id:
|
|
seen_ids.add(cur.lastrowid)
|
|
conn.commit()
|
|
pending.clear()
|
|
|
|
def queue_write(sql, params, capture_id=False):
|
|
pending.append((sql, params, capture_id))
|
|
if len(pending) >= UPGRADE_SCAN_WRITE_BATCH:
|
|
flush_pending()
|
|
|
|
# One reader, held open for the whole walk. A fresh connection per file
|
|
# would have quietly eaten a decent slice of what this saves, and twenty
|
|
# thousand of anything adds up. An unavailable index simply answers None
|
|
# to everything, which lands us back on the probe we used to do anyway.
|
|
with library_index.probe_hints() as indexed:
|
|
for d in dirs:
|
|
for path, st in _iter_audio_files(d):
|
|
if not _wait_for_duplicate_scan_priority(user_id):
|
|
print("Upgrade scan stopped because Track Upgrades was switched off")
|
|
flush_pending()
|
|
return {
|
|
"scanned": scanned,
|
|
"eligible": eligible,
|
|
"below_target": below,
|
|
"skipped_unchanged": skipped,
|
|
"from_index": from_index,
|
|
"scrubbed": 0,
|
|
"cancelled": True,
|
|
}
|
|
scanned += 1
|
|
spath = str(path)
|
|
mtime = st.st_mtime
|
|
cached = cache.get(spath)
|
|
|
|
if _looks_unchanged(cached, st):
|
|
# The file is where we left it, at the size and timestamp we left
|
|
# it. The target tier may still have moved under it, so re-rank
|
|
# from the columns we already hold and leave the disk alone.
|
|
eligible += 1
|
|
skipped += 1
|
|
seen.add(spath)
|
|
seen_ids.add(cached["id"])
|
|
file_tier = effective_tier(
|
|
cached["codec"],
|
|
cached["bitrate_kbps"],
|
|
cached["source_quality"],
|
|
cached["source_codec"],
|
|
cached["source_bitrate_kbps"] or 0,
|
|
)
|
|
is_below = 1 if file_tier < tgt else 0
|
|
below += is_below
|
|
queue_write(
|
|
"""UPDATE upgrade_candidates
|
|
SET file_tier=?, target_tier=?, below_target=?,
|
|
last_scanned=CURRENT_TIMESTAMP
|
|
WHERE user_id=? AND id=?""",
|
|
(file_tier, tgt, is_below, uid, cached["id"]),
|
|
)
|
|
continue
|
|
|
|
# The index opened this file on its last pass and wrote down
|
|
# what it found, so a row carrying this exact size and mtime is
|
|
# as good as the probe that produced it. Which matters because
|
|
# most of a real library is not ours and never will be, and we
|
|
# used to reopen all of it daily to rediscover that: 1,287 of
|
|
# the 1,673 files in this scan's scope on Karl's library, and
|
|
# 31.4 seconds of the 33.6 a pass used to take.
|
|
info = indexed(path, st)
|
|
if info is None:
|
|
info = probe_file(path)
|
|
else:
|
|
from_index += 1
|
|
if info is None or not info.get("source"):
|
|
# Not our file (or unreadable). Drop any stale row and move on.
|
|
if cached is not None:
|
|
queue_write(
|
|
"DELETE FROM upgrade_candidates WHERE user_id=? AND path=?",
|
|
(uid, spath),
|
|
)
|
|
continue
|
|
|
|
if cached is None and info.get("file_id"):
|
|
cached = cache_by_file_id.get(info["file_id"])
|
|
if cached is not None:
|
|
# Same tagged file, moved within a configured library root.
|
|
queue_write(
|
|
"UPDATE upgrade_candidates SET path=? WHERE user_id=? AND id=?",
|
|
(spath, uid, cached["id"]),
|
|
)
|
|
|
|
eligible += 1
|
|
seen.add(spath)
|
|
if cached is not None:
|
|
seen_ids.add(cached["id"])
|
|
file_tier = effective_tier(
|
|
info["codec"],
|
|
info["bitrate_kbps"],
|
|
info.get("source_quality"),
|
|
info.get("source_codec"),
|
|
info.get("source_bitrate_kbps", 0),
|
|
)
|
|
is_below = 1 if file_tier < tgt else 0
|
|
below += is_below
|
|
try:
|
|
file_hash = content_sha256(path)
|
|
except OSError:
|
|
continue
|
|
|
|
# Any byte change lapses dismissals and cached network search results.
|
|
dismissed = 0
|
|
dismissed_mtime = None
|
|
unchanged = bool(cached and cached["content_sha256"] == file_hash)
|
|
if unchanged and cached["dismissed"]:
|
|
dismissed = 1
|
|
dismissed_mtime = cached["dismissed_mtime"]
|
|
|
|
if cached is not None:
|
|
queue_write(
|
|
"""UPDATE upgrade_candidates SET path=?, mtime=?, file_size=?,
|
|
codec=?, bitrate_kbps=?, duration=?, file_tier=?, target_tier=?,
|
|
below_target=?, source=?, source_quality=?, source_codec=?,
|
|
source_bitrate_kbps=?, file_id=?, content_sha256=?, artist=?,
|
|
title=?, dismissed=?, dismissed_mtime=?,
|
|
found_searched=CASE WHEN ? THEN found_searched ELSE 0 END,
|
|
found_at=CASE WHEN ? THEN found_at ELSE NULL END,
|
|
found_source=CASE WHEN ? THEN found_source ELSE NULL END,
|
|
last_scanned=CURRENT_TIMESTAMP WHERE user_id=? AND id=?""",
|
|
(spath, mtime, st.st_size, info["codec"], info["bitrate_kbps"],
|
|
info["duration"], file_tier, tgt, is_below, info["source"],
|
|
info.get("source_quality"), info.get("source_codec"),
|
|
info.get("source_bitrate_kbps"), info.get("file_id"), file_hash,
|
|
info["artist"], info["title"], dismissed, dismissed_mtime,
|
|
unchanged, unchanged, unchanged, uid, cached["id"]),
|
|
)
|
|
else:
|
|
# capture_id, because a row we have not pruned is a row the user
|
|
# still owns; forgetting a new ID here would delete it minutes later.
|
|
queue_write(
|
|
"""INSERT INTO upgrade_candidates
|
|
(user_id,path,mtime,file_size,codec,bitrate_kbps,duration,
|
|
file_tier,target_tier,below_target,source,source_quality,
|
|
source_codec,source_bitrate_kbps,file_id,content_sha256,
|
|
artist,title,dismissed,dismissed_mtime,last_scanned)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
|
|
(uid, spath, mtime, st.st_size, info["codec"], info["bitrate_kbps"],
|
|
info["duration"], file_tier, tgt, is_below, info["source"],
|
|
info.get("source_quality"), info.get("source_codec"),
|
|
info.get("source_bitrate_kbps"), info.get("file_id"), file_hash,
|
|
info["artist"], info["title"], dismissed, dismissed_mtime),
|
|
capture_id=True,
|
|
)
|
|
|
|
flush_pending()
|
|
|
|
# Only prune after a scan that actually saw the roots. An offline/empty NAS
|
|
# must never look like the user deleted their entire library.
|
|
stale_ids = [r["id"] for r in rows if r["id"] not in seen_ids]
|
|
if stale_ids and (scanned > 0 or not rows):
|
|
with db_conn() as conn:
|
|
conn.executemany(
|
|
"DELETE FROM upgrade_candidates WHERE user_id=? AND id=?",
|
|
[(uid, candidate_id) for candidate_id in stale_ids],
|
|
)
|
|
conn.commit()
|
|
|
|
return {"scanned": scanned, "eligible": eligible, "below_target": below,
|
|
"skipped_unchanged": skipped, "from_index": from_index,
|
|
"scrubbed": len(stale_ids) if scanned > 0 else 0}
|
|
|
|
|
|
def get_candidates(user_id: str | None = None, below_only: bool = True,
|
|
include_dismissed: bool = False) -> list[dict]:
|
|
"""Return the cached scan rows for a user, newest scan first."""
|
|
uid = _norm_user(user_id)
|
|
clauses = ["user_id = ?"]
|
|
params: list = [uid]
|
|
if below_only:
|
|
clauses.append("below_target = 1")
|
|
if not include_dismissed:
|
|
clauses.append("dismissed = 0")
|
|
where = " AND ".join(clauses)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
f"SELECT path, codec, bitrate_kbps, duration, file_tier, target_tier, "
|
|
f"below_target, source, artist, title, dismissed, upgrade_state, last_scanned "
|
|
f"FROM upgrade_candidates WHERE {where} ORDER BY last_scanned DESC",
|
|
params,
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 2a: per-candidate upgrade search (on demand, user-driven, read-only)
|
|
# =============================================================================
|
|
|
|
def _estimate_result_tier(result: dict) -> tuple[int, bool]:
|
|
"""Best-effort (tier, verified) for a search result before downloading.
|
|
|
|
verified == we know the quality up front (Monochrome). slskd and the lossy web
|
|
sources are estimates, confirmed only by the Phase 2b download-and-compare.
|
|
"""
|
|
source = (result.get("source") or "").lower()
|
|
quality = result.get("quality") or ""
|
|
q = quality.upper()
|
|
if source == "monochrome":
|
|
if "LOSSLESS" in q: # HI_RES_LOSSLESS or LOSSLESS
|
|
return TIER_LOSSLESS, True
|
|
return TIER_LOSSY_320, True # HIGH
|
|
if source == "soulseek":
|
|
if any(t in q for t in ("FLAC", "WAV", "ALAC")):
|
|
return TIER_LOSSLESS, False
|
|
digits = "".join(ch for ch in quality if ch.isdigit())
|
|
if digits:
|
|
return _kbps_to_tier(int(digits)), False
|
|
return TIER_LOSSY_320, False
|
|
if source in ("youtube", "soundcloud"):
|
|
return TIER_LOSSY_192, False # lossy, rarely an upgrade; estimate low
|
|
if source in ("freemp3cloud", "jiosaavn"):
|
|
# Both declare a bitrate up front: FreeMp3Cloud 320 or 128, JioSaavn 320 or 160.
|
|
digits = "".join(ch for ch in quality if ch.isdigit())
|
|
return (_kbps_to_tier(int(digits)) if digits else TIER_LOSSY_320), False
|
|
return TIER_LOSSY_320, False # zvu4no etc, estimate, unverified
|
|
|
|
|
|
def _candidate_public(row, user_id: str | None = None) -> dict:
|
|
"""Shape a candidate row for the API/UI."""
|
|
d = dict(row)
|
|
d["filename"] = Path(d["path"]).name
|
|
d.update(upgrade_output_profile(user_id))
|
|
return d
|
|
|
|
|
|
def search_candidate(user_id: str | None, candidate_id: int, force: bool = False) -> dict | None:
|
|
"""Find the best better-than-current copy for one candidate; cache it (TTL).
|
|
|
|
Returns the refreshed candidate dict, or None if the id doesn't exist. Hits the
|
|
network via search_all, so callers should rate-limit (the UI does, one row at a time).
|
|
"""
|
|
uid = _norm_user(user_id)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM upgrade_candidates WHERE user_id=? AND id=?", (uid, candidate_id)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
|
|
# Serve cached result while it's fresh.
|
|
if not force and row["found_searched"] and row["found_at"]:
|
|
try:
|
|
if time.time() - float(row["found_at"]) < UPGRADE_SEARCH_TTL_SECONDS:
|
|
return _candidate_public(row, user_id)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
artist = row["artist"] or ""
|
|
title = row["title"] or ""
|
|
if not (artist or title):
|
|
try:
|
|
from utils import extract_artist_title
|
|
a, t = extract_artist_title(Path(row["path"]).stem, "")
|
|
artist, title = a or "", t or ""
|
|
except Exception:
|
|
pass
|
|
query = f"{artist} - {title}".strip(" -") or Path(row["path"]).stem
|
|
file_tier = row["file_tier"] or 0
|
|
expected_dur = row["duration"] or None
|
|
|
|
best = None
|
|
try:
|
|
from search import search_all
|
|
from matching import compute_match_confidence
|
|
include_sk = get_setting_bool("source_soulseek_enabled", False, user_id=user_id)
|
|
results, _ = search_all(query, 8, include_soulseek=include_sk,
|
|
slot_wait=SEARCH_SLOT_WAIT_AUTOMATED)
|
|
except Exception as e:
|
|
print(f"Upgrade search failed for candidate {candidate_id}: {e}")
|
|
results = []
|
|
|
|
for r in results:
|
|
tier, verified = _estimate_result_tier(r)
|
|
if tier <= file_tier:
|
|
continue # not actually better than what's on disk
|
|
cand_title = r.get("title") or ""
|
|
cand_artist = r.get("channel") or r.get("artist") or ""
|
|
try:
|
|
cand_dur = float(r.get("duration") or 0) or None
|
|
except (TypeError, ValueError):
|
|
cand_dur = None
|
|
conf, _ = compute_match_confidence(
|
|
artist or None, title or None, cand_title, cand_artist,
|
|
candidate_duration_s=cand_dur, expected_duration_s=expected_dur, query=query,
|
|
)
|
|
if conf < UPGRADE_MATCH_FLOOR:
|
|
continue
|
|
score = r.get("relevance_score", 0) or 0
|
|
if best is None or (tier, score) > (best["tier"], best["result"].get("relevance_score", 0) or 0):
|
|
best = {"tier": tier, "verified": verified, "confidence": conf, "result": r}
|
|
|
|
with db_conn() as conn:
|
|
if best:
|
|
r = best["result"]
|
|
conn.execute(
|
|
"""UPDATE upgrade_candidates SET found_searched=1, found_at=?,
|
|
found_source=?, found_quality=?, found_tier=?, found_confidence=?, found_verified=?,
|
|
found_video_id=?, found_source_url=?, found_slskd_username=?,
|
|
found_slskd_filename=?, found_slskd_size=?
|
|
WHERE user_id=? AND id=?""",
|
|
(time.time(), r.get("source"), r.get("quality"), best["tier"],
|
|
round(best["confidence"], 3), 1 if best["verified"] else 0,
|
|
r.get("video_id"), r.get("source_url"), r.get("slskd_username"),
|
|
r.get("slskd_filename"), r.get("slskd_size"), uid, candidate_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"""UPDATE upgrade_candidates SET found_searched=1, found_at=?,
|
|
found_source=NULL, found_quality=NULL, found_tier=NULL, found_confidence=NULL,
|
|
found_verified=0, found_video_id=NULL, found_source_url=NULL,
|
|
found_slskd_username=NULL, found_slskd_filename=NULL, found_slskd_size=NULL
|
|
WHERE user_id=? AND id=?""",
|
|
(time.time(), uid, candidate_id),
|
|
)
|
|
conn.commit()
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM upgrade_candidates WHERE user_id=? AND id=?", (uid, candidate_id)
|
|
).fetchone()
|
|
return _candidate_public(row, user_id)
|
|
|
|
|
|
def get_candidates_page(user_id: str | None, page: int = 1, per_page: int = 10,
|
|
include_dismissed: bool = False) -> dict:
|
|
"""A stable, paginated slice of below-target candidates for the Watched Upgrades view."""
|
|
uid = _norm_user(user_id)
|
|
page = max(1, int(page or 1))
|
|
per_page = max(1, min(50, int(per_page or 10)))
|
|
clauses = ["user_id=?", "below_target=1"]
|
|
params: list = [uid]
|
|
if not include_dismissed:
|
|
clauses.append("dismissed=0")
|
|
# Once we've actually looked and found nothing, there's no path to an upgrade;
|
|
# keep the row out rather than showing a dead end forever. A not-yet-searched
|
|
# row (found_searched=0) still belongs here, we just don't know yet.
|
|
clauses.append("NOT (found_searched=1 AND found_source IS NULL)")
|
|
where = " AND ".join(clauses)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) AS c FROM upgrade_candidates WHERE {where}", params
|
|
).fetchone()["c"]
|
|
rows = conn.execute(
|
|
f"SELECT * FROM upgrade_candidates WHERE {where} "
|
|
f"ORDER BY artist COLLATE NOCASE, title COLLATE NOCASE, path LIMIT ? OFFSET ?",
|
|
params + [per_page, (page - 1) * per_page],
|
|
).fetchall()
|
|
pages = (total + per_page - 1) // per_page if total else 0
|
|
return {"items": [_candidate_public(r, user_id) for r in rows], "total": total,
|
|
"page": page, "per_page": per_page, "pages": pages}
|
|
|
|
|
|
def dismiss_candidate(user_id: str | None, candidate_id: int) -> bool:
|
|
"""Dismiss a candidate. Keyed to current mtime, so a later file change re-surfaces it."""
|
|
uid = _norm_user(user_id)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT mtime FROM upgrade_candidates WHERE user_id=? AND id=?", (uid, candidate_id)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
conn.execute(
|
|
"UPDATE upgrade_candidates SET dismissed=1, dismissed_mtime=? WHERE user_id=? AND id=?",
|
|
(row["mtime"], uid, candidate_id),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 2b: download + verify + swap (the file-touching half)
|
|
# =============================================================================
|
|
|
|
# Same-recording gates. Deliberately strict: we are replacing the user's file.
|
|
UPGRADE_DURATION_TOLERANCE_S = 5.0 # plus a 5% allowance for long tracks
|
|
UPGRADE_SWAP_MATCH_FLOOR = 0.72 # higher than the search floor
|
|
UPGRADE_FP_SIMILARITY_FLOOR = 0.65 # Chromaprint bit-similarity; ~0.5 is noise
|
|
|
|
|
|
def _quarantine_dir() -> Path:
|
|
from constants import DB_PATH
|
|
d = DB_PATH.parent / ".upgrade_quarantine"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _fpcalc_raw(path: Path) -> tuple[float, list[int]] | None:
|
|
"""(duration, raw Chromaprint fingerprint ints) for a file, or None."""
|
|
import json
|
|
import subprocess
|
|
try:
|
|
out = subprocess.run(
|
|
["fpcalc", "-raw", "-json", str(path)],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
if out.returncode != 0:
|
|
return None
|
|
data = json.loads(out.stdout)
|
|
return float(data.get("duration") or 0), list(data.get("fingerprint") or [])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _fingerprint_similarity(fa: list[int], fb: list[int]) -> float:
|
|
"""Best bit-similarity (0..1) of two raw fingerprints over a small offset search.
|
|
|
|
Same recording at different qualities scores ~0.85+; unrelated tracks sit near the
|
|
0.5 noise floor, so the floor cleanly separates them. Capped in length for speed.
|
|
"""
|
|
if not fa or not fb:
|
|
return 0.0
|
|
cap = 600
|
|
fa, fb = fa[:cap], fb[:cap]
|
|
best = 0.0
|
|
for off in range(-25, 26):
|
|
x = fa[off:] if off >= 0 else fa
|
|
y = fb if off >= 0 else fb[-off:]
|
|
n = min(len(x), len(y))
|
|
if n < 40:
|
|
continue
|
|
errors = 0
|
|
for i in range(n):
|
|
errors += bin(x[i] ^ y[i]).count("1")
|
|
sim = 1.0 - errors / (32.0 * n)
|
|
if sim > best:
|
|
best = sim
|
|
return best
|
|
|
|
|
|
def _download_candidate_to_staging(user_id: str | None, row, staging_dir: Path) -> tuple[Path | None, str]:
|
|
"""Download the proposed upgrade into a staging dir using the normal pipeline.
|
|
|
|
Reuses process_download / process_slskd_download with override_dir=staging so we get
|
|
all the source-specific handling and quality probing for free, then we take the file
|
|
out of staging ourselves. Returns (file, job_id); the caller decides the job row's
|
|
fate once it knows whether the swap actually went ahead, so a genuine upgrade leaves
|
|
a normal Queue history entry instead of vanishing without a trace.
|
|
"""
|
|
import uuid as _uuid
|
|
from downloads import process_download, process_slskd_download
|
|
|
|
source = row["found_source"]
|
|
video_id = row["found_video_id"] or ""
|
|
source_url = row["found_source_url"]
|
|
artist = row["artist"] or ""
|
|
title = row["title"] or Path(row["path"]).stem
|
|
convert = get_setting_bool("default_convert_audio", False, user_id=user_id)
|
|
job_id = str(_uuid.uuid4())[:8]
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO jobs
|
|
(id, video_id, title, artist, status, download_type, source,
|
|
slskd_username, slskd_filename, slskd_size, convert_audio, source_url,
|
|
user_id, override_dir)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(job_id, video_id, title, artist, "queued", "upgrade", source,
|
|
row["found_slskd_username"], row["found_slskd_filename"], row["found_slskd_size"],
|
|
int(convert), source_url, user_id, str(staging_dir)),
|
|
)
|
|
conn.commit()
|
|
|
|
try:
|
|
if source == "soulseek":
|
|
process_slskd_download(
|
|
job_id, row["found_slskd_username"], row["found_slskd_filename"],
|
|
artist, title, convert, user_id=user_id,
|
|
override_dir=str(staging_dir), slskd_size=row["found_slskd_size"],
|
|
skip_dupe_check=True,
|
|
)
|
|
else:
|
|
process_download(
|
|
job_id, video_id, convert, source_url=source_url,
|
|
user_id=user_id, override_dir=str(staging_dir), skip_dupe_check=True,
|
|
)
|
|
except Exception as e:
|
|
print(f"Upgrade download error (candidate {row['id']}): {e}")
|
|
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
j = conn.execute("SELECT status FROM jobs WHERE id=?", (job_id,)).fetchone()
|
|
status = j["status"] if j else None
|
|
|
|
files = [p for p in staging_dir.rglob("*")
|
|
if p.is_file() and p.suffix.lower() in _AUDIO_EXTS]
|
|
if status == "completed" and files:
|
|
return max(files, key=lambda p: p.stat().st_mtime), job_id
|
|
return None, job_id
|
|
|
|
|
|
def _fix_m3u_references(user_id: str | None, old_path: Path, new_path: Path) -> None:
|
|
"""Best-effort: rewrite playlist .m3u entries when an upgrade changed the file's name."""
|
|
if old_path.name == new_path.name:
|
|
return
|
|
try:
|
|
roots = []
|
|
pl = get_playlists_dir(user_id)
|
|
if pl:
|
|
roots.append(pl)
|
|
roots.append(get_singles_dir(user_id).parent) # music root
|
|
seen = set()
|
|
for root in roots:
|
|
if not root or not root.exists():
|
|
continue
|
|
for m3u in root.rglob("*.m3u"):
|
|
if m3u in seen:
|
|
continue
|
|
seen.add(m3u)
|
|
try:
|
|
text = m3u.read_text(encoding="utf-8", errors="ignore")
|
|
if old_path.name in text:
|
|
m3u.write_text(text.replace(old_path.name, new_path.name), encoding="utf-8")
|
|
except OSError:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def perform_upgrade(user_id: str | None, candidate_id: int, force: bool = False) -> dict:
|
|
"""Download the proposed copy, verify it, and (only if safe) swap it in.
|
|
|
|
Returns {"status": "upgraded"|"rejected"|"error", "reason": ...}. On anything short of
|
|
a clean pass the original file is left exactly as it was; the download is binned.
|
|
|
|
force=True skips the same-recording gates (better/duration/match/fingerprint) for when
|
|
the user deliberately wants the proposed copy regardless, e.g. swapping a long YouTube
|
|
edit for the studio cut. The download still has to be valid audio, and the old file
|
|
still goes to quarantine, so a forced mistake is always recoverable.
|
|
"""
|
|
uid = _norm_user(user_id)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM upgrade_candidates WHERE user_id=? AND id=?", (uid, candidate_id)
|
|
).fetchone()
|
|
if row is None:
|
|
return {"status": "error", "reason": "Candidate not found"}
|
|
if not row["found_source"]:
|
|
return {"status": "error", "reason": "No proposed upgrade to apply"}
|
|
|
|
current = Path(row["path"])
|
|
if not current.exists():
|
|
return {"status": "error", "reason": "Original file no longer exists"}
|
|
current_info = probe_file(current)
|
|
if current_info is None:
|
|
return {"status": "error", "reason": "Original file is no longer readable audio"}
|
|
if row["file_id"] and current_info.get("file_id") != row["file_id"]:
|
|
scan_user_library(user_id)
|
|
return {"status": "error",
|
|
"reason": "Original file was externally replaced; library state refreshed"}
|
|
try:
|
|
original_hash = content_sha256(current)
|
|
except OSError:
|
|
return {"status": "error", "reason": "Original file could not be revalidated"}
|
|
if row["content_sha256"] and original_hash != row["content_sha256"]:
|
|
scan_user_library(user_id)
|
|
return {"status": "error",
|
|
"reason": "Original file changed since the upgrade search; search again"}
|
|
|
|
# Never trust cached quality data at the point of action.
|
|
current_tier = effective_tier(
|
|
current_info["codec"], current_info["bitrate_kbps"],
|
|
current_info.get("source_quality"),
|
|
current_info.get("source_codec"),
|
|
current_info.get("source_bitrate_kbps", 0),
|
|
)
|
|
if not force and current_tier >= (row["target_tier"] or TIER_LOSSLESS):
|
|
scan_user_library(user_id)
|
|
return {"status": "rejected", "reason": "Original file is already at target quality"}
|
|
|
|
import tempfile
|
|
staging = Path(tempfile.mkdtemp(prefix=f"upg_{candidate_id}_", dir=str(_quarantine_dir().parent)))
|
|
job_id = None
|
|
upgraded_ok = False
|
|
try:
|
|
staged, job_id = _download_candidate_to_staging(user_id, row, staging)
|
|
if staged is None:
|
|
_set_upgrade_state(uid, candidate_id, "rejected")
|
|
return {"status": "rejected", "reason": "Download failed or produced no file"}
|
|
|
|
info = probe_file(staged)
|
|
if info is None:
|
|
return {"status": "rejected", "reason": "Downloaded file was not readable audio"}
|
|
|
|
# 1) Genuinely better than what's on disk (compares the file as it landed).
|
|
staged_tier = effective_tier(
|
|
info["codec"],
|
|
info["bitrate_kbps"],
|
|
info.get("source_quality"),
|
|
info.get("source_codec"),
|
|
info.get("source_bitrate_kbps", 0),
|
|
)
|
|
if not force and staged_tier <= current_tier:
|
|
return {"status": "rejected",
|
|
"reason": f"Not actually better ({info.get('source_quality') or info['codec']})"}
|
|
|
|
# 2) Same recording: duration, then title/artist confidence, then fingerprint.
|
|
# All of these are skipped on a forced upgrade (the user is overriding identity).
|
|
if not force:
|
|
cur_dur = float(current_info["duration"] or 0)
|
|
new_dur = float(info["duration"] or 0)
|
|
tol = max(UPGRADE_DURATION_TOLERANCE_S, cur_dur * 0.05)
|
|
if cur_dur and new_dur and abs(cur_dur - new_dur) > tol:
|
|
return {"status": "rejected",
|
|
"reason": f"Duration mismatch ({cur_dur:.0f}s vs {new_dur:.0f}s)"}
|
|
|
|
try:
|
|
from matching import compute_match_confidence
|
|
conf, _ = compute_match_confidence(
|
|
row["artist"] or None, row["title"] or None,
|
|
info.get("title") or "", info.get("artist") or "",
|
|
candidate_duration_s=new_dur or None, expected_duration_s=cur_dur or None,
|
|
)
|
|
except Exception:
|
|
conf = 1.0 # matching unavailable; lean on duration + fingerprint
|
|
if conf < UPGRADE_SWAP_MATCH_FLOOR:
|
|
return {"status": "rejected", "reason": f"Title/artist match too weak ({conf:.0%})"}
|
|
|
|
fp_cur = _fpcalc_raw(current)
|
|
fp_new = _fpcalc_raw(staged)
|
|
if fp_cur and fp_new:
|
|
sim = _fingerprint_similarity(fp_cur[1], fp_new[1])
|
|
if sim < UPGRADE_FP_SIMILARITY_FLOOR:
|
|
return {"status": "rejected",
|
|
"reason": f"Fingerprint says different recording ({sim:.0%})"}
|
|
# If fpcalc is unavailable we don't hard-fail; duration + confidence already passed.
|
|
|
|
# The download may have taken minutes. Recheck the live file immediately
|
|
# before touching it to close the time-of-check/time-of-use window.
|
|
try:
|
|
if not current.exists() or content_sha256(current) != original_hash:
|
|
scan_user_library(user_id)
|
|
return {"status": "error",
|
|
"reason": "Original file changed while the upgrade downloaded; swap aborted"}
|
|
except OSError:
|
|
return {"status": "error",
|
|
"reason": "Original file could not be revalidated before swap"}
|
|
|
|
# 3) All gates passed. Quarantine the old file, swap the new one into its place.
|
|
new_path = current.with_suffix(staged.suffix)
|
|
import shutil
|
|
from datetime import datetime
|
|
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
quarantined = _quarantine_dir() / f"{stamp}__{current.name}"
|
|
shutil.move(str(current), str(quarantined))
|
|
try:
|
|
shutil.move(str(staged), str(new_path))
|
|
except Exception as e:
|
|
# Swap failed mid-flight: restore the original so we never lose the file.
|
|
shutil.move(str(quarantined), str(current))
|
|
return {"status": "error", "reason": f"Swap failed, original restored: {e}"}
|
|
upgraded_ok = True
|
|
|
|
try:
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO upgrade_quarantine
|
|
(user_id, quarantine_path, original_path, replaced_by_path,
|
|
artist, title, file_size)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(uid, str(quarantined), str(current), str(new_path),
|
|
row["artist"], row["title"], quarantined.stat().st_size),
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
print(f"Quarantine tracking row failed (candidate {candidate_id}): {e}")
|
|
|
|
# Keep the library entry's identity (artist/title/album); the download
|
|
# already stamped SOURCE/SOURCE_QUALITY. current_info was read from the
|
|
# original file before it went to quarantine, so this survives even if
|
|
# the new copy's own tags (or lack thereof) disagree.
|
|
try:
|
|
from metadata import apply_metadata_to_file
|
|
apply_metadata_to_file(
|
|
new_path, row["artist"] or "", row["title"] or new_path.stem,
|
|
album=current_info.get("album") or "",
|
|
album_artist=current_info.get("album_artist") or None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Not a move as far as the index is concerned: the file now sitting at
|
|
# new_path is a different file with a different codec, bitrate and, after
|
|
# the retagging above, possibly different tags. So the old row goes and a
|
|
# fresh one is read, rather than carrying yesterday's facts to a new home.
|
|
library_index.remove_file(current)
|
|
library_index.index_file(new_path)
|
|
|
|
_fix_m3u_references(user_id, current, new_path)
|
|
# All three, same as every other completed-download site. This used to
|
|
# poke Navidrome only, so Jellyfin and Plex users were left staring at
|
|
# the old file's tags until their next scheduled scan wandered past.
|
|
try:
|
|
from downloads import trigger_navidrome_scan, trigger_jellyfin_scan, trigger_plex_scan
|
|
trigger_navidrome_scan(user_id=user_id)
|
|
trigger_jellyfin_scan(user_id=user_id)
|
|
trigger_plex_scan(user_id=user_id)
|
|
except Exception:
|
|
pass
|
|
|
|
# Refresh the candidate row to reflect the upgraded file.
|
|
new_info = probe_file(new_path) or info
|
|
new_tier = effective_tier(
|
|
new_info["codec"],
|
|
new_info["bitrate_kbps"],
|
|
new_info.get("source_quality"),
|
|
new_info.get("source_codec"),
|
|
new_info.get("source_bitrate_kbps", 0),
|
|
)
|
|
# A genuine upgrade can still land short of the target: the fetched copy
|
|
# beats what was there, then convert-audio re-encodes it to something
|
|
# below your tier anyway. The row legitimately stays on the list in that
|
|
# case, so tell the caller rather than letting the UI assume it's gone.
|
|
still_below = new_tier < (row["target_tier"] or TIER_LOSSLESS)
|
|
try:
|
|
st = new_path.stat()
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""UPDATE upgrade_candidates SET path=?, mtime=?, file_size=?, codec=?,
|
|
bitrate_kbps=?, duration=?, file_tier=?, below_target=?, source=?,
|
|
source_quality=?, source_codec=?, source_bitrate_kbps=?,
|
|
file_id=?, content_sha256=?,
|
|
upgrade_state='upgraded', found_searched=0, found_source=NULL,
|
|
found_quality=NULL, found_tier=NULL, found_confidence=NULL,
|
|
found_verified=0, found_video_id=NULL, found_source_url=NULL,
|
|
found_slskd_username=NULL, found_slskd_filename=NULL, found_slskd_size=NULL
|
|
WHERE user_id=? AND id=?""",
|
|
(str(new_path), st.st_mtime, st.st_size, new_info["codec"],
|
|
new_info["bitrate_kbps"], new_info["duration"], new_tier,
|
|
1 if still_below else 0,
|
|
new_info.get("source") or row["source"],
|
|
new_info.get("source_quality"), new_info.get("source_codec"),
|
|
new_info.get("source_bitrate_kbps"), new_info.get("file_id"),
|
|
content_sha256(new_path), uid, candidate_id),
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
print(f"Upgrade row refresh failed (candidate {candidate_id}): {e}")
|
|
|
|
return {"status": "upgraded", "from": row["found_quality"] or "",
|
|
"path": str(new_path), "quarantined": str(quarantined),
|
|
"still_below_target": still_below,
|
|
"new_quality": new_info.get("source_quality") or new_info["codec"]}
|
|
finally:
|
|
if job_id:
|
|
# A genuine upgrade gets to stay in the Queue as an ordinary completed
|
|
# job, same as any other download, pointed at where the file actually
|
|
# ended up. Anything short of that (rejected, errored, gate failure)
|
|
# was never a real acquisition and is cleaned up as before.
|
|
try:
|
|
with db_conn() as conn:
|
|
if upgraded_ok:
|
|
conn.execute(
|
|
"UPDATE jobs SET final_path=?, override_dir=NULL WHERE id=?",
|
|
(str(new_path), job_id),
|
|
)
|
|
else:
|
|
conn.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
|
conn.commit()
|
|
except Exception as e:
|
|
print(f"Upgrade job history update failed (candidate {candidate_id}): {e}")
|
|
try:
|
|
import shutil
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _set_upgrade_state(uid: str, candidate_id: int, state: str) -> None:
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE upgrade_candidates SET upgrade_state=? WHERE user_id=? AND id=?",
|
|
(state, uid, candidate_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
# Live progress for the background "Upgrade All" run, keyed by user. Upgrade All
|
|
# is fire-and-forget in a daemon thread, so without this the UI's only option was
|
|
# to tell the user to refresh and hope they did; a run that removed nothing then
|
|
# looked identical to one that never started.
|
|
_upgrade_all_progress: dict[str, dict] = {}
|
|
_upgrade_all_lock = threading.Lock()
|
|
|
|
|
|
def get_upgrade_all_progress(user_id: str | None) -> dict:
|
|
"""Progress of this user's Upgrade All run, or an idle state if none has run."""
|
|
with _upgrade_all_lock:
|
|
state = _upgrade_all_progress.get(_norm_user(user_id))
|
|
return dict(state) if state else {
|
|
"running": False, "total": 0, "done": 0,
|
|
"upgraded": 0, "rejected": 0, "errored": 0,
|
|
}
|
|
|
|
|
|
def mark_upgrade_all_starting(user_id: str | None) -> None:
|
|
"""Flag a run as live before its thread has drawn breath.
|
|
|
|
The route calls this synchronously so a poll arriving in the gap between
|
|
"thread spawned" and "thread got scheduled" sees running=True, rather than
|
|
an idle state it would read as "already finished, nothing happened".
|
|
"""
|
|
with _upgrade_all_lock:
|
|
_upgrade_all_progress[_norm_user(user_id)] = {
|
|
"running": True, "total": 0, "done": 0,
|
|
"upgraded": 0, "rejected": 0, "errored": 0,
|
|
}
|
|
|
|
|
|
def perform_upgrade_all(user_id: str | None) -> dict:
|
|
"""Best-effort upgrade of every below-target candidate that has a proposal."""
|
|
uid = _norm_user(user_id)
|
|
mark_upgrade_all_starting(uid)
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
ids = [r["id"] for r in conn.execute(
|
|
"SELECT id FROM upgrade_candidates WHERE user_id=? AND below_target=1 "
|
|
"AND dismissed=0 AND found_source IS NOT NULL", (uid,)
|
|
).fetchall()]
|
|
|
|
with _upgrade_all_lock:
|
|
_upgrade_all_progress[uid]["total"] = len(ids)
|
|
|
|
upgraded = rejected = errored = 0
|
|
try:
|
|
for cid in ids:
|
|
try:
|
|
res = perform_upgrade(user_id, cid)
|
|
st = res.get("status")
|
|
except Exception:
|
|
st = "error"
|
|
upgraded += st == "upgraded"
|
|
rejected += st == "rejected"
|
|
errored += st == "error"
|
|
with _upgrade_all_lock:
|
|
_upgrade_all_progress[uid].update(
|
|
done=upgraded + rejected + errored,
|
|
upgraded=upgraded, rejected=rejected, errored=errored,
|
|
)
|
|
finally:
|
|
# Always stops "running", even if something above threw: a progress bar
|
|
# that spins forever is worse than no progress bar at all.
|
|
with _upgrade_all_lock:
|
|
_upgrade_all_progress[uid]["running"] = False
|
|
|
|
return {"total": len(ids), "upgraded": upgraded, "rejected": rejected, "errored": errored}
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 2c: quarantine management (restore a wrongly-upgraded file, or bin it
|
|
# for good). Nothing here ever hard-deletes the file a restore would need; a
|
|
# restore instead quarantines whatever it displaces, so undoing an undo is
|
|
# still possible. Only an explicit delete is final.
|
|
# =============================================================================
|
|
|
|
def list_quarantine(user_id: str | None, is_admin: bool = False) -> list[dict]:
|
|
"""Quarantined files this user can act on.
|
|
|
|
Admins additionally see every user's entries plus any file sitting in the
|
|
quarantine folder without a tracking row at all (predates this table, or
|
|
was dropped in by hand); those come back with "restorable": False since
|
|
their original library location was never recorded, only deletion is safe.
|
|
"""
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
if is_admin:
|
|
rows = conn.execute(
|
|
"SELECT * FROM upgrade_quarantine ORDER BY quarantined_at DESC"
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM upgrade_quarantine WHERE user_id=? ORDER BY quarantined_at DESC",
|
|
(_norm_user(user_id),),
|
|
).fetchall()
|
|
|
|
items = [dict(r, restorable=True) for r in rows]
|
|
|
|
if is_admin:
|
|
from datetime import datetime, timezone
|
|
known = {r["quarantine_path"] for r in rows}
|
|
try:
|
|
for p in _quarantine_dir().iterdir():
|
|
if not p.is_file() or str(p) in known:
|
|
continue
|
|
st = p.stat()
|
|
# UTC, same naive "YYYY-MM-DD HH:MM:SS" shape SQLite's CURRENT_TIMESTAMP
|
|
# produces for the tracked rows above, so the two sort and format together.
|
|
mtime_utc = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
|
|
items.append({
|
|
"id": None,
|
|
"user_id": None,
|
|
"quarantine_path": str(p),
|
|
"original_path": None,
|
|
"replaced_by_path": None,
|
|
"artist": None,
|
|
"title": p.name.split("__", 1)[-1] if "__" in p.name else p.name,
|
|
"file_size": st.st_size,
|
|
"quarantined_at": mtime_utc.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"restorable": False,
|
|
})
|
|
except OSError:
|
|
pass
|
|
items.sort(key=lambda x: x["quarantined_at"], reverse=True)
|
|
|
|
return items
|
|
|
|
|
|
def restore_from_quarantine(user_id: str | None, entry_id: int, is_admin: bool = False) -> dict:
|
|
"""Undo one upgrade: move the quarantined file back to where it lived, and
|
|
quarantine whatever the upgrade put there instead of deleting it outright.
|
|
"""
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute("SELECT * FROM upgrade_quarantine WHERE id=?", (entry_id,)).fetchone()
|
|
if row is None or (not is_admin and row["user_id"] != _norm_user(user_id)):
|
|
return {"status": "error", "reason": "Quarantine entry not found"}
|
|
|
|
quarantined = Path(row["quarantine_path"])
|
|
original = Path(row["original_path"])
|
|
replaced_by = Path(row["replaced_by_path"])
|
|
|
|
if not quarantined.exists():
|
|
with db_conn() as conn:
|
|
conn.execute("DELETE FROM upgrade_quarantine WHERE id=?", (entry_id,))
|
|
conn.commit()
|
|
return {"status": "error", "reason": "Quarantined file no longer exists on disk"}
|
|
|
|
import shutil
|
|
from datetime import datetime
|
|
|
|
# Whatever the upgrade put in its place gets bumped into quarantine itself
|
|
# (a format-changing upgrade, e.g. mp3 -> flac, means this is a different
|
|
# file from "original" below, so it needs clearing out of the way either way).
|
|
bumped_path = None
|
|
if replaced_by.exists():
|
|
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
bumped_path = _quarantine_dir() / f"{stamp}__{replaced_by.name}"
|
|
shutil.move(str(replaced_by), str(bumped_path))
|
|
|
|
if original != replaced_by and original.exists():
|
|
# Something unrelated is squatting on the restore target. Put the
|
|
# bumped file back and bail rather than guess what to do with either.
|
|
if bumped_path is not None:
|
|
shutil.move(str(bumped_path), str(replaced_by))
|
|
return {"status": "error",
|
|
"reason": f"Restore target already occupied, sort this out manually: {original}"}
|
|
|
|
try:
|
|
original.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(quarantined), str(original))
|
|
except Exception as e:
|
|
if bumped_path is not None:
|
|
shutil.move(str(bumped_path), str(replaced_by))
|
|
return {"status": "error", "reason": f"Restore failed: {e}"}
|
|
|
|
with db_conn() as conn:
|
|
conn.execute("DELETE FROM upgrade_quarantine WHERE id=?", (entry_id,))
|
|
if bumped_path is not None:
|
|
conn.execute(
|
|
"""INSERT INTO upgrade_quarantine
|
|
(user_id, quarantine_path, original_path, replaced_by_path,
|
|
artist, title, file_size)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(row["user_id"], str(bumped_path), str(replaced_by), str(bumped_path),
|
|
row["artist"], row["title"], bumped_path.stat().st_size),
|
|
)
|
|
conn.commit()
|
|
|
|
# Both ends of the undo. The quarantine sits under /data, outside every
|
|
# library root, so the index reads these as one departure and one arrival,
|
|
# which is exactly what they are.
|
|
if bumped_path is not None:
|
|
library_index.move_file(replaced_by, bumped_path)
|
|
library_index.move_file(quarantined, original)
|
|
|
|
scan_user_library(row["user_id"])
|
|
return {"status": "restored", "path": str(original)}
|
|
|
|
|
|
def delete_quarantine_entry(user_id: str | None, entry_id: int, is_admin: bool = False) -> dict:
|
|
"""Permanently discard a quarantined file. No undo past this point."""
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute("SELECT * FROM upgrade_quarantine WHERE id=?", (entry_id,)).fetchone()
|
|
if row is None or (not is_admin and row["user_id"] != _norm_user(user_id)):
|
|
return {"status": "error", "reason": "Quarantine entry not found"}
|
|
|
|
try:
|
|
path = Path(row["quarantine_path"])
|
|
if path.exists():
|
|
path.unlink()
|
|
except OSError as e:
|
|
return {"status": "error", "reason": f"Could not delete file: {e}"}
|
|
|
|
with db_conn() as conn:
|
|
conn.execute("DELETE FROM upgrade_quarantine WHERE id=?", (entry_id,))
|
|
conn.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
def delete_orphaned_quarantine_file(filename: str) -> dict:
|
|
"""Admin-only cleanup for a quarantine-folder file with no tracking row
|
|
(predates this table, or was dropped in by hand): no known original
|
|
location, so deletion is the only safe action.
|
|
"""
|
|
path = _quarantine_dir() / filename
|
|
try:
|
|
if path.parent.resolve() != _quarantine_dir().resolve() or not path.is_file():
|
|
return {"status": "error", "reason": "File not found"}
|
|
path.unlink()
|
|
except OSError as e:
|
|
return {"status": "error", "reason": f"Could not delete file: {e}"}
|
|
return {"status": "deleted"}
|
|
|
|
|
|
def _eligible_user_ids() -> list[str | None]:
|
|
"""Active non-peon users to scan, plus the global/single-user pass (None)."""
|
|
try:
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT id FROM users WHERE is_active = 1 AND role != 'peon'"
|
|
).fetchall()
|
|
ids = [r["id"] for r in rows]
|
|
except Exception:
|
|
ids = []
|
|
# None covers single-user mode (no per-user dirs) and the global settings fallback.
|
|
return ids if ids else [None]
|
|
|
|
|
|
_scheduler_running = False
|
|
_scheduler_lock = threading.Lock()
|
|
|
|
|
|
def run_scan_all() -> dict:
|
|
"""Scan every eligible user once. Safe to call from a route or the scheduler."""
|
|
totals = {"users": 0, "scanned": 0, "eligible": 0, "below_target": 0,
|
|
"skipped_unchanged": 0, "from_index": 0}
|
|
for uid in _eligible_user_ids():
|
|
try:
|
|
summary = scan_user_library(uid)
|
|
except Exception as e:
|
|
print(f"Upgrade scan error for user {uid}: {e}")
|
|
continue
|
|
totals["users"] += 1
|
|
totals["scanned"] += summary["scanned"]
|
|
totals["eligible"] += summary["eligible"]
|
|
totals["below_target"] += summary["below_target"]
|
|
totals["skipped_unchanged"] += summary.get("skipped_unchanged", 0)
|
|
totals["from_index"] += summary.get("from_index", 0)
|
|
return totals
|
|
|
|
|
|
def upgrade_scan_scheduler():
|
|
"""Background loop: periodically rescan eligible libraries. Cheap, no network."""
|
|
print(f"Upgrade scan scheduler started (every {UPGRADE_SCAN_INTERVAL_HOURS}h)")
|
|
time.sleep(45) # let the heavier watched schedulers settle first
|
|
while _scheduler_running:
|
|
try:
|
|
interval = get_setting_int("upgrade_scan_interval_hours", UPGRADE_SCAN_INTERVAL_HOURS)
|
|
if interval <= 0:
|
|
interval = UPGRADE_SCAN_INTERVAL_HOURS
|
|
totals = run_scan_all()
|
|
if totals["users"]:
|
|
print(f"Upgrade scan: {totals['below_target']} below-target across "
|
|
f"{totals['eligible']} eligible files ({totals['users']} user(s)), "
|
|
f"{totals['skipped_unchanged']} unchanged and left on disk, "
|
|
f"{totals['from_index']} answered by the index")
|
|
except Exception as e:
|
|
print(f"Upgrade scan scheduler error: {e}")
|
|
interval = UPGRADE_SCAN_INTERVAL_HOURS
|
|
|
|
sleep_seconds = max(3600, interval * 3600)
|
|
elapsed = 0
|
|
while elapsed < sleep_seconds and _scheduler_running:
|
|
time.sleep(60)
|
|
elapsed += 60
|
|
|
|
|
|
def start_upgrade_scheduler():
|
|
"""Start the background scan scheduler if not already running."""
|
|
global _scheduler_running
|
|
with _scheduler_lock:
|
|
if _scheduler_running:
|
|
return
|
|
_scheduler_running = True
|
|
spawn_daemon_thread(upgrade_scan_scheduler)
|