From d28f8cd20858bc2d45103a3bc9bd6d07d9870789 Mon Sep 17 00:00:00 2001 From: DNSZLSK <155122610+DNSZLSK@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:43:34 +0200 Subject: [PATCH 1/3] fix: protect library files and release 0.2.25 --- .github/workflows/build-macos.yml | 3 + .github/workflows/tests.yml | 31 +++++ README.md | 43 ++++--- ddd/__init__.py | 2 +- ddd/cli.py | 23 ++-- ddd/core/content.py | 35 ++++++ ddd/core/detect.py | 4 +- ddd/core/fsutil.py | 36 ++++++ ddd/core/quality.py | 42 +++---- ddd/core/rename.py | 33 +---- ddd/core/scan.py | 24 ++-- ddd/core/soulseek.py | 33 ----- ddd/core/upgrade.py | 198 ++++++++++++++++-------------- ddd/gui.py | 41 ++++--- ddd/gui_theme.py | 4 +- docker/README.md | 4 +- docs/index.html | 28 ++--- packaging/README.md | 4 +- packaging/build.ps1 | 5 + pyproject.toml | 4 + tests/test_cjk_paths.py | 2 +- tests/test_detect.py | 2 +- tests/test_legacy_scripts.py | 37 ++++++ tests/test_library_safety.py | 192 +++++++++++++++++++++++++++++ tests/test_safety_wiring.py | 95 ++++++++++++++ tests/test_scan_merge.py | 10 +- tests/test_upgrade_forced.py | 6 +- tests/test_upgrade_logic.py | 64 +++++----- 28 files changed, 698 insertions(+), 307 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 ddd/core/content.py create mode 100644 tests/test_legacy_scripts.py create mode 100644 tests/test_library_safety.py create mode 100644 tests/test_safety_wiring.py diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index ef10901..c44e4e5 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -14,7 +14,10 @@ permissions: contents: write # upload de l'asset sur la release jobs: + tests: + uses: ./.github/workflows/tests.yml build: + needs: tests name: DDD.app (Apple Silicon) runs-on: macos-14 steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fc5253f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install test dependencies + run: python -m pip install -e ".[gui,test]" + - name: Run all tests (core, CLI, GUI and legacy scenarios) + run: python -m pytest -q + - name: CLI smoke test + run: python -m ddd --help diff --git a/README.md b/README.md index 856d458..218ed82 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ **DDD cleans up your music library and bumps it to club-playable quality, on its own.** You point it at a folder (or your Discogs / Bandcamp favorites), and DDD: -- spots the **fake lossless**: MP3s re-encoded as .flac/.wav/.aiff that *look* lossless but aren't, -- goes and finds a **real version** on Soulseek (FLAC, WAV or AIFF, with an automatic MP3 320 fallback if nothing lossless turns up), -- checks the **spectrum** of the downloaded file to make sure it holds up (not an upscale), that it's the **right track** and not a snippet, -- files it into **a single** clean library, and sends the fakes to the trash. +- flags **limited bandwidth and suspicious encodings** for review, +- searches for a **candidate version** on Soulseek (FLAC, WAV or AIFF, with an automatic MP3 320 fallback if nothing lossless turns up), +- checks the downloaded file against your **bandwidth threshold**, requested identity and duration, +- adds accepted candidates to your library and **retains originals by default**. No need to be a developer: download the `.exe`, double-click, it's a window. @@ -57,26 +57,28 @@ No need to be a developer: download the `.exe`, double-click, it's a window. ## What it does - **Quality scan**: every file is ranked by its **spectral cutoff** (the frequency where the sound stops) into one of four bands, plus duplicates: - - **Lossless** (green): full spectrum, real lossless. + - **Wide** (green): wide spectrum observed; compression history remains unknown. The CSV identifier `LOSSLESS` is retained for compatibility. - **HQ** (blue): >= 18 kHz, playable on a big system (includes MP3 320). - **Iffy** (yellow): 16-18 kHz, borderline. - - **Bad** (red): < 16 kHz, mush. + - **Review** (red): reduced bandwidth or bitrate below the configured floor. - **Reads pretty much anything**: FLAC, WAV, AIFF, MP3, OGG/Opus, **MP4 / M4A** (AAC *and* ALAC), **WMA** (including WMA Lossless), APE, TTA, WavPack. That old folder of iTunes-era `.mp4` rips or Windows Media `.wma` gets scanned like the rest, instead of showing up empty. What's *inside* the container decides: an ALAC `.m4a` is treated as lossless and put through the same anti-upscale spectral check as a FLAC, not written off as lossy because of its extension. - **Quality / target modes** (in Settings) - the bar DDD keeps to, and what it goes hunting for: - **DJ Club** (>= 18 kHz) - *default*: keeps anything club-playable, MP3 320 included. - **Audiophile** (>= 20 kHz): rejects MP3s below 320. - - **Purist** (pure lossless): real full-spectrum lossless only; if it's not on Soulseek -> buy links, no MP3 fallback. + - **Purist** (wide spectrum): only candidates classified as wide spectrum; if it's not on Soulseek -> buy links, no MP3 fallback. - **MP3 320** (vintage / mobile): hunts MP3 320 straight, skips FLAC - for old gear that won't read FLAC, or syncing over mobile data. Bumps your sub-320s up to 320; leaves the lossless you already have untouched. - **WAV/AIFF only** / **FLAC only**: target a single lossless container (old CDJs/samplers that won't read FLAC, or a FLAC-homogeneous library). DDD never transcodes what you already own - the mode just picks the format of what it fetches. -- **Upgrade**: replaces your below-the-bar files with something better, found on Soulseek. In **DJ Club / Audiophile** it looks for FLAC, WAV and AIFF (lots of DJs share in WAV/AIFF), with an **automatic MP3 320 fallback** for tracks that can't be found in lossless. MP3s below 320 kbps are **banned across the board**, whatever the mode. +- **Upgrade**: downloads candidates from Soulseek and retains the originals by default. The CLI `--trash-original` option or GUI checkbox explicitly enables removal, only after a verified copy has been installed. Name collisions retain both names using a numbered suffix. In **DJ Club / Audiophile** it looks for FLAC, WAV and AIFF (lots of DJs share in WAV/AIFF), with an **automatic MP3 320 fallback** for tracks that can't be found in lossless. MP3s below 320 kbps are **banned across the board**, whatever the mode. - **Get favorites**: scrapes your Discogs wantlist / Bandcamp wishlist and downloads it. - **YouTube set / playlist**: paste a set URL (YouTube / 1001Tracklists) or a **YouTube playlist** (each video = a track) -> DDD extracts the tracklist into a want-list (CSV). -- **Single library**: everything that passes lands in `~/Music/DDD` (changeable in Settings), de-duplicated. Rejects go to the **trash** (recoverable), never hard-deleted. +- **Single library**: everything that passes lands in `~/Music/DDD` (changeable in Settings), de-duplicated. Rejected download candidates go to the **trash**. Import leaves rejected, unreadable and duplicate source files in place. - **Sort by genre** (*Sort by genre* button / `ddd sort`): files your loose tracks into your own **vibe folders** through a cascade - the file's **ID3 genre tag**, then **Discogs** (+ MusicBrainz), and when both come up empty a **local audio model** (Discogs-EffNet, 400 Discogs styles) that reads the genre **from the spectrum** - so even an untagged, badly-named edit (`Track_01.flac`) lands in the right folder instead of `_INBOX`. The default set is house/techno-oriented (ACID, DEEPWATER, HOUSERZ, PROG, TECHNO, TRANCE, GARAGE, DISCO-FUNK, BREAKS-ELECTRO) and is **fully editable** in Settings. **Dry-run by default** - you preview, then Apply. Only loose files are touched, never your curated subfolders. (The audio model runs **on-device, no cloud**; by the MTG/UPF, CC BY-NC.) - **Identify - recover lost names** (*Identify* tab / `ddd identify`): files called `YH1`, `track01`, `unknown 04`? DDD reads each file's **acoustic fingerprint** (Chromaprint) and matches it against **AcoustID** (the open, MusicBrainz-backed database, same idea as Shazam) to recover *Artist - Title*. It **proposes**, you **confirm track by track** (confident matches pre-ticked, uncertain ones flagged), then it renames + tags only what you kept - **never a blind rename**. Underground / unreleased tracks that aren't in the database just stay untouched. - **Not found -> buy links**: whatever Soulseek can't find comes out as a clickable page (DDD logo + theme) with **Discogs** (vinyl marketplace, perfect for old pressings) and **Bandcamp** links to buy it. -**The safety net: the spectrum is law.** Every download is re-audited by spectrum (FFT); **the declared format and bitrate are only used for the Soulseek search, never for the keep-or-reject decision.** The spectrum doesn't lie, tags do - that's what tells a real 320 / lossless apart from an upscale (an MP3 128 re-encoded as .flac or .wav, which Soulseek's filters can't see). A file is only kept if it passes three checks: **spectral** (above the preset's bar, not an upscale), **duration** (not a snippet / preview) and **title + artist identity** (the right track, not a wrong match). Otherwise -> trash. +**Safety and limits.** Spectral analysis measures bandwidth; it cannot prove the history of a recording. A genuine filtered recording can fall below your chosen threshold. Review by listening before removing originals. Downloads must match the complete normalized title, a complete named collaborator, the requested version and measured duration (10% tolerance, minimum 2 seconds). Without a reference duration, candidates under 90 seconds are rejected; unknown duration or artist is not accepted. Matching is conservative and can reject valid naming variations. + +Accepted downloads are copied without overwriting existing files, flushed and checked by SHA-256 before staging is removed. Original removal is opt-in and happens afterwards. Import never trashes source files; importing a library into itself is a no-op. Scan and rename only label content-identical files as duplicates; import retains such copies for review. ## Getting started (user) @@ -91,7 +93,7 @@ No need to be a developer: download the `.exe`, double-click, it's a window. **Get favorites** tab: pick Discogs/Bandcamp -> *Fetch & download*. > The 3 D's: **DIG** your sources -> **DOWNLOAD** from Soulseek -> **DETECT** by spectrum. The output -> is your verified lossless library, which you can then share / point anywhere you want. +> is a library of candidates checked against your selected thresholds, which you can then share / point anywhere you want. ## Usage & responsibility @@ -127,10 +129,10 @@ playlists (yt-dlp). Everything is bundled into the `.exe` (no Python, no ffmpeg # Scan a folder: Lossless / HQ / Iffy / Bad? well named? duplicates? .\.venv\Scripts\python.exe -m ddd scan "C:\path\to\Music" -# Upgrade: drops real lossless into the library, fake source -> trash +# Upgrade: add accepted copies; retain originals (use --trash-original to opt into removal) .\.venv\Scripts\python.exe -m ddd upgrade "C:\path\to\Music" -# Import an existing folder into the library (lossless kept, the rest trashed) +# Import accepted files; leave rejects, errors and confirmed duplicates at source .\.venv\Scripts\python.exe -m ddd import "C:\path\to\Music" # Rename a folder back to "Artist - Title" (from name + tags; dry-run, --apply to write) @@ -164,10 +166,21 @@ Query resolution: name `Artist - Title` -> else ID3/Vorbis tags -> else title-on Compilations (`Various Artists`), vinyl side prefixes (`A1`, `B2`...) and artists duplicated in the title are normalized before the search. +### Verification + +```sh +python -m pip install -e ".[gui,test]" +python -m pytest -q +``` + +The suite includes the standalone legacy scenarios in isolated subprocesses. CI runs core, +CLI and GUI construction tests on Windows, Linux and macOS for pull requests. The macOS +release build depends on this suite; the Windows build script also runs it before packaging. + ### Building the `.exe` ```powershell -.\.venv\Scripts\python.exe -m pip install -e ".[gui,build]" +.\.venv\Scripts\python.exe -m pip install -e ".[gui,build,test]" .\packaging\build.ps1 ``` @@ -189,7 +202,7 @@ docker run --rm -v /mnt/music:/music ddd scan /music -o /music/ddd-scan.csv docker run --rm \ -e DDD_SOULSEEK_USER=you -e DDD_SOULSEEK_PASS=secret \ -v /mnt/music:/music \ - ddd upgrade /music --download-dir /music --apply + ddd upgrade /music --download-dir /music ``` x86_64 only. Details: `docker/README.md`. diff --git a/ddd/__init__.py b/ddd/__init__.py index ab5b162..08e9821 100644 --- a/ddd/__init__.py +++ b/ddd/__init__.py @@ -4,4 +4,4 @@ dig le spectre du fichier. Coeur Python portable (CLI + futur GUI). """ -__version__ = "0.2.24" +__version__ = "0.2.25" diff --git a/ddd/cli.py b/ddd/cli.py index 1c54ed1..6fcdace 100644 --- a/ddd/cli.py +++ b/ddd/cli.py @@ -27,7 +27,7 @@ # Ordre d'affichage du resume (du plus actionnable au moins) VERDICT_ORDER = [LOSSLESS, HQ, DOUTEUX, MAUVAIS, "ERROR", "SKIPPED"] VERDICT_LABEL = { - LOSSLESS: "lossless (full spectrum)", + LOSSLESS: "wide spectrum (compression history unknown)", HQ: "HQ (club-playable, >=18 kHz)", DOUTEUX: "iffy (16-18 kHz)", MAUVAIS: "bad (<16 kHz)", @@ -144,12 +144,14 @@ def progress(*a) -> None: print(f" scan [{a[0]}/{a[1]}] {Path(a[2]).name}", file=sys.stderr) print(f"Upgrade of {folder} -> library {dl_dir}", file=sys.stderr) - print("(real lossless added to the library; fake sources sent to trash)", + print(("(verified copies added; originals sent to trash after verification)" + if args.trash_original else "(verified copies added; originals retained)"), file=sys.stderr) outcomes = upgrade_mod.run_upgrade( folder, root=root, staging_dir=staging, download_dir=dl_dir, preset=preset, exclude_names=args.exclude, limit=args.limit, profile=args.profile, progress=progress, log_path=log_path, + trash_original=args.trash_original, ) from collections import Counter @@ -158,9 +160,9 @@ def progress(*a) -> None: for action, n in counts.most_common(): print(f" {action:<16} {n:>5}") - replaced = [o for o in outcomes if o.action in (upgrade_mod.ACT_REPLACED, upgrade_mod.ACT_WOULD_REPLACE)] + replaced = [o for o in outcomes if o.action in (upgrade_mod.ACT_REPLACED, upgrade_mod.ACT_KEPT_BESIDE, upgrade_mod.ACT_WOULD_REPLACE)] if replaced: - print(f"\n --- {len(replaced)} AUTHENTIC upgrade(s) ---") + print(f"\n --- {len(replaced)} accepted upgrade(s) ---") for o in replaced: print(f" {o.artist} - {o.title} cutoff {o.new_cutoff_hz:.0f} Hz") @@ -601,7 +603,7 @@ def progress(*a) -> None: print(f" {action:<16} {n:>5}") acquired = [o for o in outcomes if o.action == upgrade_mod.ACT_ACQUIRED] if acquired: - print(f"\n --- {len(acquired)} AUTHENTIC track(s) in the library ---") + print(f"\n --- {len(acquired)} accepted track(s) in the library ---") for o in acquired: print(f" {o.artist} - {o.title} cutoff {o.new_cutoff_hz:.0f} Hz") buy_html = stores_mod.write_unfindable(outcomes, paths.outputs_dir(), src.stem) @@ -625,9 +627,10 @@ def progress(i: int, total: int, f: Path) -> None: print(f"Import of {src} -> library {dl_dir}", file=sys.stderr) stats = upgrade_mod.import_folder(src, dl_dir, exclude_names=args.exclude, progress=progress) print(f"\n=== Import: {src.name} ===") - print(f" {stats['kept']:>5} real lossless moved to the library") - print(f" {stats['duplicates']:>5} duplicates (already in library) -> trash") - print(f" {stats['trashed']:>5} non-lossless -> trash") + print(f" {stats['kept']:>5} accepted files moved to the library") + print(f" {stats['duplicates']:>5} confirmed duplicates retained at source") + print(f" {stats['retained']:>5} below threshold, retained for review") + print(f" {stats['errors']:>5} unreadable / failed, retained") print(f" {stats['total']:>5} files scanned in total") return 0 @@ -715,6 +718,8 @@ def build_parser() -> argparse.ArgumentParser: p_up.add_argument("--profile", default=None, help="force a sldl profile (default: derived from --preset)") p_up.add_argument("-v", "--verbose", action="store_true", help="show each scanned file") + p_up.add_argument("--trash-original", action="store_true", + help="send originals to trash only after verified installation (default: retain)") p_up.set_defaults(func=_cmd_upgrade) p_sc = sub.add_parser("scrape", help="scrape favorites/tracklists -> CSV want-list (-> acquire)") @@ -743,7 +748,7 @@ def build_parser() -> argparse.ArgumentParser: p_ac.set_defaults(func=_cmd_acquire) p_im = sub.add_parser("import", - help="migrate a folder into the library (lossless kept, the rest trashed)") + help="move accepted files into the library; retain rejects, errors and duplicates") p_im.add_argument("folder", help="folder to import/sort") p_im.add_argument("--download-dir", help="target library (default: config / ~/Music/DDD)") p_im.add_argument("-x", "--exclude", action="append", default=[], metavar="NAME", diff --git a/ddd/core/content.py b/ddd/core/content.py new file mode 100644 index 0000000..12e090f --- /dev/null +++ b/ddd/core/content.py @@ -0,0 +1,35 @@ +"""Content checks shared by scan, rename and library imports.""" + +from collections import defaultdict +import hashlib +from pathlib import Path + + +def file_hash(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def duplicate_paths(files): + """Group readable files by size then SHA-256; unreadable files stay unique.""" + sizes = defaultdict(list) + for path in dict.fromkeys(Path(p) for p in files): + try: + sizes[path.stat().st_size].append(path) + except OSError: + continue + groups = [] + for candidates in sizes.values(): + if len(candidates) < 2: + continue + hashes = defaultdict(list) + for path in candidates: + try: + hashes[file_hash(path)].append(path) + except OSError: + continue + groups.extend(group for group in hashes.values() if len(group) > 1) + return groups diff --git a/ddd/core/detect.py b/ddd/core/detect.py index a96d83b..bac52dd 100644 --- a/ddd/core/detect.py +++ b/ddd/core/detect.py @@ -122,8 +122,8 @@ def forensic_classify( # aucune signature MP3 confirmee (est1 None) + pas de red flag conteneur. Un vrai 320 # plafonne ~20.5 kHz (< 95% Nyquist), donc il ne remonte PAS ici -> promotion sure. if base_v != q.LOSSLESS and est1 is None and near_nyquist and not flac_red_flag: - reason = f"cutoff {cutoff:.0f} Hz >= 95% Nyquist, aucune signature MP3 (Rule 1/8) -> authentique" - return q.LOSSLESS, "high", reason, est, signals + reason = f"cutoff {cutoff:.0f} Hz >= 95% Nyquist, bande passante large (Rule 1/8)" + return q.LOSSLESS, "uncertain", reason + "; compression history unknown", est, signals # ZONE GRISE : le legacy retrograde (HQ/DOUTEUX) mais Rule 1 ne confirme PAS de MP3. # On ne change NI le verdict NI le cutoff (is_accepted inchange) : juste la confiance. diff --git a/ddd/core/fsutil.py b/ddd/core/fsutil.py index 11a4420..332a3d6 100644 --- a/ddd/core/fsutil.py +++ b/ddd/core/fsutil.py @@ -8,8 +8,44 @@ from __future__ import annotations import shutil +import os from pathlib import Path +from .content import file_hash + + +def verified_deposit(src, dest_dir) -> Path: + """Copy into an exclusively reserved name and verify bytes before removing staging. + + On failure only our incomplete copy is removed. The source remains available. + A collision keeps both files; existing library files are never overwritten. + """ + src, dest_dir = Path(src), Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / src.name + if dest.resolve() == src.resolve(): + raise ValueError("download staging must be separate from the library") + index = 0 + while True: + try: + output = dest.open("xb") + break + except FileExistsError: + index += 1 + dest = dest_dir / f"{src.stem} ({index}){src.suffix}" + try: + with output, src.open("rb") as source: + shutil.copyfileobj(source, output) + output.flush() + os.fsync(output.fileno()) + if src.stat().st_size != dest.stat().st_size or file_hash(src) != file_hash(dest): + raise OSError("download copy verification failed") + except BaseException: + dest.unlink(missing_ok=True) + raise + src.unlink() + return dest + def safe_move(src, dest_dir, *, dry_run: bool = False) -> Path: """Deplace `src` dans `dest_dir`, en suffixant ' (n)' si le nom est deja pris. diff --git a/ddd/core/quality.py b/ddd/core/quality.py index d393ab8..4d8931a 100644 --- a/ddd/core/quality.py +++ b/ddd/core/quality.py @@ -1,14 +1,11 @@ -"""Detecteur de qualite lossless universel. +"""Mesure de bande passante audio et classement selon les seuils utilisateur. -Classe n'importe quel fichier audio en vrai lossless vs lossy/upscale via -l'analyse du cutoff spectral. On reutilise la math eprouvee de flac-detective -(detect_cutoff, calculate_high_frequency_energy, estimate_mp3_bitrate) mais avec -un lecteur leger par fenetres (3 x 30 s) qui marche pareil sur WAV/FLAC/AIFF/MP3, -sans la copie-vers-temp ni le cache oriente-FLAC de flac-detective. +Les bandes mesurent le cutoff spectral. Elles ne prouvent ni l'historique de +compression ni la qualite perceptuelle. Un enregistrement filtre peut etre +lossless avec une bande passante reduite. LOSSLESS est un identifiant historique +conserve dans les rapports pour la bande large, pas une preuve de provenance. -Principe : un vrai lossless garde de l'energie jusqu'a ~Nyquist (~20-22 kHz en -44.1 kHz) ; un MP3 reencode en lossless a un mur (cutoff) plus bas -(~16 kHz pour du 128, ~20 kHz pour du 320). +Analyse par fenetres (3 x 30 s), avec les fonctions spectrales de flac-detective. """ from __future__ import annotations @@ -45,7 +42,7 @@ # Verdicts : 4 bandes orientees "jouable en club", classees par le cutoff spectral # mesure. La math reste celle de flac-detective (detect_cutoff/estimate_mp3_bitrate) ; # on ne change QUE la facon de nommer/grouper le resultat. -LOSSLESS = "LOSSLESS" # plein spectre (estimate_mp3_bitrate == 0) - vrai lossless +LOSSLESS = "LOSSLESS" # bande large (identifiant historique, pas une preuve de provenance) HQ = "HQ" # cutoff >= 18 kHz - jouable club (inclut le MP3 320) DOUTEUX = "DOUTEUX" # 16-18 kHz - audible sur bon systeme, a revoir MAUVAIS = "MAUVAIS" # < 16 kHz - bouillie / MP3 bas debit @@ -54,8 +51,7 @@ SAMPLE_WINDOW_S = 30.0 LONG_FILE_S = 90.0 -# Un FLAC dont le bitrate conteneur est sous ce seuil vient quasi surement d'un MP3. -# (Ne s'applique pas au WAV : le WAV non compresse est toujours ~1411 kbps.) +# Indice secondaire du mode forensic, jamais une preuve de compression avec perte. FLAC_BITRATE_RED_FLAG = 160 # Frontieres de bande (Hz) sur le cutoff mesure @@ -218,14 +214,7 @@ def _spectral(path: Path, info: AudioInfo) -> Optional[Tuple]: def _aggregate_windows(cutoffs: List[float], hfs: List[float]) -> Tuple[float, float, float]: - """Agrege les fenetres en (cutoff, cutoff_std, hf) de la fenetre LA PLUS REVELATRICE. - - On prend le MAX du cutoff, pas le min : une seule fenetre plein spectre prouve que le - fichier porte vraiment les aigus -> vrai lossless. Un transcode ne peut PAS fabriquer une - fenetre a cutoff haut (les aigus sont jetes a l'encodage), donc le max ne laisse pas passer - de faux ; le min, lui, rejetait a tort les morceaux dynamiques (un breakdown filtre ou une - intro calme = fenetre pauvre en HF qui tirait tout le fichier sous la barre). Le hf suit la - meme fenetre que le cutoff retenu (coherence pour un futur detecteur de resample).""" + """Use the widest observed bandwidth; this is not proof of lossless provenance.""" best = max(range(len(cutoffs)), key=cutoffs.__getitem__) cutoff_std = float(np.std(cutoffs)) if len(cutoffs) > 1 else 0.0 return cutoffs[best], cutoff_std, hfs[best] @@ -238,19 +227,16 @@ def _band(cutoff: float, est: int) -> Tuple[str, str, str]: position du mur : >=18 kHz jouable (HQ), 16-18 limite (DOUTEUX), <16 bouillie. """ if est == 0: - return LOSSLESS, "high", f"spectre plein, cutoff {cutoff:.0f} Hz" + return LOSSLESS, "uncertain", f"spectre large, cutoff {cutoff:.0f} Hz; origine de compression indeterminee" if cutoff >= HQ_CUTOFF_HZ: - return HQ, "medium", f"cutoff {cutoff:.0f} Hz (~{est} kbps) - jouable club" + return HQ, "uncertain", f"cutoff {cutoff:.0f} Hz; bande passante elevee, origine indeterminee" if cutoff >= DOUTEUX_CUTOFF_HZ: - return DOUTEUX, "medium", f"cutoff {cutoff:.0f} Hz (~{est} kbps) - limite, a revoir" - return MAUVAIS, "high", f"cutoff {cutoff:.0f} Hz (~{est} kbps) - source lossy bas debit" + return DOUTEUX, "uncertain", f"cutoff {cutoff:.0f} Hz; bande passante limitee, a ecouter" + return MAUVAIS, "uncertain", f"cutoff {cutoff:.0f} Hz; bande passante reduite, origine indeterminee" def _classify_lossless(cutoff: float, container_bitrate: int, ext: str) -> Tuple[str, str, str]: est = estimate_mp3_bitrate(cutoff) - if ext == ".flac" and 0 < container_bitrate < FLAC_BITRATE_RED_FLAG: - return (MAUVAIS, "high", - f"bitrate conteneur FLAC {container_bitrate} kbps < {FLAC_BITRATE_RED_FLAG} (source lossy)") return _band(cutoff, est) @@ -380,7 +366,7 @@ def is_accepted(qr: "QualityResult", preset: str = DEFAULT_PRESET) -> bool: preset = DEFAULT_PRESET floor = QUALITY_PRESETS[preset] if floor is None: # puriste / wav_aiff / flac_only : plein spectre uniquement - return qr.verdict == LOSSLESS + return qr.verdict == LOSSLESS and qr.format_class == "lossless_container" return qr.verdict == LOSSLESS or qr.cutoff_hz >= floor diff --git a/ddd/core/rename.py b/ddd/core/rename.py index a471cc1..670ce73 100644 --- a/ddd/core/rename.py +++ b/ddd/core/rename.py @@ -12,15 +12,14 @@ from __future__ import annotations import csv -import hashlib import logging import re -from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Sequence from . import trash +from .content import duplicate_paths as _dup_groups from .naming import resolve_name from .scan import iter_audio_files @@ -73,36 +72,6 @@ def _sanitize(name: str) -> str: return _ILLEGAL.sub("_", name).strip().rstrip(". ") -def _file_hash(p: Path, chunk: int = 1 << 20) -> str: - h = hashlib.sha1() - with open(p, "rb") as fh: - for blk in iter(lambda: fh.read(chunk), b""): - h.update(blk) - return h.hexdigest() - - -def _dup_groups(files: Sequence[Path]) -> List[List[Path]]: - """Groupes de fichiers byte-identiques : meme taille PUIS meme hash (hash que si collision de taille).""" - by_size: Dict[int, List[Path]] = defaultdict(list) - for f in files: - try: - by_size[f.stat().st_size].append(f) - except OSError: - pass - groups: List[List[Path]] = [] - for fs in by_size.values(): - if len(fs) < 2: - continue - by_hash: Dict[str, List[Path]] = defaultdict(list) - for f in fs: - try: - by_hash[_file_hash(f)].append(f) - except OSError: - pass - groups.extend(g for g in by_hash.values() if len(g) > 1) - return groups - - def _choose_keep(group: Sequence[Path]) -> Path: """Garde l'exemplaire au nom le plus propre (pas 'Copie'/'copy', le plus court, stable).""" def score(p: Path): diff --git a/ddd/core/scan.py b/ddd/core/scan.py index be9feaa..68a0995 100644 --- a/ddd/core/scan.py +++ b/ddd/core/scan.py @@ -11,12 +11,13 @@ import json import logging import os -from collections import Counter, defaultdict +from collections import Counter from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, Iterator, List, Optional, Sequence from . import audit as audit_mod +from .content import duplicate_paths from .audit import NameAudit from .quality import CSV_FIELDS, LOSSLESS_EXTS, LOSSY_EXTS, QualityResult, analyze_file @@ -92,7 +93,7 @@ class ScanRecord: quality: QualityResult naming: NameAudit size_bytes: int - dup_count: int # nb de fichiers partageant exactement cette taille (1 = unique) + dup_count: int # nb de fichiers de contenu identique (1 = unique) @property def is_duplicate(self) -> bool: @@ -137,11 +138,10 @@ def scan_library( exclude_names: Sequence[str] = (), progress: Optional[ProgressCb] = None, ) -> List[ScanRecord]: - """Scan complet : pour chaque fichier, qualite + nommage/tags, puis doublons (taille).""" + """Scan complet : pour chaque fichier, qualite + nommage/tags, puis doublons confirmes par contenu.""" files = list(iter_audio_files(root, exclude_names)) total = len(files) raw = [] - size_counts: Dict[int, int] = defaultdict(int) for i, f in enumerate(files, 1): try: q = analyze_file(f) @@ -151,8 +151,6 @@ def scan_library( except OSError: size = 0 raw.append((q, n, size)) - if size > 0: - size_counts[size] += 1 except Exception as e: # noqa: BLE001 # Un seul fichier bancal (tag exotique, I/O reseau en rade...) ne doit JAMAIS # tuer tout le scan : on le loggue et on l'ignore. @@ -162,17 +160,17 @@ def scan_library( records: List[ScanRecord] = [] for q, n, size in raw: - dup = size_counts.get(size, 1) if size > 0 else 1 + dup = 1 records.append(ScanRecord(q, n, size, dup)) + for group in duplicate_groups(records): + for rec in group: + rec.dup_count = len(group) return records def duplicate_groups(records: Sequence[ScanRecord]) -> List[List[ScanRecord]]: - """Regroupe les doublons (meme taille en octets) ; groupes de 2+ uniquement.""" - by_size: Dict[int, List[ScanRecord]] = defaultdict(list) - for r in records: - if r.size_bytes > 0: - by_size[r.size_bytes].append(r) - groups = [g for g in by_size.values() if len(g) > 1] + """Group files only after content equality has been confirmed.""" + by_path = {Path(r.quality.path): r for r in records} + groups = [[by_path[p] for p in group] for group in duplicate_paths(by_path)] groups.sort(key=lambda g: g[0].size_bytes, reverse=True) return groups diff --git a/ddd/core/soulseek.py b/ddd/core/soulseek.py index 94d47e4..ebe560c 100644 --- a/ddd/core/soulseek.py +++ b/ddd/core/soulseek.py @@ -185,39 +185,6 @@ def _slskd_config_path() -> Optional[Path]: return Path.home() / ".local" / "share" / "slskd" / "slskd.yml" -def stop_slskd() -> bool: - """Arrete slskd s'il tourne (Soulseek = un seul login par compte). True si stoppe.""" - try: - if platform.system() == "Windows": - r = subprocess.run(["taskkill", "/IM", "slskd.exe", "/F"], - capture_output=True, text=True, **_no_window_kwargs()) - return r.returncode == 0 - r = subprocess.run(["pkill", "-f", "slskd"], capture_output=True, text=True) - return r.returncode == 0 - except Exception as e: # noqa: BLE001 - logger.debug("stop_slskd: %r", e) - return False - - -def stop_orphan_sldl() -> bool: - """Tue d'eventuels sldl.exe orphelins d'un run precedent. - - Un sldl reste parfois vivant (loggue, port d'ecoute Soulseek 50300 ouvert) apres - une fermeture brutale de l'app. Le sldl suivant ne peut alors PAS binder le port - -> 'Failed to start listening on 0.0.0.0:50300' -> crash. On nettoie avant de lancer. - """ - try: - if platform.system() == "Windows": - r = subprocess.run(["taskkill", "/IM", "sldl.exe", "/F"], - capture_output=True, text=True, **_no_window_kwargs()) - return r.returncode == 0 - r = subprocess.run(["pkill", "-f", "sldl"], capture_output=True, text=True) - return r.returncode == 0 - except Exception as e: # noqa: BLE001 - logger.debug("stop_orphan_sldl: %r", e) - return False - - # --- Choix du port d'ecoute Soulseek ----------------------------------------- # sldl/slskd ecoutent par defaut sur 50300, qui tombe dans la plage ephemere Windows # (49152-65535) que Hyper-V/WSL/Docker RESERVENT par blocs. Le bind y echoue alors diff --git a/ddd/core/upgrade.py b/ddd/core/upgrade.py index 97a3370..b9ecfb6 100644 --- a/ddd/core/upgrade.py +++ b/ddd/core/upgrade.py @@ -8,23 +8,27 @@ en DJ Club/Audiophile, si rien n'est trouve en lossless, une 2e passe retente en MP3 320 (jamais sous 320) ; les modes cible-format cherchent directement leur format, sans repli. -Le remplacement est opt-in (apply=True). Par defaut : telecharge en staging, -re-audite, et rapporte ce qui SERAIT remplace, sans toucher la bibliotheque. +Les candidats acceptes sont deposes dans la bibliotheque. Les originaux sont +conserves par defaut ; trash_original=True autorise leur retrait apres depot verifie. """ from __future__ import annotations import logging import os +import math +import re +import unicodedata from dataclasses import dataclass, asdict, field from pathlib import Path from typing import Callable, Dict, List, Optional, Sequence from . import quality -from .fsutil import safe_move +from .fsutil import verified_deposit +from .content import duplicate_paths from .naming import match_key, parse_filename, normalize_artist_title, resolve_name, read_tags, search_title from .scan import scan_folder, scan_library, AUDIO_EXTS -from .tokenize import get_tokens, token_coverage, core_title_tokens, loose_title_tokens, version_key +from .tokenize import version_key from . import soulseek from . import trash from .soulseek import WantItem @@ -51,7 +55,6 @@ # Garde-fous post-download (sldl tourne en fuzzy ; c'est DDD qui filtre intelligemment) MIN_DURATION_S = 90 # < 90 s = quasi sûr un extrait / preview Soulseek -MIN_TITLE_COVERAGE = 0.6 # le fichier recu doit couvrir >=60% des tokens du titre (noyau) CHUNK_SIZE = 25 # taille des lots sldl : feedback par piste periodique sur gros batch @@ -60,17 +63,18 @@ def _chunks(seq, n: int): yield seq[i:i + n] -def _existing_keys(folder) -> set: +def _existing_keys(folder, exclude_paths=()) -> set: """match_key des pistes deja presentes (comme fichiers audio) dans un dossier. Sert a ne PAS re-telecharger ce qu'on a deja (acquire relance / inbox rempli). """ folder = Path(folder) keys = set() + excluded = {Path(p).resolve() for p in exclude_paths} if not folder.exists(): return keys for p in folder.rglob("*"): - if p.is_file() and p.suffix.lower() in AUDIO_EXTS: + if p.resolve() not in excluded and p.is_file() and p.suffix.lower() in AUDIO_EXTS: parsed = parse_filename(str(p)) if parsed.parseable: # MEME normalisation que la want-list et que existing.add au depot @@ -81,60 +85,59 @@ def _existing_keys(folder) -> set: return keys -def _title_coverage(req_title, cand_title): - """Couverture du titre demande dans le candidat (0..1), ou None si le titre demande - n'a aucun token jugeable meme en mode loose. +def _words(value): + normalized = unicodedata.normalize("NFKD", value).casefold() + return tuple(re.findall(r"[^\W_]+", "".join(c for c in normalized if not unicodedata.combining(c)))) - Le noyau (>=3 lettres, sans version/feat) est essaye d'abord ; repli loose (chiffres - + mots courts) pour les titres tres courts ('2 ME') qui sinon ne donneraient aucun - token et feraient sauter le check (l'ancien fail-open a -1).""" - core_req = core_title_tokens(req_title) - if core_req: - return token_coverage(core_req, core_title_tokens(cand_title)) - loose_req = loose_title_tokens(req_title) - if not loose_req: - return None - return token_coverage(loose_req, loose_title_tokens(cand_title)) + +def _artist_names(value): + # Match a complete collaborator, including short names and non-Latin scripts. + parts = re.split(r"\s*(?:,|&|/|\+|\b(?:feat\.?|ft\.?|featuring|vs\.?|with)\b|\s[xX]\s)\s*", value, flags=re.I) + return {words for part in parts if (words := _words(part))} + + +def _identity_title(value): + value = re.sub(r"(?i)\s*[([]?\b(?:feat\.?|ft\.?|featuring)\b.*$", "", value) + value = search_title(value) + value = re.sub(r"[([{](.*?)[])}]", + lambda m: " " if version_key(m[1]) or _words(m[1]) in (("original",), ("original", "mix")) else m[0], value) + return _words(value) + + +def _version_identity(value): + # Generic Original Mix is equivalent to no version. Named remixes stay distinct. + value = re.sub(r"(?i)[([]\s*original(?: mix| version)?\s*[])]", "", value) + qualifiers = re.findall(r"[([{](.*?)[])}]", value) + versions = tuple(_words(v) for v in qualifiers if version_key(v)) + return version_key(value), versions def _reject_reason(it, dl, q, preset=quality.DEFAULT_PRESET): - """Raison de rejet d'un download (action, note) ou None s'il est bon. - - Ordre : trop court (preview) -> mauvais match (identite) -> sous le seuil qualite du - preset (upscale/lossy/MP3<320). Le re-audit spectral ne verifie QUE la qualite, pas - l'identite ni la duree -> c'est ICI qu'on garantit qu'on ne remplace pas un fichier par - un autre track. Operation destructive : dans le doute, on garde l'original. - - Identite comparee au candidat PARSE (artiste vs champ artiste, titre vs titre), pas au - stem entier melange -> un mot du TITRE du candidat ne peut plus valider l'artiste demande - (bug "DJ Schema - 2 ME" remplace par "Theo Meier - Schema (Remix)" parce que "schema" - etait present quelque part) : - - TITRE : couverture du noyau >= 60% (repli loose pour les titres tres courts) ; - - ARTISTE : au moins un artiste demande present dans le CHAMP ARTISTE du candidat (repli - sur le stem entier si le nom n'a pas de ' - ' exploitable) -> tolere les collabs - nommees par l'autre membre, rejette une reprise par un autre artiste ; - - VERSION : original != (X Remix) != Extended/Radio... (symetrique selon la demande). + """Check measured duration, complete title, one complete collaborator and version. + + Unknown duration/artist fails closed. A known short recording is allowed when + its measured duration matches within 10% (at least two seconds tolerance). """ dur = getattr(q, "duration_s", 0) or 0 - if 0 < dur < MIN_DURATION_S: - return ACT_TOO_SHORT, f"too short ({dur:.0f}s < {MIN_DURATION_S}s): likely preview/sample" + if not math.isfinite(dur) or dur <= 0: + return ACT_WRONG_MATCH, "download duration unavailable" + if it.length and it.length > 0: + tolerance = max(2.0, it.length * 0.10) + if abs(dur - it.length) > tolerance: + return ACT_WRONG_MATCH, f"duration mismatch: expected {it.length}s, received {dur:.1f}s" + elif dur < MIN_DURATION_S: + return ACT_TOO_SHORT, f"too short ({dur:.0f}s < {MIN_DURATION_S}s): review required" cand = parse_filename(dl.filepath) - - t_cov = _title_coverage(it.title, cand.title) # None = titre non jugeable - title_ok = t_cov is not None and t_cov >= MIN_TITLE_COVERAGE - - artist_req = get_tokens(it.artist) # tous les collaborateurs (presence, pas couverture) - cand_artist = set(get_tokens(cand.artist)) if cand.parseable \ - else set(get_tokens(Path(dl.filepath).stem)) - artist_ok = (not artist_req) or any(tok in cand_artist for tok in artist_req) - - ver_ok = version_key(it.title) == version_key(cand.title) + title_ok = bool(_identity_title(it.title)) and _identity_title(it.title) == _identity_title(cand.title) + requested_artists = _artist_names(it.artist) + artist_ok = bool(cand.parseable and requested_artists & _artist_names(cand.artist)) + ver_ok = _version_identity(it.title) == _version_identity(cand.title) if not (title_ok and artist_ok and ver_ok): bits = [] if not title_ok: - bits.append("title n/a" if t_cov is None else f"title {t_cov:.0%}") + bits.append("title differs") if not artist_ok: bits.append("artist missing") if not ver_ok: @@ -243,7 +246,7 @@ def _deposit(src, download_dir) -> Path: `downloads/` ne contient donc que du lossless verifie. Collision de nom (rare) -> suffixe ' (n)'. Delegue le deplacement a `fsutil.safe_move`. Retourne le chemin final. """ - return safe_move(src, download_dir) + return verified_deposit(src, download_dir) def _finalize_download(it, dl, q, *, preset, download_dir, existing, trash_origin): @@ -258,29 +261,28 @@ def _finalize_download(it, dl, q, *, preset, download_dir, existing, trash_origi base.action = ACT_NOT_FOUND base.note = "sldl returned no file" return base, False + if it.origin_path and Path(dl.filepath).resolve() == Path(it.origin_path).resolve(): + base.action = ACT_WRONG_MATCH + base.note = "download points to original; retained" + return base, False base.new_file, base.new_verdict, base.new_cutoff_hz = dl.filepath, q.verdict, q.cutoff_hz rej = _reject_reason(it, dl, q, preset) if rej: base.action, base.note = rej trash.send_to_trash(dl.filepath) # candidat rejete -> corbeille return base, False - # Depot : par defaut a la racine de la bibliotheque. Mais si le SOURCE est deja DANS la - # bibliotheque (upgrade d'une track deja rangee -> typiquement un clic manuel), on REMPLACE - # sur place dans SON dossier au lieu de l'eparpiller a la racine. On vide la place AVANT le - # move (corbeille, recuperable) pour garder le bon nom (pas de suffixe ' (1)'). - in_place = bool(trash_origin and it.origin_path and _is_within(it.origin_path, download_dir)) - if in_place: - trash.send_to_trash(it.origin_path) - final = _deposit(dl.filepath, Path(it.origin_path).parent) - else: - final = _deposit(dl.filepath, download_dir) - if trash_origin: - trash.send_to_trash(it.origin_path) # le faux source -> corbeille + # Keep the old file until the complete candidate has been copied and verified. + in_place = bool(it.origin_path and _is_within(it.origin_path, download_dir)) + target = Path(it.origin_path).parent if in_place else download_dir + final = _deposit(dl.filepath, target) existing.add(match_key(it.artist, it.title)) base.new_file = str(final) - if trash_origin: - base.action = ACT_REPLACED - base.note = "upgraded in place" if in_place else "added to the library; original to trash" + if it.origin_path: + removed = trash_origin and trash.send_to_trash(it.origin_path) + base.action = ACT_REPLACED if removed else ACT_KEPT_BESIDE + base.note = ("verified copy installed; original sent to trash" if removed else + "verified copy installed; original retained" + + (" (trash failed)" if trash_origin else "")) else: base.action = ACT_ACQUIRED base.note = f"added to the library: {final}" @@ -370,11 +372,12 @@ def run_upgrade( log_path=None, on_chunk: Optional[Callable] = None, forced: bool = False, + trash_original: bool = False, ) -> List[UpgradeOutcome]: - """Upgrade : pour chaque faux/lossy, telecharge un vrai lossless valide, le DEPOSE - dans la bibliotheque `download_dir`, et envoie le fichier source (le faux) a la - corbeille. Pas de remplacement en place. `staging_dir` = cache transitoire (sldl - telecharge la avant validation). Retourne le rapport par fichier. + """Download and verify candidates, retaining originals unless explicitly requested. + + A retained original is reported as KEPT_BESIDE. Removal failure also retains it + and is recorded in the outcome note. In-library upgrades stay in their folder. """ root = Path(root) staging_dir = Path(staging_dir) @@ -403,7 +406,7 @@ def run_upgrade( # Dedoublonnage a l'entree : ce qui est already in library -> DUPLICATE. # On ne touche PAS au source dans ce cas (pas de check de version -> jamais de # suppression a l'aveugle sur un simple match de cle). - existing = _existing_keys(download_dir) + existing = _existing_keys(download_dir, (it.origin_path for it in plan.items)) to_dl: List[WantItem] = [] for it in plan.items: if not forced and match_key(it.artist, it.title) in existing: @@ -423,7 +426,7 @@ def run_upgrade( outcomes += _download_pass( to_dl, root=root, staging_dir=staging_dir, download_dir=download_dir, existing=existing, preset=preset, profile=profile, fallback_profile=fallback_profile, - trash_origin=True, csv_name="ddd_upgrade.csv", fallback_csv_name="ddd_upgrade_mp3.csv", + trash_origin=trash_original, csv_name="ddd_upgrade.csv", fallback_csv_name="ddd_upgrade_mp3.csv", progress=progress, on_item=on_item, on_proc=on_proc, cancel=cancel, log_path=log_path, on_chunk=on_chunk, ) @@ -459,8 +462,6 @@ def download_and_audit( input_csv = staging_dir / csv_name soulseek.write_input_csv(items, input_csv) - soulseek.stop_slskd() - soulseek.stop_orphan_sldl() # tue un sldl fige d'un run precedent (sinon port 50300 bloque) creds = creds or soulseek.read_soulseek_creds() if on_item: @@ -575,31 +576,40 @@ def import_folder( exclude_names: Sequence[str] = (), progress: Optional[Callable] = None, ) -> Dict[str, int]: - """Migre un dossier existant dans la bibliotheque : scanne `src`, DEPLACE ce qui - passe le seuil du preset (dedoublonne par match_key) vers `download_dir`, envoie - le reste (sous le seuil) a la corbeille. Reversible. Retourne un bilan. + """Move accepted, unique files; retain rejects, errors and confirmed duplicates. + + Never scan the destination as source, including a destination nested in src. + No file is trashed by import. Unknown quality is a review decision, not deletion. """ - download_dir = Path(download_dir) + src, download_dir = Path(src).resolve(), Path(download_dir).resolve() + stats = dict(total=0, kept=0, duplicates=0, trashed=0, retained=0, errors=0) + if _is_within(src, download_dir): + return stats download_dir.mkdir(parents=True, exist_ok=True) preset = preset or quality.preset_from_config() - records = scan_library(src, exclude_names=exclude_names, progress=progress) - existing = _existing_keys(download_dir) - stats = {"total": len(records), "kept": 0, "duplicates": 0, "trashed": 0} + records = [r for r in scan_library(src, exclude_names=exclude_names, progress=progress) + if not _is_within(r.quality.path, download_dir)] + stats["total"] = len(records) + existing_files = list(p for p in download_dir.rglob("*") if p.is_file() and p.suffix.lower() in AUDIO_EXTS) for rec in records: q = rec.quality - if quality.is_accepted(q, preset): - parsed = parse_filename(q.path) - key = match_key(parsed.artist, parsed.title) if parsed.parseable else None - if key and key in existing: - trash.send_to_trash(q.path) # vrai lossless mais doublon -> corbeille - stats["duplicates"] += 1 - else: - _deposit(q.path, download_dir) - if key: - existing.add(key) - stats["kept"] += 1 - else: - trash.send_to_trash(q.path) # pas un vrai lossless -> corbeille - stats["trashed"] += 1 + if q.verdict in (quality.ERROR, quality.SKIPPED): + stats["errors"] += 1 + continue + if not quality.is_accepted(q, preset): + stats["retained"] += 1 + continue + source = Path(q.path) + if any(source in group for group in duplicate_paths([source, *existing_files])): + stats["duplicates"] += 1 + continue + try: + final = _deposit(source, download_dir) + except OSError as exc: + logger.warning("import failed for %s: %s", source, exc) + stats["errors"] += 1 + continue + existing_files.append(final) + stats["kept"] += 1 logger.info("import_folder %s -> %s", src, stats) return stats diff --git a/ddd/gui.py b/ddd/gui.py index 3f2d773..705e0ad 100644 --- a/ddd/gui.py +++ b/ddd/gui.py @@ -112,7 +112,7 @@ def _is_upgradable(qr, preset): upgrade_mod.ACT_KEPT_BESIDE: ("kept beside ✓", theme.GREEN, False), upgrade_mod.ACT_ACQUIRED: ("kept in inbox ✓", theme.GREEN, False), upgrade_mod.ACT_ALREADY_GOOD: ("already good ✓", theme.BLUE, False), - upgrade_mod.ACT_REJECTED_FAKE: ("upscale -> trash ✗", theme.PINK, False), + upgrade_mod.ACT_REJECTED_FAKE: ("below threshold ✗", theme.PINK, False), upgrade_mod.ACT_TOO_SHORT: ("too short ✗", theme.PINK, False), upgrade_mod.ACT_WRONG_MATCH: ("wrong match ✗", theme.PINK, False), upgrade_mod.ACT_NOT_FOUND: ("not found ✗", TXT_DIM, False), @@ -124,10 +124,10 @@ def _is_upgradable(qr, preset): # les dicts de libelle ci-dessus -> la legende ne peut pas deriver de ce qui est reellement # rendu dans la colonne STATUS / la pastille BAND (retour testeur : aucune doc sur les statuts). VERDICT_HELP = { - quality.LOSSLESS: "Full spectrum, no lossy signature: verified true lossless.", + quality.LOSSLESS: "Wide spectrum observed. This does not prove lossless provenance.", quality.HQ: "Cutoff at or above 18 kHz: club-playable (includes a clean MP3 320).", - quality.DOUTEUX: "Cutoff 16-18 kHz: audible on a good system, worth re-checking.", - quality.MAUVAIS: "Cutoff below 16 kHz: low-bitrate mush.", + quality.DOUTEUX: "Cutoff 16-18 kHz: limited bandwidth. Compression history unknown.", + quality.MAUVAIS: "Reduced bandwidth or below the configured bitrate threshold. Review before replacing.", "SKIPPED": "Not an analyzable audio file (or the read failed).", } PHASE_HELP = { @@ -143,7 +143,7 @@ def _is_upgradable(qr, preset): upgrade_mod.ACT_KEPT_BESIDE: "Downloaded and verified, kept next to the original.", upgrade_mod.ACT_ACQUIRED: "New track fetched and kept in the library.", upgrade_mod.ACT_ALREADY_GOOD: "Already above the quality bar: nothing to upgrade.", - upgrade_mod.ACT_REJECTED_FAKE: "The download was a fake/upscale below the bar: discarded.", + upgrade_mod.ACT_REJECTED_FAKE: "The candidate was below the selected threshold; this does not prove a fake.", upgrade_mod.ACT_TOO_SHORT: "The download was a preview/sample (under 90 s): discarded.", upgrade_mod.ACT_WRONG_MATCH: "The download was a different track (title/artist/version): discarded.", upgrade_mod.ACT_NOT_FOUND: "Nothing suitable found on Soulseek.", @@ -324,7 +324,8 @@ def _refresh_slsk() -> None: # Helpers partages # ==================================================================== def set_busy(b: bool) -> None: - """Une seule operation a la fois : verrouille les actions des 2 onglets.""" + """Une seule operation a la fois : verrouille les actions des onglets.""" + trash_original_cb.disabled = b state.busy = b progress.visible = b for btn in (browse_btn, sort_browse_btn, scan_btn, acquire_btn, djset_fetch_btn, @@ -457,17 +458,13 @@ def do_cancel(_e) -> None: page.update() def _kill_sldl_on_exit(_e=None) -> None: - """Filet de securite a la fermeture de la fenetre : tue le sldl en cours s'il y en - a un. Sinon il survit orphelin et tient le port 50300 -> ralentit/bloque le run - suivant (le zombie recurrent). Le kill en tete de chaque lot le rattrape aussi, - mais autant ne jamais le creer.""" + """Terminate only the child process owned by this window.""" proc = state.active_proc if proc is not None: try: proc.terminate() except Exception: # noqa: BLE001 pass - soulseek.stop_orphan_sldl() page.on_disconnect = _kill_sldl_on_exit # fenetre fermee / client deconnecte atexit.register(_kill_sldl_on_exit) # repli : sortie du process @@ -509,7 +506,7 @@ def _refresh_preset_label() -> None: l1, l2 = { "dj_club": ("keep >= 18 kHz", "DJ CLUB"), "audiophile": ("keep >= 20 kHz", "AUDIOPHILE"), - "puriste": ("pure lossless", "PURIST"), + "puriste": ("wide spectrum", "PURIST"), "mp3_320": ("target MP3 320", "MP3 320"), "wav_aiff": ("target WAV/AIFF", "WAV/AIFF"), "flac_only": ("target FLAC", "FLAC ONLY"), @@ -529,7 +526,7 @@ def _refresh_upgrade_count() -> None: options=[ ft.dropdown.Option(key="upgradable", text="To upgrade (below the bar)"), ft.dropdown.Option(key="all", text="All"), - ft.dropdown.Option(key=quality.LOSSLESS, text="Lossless"), + ft.dropdown.Option(key=quality.LOSSLESS, text="Wide spectrum"), ft.dropdown.Option(key=quality.HQ, text="HQ"), ft.dropdown.Option(key=quality.DOUTEUX, text="Iffy"), ft.dropdown.Option(key=quality.MAUVAIS, text="Bad"), @@ -785,6 +782,7 @@ def do_upgrade(_e) -> None: # Clic manuel (au moins une case cochee) = override : DDD cherche mieux meme pour une # track deja bonne ou deja dans la lib (bypass is_accepted + dedup, depot in-place). forced = bool(state.selected) + trash_original = bool(trash_original_cb.value) if not chosen: status.value = "Nothing to upgrade (check files or change the filter)." page.update() @@ -850,10 +848,11 @@ def prog(*a) -> None: state.folder, root=paths.resource_base(), staging_dir=staging, download_dir=dl_dir, scan_results=chosen, progress=prog, on_item=on_item, on_proc=on_proc, cancel=is_cancelled, log_path=log_path, forced=forced, - on_chunk=on_chunk) + on_chunk=on_chunk, trash_original=trash_original) from collections import Counter c = Counter(o.action for o in outcomes) - ok = c.get(upgrade_mod.ACT_REPLACED, 0) + ok = c.get(upgrade_mod.ACT_REPLACED, 0) + c.get(upgrade_mod.ACT_KEPT_BESIDE, 0) + retained = c.get(upgrade_mod.ACT_KEPT_BESIDE, 0) rej = _count_rejected(c) nf = c.get(upgrade_mod.ACT_NOT_FOUND, 0) dup = c.get(upgrade_mod.ACT_DUPLICATE, 0) @@ -868,7 +867,7 @@ def prog(*a) -> None: summary = (f"Upgrade cancelled: {ok} in library, {rej} rejected, " f"{nf} not found{dup_txt}{ag_txt} (partial).") else: - summary = (f"Upgrade done: {ok} added to library (fakes -> trash), " + summary = (f"Upgrade done: {ok} added to library ({retained} originals retained), " f"{rej} rejected, {nf} not found{dup_txt}{ag_txt}.") status.value = summary state.last_upgraded += ok @@ -1202,7 +1201,7 @@ def prog(*a) -> None: f"{nf} not found{dup_txt} (partial).") else: summary = (f"Fetch done: {acq} in library, {rej} rejected " - f"(upscale/short/wrong match), {nf} not found{dup_txt}.") + f"(below threshold/short/wrong match), {nf} not found{dup_txt}.") status.value = summary show_buy_links(outcomes, source) _banner(summary, bool(acq) and not state.cancel_requested) @@ -1458,7 +1457,7 @@ def browse_dl(_e) -> None: options=[ ft.dropdown.Option(key="dj_club", text="DJ Club (>=18 kHz, MP3 320 included)"), ft.dropdown.Option(key="audiophile", text="Audiophile (>=20 kHz)"), - ft.dropdown.Option(key="puriste", text="Purist (pure lossless)"), + ft.dropdown.Option(key="puriste", text="Purist (wide spectrum)"), ft.dropdown.Option(key="mp3_320", text="MP3 320 only (vintage / mobile)"), ft.dropdown.Option(key="wav_aiff", text="WAV/AIFF only (uncompressed)"), ft.dropdown.Option(key="flac_only", text="FLAC only"), @@ -1527,7 +1526,7 @@ def save_settings(_e) -> None: ft.Row([slsk_user, slsk_pass], wrap=True), ft.Row([discogs_user, discogs_tok], wrap=True), ft.Row([bandcamp_user], wrap=True), - ft.Text("Everything DDD validates (upgrade + favorites) lands here; fakes/rejects " + ft.Text("Accepted downloads land here. Originals are retained by default; rejected candidates " "go to the trash.", size=12, color=TXT_DIM), ft.Row([dl_dir_field, dl_browse_btn]), ft.Text("Quality bar (what DDD keeps); the MP3 320 / WAV-AIFF / FLAC modes also set " @@ -1587,6 +1586,9 @@ def toggle_settings(_e) -> None: style=_ink_style) upgrade_btn = ft.FilledButton(text="Upgrade selection · 0", icon=ft.Icons.UPGRADE, on_click=do_upgrade, disabled=True, style=_pink_style) + trash_original_cb = ft.Checkbox( + label="Trash originals after verified replacement", value=False, + tooltip="Off by default. Keep originals for listening and comparison.") lib_cancel_btn = ft.OutlinedButton(text="Cancel", icon=ft.Icons.CANCEL, on_click=do_cancel, visible=False) sort_btn = ft.FilledButton( @@ -1670,6 +1672,7 @@ def _table_surface(header_row, body) -> ft.Container: # PAS de wrap=True ici : un enfant expand (le spacer) dans un Wrap fait jeter # un layout error a Flutter -> toute la zone table devient un carre gris # (ErrorWidget release-mode #C3C3C2). Row normal -> l'expand est valide. + trash_original_cb, ft.Row([filter_dd, check_all_btn, uncheck_all_btn, legend_btn, lib_cancel_btn, ft.Container(expand=True), dup_text], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER), diff --git a/ddd/gui_theme.py b/ddd/gui_theme.py index 917becf..bfa57fa 100644 --- a/ddd/gui_theme.py +++ b/ddd/gui_theme.py @@ -45,10 +45,10 @@ "SKIPPED": NEUTRAL, } VERDICT_LABEL = { - quality.LOSSLESS: "Lossless", + quality.LOSSLESS: "Wide", quality.HQ: "HQ", quality.DOUTEUX: "Iffy", - quality.MAUVAIS: "Bad", + quality.MAUVAIS: "Review", "ERROR": "Error", "SKIPPED": "Skipped", } diff --git a/docker/README.md b/docker/README.md index fda5de0..34bdd98 100644 --- a/docker/README.md +++ b/docker/README.md @@ -35,7 +35,7 @@ sur ton volume monte avec `--download-dir` pour recuperer les fichiers : docker run --rm \ -e DDD_SOULSEEK_USER=tonuser -e DDD_SOULSEEK_PASS=tonpass \ -v /mnt/musique:/music \ - ddd upgrade /music --download-dir /music --apply + ddd upgrade /music --download-dir /music ``` `upgrade` re-audite chaque download et ne garde que le vrai AUTHENTIC (les filtres @@ -66,3 +66,5 @@ docker run --rm \ -v "$PWD/ddd-data":/app/outputs \ ddd scan /music ``` + +Les originaux restent en place par defaut. `upgrade --trash-original` autorise leur mise a la corbeille uniquement apres depot verifie du candidat. `import` conserve les refus, erreurs et doublons a la source. Le spectre mesure une bande passante, pas la provenance de compression. diff --git a/docs/index.html b/docs/index.html index 9385af6..86ea1fb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ DigDigDig - the crate digger that digs three times - + @@ -183,7 +183,7 @@

DIGDIGDIG

What is it?

DDD cleans up your music library and bumps it to club-playable quality, on its own. - It unmasks fake lossless (MP3s disguised as .flac/.wav), finds a real version on Soulseek + It flags limited bandwidth for review and searches for candidates on Soulseek (FLAC, WAV or AIFF, with an automatic MP3 320 fallback), checks every file by spectrum, and files it into a clean library - sorted by genre from the audio itself, not the filename. No need to be a developer : it's a window. @@ -203,7 +203,7 @@

Soulseek

3 · DETECT

The spectrum

-

FFT analysis : ranks as Lossless / HQ / Iffy / Bad, plus duration and identity checks. Only the right track, above your bar, is kept.

+

FFT analysis : ranks as Wide / HQ / Iffy / Review, plus duration and identity checks. Only the right track, above your bar, is kept.

@@ -233,24 +233,22 @@

What it looks like

-

The thing that changes everything : the spectrum is law

+

The thing that changes everything : measure, compare, review

- An MP3 320 re-encoded as FLAC is still an MP3. Soulseek's filters can't see it : - the container proudly shows "1411 kbps". DDD looks at the real spectrum : - a wall of frequencies at 16 kHz gives away the lossy source. The declared format and bitrate - are only used for the Soulseek search, never for the keep-or-reject decision. - The spectrum doesn't lie, tags do : that's what tells a real 320 / lossless apart from an upscale. + Spectral analysis measures bandwidth, not compression history. + A genuine filtered recording can have little high-frequency content. + DDD flags measurements against your chosen threshold; listen before removing originals. Every downloaded file is re-audited (spectrum + duration + title/artist identity) and ranked by its real cutoff. You pick what DDD keeps - and what it hunts for : DJ Club (>= 18 kHz, default, MP3 320 included), Audiophile (>= 20 kHz), - Purist (pure lossless), or a target-format mode - MP3 320 (vintage / mobile), - WAV/AIFF only or FLAC only. MP3s below 320 kbps are banned no matter what ; the rest goes to the trash. + Purist (wide spectrum), or a target-format mode - MP3 320 (vintage / mobile), + WAV/AIFF only or FLAC only. MP3s below 320 kbps are banned no matter what ; originals are retained by default.

- LOSSLESS + WIDE SPECTRUM HQ >= 18 kHz IFFY 16-18 kHz - BAD < 16 kHz + REVIEW < 16 kHz
@@ -272,7 +270,7 @@

How it works

  1. Fill in your access (Settings) : Soulseek login, Discogs token, and the library folder.
  2. Scan a folder (or pull your favorites) : DDD ranks every file and spots duplicates.
  3. -
  4. Upgrade : what passes your bar lands in your library, the rest goes to the trash.
  5. +
  6. Upgrade : what passes your bar lands in your library, originals are retained by default.
  7. Sort by genre : file the whole pile into vibe folders, read from the spectrum. One clean, de-duplicated library.
  8. Identify (lost the names?) : recover Artist - Title for no-name files by acoustic fingerprint (AcoustID) - you confirm each match, then it renames + tags.
@@ -294,7 +292,7 @@

How it works

 ·  Source code on GitHub

-

DDD - DigDigDig · the spectrum is law.

+

DDD - DigDigDig · measure, compare, review.

DDD is a tool to manage your library ; it hosts and provides no content. Soulseek is a peer-to-peer network : downloading copyrighted music without authorization diff --git a/packaging/README.md b/packaging/README.md index 51803b5..321d681 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -19,7 +19,7 @@ meme installee dans un emplacement en lecture seule. Voir `ddd/paths.py`. ```powershell # une fois : installer l'outillage de build dans le venv -.\.venv\Scripts\python.exe -m pip install -e ".[gui,build]" +.\.venv\Scripts\python.exe -m pip install -e ".[gui,build,test]" # build .\packaging\build.ps1 # ou: .\packaging\build.ps1 -Clean @@ -71,3 +71,5 @@ l'OS depuis le projet slsk-batchdl et la placer dans `bin/sldl/`. - Taille du bundle : ~150-250 MB (client Flutter + scipy + sldl). Normal pour une app desktop Python+Flutter. - La CLI reste disponible sans empaquetage : `python -m ddd scan|upgrade|rename|buy|scrape|acquire|gui`. + +Le script Windows lance toute la suite pytest avant PyInstaller. Le workflow macOS depend de la suite Windows/Linux/macOS via `tests.yml`. Un echec bloque le packaging. Les tests incluent les scenarios historiques isoles, le cablage CLI et la construction GUI. diff --git a/packaging/build.ps1 b/packaging/build.ps1 index bd301cd..9cbf447 100644 --- a/packaging/build.ps1 +++ b/packaging/build.ps1 @@ -20,6 +20,10 @@ Set-Location $Root $py = Join-Path $Root ".venv\Scripts\python.exe" if (-not (Test-Path $py)) { throw "venv introuvable : $py" } +Write-Host "Verification core / CLI / GUI avant packaging..." +& $py -m pytest -q +if ($LASTEXITCODE -ne 0) { throw "Tests en echec : packaging annule." } + if ($Clean) { Write-Host "Nettoyage build/ et dist/..." Remove-Item -Recurse -Force "$Root\build", "$Root\dist" -ErrorAction SilentlyContinue @@ -27,6 +31,7 @@ if ($Clean) { Write-Host "PyInstaller : build de DDD.exe (peut prendre 1-3 min)..." & $py -m PyInstaller "packaging\ddd.spec" --noconfirm --distpath "$Root\dist" --workpath "$Root\build" +if ($LASTEXITCODE -ne 0) { throw "PyInstaller en echec." } $exe = Join-Path $Root "dist\DDD\DDD.exe" if (Test-Path $exe) { diff --git a/pyproject.toml b/pyproject.toml index 1b59f6f..42bce43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ ] [project.optional-dependencies] +test = ["pytest>=8,<10"] # Phase 4 (GUI + packaging) - non requis pour le coeur CLI. # Pin sur la ligne stable 0.28.x : la 0.85+ ("Flet 1.0", alpha) a des constructeurs # incompatibles (TextField sans label, boutons sans label positionnel). @@ -54,3 +55,6 @@ version = {attr = "ddd.__version__"} [tool.setuptools.packages.find] include = ["ddd*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_cjk_paths.py b/tests/test_cjk_paths.py index 83b78e3..cec2a2e 100644 --- a/tests/test_cjk_paths.py +++ b/tests/test_cjk_paths.py @@ -50,7 +50,7 @@ def main(): assert q is not None, "quality.analyze_file a renvoye None" assert q.filename == fpath.name, (q.filename, fpath.name) assert "東京テスト" in q.path, q.path - assert q.verdict == "LOSSLESS", f"WAV plein spectre attendu LOSSLESS, recu {q.verdict}" + assert q.verdict not in (quality.ERROR, quality.SKIPPED), q.reason print("OK - quality.analyze_file (verdict + nom/chemin japonais intacts)") # 4. parseur de nom : decoupe 'Artiste - Titre' en CJK (+ parentheses pleine-chasse). diff --git a/tests/test_detect.py b/tests/test_detect.py index c60709a..ce342a7 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -31,7 +31,7 @@ def test_promotes_genuine_near_nyquist_lossless(): # cutoff 21.2 kHz (>= 95% Nyquist) + bitrate eleve : le legacy dit HQ (est=320 car # 21.2k < 21.5k), le forensique le reconnait LOSSLESS. Un vrai 320 plafonne ~20.5k. v, conf, _r, _est, _sig = _fc(21200, std=50, hf=0.00002, container=800) - assert v == quality.LOSSLESS and conf == "high" + assert v == quality.LOSSLESS and conf == "uncertain" def test_real_320_transcode_stays_hq_not_promoted(): diff --git a/tests/test_legacy_scripts.py b/tests/test_legacy_scripts.py new file mode 100644 index 0000000..828a477 --- /dev/null +++ b/tests/test_legacy_scripts.py @@ -0,0 +1,37 @@ +"""Run standalone scenarios in separate processes to isolate their global patches.""" + +import ast +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +def standalone_scripts(): + result = [] + for path in Path(__file__).parent.glob("test_*.py"): + tree = ast.parse(path.read_text(encoding="utf-8-sig")) + functions = [node.name for node in tree.body if isinstance(node, ast.FunctionDef)] + if "main" in functions and not any(name.startswith("test_") for name in functions): + result.append(path) + return sorted(result) + + +@pytest.mark.parametrize("script", standalone_scripts(), ids=lambda p: p.name) +def test_standalone_scenario(script, tmp_path): + code = ( + "import runpy,sys; from pathlib import Path; " + "ns=runpy.run_path(sys.argv[1]); " + "ns['main'].__globals__['ROOT']=Path(sys.argv[2]); " + "ns['main']()" + ) + # The singleton scenario imports the package again in its child process. + root = script.parent.parent if script.stem == "test_singleton" else tmp_path + result = subprocess.run( + [sys.executable, "-c", code, str(script.resolve()), str(root)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_library_safety.py b/tests/test_library_safety.py new file mode 100644 index 0000000..a93effd --- /dev/null +++ b/tests/test_library_safety.py @@ -0,0 +1,192 @@ +from pathlib import Path +from types import SimpleNamespace as NS + +import numpy as np +import pytest +import soundfile as sf + +from ddd.core import fsutil, quality, scan, upgrade +from ddd.core.content import duplicate_paths +from ddd.core.soulseek import WantItem + + +def result(path, verdict=quality.LOSSLESS, duration=300): + return quality.QualityResult( + str(path), Path(path).name, Path(path).suffix, "lossless_container", + 44100, 2, duration, 22000, 0, 0, 0, 1000, verdict, "uncertain", "test") + + +def test_import_itself_or_subfolder_is_noop(tmp_path, monkeypatch): + source = tmp_path / "Artist - Title.flac" + source.write_bytes(b"original") + monkeypatch.setattr(upgrade, "scan_library", lambda *a, **kw: pytest.fail("must not scan itself")) + for origin in (tmp_path, tmp_path / "subfolder"): + stats = upgrade.import_folder(origin, tmp_path) + assert stats["total"] == stats["trashed"] == 0 + assert source.read_bytes() == b"original" + + +def test_import_retains_errors_rejects_and_real_duplicates(tmp_path, monkeypatch): + source, library = tmp_path / "source", tmp_path / "library" + source.mkdir(); library.mkdir() + records = [] + for name, verdict, data in [ + ("error", quality.ERROR, b"unreadable"), + ("review", quality.DOUTEUX, b"filtered"), + ("copy", quality.LOSSLESS, b"same content"), + ("Artist - Title", quality.LOSSLESS, b"different content"), + ]: + path = source / f"{name}.flac" + path.write_bytes(data) + qr = result(path, verdict) + if verdict == quality.DOUTEUX: + qr.cutoff_hz = 16000 + records.append(NS(quality=qr)) + (library / "Artist - Title.flac").write_bytes(b"same content") + monkeypatch.setattr(upgrade, "scan_library", lambda *a, **kw: records) + monkeypatch.setattr(upgrade.trash, "send_to_trash", lambda *a: pytest.fail("import must not trash")) + stats = upgrade.import_folder(source, library, preset="dj_club") + assert stats == dict(total=4, kept=1, duplicates=1, retained=1, errors=1, trashed=0) + assert (library / "Artist - Title (1).flac").read_bytes() == b"different content" + assert (source / "copy.flac").exists() + + +def test_import_excludes_nested_destination(tmp_path, monkeypatch): + library = tmp_path / "library" + library.mkdir() + existing = library / "Artist - Title.flac" + existing.write_bytes(b"keep") + monkeypatch.setattr(upgrade, "scan_library", lambda *a, **kw: [NS(quality=result(existing))]) + assert upgrade.import_folder(tmp_path, library)["total"] == 0 + assert existing.read_bytes() == b"keep" + + +@pytest.mark.parametrize("failure", ["copy", "verify"]) +def test_failed_deposit_retains_original_and_candidate(tmp_path, monkeypatch, failure): + library, staging = tmp_path / "library", tmp_path / "staging" + library.mkdir(); staging.mkdir() + original = library / "Artist - Title.flac" + original.write_bytes(b"old") + candidate = staging / original.name + candidate.write_bytes(b"new") + if failure == "copy": + def fail(*args): + raise OSError("disk full") + monkeypatch.setattr(fsutil.shutil, "copyfileobj", fail) + else: + monkeypatch.setattr(fsutil, "file_hash", lambda p: str(p)) + monkeypatch.setattr(upgrade.trash, "send_to_trash", lambda *a: pytest.fail("original must survive")) + with pytest.raises(OSError): + upgrade._finalize_download( + WantItem("Artist", "Title", 300, str(original)), + NS(filepath=str(candidate), downloaded=True), result(candidate), + preset="dj_club", download_dir=library, existing=set(), trash_origin=True) + assert original.read_bytes() == b"old" + assert candidate.read_bytes() == b"new" + assert list(library.iterdir()) == [original] + + +@pytest.mark.parametrize("remove,trash_success", [(False, False), (True, False), (True, True)]) +def test_original_removed_only_after_verified_install(tmp_path, monkeypatch, remove, trash_success): + library, staging = tmp_path / "library", tmp_path / "staging" + library.mkdir(); staging.mkdir() + original = library / "Artist - Title.flac" + original.write_bytes(b"old") + candidate = staging / original.name + candidate.write_bytes(b"new") + calls = [] + def trash(path): + assert (library / "Artist - Title (1).flac").read_bytes() == b"new" + assert original.read_bytes() == b"old" + calls.append(path) + return trash_success + monkeypatch.setattr(upgrade.trash, "send_to_trash", trash) + outcome, installed = upgrade._finalize_download( + WantItem("Artist", "Title", 300, str(original)), + NS(filepath=str(candidate), downloaded=True), result(candidate), + preset="dj_club", download_dir=library, existing=set(), trash_origin=remove) + assert installed + assert bool(calls) == remove + assert outcome.action == (upgrade.ACT_REPLACED if remove and trash_success else upgrade.ACT_KEPT_BESIDE) + if remove and not trash_success: + assert "trash failed" in outcome.note + + +@pytest.mark.parametrize("artist,title,duration", [ + ("John Other", "Title", 300), ("John Smith", "Title Extra", 300), + ("John Smith", "Title", 100), ("John Smith", "Title", 700), + ("John Smith", "Title", 0), ("John Smith", "Title", float("nan")), + ("John Smith", "Title (Someone Remix)", 300), +]) +def test_final_gate_rejects_wrong_identity_or_duration(artist, title, duration): + candidate = f"{artist} - {title}.flac" + reason = upgrade._reject_reason(WantItem("John Smith", "Title", 300, ""), + NS(filepath=candidate), result(candidate, duration=duration)) + assert reason and reason[0] == upgrade.ACT_WRONG_MATCH + + +def test_final_gate_distinguishes_named_remixes(): + reason = upgrade._reject_reason( + WantItem("Artist", "Title (Alice Remix)", 300, ""), + NS(filepath="Artist - Title (Bob Remix).flac"), result("x.flac")) + assert reason and reason[0] == upgrade.ACT_WRONG_MATCH + + +def test_subtitle_is_part_of_identity(): + reason = upgrade._reject_reason( + WantItem("Artist", "Title (Part One)", 300, ""), + NS(filepath="Artist - Title (Part Two).flac"), result("x.flac")) + assert reason and reason[0] == upgrade.ACT_WRONG_MATCH + + +def test_wide_lossy_container_cannot_satisfy_purist(): + qr = result("wide.mp3") + qr.format_class = "lossy" + assert quality.is_accepted(qr, "dj_club") + assert not quality.is_accepted(qr, "puriste") + + +def test_index_pointing_to_original_cannot_trash_it(tmp_path, monkeypatch): + original = tmp_path / "Artist - Title.flac" + original.write_bytes(b"original") + monkeypatch.setattr(upgrade.trash, "send_to_trash", lambda *a: pytest.fail("original must survive")) + outcome, installed = upgrade._finalize_download( + WantItem("Artist", "Title", 300, str(original)), + NS(filepath=str(original), downloaded=True), result(original, quality.ERROR), + preset="dj_club", download_dir=tmp_path, existing=set(), trash_origin=True) + assert outcome.action == upgrade.ACT_WRONG_MATCH and not installed + assert original.read_bytes() == b"original" + + +@pytest.mark.parametrize("artist,candidate", [("Daft Punk vs Stardust", "Stardust"), + ("中田ヤスタカ", "中田ヤスタカ"), ("A", "A")]) +def test_final_gate_accepts_complete_collaborator_and_short_known_track(artist, candidate): + assert upgrade._reject_reason( + WantItem(artist, "Title", 60, ""), NS(filepath=f"{candidate} - Title.flac"), + result("x.flac", duration=60)) is None + + +def test_content_not_size_defines_duplicates(tmp_path, monkeypatch): + files = [tmp_path / f"{i}.wav" for i in range(3)] + for path, data in zip(files, [b"AAAA", b"BBBB", b"AAAA"]): + path.write_bytes(data) + assert duplicate_paths(files) == [[files[0], files[2]]] + monkeypatch.setattr(scan, "analyze_file", result) + monkeypatch.setattr(scan.audit_mod, "audit_file", lambda p: None) + records = scan.scan_library(tmp_path) + assert [r.dup_count for r in records] == [2, 1, 2] + assert len(scan.duplicate_groups(records)) == 1 + + +@pytest.mark.parametrize("detector", ["legacy", "forensic"]) +def test_direct_filtered_flac_is_not_claimed_proven_lossy(tmp_path, detector): + sr = 44100 + data = np.random.default_rng(42).normal(0, .1, (sr * 3, 2)) + spectrum = np.fft.rfft(data, axis=0) + spectrum[np.fft.rfftfreq(len(data), 1 / sr) > 15000] = 0 + path = tmp_path / "direct.flac" + sf.write(path, np.fft.irfft(spectrum, n=len(data), axis=0), sr, subtype="PCM_16") + qr = quality.analyze_file(path, detector=detector) + assert qr.confidence == "uncertain" + assert "source lossy" not in qr.reason + assert not quality.is_accepted(qr, "dj_club") # The user's bandwidth threshold still applies. diff --git a/tests/test_safety_wiring.py b/tests/test_safety_wiring.py new file mode 100644 index 0000000..595a72d --- /dev/null +++ b/tests/test_safety_wiring.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace as NS + +import pytest + +from ddd import cli, gui +from ddd.core import quality, upgrade +from ddd.core.scan import ScanRecord +from test_gui_build import StubPage +from test_library_safety import result + + +@pytest.mark.parametrize("remove", [False, True]) +def test_cli_upgrade_explicit_removal(tmp_path, monkeypatch, capsys, remove): + captured = {} + def run(folder, **kwargs): + captured.update(kwargs) + return [] + monkeypatch.setattr(upgrade, "run_upgrade", run) + monkeypatch.setattr(cli.paths, "logs_dir", lambda: tmp_path) + monkeypatch.setattr(cli.paths, "outputs_dir", lambda: tmp_path) + args = ["upgrade", str(tmp_path), "--download-dir", str(tmp_path)] + if remove: + args.append("--trash-original") + assert cli.main(args) == 0 + assert captured["trash_original"] is remove + assert ("originals retained" in capsys.readouterr().err) is not remove + + +def test_cli_import_reports_retained_and_errors(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(upgrade, "import_folder", lambda *a, **kw: + dict(total=4, kept=1, duplicates=1, retained=1, errors=1, trashed=0)) + assert cli.main(["import", str(tmp_path), "--download-dir", str(tmp_path)]) == 0 + output = capsys.readouterr().out + assert "confirmed duplicates retained" in output + assert "unreadable / failed, retained" in output + assert "-> trash" not in output + + +def walk(control): + yield control + for child in control._get_children(): + yield from walk(child) + + +@pytest.mark.parametrize("remove", [False, True]) +def test_gui_upgrade_option_and_retained_count(tmp_path, monkeypatch, remove): + state = gui.AppState() + monkeypatch.setattr(gui, "AppState", lambda: state) + monkeypatch.setattr(gui.config_mod, "load", lambda: {"soulseek_user": "test", "soulseek_pass": "test"}) + monkeypatch.setattr(gui.paths, "download_dir", lambda *a: tmp_path) + monkeypatch.setattr(gui.paths, "logs_dir", lambda: tmp_path) + monkeypatch.setattr(gui.paths, "outputs_dir", lambda: tmp_path) + monkeypatch.setattr(gui.paths, "cache_dl_dir", lambda: tmp_path / "cache") + monkeypatch.setattr(gui.atexit, "register", lambda *a: None) + monkeypatch.delenv("DDD_UPDATE_CHECK", raising=False) + page = StubPage() + gui.main(page) + controls = [item for root in page.controls for item in walk(root)] + option = next(c for c in controls if getattr(c, "label", "") == "Trash originals after verified replacement") + assert option.value is False + option.value = remove + button = next(c for c in controls if str(getattr(c, "text", "")).startswith("Upgrade selection")) + state.folder = str(tmp_path) + state.records = [ScanRecord(result(tmp_path / "Artist - Title.flac"), None, 1, 1)] + state.selected = {0} + captured = {} + def run(folder, **kwargs): + captured.update(kwargs) + return [upgrade.UpgradeOutcome(upgrade.ACT_KEPT_BESIDE, "Artist", "Title", "original")] + monkeypatch.setattr(upgrade, "run_upgrade", run) + monkeypatch.setattr(gui, "scan_library", lambda *a, **kw: []) + monkeypatch.setattr(gui.threading, "Thread", lambda target, **kw: NS(start=target)) + button.on_click(None) + assert captured["trash_original"] is remove + assert state.last_upgraded == 1 + assert option.disabled is False + assert any("1 originals retained" in str(getattr(c, "value", "")) for c in controls if isinstance(c, gui.ft.Text)) + + +def test_upgrade_in_library_does_not_deduplicate_against_itself(tmp_path, monkeypatch): + source = tmp_path / "Artist - Title.flac" + source.write_bytes(b"old") + qr = result(source, quality.DOUTEUX) + qr.cutoff_hz = 16000 + captured = {} + def download(items, **kwargs): + captured.update(kwargs) + captured["items"] = items + return [] + monkeypatch.setattr(upgrade, "_download_pass", download) + monkeypatch.setattr(upgrade.soulseek, "clear_run_staging", lambda *a: None) + upgrade.run_upgrade(tmp_path, root=tmp_path, download_dir=tmp_path, + staging_dir=tmp_path / "cache", preset="dj_club", scan_results=[qr]) + assert len(captured["items"]) == 1 + assert captured["trash_origin"] is False diff --git a/tests/test_scan_merge.py b/tests/test_scan_merge.py index 9ce9667..aef2eed 100644 --- a/tests/test_scan_merge.py +++ b/tests/test_scan_merge.py @@ -28,13 +28,17 @@ def _rec(path, verdict, status, size): return ScanRecord(q, n, size, 0) # dup_count recompute by hand below -def main(): +def test_scan_merge(tmp_path): # 3 fichiers : 2 partagent une taille (doublon), 1 unique recs = [ _rec(r"C:\lib\A\x.wav", quality.DOUTEUX, audit.OK, 5_000_000), _rec(r"C:\lib\B\x.wav", quality.DOUTEUX, audit.OK, 5_000_000), # meme taille que le 1er _rec(r"C:\lib\C\y.wav", quality.LOSSLESS, audit.OK, 7_000_000), ] + for i, rec in enumerate(recs): + path = tmp_path / f"{i}.wav" + path.write_bytes(b"same" if i < 2 else b"different") + rec.quality.path = str(path) # recompute dup_count comme le fait scan_library from collections import Counter sizes = Counter(r.size_bytes for r in recs) @@ -56,7 +60,3 @@ def main(): assert d["dup_count"] == 2 print("OK - merge qualite+nommage + detection doublons : assertions passent") - - -if __name__ == "__main__": - main() diff --git a/tests/test_upgrade_forced.py b/tests/test_upgrade_forced.py index 791d6ab..887aec1 100644 --- a/tests/test_upgrade_forced.py +++ b/tests/test_upgrade_forced.py @@ -54,10 +54,10 @@ def main(): soulseek.read_index = lambda _i: [DownloadResult("Artist X", "Tune", str(dl_flac), 300, "1", "0")] up.quality.analyze_file = lambda p: _qr(p, quality.LOSSLESS, 22050.0, ".flac", 1000, "lossless_container") trashed = [] - up.trash.send_to_trash = lambda p: trashed.append(str(p)) + up.trash.send_to_trash = lambda p: (trashed.append(str(p)), True)[1] # 1) SANS forced : HQ accepte -> already_good, rien telecharge, original intact - out_auto = up.run_upgrade(str(acid), root=ROOT, staging_dir=cache, download_dir=lib, + out_auto = up.run_upgrade(folder=str(acid), root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib, scan_results=[src_qr], preset="dj_club", fallback_profile=None, forced=False) assert any(o.action == up.ACT_ALREADY_GOOD for o in out_auto), "sans forced : HQ -> already_good" assert not any(o.action == up.ACT_REPLACED for o in out_auto), "sans forced : rien remplace" @@ -66,7 +66,7 @@ def main(): # 2) AVEC forced : bypass is_accepted ET dedup -> telecharge, re-audit LOSSLESS, REMPLACE IN-PLACE trashed.clear() _mk(dl_flac) # re-cree le download (la passe precedente a pu le deplacer) - out_f = up.run_upgrade(str(acid), root=ROOT, staging_dir=cache, download_dir=lib, + out_f = up.run_upgrade(folder=str(acid), root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib, scan_results=[src_qr], preset="dj_club", fallback_profile=None, forced=True) assert any(o.action == up.ACT_REPLACED for o in out_f), "forced : doit chercher+remplacer malgre HQ+in-library" assert (acid / "Artist X - Tune.flac").exists(), "upgrade in-place : le FLAC reste dans ACID" diff --git a/tests/test_upgrade_logic.py b/tests/test_upgrade_logic.py index 03954b5..90e72e7 100644 --- a/tests/test_upgrade_logic.py +++ b/tests/test_upgrade_logic.py @@ -75,43 +75,43 @@ def fake_analyze(p): up.trash.send_to_trash = lambda p: (trashed.append(str(p)), True)[1] scan = [ - _qr(r"C:\lib\Artist A - Good.wav", quality.DOUTEUX), # -> REPLACED (depose + source corbeille) - _qr(r"C:\lib\Artist B - Upscale.wav", quality.DOUTEUX), # -> REJECTED_FAKE (candidat corbeille) - _qr(r"C:\lib\Artist C - Rare.wav", quality.DOUTEUX), # -> NOT_FOUND - _qr(r"C:\lib\groove 2 me.wav", quality.DOUTEUX), # sans ' - ' -> recherche titre-seul - _qr(r"C:\lib\Artist D - Real.flac", quality.LOSSLESS, cutoff=22050.0), # accepte -> hors want-list + _qr(r"C:/lib/Artist A - Good.wav", quality.DOUTEUX), # -> REPLACED (depose + source corbeille) + _qr(r"C:/lib/Artist B - Upscale.wav", quality.DOUTEUX), # -> REJECTED_FAKE (candidat corbeille) + _qr(r"C:/lib/Artist C - Rare.wav", quality.DOUTEUX), # -> NOT_FOUND + _qr(r"C:/lib/groove 2 me.wav", quality.DOUTEUX), # sans ' - ' -> recherche titre-seul + _qr(r"C:/lib/Artist D - Real.flac", quality.LOSSLESS, cutoff=22050.0), # accepte -> hors want-list ] # === run_upgrade : depot bibliotheque + corbeille (preset dj_club, pas de repli MP3) === _mk(cache / "Artist A - Good.flac") _mk(cache / "Artist B - Upscale.flac") - outcomes = up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib, + outcomes = up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib, scan_results=scan, preset="dj_club", fallback_profile=None) by = {o.action: o for o in outcomes} assert by[up.ACT_REPLACED].artist == "Artist A" assert (lib / "Artist A - Good.flac").exists(), "le vrai lossless doit etre depose en bibliotheque" - assert r"C:\lib\Artist A - Good.wav" in trashed, "le faux SOURCE doit partir a la corbeille" + assert r"C:/lib/Artist A - Good.wav" in trashed, "le faux SOURCE doit partir a la corbeille" assert by[up.ACT_REJECTED_FAKE].artist == "Artist B" assert str(cache / "Artist B - Upscale.flac") in trashed, "le candidat upscale -> corbeille" assert up.ACT_NOT_FOUND in by, "Artist C introuvable" # 'groove 2 me.wav' (sans separateur) doit etre CHERCHE en titre-seul, pas skippe - assert any(o.original == r"C:\lib\groove 2 me.wav" for o in outcomes), \ + assert any(o.original == r"C:/lib/groove 2 me.wav" for o in outcomes), \ "un fichier sans ' - ' doit etre cherche (titre-seul), pas ignore" assert all(o.action != up.ACT_UNPARSEABLE for o in outcomes), "plus de skip sur nom sans separateur" # le deja-accepte (LOSSLESS) n'est PAS telecharge, mais est desormais REPORTE # (ACT_ALREADY_GOOD) au lieu d'etre droppe en silence -> la ligne GUI ne reste plus figee. - d_out = [o for o in outcomes if o.original == r"C:\lib\Artist D - Real.flac"] + d_out = [o for o in outcomes if o.original == r"C:/lib/Artist D - Real.flac"] assert d_out and d_out[0].action == up.ACT_ALREADY_GOOD, "le deja-accepte (LOSSLESS) -> already_good" print("OK run_upgrade : accepte depose, source sous seuil + upscale a la corbeille, fallback titre-seul") # === dedup : un fichier deja dans la bibliotheque -> DUPLICATE, source intacte === trashed.clear() _mk(lib / "Artist C - Rare.flac") # on a deja Artist C dans la bibliotheque - out2 = up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib, - scan_results=[_qr(r"C:\lib\Artist C - Rare.wav", quality.DOUTEUX)], + out2 = up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib, + scan_results=[_qr(r"C:/lib/Artist C - Rare.wav", quality.DOUTEUX)], preset="dj_club", fallback_profile=None) assert any(o.action == up.ACT_DUPLICATE for o in out2), "deja en bibliotheque -> DUPLICATE" - assert r"C:\lib\Artist C - Rare.wav" not in trashed, "un doublon ne doit PAS supprimer le source a l'aveugle" + assert r"C:/lib/Artist C - Rare.wav" not in trashed, "un doublon ne doit PAS supprimer le source a l'aveugle" print("OK run_upgrade : dedup bibliotheque (DUPLICATE) sans toucher au source") # === chemin GUI : run_upgrade accepte des ScanRecord (non-regression) === @@ -120,7 +120,7 @@ def fake_analyze(p): _mk(cache / "Artist A - Good.flac") _mk(cache / "Artist B - Upscale.flac") recs = [ScanRecord(quality=q, naming=None, size_bytes=0, dup_count=1) for q in scan] - gui_out = up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib2, + gui_out = up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib2, scan_results=recs, preset="dj_club", fallback_profile=None) gui_actions = {o.action for o in gui_out} assert up.ACT_REPLACED in gui_actions and up.ACT_REJECTED_FAKE in gui_actions @@ -135,7 +135,7 @@ def fake_analyze(p): _mk(cache / "Artist A - Good.flac") _mk(cache / "Artist B - Upscale.flac") chunk_calls = [] - up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib_ch, + up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib_ch, scan_results=scan, preset="dj_club", fallback_profile=None, on_chunk=lambda idx, total: chunk_calls.append((idx, total))) assert chunk_calls, "on_chunk doit etre appele (sinon la GUI n'a aucun repere de progression)" @@ -183,7 +183,7 @@ def _dl(name): assert up._reject_reason(kraml, _dl("Andre Kraml - Safari"), q_ok) is None, "vrai renomme -> garde" assert up._reject_reason(kraml, _dl("Andre Kraml - Different Song"), q_ok)[0] == up.ACT_WRONG_MATCH collab = WantItem("Daft Punk vs Stardust", "Music Sounds Better", None, "") - assert up._reject_reason(collab, _dl("Stardust - Music Sounds Better With You"), q_ok) is None, "collab -> garde" + assert up._reject_reason(collab, _dl("Stardust - Music Sounds Better"), q_ok) is None, "collab -> garde" print("OK _reject_reason : court / mauvais match / renomme / collab") # === _reject_reason durci : artiste vs CHAMP du candidat, titres courts, version === @@ -231,34 +231,34 @@ def _dl(name): up.scan_library = lambda src, **k: recs_imp # mock le scan reel trashed.clear() stats = up.import_folder(src_imp, lib4, preset="dj_club") - assert stats["kept"] == 1 and stats["trashed"] == 1, stats + assert stats["kept"] == 1 and stats["retained"] == 1 and stats["trashed"] == 0, stats assert (lib4 / "Foo - RealTrack.flac").exists(), "accepte deplace en bibliotheque" - assert str(fake_b) in trashed, "sous le seuil -> corbeille" + assert str(fake_b) not in trashed and fake_b.exists(), "below threshold remains at source" print("OK import_folder : accepte garde, reste corbeille") # === build_plan : nom sans separateur -> requete construite depuis les TAGS === up.read_tags = lambda p: ({"artist": "Gary Beck", "title": "Get Down", "album": ""} if "gary" in str(p).lower() else {"artist": "", "title": "", "album": ""}) - plan = up.build_plan([_qr(r"C:\lib\gary-beck-get-down.mp3", quality.DOUTEUX)], preset="dj_club") + plan = up.build_plan([_qr(r"C:/lib/gary-beck-get-down.mp3", quality.DOUTEUX)], preset="dj_club") assert plan.items and plan.items[0].artist == "Gary Beck" and plan.items[0].title == "Get Down", \ f"nom sans ' - ' doit utiliser les tags : {plan.items}" # sans tag exploitable -> titre-seul depuis le nom (fallback) - plan2 = up.build_plan([_qr(r"C:\lib\Mysterious Title.wav", quality.DOUTEUX)], preset="dj_club") + plan2 = up.build_plan([_qr(r"C:/lib/Mysterious Title.wav", quality.DOUTEUX)], preset="dj_club") assert plan2.items and plan2.items[0].artist == "" and plan2.items[0].title == "Mysterious Title", \ f"sans tag -> titre-seul depuis le nom : {plan2.items}" print("OK build_plan : tags d'abord, puis titre-seul en fallback") # === is_accepted : meme fichier HQ, deux presets, deux resultats === - hq = _qr(r"C:\lib\track.flac", quality.HQ, cutoff=19000.0) + hq = _qr(r"C:/lib/track.flac", quality.HQ, cutoff=19000.0) hq.est_source_bitrate = 320 assert quality.is_accepted(hq, "dj_club"), "HQ 19 kHz accepte en DJ Club" assert not quality.is_accepted(hq, "audiophile"), "HQ 19 kHz refuse en Audiophile (<20 kHz)" assert not quality.is_accepted(hq, "puriste"), "HQ refuse en Puriste (pas plein spectre)" - loss = _qr(r"C:\lib\real.flac", quality.LOSSLESS, cutoff=22050.0) + loss = _qr(r"C:/lib/real.flac", quality.LOSSLESS, cutoff=22050.0) loss.est_source_bitrate = 0 assert all(quality.is_accepted(loss, p) for p in ("dj_club", "audiophile", "puriste")), \ "LOSSLESS accepte par tous les presets" - bad = _qr(r"C:\lib\bad.wav", quality.MAUVAIS, cutoff=14000.0) + bad = _qr(r"C:/lib/bad.wav", quality.MAUVAIS, cutoff=14000.0) assert not any(quality.is_accepted(bad, p) for p in ("dj_club", "audiophile", "puriste")), \ "MAUVAIS refuse par tous les presets" # build_plan : le MEME HQ est skippe en dj_club (accepte) mais candidat en puriste @@ -273,23 +273,23 @@ def _dl(name): print("OK already_good : track acceptee enregistree (plus de drop silencieux -> plus de ligne figee)") # forensic "?" : un HQ SUSPECT (artefacts ancres) n'est PAS "deja bon" en dj_club malgre le # cutoff -> il redevient candidat (le spectre prime). 'uncertain' reste clement (accepte). - hq_susp = _qr(r"C:\lib\Some Artist - Sketchy.mp3", quality.HQ, cutoff=19000.0, fclass="lossy") + hq_susp = _qr(r"C:/lib/Some Artist - Sketchy.mp3", quality.HQ, cutoff=19000.0, fclass="lossy") hq_susp.container_bitrate = 320 hq_susp.confidence = "suspect" assert not quality.is_accepted(hq_susp, "dj_club"), "HQ suspect ne doit PAS etre accepte en dj_club" assert up.build_plan([hq_susp], preset="dj_club").items, "HQ suspect -> candidat a l'upgrade" - hq_unc = _qr(r"C:\lib\Some Artist - GreyZone.mp3", quality.HQ, cutoff=19000.0, fclass="lossy") + hq_unc = _qr(r"C:/lib/Some Artist - GreyZone.mp3", quality.HQ, cutoff=19000.0, fclass="lossy") hq_unc.container_bitrate = 320 hq_unc.confidence = "uncertain" assert quality.is_accepted(hq_unc, "dj_club"), "HQ uncertain reste accepte (clement) en dj_club" print("OK forensic suspect : HQ suspect -> candidat ; HQ uncertain reste accepte (clement)") # === ban universel MP3 < 320 (jamais accepte, meme en DJ Club) === - mp3_192 = _qr(r"C:\lib\lo.mp3", quality.MAUVAIS, cutoff=19000.0, fclass="lossy") + mp3_192 = _qr(r"C:/lib/lo.mp3", quality.MAUVAIS, cutoff=19000.0, fclass="lossy") mp3_192.container_bitrate = 192 assert not any(quality.is_accepted(mp3_192, p) for p in ("dj_club", "audiophile", "puriste")), \ "MP3 192 kbps banni partout, meme a cutoff 19 kHz" - mp3_320 = _qr(r"C:\lib\hi.mp3", quality.HQ, cutoff=20000.0, fclass="lossy") + mp3_320 = _qr(r"C:/lib/hi.mp3", quality.HQ, cutoff=20000.0, fclass="lossy") mp3_320.container_bitrate = 320 assert quality.is_accepted(mp3_320, "dj_club"), "MP3 320 (cutoff 20 kHz) accepte en DJ Club" print("OK ban MP3 < 320 : 192 refuse partout, 320 accepte en DJ Club") @@ -336,12 +336,12 @@ def fb_analyze(p): # is_accepted : mp3_320 garde le 320 (>= 18 kHz) ; wav_aiff/flac_only ne gardent que le plein # spectre (un 320 reste candidat -> re-cherche dans le format cible) ; un vrai lossless est garde # par tous (jamais transcode). - mp3_320_ok = _qr(r"C:\lib\hi.mp3", quality.HQ, cutoff=20000.0, fclass="lossy") + mp3_320_ok = _qr(r"C:/lib/hi.mp3", quality.HQ, cutoff=20000.0, fclass="lossy") mp3_320_ok.container_bitrate = 320 assert quality.is_accepted(mp3_320_ok, "mp3_320"), "MP3 320 garde en mode MP3 320" assert not quality.is_accepted(mp3_320_ok, "flac_only"), "un 320 reste candidat en FLAC only" assert not quality.is_accepted(mp3_320_ok, "wav_aiff"), "un 320 reste candidat en WAV/AIFF only" - real_loss = _qr(r"C:\lib\real.flac", quality.LOSSLESS, cutoff=22050.0) + real_loss = _qr(r"C:/lib/real.flac", quality.LOSSLESS, cutoff=22050.0) real_loss.est_source_bitrate = 0 assert all(quality.is_accepted(real_loss, p) for p in ("mp3_320", "wav_aiff", "flac_only")), \ "un vrai lossless est garde par tous les modes" @@ -356,13 +356,13 @@ def spy_profile(*a, **k): soulseek.read_index = lambda _idx: [] # rien trouve -> NOT_FOUND, pas d'audit lib6 = base / "_lib6" lib6.mkdir(exist_ok=True) - up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib6, - scan_results=[_qr(r"C:\lib\X - Y.mp3", quality.MAUVAIS, fclass="lossy")], + up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib6, + scan_results=[_qr(r"C:/lib/X - Y.mp3", quality.MAUVAIS, fclass="lossy")], preset="mp3_320") assert seen_profiles == ["mp3-only"], f"mp3_320 -> mp3-only sans repli : {seen_profiles}" seen_profiles.clear() - up.run_upgrade("C:\\lib", root=ROOT, staging_dir=cache, download_dir=lib6, - scan_results=[_qr(r"C:\lib\X - Z.mp3", quality.MAUVAIS, fclass="lossy")], + up.run_upgrade(folder="C:/lib", root=ROOT, staging_dir=cache, trash_original=True, download_dir=lib6, + scan_results=[_qr(r"C:/lib/X - Z.mp3", quality.MAUVAIS, fclass="lossy")], preset="flac_only") assert seen_profiles == ["flac-only"], f"flac_only -> flac-only : {seen_profiles}" print("OK modes cible format : search_profiles_for + is_accepted + profil derive du preset") From 1d3b133f4044c758c7dcb3a57685040d8e69c8ee Mon Sep 17 00:00:00 2001 From: DNSZLSK <155122610+DNSZLSK@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:49 +0200 Subject: [PATCH 2/3] fix: pin calibrated flac-detective API for reproducible installs --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42bce43..523e258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ # Le wheel embarque ses propres libs ffmpeg -> toujours aucun ffmpeg systeme requis. "av>=12", "mutagen>=1.46", - "flac-detective>=0.9.6", + # Internal spectral APIs are version-specific; 1.x removed new_scoring.artifacts. + # Keep the published version used to calibrate and test both detectors. + "flac-detective==0.9.6", "requests>=2.28", "cloudscraper>=1.2.71", "beautifulsoup4>=4.11", From 137b9c3683db2be4a864b8027c767d64e298cae7 Mon Sep 17 00:00:00 2001 From: DNSZLSK <155122610+DNSZLSK@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:57:54 +0200 Subject: [PATCH 3/3] fix: use published flac-detective 0.9.7 without circular imports --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 523e258..1629ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,8 @@ dependencies = [ "av>=12", "mutagen>=1.46", # Internal spectral APIs are version-specific; 1.x removed new_scoring.artifacts. - # Keep the published version used to calibrate and test both detectors. - "flac-detective==0.9.6", + # 0.9.7 fixes the circular import in 0.9.6 while preserving these APIs. + "flac-detective==0.9.7", "requests>=2.28", "cloudscraper>=1.2.71", "beautifulsoup4>=4.11",