v0.16.0: the file already told you

Two real failures from the first hour of round 45, both fixed by treating
the file itself as evidence.

"B. Clem - Zuuso [1025657891]" fingerprinted fine and matched nothing.
Checked before building: AcoustID returns zero results, a MusicBrainz text
search for "Zuuso" returns zero, iTunes returns zero. The track is in no
metadata service on earth, so no smarter lookup could have answered it —
but its filename said exactly what it was. New filename_tags.py reads that,
validated against all 474 files in the untagged folder rather than invented
examples: it strips yt-dlp ids, drops "(Official Video)" noise, splits
Artist - Title, reads a leading 1-04, and falls back to the iTunes tree.
It is careful about what it removes — an 11-char trailing token counts as a
YouTube id only if it carries a digit, an underscore or flipping case, or
"Cold Draft-Underground" loses a word, and a hyphen only splits when it has
spaces around it so Jay-Z survives.

A guess says it is one: candidates carry a source, and the dialog quotes a
confidence only for real fingerprint matches.

The file now ranks lookup results too, which fixes the Alhambra case: token
overlap (words double, bare numbers single), a duration bucket against the
recording lengths AcoustID returns, and a filename year that predates what
the database knows. Live also stops counting as a demerit — it was lumped in
with Compilation and buried a live album for a file whose name said "Live At
The Alhambra". That track now proposes the right album with year 1961
instead of a compilation with 2002.

The year genuinely needed the filename: MusicBrainz's own first-release-date
for "Ahmad Jamal's Alhambra" is 2002, because its three original 1961
pressings are stored undated. The obvious fix of a second MusicBrainz call
returns the same wrong answer.

Not done on purpose: trav rejected "never propose a shorter title" —
truncating is wanted, since the garbage is usually the part being dropped.

Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
This commit is contained in:
2026-09-02 17:24:07 -04:00
parent 312748d05e
commit 33d74ab1f5
9 changed files with 674 additions and 36 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.15.0"
__version__ = "0.16.0"
+164
View File
@@ -0,0 +1,164 @@
"""Reading tags out of a file's own name, for when no database knows the song.
Fingerprinting answers "who is this?" only for music somebody has already
submitted to AcoustID. A SoundCloud rip of an underground artist isn't in
AcoustID, isn't in MusicBrainz and isn't in iTunes — but its filename usually
says exactly what it is ("B. Clem - Zuuso [1025657891]"), because that's how
yt-dlp and friends name what they download.
So this module is the offline half of Identify Track: pure string work, no
network, no Qt. It strips the download id and the "(Official Video)" noise,
splits "Artist - Title", and falls back to the iTunes-style folder tree
(<root>/Artist/Album/NN Title.ext) for anything the name itself doesn't say.
Everything here is a *guess* and is presented as one — the dialog labels these
proposals "from filename" rather than quoting a match confidence.
"""
import re
from pathlib import Path
# yt-dlp appends the source's id: "[1025657891]" (SoundCloud, numeric) or
# "-WC7gK2kgyTQ" / "[EtNZnhxWLHo]" (YouTube, 11 chars of base64url).
_YT_ID_CHARS = re.compile(r"^[A-Za-z0-9_-]{11}$")
_NUMERIC_ID = re.compile(r"[\[\(]\s*\d{6,}\s*[\]\)]\s*$|[-_]\d{6,}\s*$")
_BRACKET_ID = re.compile(r"[\[\(]\s*([A-Za-z0-9_-]{11})\s*[\]\)]\s*$")
_TRAILING_ID = re.compile(r"-([A-Za-z0-9_-]{11})\s*$")
# "(Official Video)", "[HD]", "(mp3)" and friends — noise, never a title.
_NOISE = re.compile(
r"\s*[\(\[]\s*(?:"
# an optional "official", an optional qualifier, then video/audio —
# covers "Official Video", "Official Lyric Video", "Music Video", "Audio"
r"(?:official\s*)?(?:music\s*|lyrics?\s*|full\s*)?(?:video|audio)|"
r"official|lyrics?|visuali[sz]er|"
r"hd|hq|4k|full\s*album|free\s*download|out\s*now|mp3|m4a|flac"
r")\s*[\)\]]", re.I)
# "02 Title", "1-01 Title", "03. Title"
_LEADING_NUMBER = re.compile(r"^\s*(?:(\d{1,2})[-_ ]+)?(\d{1,3})[\.\)]?\s+(?=\S)")
# A bare four-digit year. Only trusted when the text holds exactly one, so a
# range ("1958-62", "Tour 1958-1961") is never mistaken for a release year.
_YEAR = re.compile(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)")
# Filesystem-safe stand-ins for an apostrophe: "That_s So Sick!"
_APOSTROPHE = re.compile(r"_(s|t|re|ve|ll|d|m)\b", re.I)
_UNKNOWN_DIRS = {"unknown artist", "unknown album", "various artists",
"music", "downloads", "unknown", ""}
def _looks_like_a_youtube_id(token: str) -> bool:
"""An 11-char base64url token that reads as random rather than as a word.
A real word can be 11 characters ("Underground"), so length alone would
eat legitimate titles. Random ids carry a digit, an underscore, or case
that flips repeatedly — an English word does none of those.
"""
if not _YT_ID_CHARS.match(token):
return False
if any(c.isdigit() for c in token) or "_" in token:
return True
transitions = sum(
1 for a, b in zip(token, token[1:])
if a.isalpha() and b.isalpha() and a.isupper() != b.isupper())
return transitions >= 3
def strip_download_id(stem: str) -> str:
"""Remove a trailing yt-dlp source id, in brackets or hyphen-attached."""
text = _NUMERIC_ID.sub("", stem).strip()
match = _BRACKET_ID.search(text)
if match and _looks_like_a_youtube_id(match.group(1)):
return text[:match.start()].strip()
match = _TRAILING_ID.search(text)
if match and _looks_like_a_youtube_id(match.group(1)):
return text[:match.start()].strip()
return text
def strip_noise(text: str) -> str:
"""Drop "(Official Video)"-style decorations."""
previous = None
while previous != text:
previous = text
text = _NOISE.sub("", text).strip()
return text.strip(" -_·—–").strip()
def restore_apostrophes(text: str) -> str:
return _APOSTROPHE.sub(r"'\1", text)
def leading_track_number(text: str) -> tuple[str, int | None, int | None]:
"""Split "1-01 Title" into (title, track number, disc number)."""
match = _LEADING_NUMBER.match(text)
if not match:
return text, None, None
disc, number = match.group(1), match.group(2)
rest = text[match.end():].strip()
if not rest:
return text, None, None
return (rest, int(number), int(disc) if disc else None)
def year_in(text: str) -> int | None:
"""The year a string names, when it names exactly one."""
years = set(_YEAR.findall(text or ""))
if len(years) != 1:
return None
return int(next(iter(years)))
def split_artist_title(text: str) -> tuple[str | None, str]:
"""Split "Artist - Title" on the first hyphen that has space around it.
A hyphen inside a word ("Jay-Z", "re-recorded") is never a separator, and
a doubled artist ("DJ X - DJ X - Track") collapses to one.
"""
parts = [p.strip() for p in re.split(r"\s+[-–—]\s+", text) if p.strip()]
if len(parts) < 2:
return None, text.strip()
# yt-dlp sometimes repeats the uploader: keep one copy.
while len(parts) > 2 and parts[0].casefold() == parts[1].casefold():
parts.pop(0)
artist = parts[0]
title = " - ".join(parts[1:]) if len(parts) > 2 else parts[1]
return artist, title
def parse_filename(location: str) -> dict:
"""Best-effort tags from a path. Keys are LinTunes field names; absent
keys mean "the name doesn't say"."""
path = Path(location)
stem = strip_download_id(path.stem)
stem = strip_noise(stem)
stem, number, disc = leading_track_number(stem)
artist, title = split_artist_title(stem)
title = restore_apostrophes(strip_noise(title)).strip()
fields: dict = {}
if title:
fields["name"] = title
if artist:
fields["artist"] = restore_apostrophes(artist).strip()
if number is not None:
fields["track_number"] = number
if disc is not None:
fields["disc_number"] = disc
# The iTunes-style tree fills in what the name itself doesn't say:
# <root>/Artist/Album/NN Title.ext
parents = [p.name for p in path.parents]
if len(parents) >= 2:
album_dir, artist_dir = parents[0], parents[1]
if album_dir.casefold() not in _UNKNOWN_DIRS:
fields.setdefault("album", album_dir)
if artist_dir.casefold() not in _UNKNOWN_DIRS:
fields.setdefault("artist", artist_dir)
year = year_in(path.stem)
if year is not None:
fields["year"] = year
return fields
+160 -26
View File
@@ -12,13 +12,17 @@ AcoustID application key is user-supplied via Preferences (the Last.fm
precedent); registration is free at https://acoustid.org/new-application.
"""
import json
import re
import shutil
import subprocess
import threading
from dataclasses import asdict, dataclass
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes import filename_tags
ACOUSTID_LOOKUP_URL = "https://api.acoustid.org/v2/lookup"
LOOKUP_META = "recordings releasegroups releases tracks compress"
@@ -33,6 +37,10 @@ class IdentifyCandidate:
"""One proposed identification. Field names match Track/tagging so
``fields()`` can go straight into ``LibraryManager.edit_track_fields``."""
score: float
# "acoustid" (a fingerprint match, so ``score`` is a real confidence) or
# "filename" (a guess read off the file's own name — no confidence to
# quote, and the dialog says so rather than implying one).
source: str = "acoustid"
name: str | None = None
artist: str | None = None
album_artist: str | None = None
@@ -45,7 +53,7 @@ class IdentifyCandidate:
def fields(self) -> dict:
"""Proposed edits keyed by LinTunes field names, Nones omitted."""
return {k: v for k, v in asdict(self).items()
if k != "score" and v is not None}
if k not in ("score", "source") and v is not None}
# ---- pure parsing/ranking helpers (offline-testable) ----
@@ -78,18 +86,75 @@ def _oldest_year(releasegroup: dict) -> int | None:
return min(years) if years else None
# Secondary types that mean "this is not the album the song came from".
# Live is deliberately absent: a live album is a real album, and demoting it
# buried "Ahmad Jamal's Alhambra" under a compilation for a file whose own
# name said "Live At The Alhambra".
_DEMOTED_SECONDARY = {"compilation", "interview", "remix", "dj-mix",
"mixtape/street", "demo", "audiobook"}
def _releasegroup_sort_key(releasegroup: dict) -> tuple:
"""Rank a release group for the default proposal: the earliest proper
studio Album first, then EPs/Singles, then Compilations/Live/etc."""
"""Rank a release group for the default proposal: a proper album first,
then EPs/Singles, with compilations and the like pushed down."""
rg_type = releasegroup.get("type") or ""
secondary = releasegroup.get("secondarytypes") or []
if not secondary:
rank = {"Album": 0, "EP": 1, "Single": 2}.get(rg_type, 4)
else:
rank = 3 if rg_type == "Album" else 4
rank = {"Album": 0, "EP": 1, "Single": 2}.get(rg_type, 4)
if any((s or "").casefold() in _DEMOTED_SECONDARY for s in secondary):
rank += 3
return (rank, _oldest_year(releasegroup) or 9999)
_TOKEN = re.compile(r"[^\W\d_]+|\d+", re.UNICODE)
_STOPWORDS = {"the", "a", "an", "of", "and", "in", "at", "on", "live",
"feat", "ft", "remastered", "version", "mix", "edit"}
def _tokens(text: str) -> set:
"""Comparable words from a title/album/filename."""
return {t.casefold() for t in _TOKEN.findall(text or "")
if len(t) > 1 and t.casefold() not in _STOPWORDS}
def _duration_bucket(recording: dict, duration: float | None) -> int:
"""How well a recording's length matches the file's, coarsely.
AcoustID often returns several recordings of the same song — a studio
take, a live take, an edit — and their lengths are what tell them apart.
Buckets rather than raw difference so a second or two never outweighs the
other evidence, but a clearly different take sinks.
"""
other = recording.get("duration")
if duration is None or not isinstance(other, (int, float)):
return 1 # unknown: behind an exact match, ahead of a bad one
diff = abs(other - duration)
if diff <= 1.5:
return 0
if diff <= 4:
return 1
if diff <= 12:
return 2
return 3
def _hint_overlap(hint_tokens: set, candidate: IdentifyCandidate) -> int:
"""How much a candidate echoes what the file already claims to be.
The distinctive words in "Snowfall (Live At The Alhambra_1961)" —
*snowfall*, *alhambra*, *1961* — are the best evidence available about
which of eight plausible releases this actually is, and ignoring them was
why a compilation could outrank the album named in the filename.
"""
if not hint_tokens:
return 0
text = " ".join(p for p in (candidate.name, candidate.artist,
candidate.album_artist, candidate.album) if p)
# A word like "alhambra" names a release; a bare number like "1961" turns
# up in every compilation spanning that year, so it counts for less.
return sum(2 if not token.isdigit() else 1
for token in hint_tokens & _tokens(text))
def _pick_release(releasegroup: dict) -> dict | None:
"""Earliest dated release that carries mediums (track positions); falls
back to the earliest dated one, then the first."""
@@ -126,7 +191,8 @@ def _track_position(release: dict | None) -> tuple[int | None, int | None,
def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
limit: int = MAX_CANDIDATES) -> list[IdentifyCandidate]:
limit: int = MAX_CANDIDATES, hint: str = "",
duration: float | None = None) -> list[IdentifyCandidate]:
"""Turn an AcoustID lookup response into ranked candidates.
One candidate per release group of each matched recording, so the
@@ -135,16 +201,23 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
the minimum across all its release groups' releases — even when the
proposed album is a later compilation: years sort by when the song first
came out, not by which pressing this file happens to be from.
``hint`` is whatever the file already claims to be (its tags and its
filename). It never invents a candidate, but it breaks ties: a release
whose words echo the hint outranks one that doesn't, and a year named in
the hint wins when it predates anything the database knows — MusicBrainz
often holds only a reissue's date, with the original pressing undated.
"""
candidates = []
hint_tokens = _tokens(hint)
hint_year = _single_year(hint)
scored = []
seen = set()
results = sorted(payload.get("results", []),
key=lambda r: r.get("score", 0), reverse=True)
for result in results:
for result in payload.get("results", []):
score = result.get("score", 0)
if score < threshold:
continue
for recording in result.get("recordings", []):
length = _duration_bucket(recording, duration)
title = recording.get("title")
artist = _join_artists(recording.get("artists"))
groups = sorted(recording.get("releasegroups") or [],
@@ -152,10 +225,16 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
original_years = [y for rg in groups
if (y := _oldest_year(rg)) is not None]
original_year = min(original_years) if original_years else None
if hint_year is not None and (original_year is None
or hint_year < original_year):
original_year = hint_year
if not groups:
if title:
candidates.append(IdentifyCandidate(
score=score, name=title, artist=artist))
bare = IdentifyCandidate(score=score, name=title,
artist=artist, year=hint_year)
scored.append(((-round(score, 2), length,
-_hint_overlap(hint_tokens, bare),
4, 9999), bare))
continue
for releasegroup in groups:
album = releasegroup.get("title")
@@ -165,7 +244,7 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
seen.add(key)
number, count, disc = _track_position(
_pick_release(releasegroup))
candidates.append(IdentifyCandidate(
candidate = IdentifyCandidate(
score=score,
name=title,
artist=artist,
@@ -175,8 +254,47 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
track_number=number,
track_count=count,
disc_number=disc,
))
return candidates[:limit]
)
rank, rg_year = _releasegroup_sort_key(releasegroup)
scored.append(((-round(score, 2), length,
-_hint_overlap(hint_tokens, candidate),
rank, rg_year), candidate))
scored.sort(key=lambda pair: pair[0])
return [candidate for _key, candidate in scored][:limit]
def _single_year(text: str) -> int | None:
"""The year a string names, when it names exactly one (so a range like
"1958-62" is never mistaken for a release year)."""
return filename_tags.year_in(text or "")
def hint_for(track) -> str:
"""What a track already claims to be — its tags plus its filename. Used
to rank lookup results, and to fall back on when nothing matches."""
parts = [track.name or "", track.artist or "", track.album or ""]
if track.location:
parts.append(Path(track.location).stem)
return " ".join(p for p in parts if p)
def candidate_from_filename(track) -> IdentifyCandidate | None:
"""A proposal read off the file's own name, for the very common case that
no database has ever heard of the song.
Underground and self-released music simply isn't in AcoustID — the
fingerprint is fine, there is just nothing to match it against — but the
download named the file "Artist - Title [id]", which is how a human reads
it too. Returns None when the name yields nothing usable.
"""
if not track.location:
return None
fields = filename_tags.parse_filename(track.location)
candidate = IdentifyCandidate(score=0.0, source="filename")
for key, value in fields.items():
if hasattr(candidate, key):
setattr(candidate, key, value)
return candidate if candidate.fields() else None
# ---- subprocess + network ----
@@ -209,8 +327,8 @@ def fingerprint_file(location: str) -> tuple[int, str]:
raise OSError(f"couldn't read fpcalc output for {name}") from e
def lookup_fingerprint(api_key: str, duration: int,
fingerprint: str) -> list[IdentifyCandidate]:
def lookup_fingerprint(api_key: str, duration: int, fingerprint: str,
hint: str = "") -> list[IdentifyCandidate]:
import requests
response = requests.post(
ACOUSTID_LOOKUP_URL,
@@ -224,25 +342,41 @@ def lookup_fingerprint(api_key: str, duration: int,
if payload.get("status") != "ok":
message = (payload.get("error") or {}).get("message", "lookup failed")
raise RuntimeError(message)
return parse_lookup(payload)
return parse_lookup(payload, hint=hint, duration=duration)
class TrackIdentifier(QObject):
"""One fingerprint+lookup running off the GUI thread.
``finished`` carries {"track_id": int, "candidates": [IdentifyCandidate]}
or {"track_id": int, "error": str}.
or {"track_id": int, "error": str}. A filename guess is appended to the
fingerprint's matches, and stands alone when there are none — a lookup
that found nothing is not the same as nothing to propose.
"""
finished = pyqtSignal(object)
def identify(self, track_id: int, location: str, api_key: str):
def identify(self, track, api_key: str):
track_id = track.track_id
location = track.location
hint = hint_for(track)
guess = candidate_from_filename(track)
def work():
try:
duration, fp = fingerprint_file(location)
candidates = lookup_fingerprint(api_key, duration, fp)
self.finished.emit(
{"track_id": track_id, "candidates": candidates})
candidates = lookup_fingerprint(api_key, duration, fp, hint)
except Exception as e:
self.finished.emit({"track_id": track_id, "error": str(e)})
# A file too short or too odd to fingerprint still has a name.
if guess is not None:
self.finished.emit({"track_id": track_id,
"candidates": [guess],
"note": str(e)})
else:
self.finished.emit({"track_id": track_id, "error": str(e)})
return
if guess is not None:
candidates = candidates + [guess]
self.finished.emit(
{"track_id": track_id, "candidates": candidates})
threading.Thread(target=work, daemon=True).start()
+19 -7
View File
@@ -35,19 +35,20 @@ class IdentifyDialog(QDialog):
self.cancel_all = False
layout = QVBoxLayout(self)
best = candidates[0]
header = QLabel(f"Identified “{track.name or '(untitled)'}” — "
f"match confidence {round(best.score * 100)}%")
header.setWordWrap(True)
layout.addWidget(header)
self._header = QLabel()
self._header.setWordWrap(True)
layout.addWidget(self._header)
# All alternates at a glance — a candidate summarizes to one line, so
# a dropdown beats AlbumArtDialog's blind Next-clicking.
self._combo = QComboBox()
for c in candidates:
year = f" ({c.year})" if c.year else ""
self._combo.addItem(" — ".join(
p for p in (c.name, c.artist, c.album) if p) + year)
summary = " — ".join(
p for p in (c.name, c.artist, c.album) if p) + year
if c.source == "filename":
summary = f"From the filename: {summary}"
self._combo.addItem(summary)
layout.addWidget(self._combo)
grid = QGridLayout()
@@ -105,6 +106,17 @@ class IdentifyDialog(QDialog):
iff the candidate proposes a value that differs from the current one;
rows with nothing proposed are unchecked and disabled."""
candidate = self._candidates[index]
name = self._track.name or "(untitled)"
if candidate.source == "filename":
# No database matched this, so there is no confidence to quote —
# say where the proposal actually came from instead of implying
# something recognized the audio.
self._header.setText(
f"No database knows “{name}”. Read from the file's name:")
else:
self._header.setText(
f"Identified “{name}” — match confidence "
f"{round(candidate.score * 100)}%")
for field, _label, is_number, _max in _FIELDS:
current = self._current_value(field)
proposed = getattr(candidate, field)
+3 -2
View File
@@ -1138,7 +1138,7 @@ class MainWindow(QMainWindow):
self._identifier = TrackIdentifier()
self._identifier.finished.connect(
lambda result, t=track, k=api_key: self._on_identified(t, k, result))
self._identifier.identify(track.track_id, track.location, api_key)
self._identifier.identify(track, api_key)
def _on_identified(self, track, api_key: str, result: dict):
if "error" in result:
@@ -1146,7 +1146,8 @@ class MainWindow(QMainWindow):
f"Couldn't identify “{track.name}”: {result['error']}", 6000)
elif not result["candidates"]:
self.statusBar().showMessage(
f"No match found for “{track.name}”", 6000)
f"Nothing to propose for “{track.name}” — no database match "
f"and the filename says nothing", 6000)
else:
self.statusBar().clearMessage()
dialog = IdentifyDialog(track, result["candidates"],