From e9fba3bb1da6dcc516b10749cb51649e41a65132 Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Wed, 2 Sep 2026 14:12:16 -0500 Subject: [PATCH] Check the audio's length, since the filename can agree and still be wrong The file that got filed as Michael Jackson's "Chicago" had every textual signal in its favour: the title matched exactly, and once approved it landed under the right artist, album and track number. The only thing that disagreed was the running time -- 359 seconds against Deezer's 245 -- because it was The Fall. Duration is the one signal a stranger's filename cannot fake, and muse already had it: Deezer supplies it at request time and it has been sitting unused in the jobs table. On import the real length is read off the downloaded audio and compared; a drift past MUSE_LENGTH_TOLERANCE multiplies confidence down and explains itself in the review queue rather than leaving a bare number. For this case that is 0.75 -> 0.26, which falls below REVIEW_CONFIDENCE and so reads as "almost certainly wrong" instead of "worth a look". It runs on import rather than in confidence() because the file does not exist when the candidate is chosen. Co-Authored-By: Claude Opus 5 --- README.md | 11 ++++++++ app.py | 56 +++++++++++++++++++++++++++++++++++++-- tests/test_acquisition.py | 31 ++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe4d4cb..f127ba2 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,16 @@ filename alone: Then: +**Length is checked against the audio, not the name.** Every textual signal can +agree and the file still be the wrong song: right title, right folder, right +album — and six minutes of a track that runs four. So once the download +finishes, the file's real duration is compared against what Deezer said, and a +drift past `MUSE_LENGTH_TOLERANCE` multiplies confidence down and says so in +words: *"runs 5:59 but the track is 4:05 (47% out), so this is probably a +different recording"*. This is applied on import rather than inside +`confidence()` for the obvious reason that the file does not exist yet when the +candidate is picked. + - **≥ 0.95** — filed automatically. Nothing to do. - **0.70 – 0.95** — held in **Review** with the reasons shown. - **< 0.70** — also held, but it will almost certainly be rejected. @@ -588,6 +598,7 @@ stat -c "%h" "/data/media/music// ()/ - .flac" | `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing | | `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking | | `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong | +| `MUSE_LENGTH_TOLERANCE` | `0.2` | duration drift before a file is called a different recording | | `MUSE_WORKERS` | `3` | concurrent Soulseek searches | | `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above | | `MUSE_SMTP_PORT` | `587` | | diff --git a/app.py b/app.py index c1a0507..eb2723e 100644 --- a/app.py +++ b/app.py @@ -43,6 +43,7 @@ from pathlib import Path from urllib.parse import urlencode import httpx +import mutagen from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response) @@ -1304,6 +1305,43 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path: return dest +# How far a file's length may drift from what Deezer said before it stops being +# plausibly the same recording. Generous, because edits, fades, hidden tracks +# and a stray second of silence are all normal; a different song is not. +LENGTH_TOLERANCE = float(os.getenv("MUSE_LENGTH_TOLERANCE", "0.2")) + + +def length_check(src: Path, want_ms) -> tuple[float, str] | None: + """Compare the downloaded audio's real length against the request. + + The one signal filename matching cannot fake. A file named exactly right, + in a folder named exactly right, is still the wrong song if it runs six + minutes when the track is four -- and that is not hypothetical: The Fall's + "Chicago, Now!" (359s) was filed as Michael Jackson's "Chicago" (245s) + because every *textual* signal agreed. + + Returns a multiplier and a reason, or None when there is nothing to say. + """ + want = (want_ms or 0) / 1000.0 + if want <= 0: + return None + try: + got = mutagen.File(str(src)).info.length + except Exception: + return None # unreadable is review's problem + if got <= 0: + return None + drift = abs(got - want) / want + if drift <= LENGTH_TOLERANCE: + return None + return (0.35, f"runs {mmss(got)} but the track is {mmss(want)} " + f"({drift:.0%} out), so this is probably a different recording") + + +def mmss(seconds: float) -> str: + return f"{int(seconds) // 60}:{int(seconds) % 60:02d}" + + def downloaded_index() -> dict[str, Path]: """Every completed file, by basename. @@ -1470,12 +1508,26 @@ async def import_worker(): actual = job["size"] conf = job["confidence"] + why = job["match_why"] or "" + # Applied here rather than in confidence(), because the + # file does not exist yet when the candidate is chosen. + penalty = length_check(src, job["duration"]) + if penalty is not None and conf is not None: + mult, reason = penalty + conf = round(conf * mult, 3) + why = f"{why}; {reason}" if why else reason + with db() as c: + c.execute("UPDATE jobs SET confidence=?," + " match_why=? WHERE id=?", + (conf, why, job["id"])) + log.info("job %s length mismatch: %s", + job["id"][:8], reason) if conf is not None and conf < AUTO_CONFIDENCE: band = ("almost certainly wrong" if conf < REVIEW_CONFIDENCE else "worth a look") set_state(job["id"], "review", - f"{conf:.0%} confident, {band} -- " - f"{job['match_why'] or ''}", size=actual) + f"{conf:.0%} confident, {band} -- {why}", + size=actual) await notify_review(job) continue diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index 27587f1..7a553ad 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -8,6 +8,7 @@ the server filters -- can be exercised deterministically. Run standalone: python tests/test_acquisition.py """ import asyncio, json, os, sys +from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("MUSE_AUTH", "off") @@ -311,6 +312,36 @@ def main() -> int: print("\nsearch hygiene") check("finished searches are cleaned up", len(flaky.deleted) >= 1) + print("\nlength cross-check") + # The signal a filename cannot fake. Every textual cue agreed on the file + # that got filed as Michael Jackson; only the running time disagreed. + class FakeInfo: + def __init__(self, n): self.length = n + + class FakeAudio: + def __init__(self, n): self.info = FakeInfo(n) + + real_file = mutagen_file = app.mutagen.File + try: + app.mutagen.File = lambda p: FakeAudio(359.0) # The Fall + got = app.length_check(Path("x.flac"), 245 * 1000) # Michael Jackson + check("a 47% length disagreement is caught", got is not None, got) + check("and it drops the file below the review floor", + got and round(0.75 * got[0], 3) < app.REVIEW_CONFIDENCE, + got and round(0.75 * got[0], 3)) + check("the reason names both lengths", + got and "5:59" in got[1] and "4:05" in got[1], got and got[1]) + + app.mutagen.File = lambda p: FakeAudio(247.0) + check("a two-second drift is not flagged", + app.length_check(Path("x.flac"), 245 * 1000) is None) + + app.mutagen.File = lambda p: FakeAudio(359.0) + check("nothing to compare against is not a failure", + app.length_check(Path("x.flac"), None) is None) + finally: + app.mutagen.File = mutagen_file + print("\nstall clock") from datetime import datetime, timedelta, timezone with app.db() as c: