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 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-09-02 14:12:16 -05:00
1 parent 5b17d5fc8d
commit e9fba3bb1d
3 files changed
+96 -2

No files matched your search

+11
View File
@@ -308,6 +308,16 @@ filename alone:
Then: 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.95** — filed automatically. Nothing to do.
- **0.70 – 0.95** — held in **Review** with the reasons shown. - **0.70 – 0.95** — held in **Review** with the reasons shown.
- **< 0.70** — also held, but it will almost certainly be rejected. - **< 0.70** — also held, but it will almost certainly be rejected.
@@ -588,6 +598,7 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing | | `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking | | `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong | | `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_WORKERS` | `3` | concurrent Soulseek searches |
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above | | `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
| `MUSE_SMTP_PORT` | `587` | | | `MUSE_SMTP_PORT` | `587` | |
+54 -2
View File
@@ -43,6 +43,7 @@ from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
import mutagen
from fastapi import FastAPI, HTTPException, Query, Request from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
RedirectResponse, Response) RedirectResponse, Response)
@@ -1304,6 +1305,43 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
return dest 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]: def downloaded_index() -> dict[str, Path]:
"""Every completed file, by basename. """Every completed file, by basename.
@@ -1470,12 +1508,26 @@ async def import_worker():
actual = job["size"] actual = job["size"]
conf = job["confidence"] 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: if conf is not None and conf < AUTO_CONFIDENCE:
band = ("almost certainly wrong" band = ("almost certainly wrong"
if conf < REVIEW_CONFIDENCE else "worth a look") if conf < REVIEW_CONFIDENCE else "worth a look")
set_state(job["id"], "review", set_state(job["id"], "review",
f"{conf:.0%} confident, {band} -- " f"{conf:.0%} confident, {band} -- {why}",
f"{job['match_why'] or ''}", size=actual) size=actual)
await notify_review(job) await notify_review(job)
continue continue
+31
View File
@@ -8,6 +8,7 @@ the server filters -- can be exercised deterministically.
Run standalone: python tests/test_acquisition.py Run standalone: python tests/test_acquisition.py
""" """
import asyncio, json, os, sys import asyncio, json, os, sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("MUSE_AUTH", "off") os.environ.setdefault("MUSE_AUTH", "off")
@@ -311,6 +312,36 @@ def main() -> int:
print("\nsearch hygiene") print("\nsearch hygiene")
check("finished searches are cleaned up", len(flaky.deleted) >= 1) 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") print("\nstall clock")
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
with app.db() as c: with app.db() as c: