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:
1 parent
5b17d5fc8d
commit
e9fba3bb1d
3 files changed
+96
-2
No files matched your search
@@ -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` | |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user