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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
This commit is contained in:
@@ -217,6 +217,34 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
it never belongs in git). Audio under ~3 s has no fingerprint at all
|
it never belongs in git). Audio under ~3 s has no fingerprint at all
|
||||||
("Empty fingerprint"), which is a reported failure, not a crash.
|
("Empty fingerprint"), which is a reported failure, not a crash.
|
||||||
|
|
||||||
|
- **`lintunes/filename_tags.py`** — the offline half of Identify Track, and the
|
||||||
|
answer to its biggest limitation: **AcoustID only knows music somebody
|
||||||
|
submitted**, so an underground/SoundCloud rip fingerprints perfectly and
|
||||||
|
matches *nothing* (verified: "B. Clem — Zuuso" returns zero results from
|
||||||
|
AcoustID, zero from a MusicBrainz text search, zero from iTunes). Its
|
||||||
|
filename, though, says exactly what it is. Pure string work, no network, no
|
||||||
|
Qt: strips yt-dlp's trailing id (`[1025657891]`, `-WC7gK2kgyTQ` — an 11-char
|
||||||
|
token is only treated as a YouTube id if it carries a digit, an underscore,
|
||||||
|
or repeatedly flipping case, or "Underground" would be eaten), drops
|
||||||
|
`(Official Video)`-style noise, splits `Artist - Title` on a *spaced* hyphen
|
||||||
|
only (so "Jay-Z" and "350-440-DialTone" survive), collapses a doubled
|
||||||
|
uploader, reads a leading `1-04`, restores `_s`→`'s`, and falls back to the
|
||||||
|
iTunes tree (`<root>/Artist/Album/NN Title.ext`), ignoring placeholder dirs
|
||||||
|
like "Unknown Artist". `fingerprint.candidate_from_filename` wraps it as a
|
||||||
|
candidate with `source="filename"`, appended to every lookup and standing
|
||||||
|
alone when there are no matches — the dialog then says where the guess came
|
||||||
|
from instead of quoting a fabricated confidence. The same filename also
|
||||||
|
feeds `hint_for(track)`, which ranks real lookup results: token overlap
|
||||||
|
(words worth double, bare numbers single — "alhambra" identifies a release,
|
||||||
|
"1961" appears in every compilation spanning it), a coarse **duration**
|
||||||
|
bucket off the recording lengths AcoustID returns (which is what separates a
|
||||||
|
150 s studio take from a 155 s live one), and a year named in the filename
|
||||||
|
that predates anything the database knows. That last one is not a nicety:
|
||||||
|
MusicBrainz's own `first-release-date` for "Ahmad Jamal's Alhambra" is
|
||||||
|
**2002**, because its three original 1961 pressings are in the database
|
||||||
|
undated — so a second MusicBrainz call would return the same wrong year, and
|
||||||
|
the file's own name is the only place 1961 exists.
|
||||||
|
|
||||||
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
||||||
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
||||||
arrow keys are handled locally via `MainWindow.eventFilter`.
|
arrow keys are handled locally via `MainWindow.eventFilter`.
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ When a round closes, move its finished items to `tasks-done.md`.
|
|||||||
|
|
||||||
- [ ] archive the done tasks in here to another file, this is crufty....
|
- [ ] archive the done tasks in here to another file, this is crufty....
|
||||||
|
|
||||||
|
## Round 46 (2026-09-02) — The file already told you: done, see tasks-done.md
|
||||||
|
|
||||||
|
Filename-derived proposals when no database knows the song, plus using the
|
||||||
|
file's own tags/name/duration to rank real lookup results. Left on the table:
|
||||||
|
|
||||||
|
- [ ] **Submit fingerprints back to AcoustID.** The Zuuso case is unfixable by
|
||||||
|
lookup because nobody ever submitted it — and LinTunes is holding a good
|
||||||
|
fingerprint plus (after an identify) confirmed tags. `/v2/submit` takes
|
||||||
|
exactly that. It would make the library better for everyone and fix
|
||||||
|
trav's own second machine. Needs a user API key (not just the app key)
|
||||||
|
and must be explicitly opt-in — never submit tags the user hasn't
|
||||||
|
confirmed.
|
||||||
|
- [ ] **An LLM pass for the genuinely ambiguous ones.** Raised by trav
|
||||||
|
2026-09-02. Worth being precise about where it helps: *not* the Zuuso
|
||||||
|
case (no data exists to reason over) and *not* the Alhambra year (1961
|
||||||
|
isn't in the response either). It helps where there are many plausible
|
||||||
|
candidates and the signal is semantic — an odd filename convention, or
|
||||||
|
deciding whether a number is a performance year or a release year. Would
|
||||||
|
be the app's first LLM dependency; keep it behind the same
|
||||||
|
"nothing is written until accepted" gate.
|
||||||
|
- [ ] **A "clean up this filename" action independent of identify.** Half of
|
||||||
|
what round 46 built is useful with no network at all: 474 files in
|
||||||
|
Unknown Artist/Unknown Album could have artist/title/track filled from
|
||||||
|
their names in one pass. Wants the worker+progress split, not a dialog
|
||||||
|
per track.
|
||||||
|
|
||||||
## Round 45 (2026-09-02) — Ask the song what it is: done, see tasks-done.md
|
## Round 45 (2026-09-02) — Ask the song what it is: done, see tasks-done.md
|
||||||
|
|
||||||
Right-click → Identify Track…: Chromaprint fingerprint + AcoustID lookup, a
|
Right-click → Identify Track…: Chromaprint fingerprint + AcoustID lookup, a
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.15.0"
|
__version__ = "0.16.0"
|
||||||
|
|||||||
@@ -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
@@ -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.
|
precedent); registration is free at https://acoustid.org/new-application.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
from lintunes import filename_tags
|
||||||
|
|
||||||
|
|
||||||
ACOUSTID_LOOKUP_URL = "https://api.acoustid.org/v2/lookup"
|
ACOUSTID_LOOKUP_URL = "https://api.acoustid.org/v2/lookup"
|
||||||
LOOKUP_META = "recordings releasegroups releases tracks compress"
|
LOOKUP_META = "recordings releasegroups releases tracks compress"
|
||||||
@@ -33,6 +37,10 @@ class IdentifyCandidate:
|
|||||||
"""One proposed identification. Field names match Track/tagging so
|
"""One proposed identification. Field names match Track/tagging so
|
||||||
``fields()`` can go straight into ``LibraryManager.edit_track_fields``."""
|
``fields()`` can go straight into ``LibraryManager.edit_track_fields``."""
|
||||||
score: float
|
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
|
name: str | None = None
|
||||||
artist: str | None = None
|
artist: str | None = None
|
||||||
album_artist: str | None = None
|
album_artist: str | None = None
|
||||||
@@ -45,7 +53,7 @@ class IdentifyCandidate:
|
|||||||
def fields(self) -> dict:
|
def fields(self) -> dict:
|
||||||
"""Proposed edits keyed by LinTunes field names, Nones omitted."""
|
"""Proposed edits keyed by LinTunes field names, Nones omitted."""
|
||||||
return {k: v for k, v in asdict(self).items()
|
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) ----
|
# ---- pure parsing/ranking helpers (offline-testable) ----
|
||||||
@@ -78,18 +86,75 @@ def _oldest_year(releasegroup: dict) -> int | None:
|
|||||||
return min(years) if years else 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:
|
def _releasegroup_sort_key(releasegroup: dict) -> tuple:
|
||||||
"""Rank a release group for the default proposal: the earliest proper
|
"""Rank a release group for the default proposal: a proper album first,
|
||||||
studio Album first, then EPs/Singles, then Compilations/Live/etc."""
|
then EPs/Singles, with compilations and the like pushed down."""
|
||||||
rg_type = releasegroup.get("type") or ""
|
rg_type = releasegroup.get("type") or ""
|
||||||
secondary = releasegroup.get("secondarytypes") or []
|
secondary = releasegroup.get("secondarytypes") or []
|
||||||
if not secondary:
|
rank = {"Album": 0, "EP": 1, "Single": 2}.get(rg_type, 4)
|
||||||
rank = {"Album": 0, "EP": 1, "Single": 2}.get(rg_type, 4)
|
if any((s or "").casefold() in _DEMOTED_SECONDARY for s in secondary):
|
||||||
else:
|
rank += 3
|
||||||
rank = 3 if rg_type == "Album" else 4
|
|
||||||
return (rank, _oldest_year(releasegroup) or 9999)
|
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:
|
def _pick_release(releasegroup: dict) -> dict | None:
|
||||||
"""Earliest dated release that carries mediums (track positions); falls
|
"""Earliest dated release that carries mediums (track positions); falls
|
||||||
back to the earliest dated one, then the first."""
|
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,
|
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.
|
"""Turn an AcoustID lookup response into ranked candidates.
|
||||||
|
|
||||||
One candidate per release group of each matched recording, so the
|
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
|
the minimum across all its release groups' releases — even when the
|
||||||
proposed album is a later compilation: years sort by when the song first
|
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.
|
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()
|
seen = set()
|
||||||
results = sorted(payload.get("results", []),
|
for result in payload.get("results", []):
|
||||||
key=lambda r: r.get("score", 0), reverse=True)
|
|
||||||
for result in results:
|
|
||||||
score = result.get("score", 0)
|
score = result.get("score", 0)
|
||||||
if score < threshold:
|
if score < threshold:
|
||||||
continue
|
continue
|
||||||
for recording in result.get("recordings", []):
|
for recording in result.get("recordings", []):
|
||||||
|
length = _duration_bucket(recording, duration)
|
||||||
title = recording.get("title")
|
title = recording.get("title")
|
||||||
artist = _join_artists(recording.get("artists"))
|
artist = _join_artists(recording.get("artists"))
|
||||||
groups = sorted(recording.get("releasegroups") or [],
|
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
|
original_years = [y for rg in groups
|
||||||
if (y := _oldest_year(rg)) is not None]
|
if (y := _oldest_year(rg)) is not None]
|
||||||
original_year = min(original_years) if original_years else 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 not groups:
|
||||||
if title:
|
if title:
|
||||||
candidates.append(IdentifyCandidate(
|
bare = IdentifyCandidate(score=score, name=title,
|
||||||
score=score, name=title, artist=artist))
|
artist=artist, year=hint_year)
|
||||||
|
scored.append(((-round(score, 2), length,
|
||||||
|
-_hint_overlap(hint_tokens, bare),
|
||||||
|
4, 9999), bare))
|
||||||
continue
|
continue
|
||||||
for releasegroup in groups:
|
for releasegroup in groups:
|
||||||
album = releasegroup.get("title")
|
album = releasegroup.get("title")
|
||||||
@@ -165,7 +244,7 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
number, count, disc = _track_position(
|
number, count, disc = _track_position(
|
||||||
_pick_release(releasegroup))
|
_pick_release(releasegroup))
|
||||||
candidates.append(IdentifyCandidate(
|
candidate = IdentifyCandidate(
|
||||||
score=score,
|
score=score,
|
||||||
name=title,
|
name=title,
|
||||||
artist=artist,
|
artist=artist,
|
||||||
@@ -175,8 +254,47 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
|
|||||||
track_number=number,
|
track_number=number,
|
||||||
track_count=count,
|
track_count=count,
|
||||||
disc_number=disc,
|
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 ----
|
# ---- 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
|
raise OSError(f"couldn't read fpcalc output for {name}") from e
|
||||||
|
|
||||||
|
|
||||||
def lookup_fingerprint(api_key: str, duration: int,
|
def lookup_fingerprint(api_key: str, duration: int, fingerprint: str,
|
||||||
fingerprint: str) -> list[IdentifyCandidate]:
|
hint: str = "") -> list[IdentifyCandidate]:
|
||||||
import requests
|
import requests
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
ACOUSTID_LOOKUP_URL,
|
ACOUSTID_LOOKUP_URL,
|
||||||
@@ -224,25 +342,41 @@ def lookup_fingerprint(api_key: str, duration: int,
|
|||||||
if payload.get("status") != "ok":
|
if payload.get("status") != "ok":
|
||||||
message = (payload.get("error") or {}).get("message", "lookup failed")
|
message = (payload.get("error") or {}).get("message", "lookup failed")
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return parse_lookup(payload)
|
return parse_lookup(payload, hint=hint, duration=duration)
|
||||||
|
|
||||||
|
|
||||||
class TrackIdentifier(QObject):
|
class TrackIdentifier(QObject):
|
||||||
"""One fingerprint+lookup running off the GUI thread.
|
"""One fingerprint+lookup running off the GUI thread.
|
||||||
|
|
||||||
``finished`` carries {"track_id": int, "candidates": [IdentifyCandidate]}
|
``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)
|
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():
|
def work():
|
||||||
try:
|
try:
|
||||||
duration, fp = fingerprint_file(location)
|
duration, fp = fingerprint_file(location)
|
||||||
candidates = lookup_fingerprint(api_key, duration, fp)
|
candidates = lookup_fingerprint(api_key, duration, fp, hint)
|
||||||
self.finished.emit(
|
|
||||||
{"track_id": track_id, "candidates": candidates})
|
|
||||||
except Exception as e:
|
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()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|||||||
@@ -35,19 +35,20 @@ class IdentifyDialog(QDialog):
|
|||||||
self.cancel_all = False
|
self.cancel_all = False
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
best = candidates[0]
|
self._header = QLabel()
|
||||||
header = QLabel(f"Identified “{track.name or '(untitled)'}” — "
|
self._header.setWordWrap(True)
|
||||||
f"match confidence {round(best.score * 100)}%")
|
layout.addWidget(self._header)
|
||||||
header.setWordWrap(True)
|
|
||||||
layout.addWidget(header)
|
|
||||||
|
|
||||||
# All alternates at a glance — a candidate summarizes to one line, so
|
# All alternates at a glance — a candidate summarizes to one line, so
|
||||||
# a dropdown beats AlbumArtDialog's blind Next-clicking.
|
# a dropdown beats AlbumArtDialog's blind Next-clicking.
|
||||||
self._combo = QComboBox()
|
self._combo = QComboBox()
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
year = f" ({c.year})" if c.year else ""
|
year = f" ({c.year})" if c.year else ""
|
||||||
self._combo.addItem(" — ".join(
|
summary = " — ".join(
|
||||||
p for p in (c.name, c.artist, c.album) if p) + year)
|
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)
|
layout.addWidget(self._combo)
|
||||||
|
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
@@ -105,6 +106,17 @@ class IdentifyDialog(QDialog):
|
|||||||
iff the candidate proposes a value that differs from the current one;
|
iff the candidate proposes a value that differs from the current one;
|
||||||
rows with nothing proposed are unchecked and disabled."""
|
rows with nothing proposed are unchecked and disabled."""
|
||||||
candidate = self._candidates[index]
|
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:
|
for field, _label, is_number, _max in _FIELDS:
|
||||||
current = self._current_value(field)
|
current = self._current_value(field)
|
||||||
proposed = getattr(candidate, field)
|
proposed = getattr(candidate, field)
|
||||||
|
|||||||
@@ -1138,7 +1138,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._identifier = TrackIdentifier()
|
self._identifier = TrackIdentifier()
|
||||||
self._identifier.finished.connect(
|
self._identifier.finished.connect(
|
||||||
lambda result, t=track, k=api_key: self._on_identified(t, k, result))
|
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):
|
def _on_identified(self, track, api_key: str, result: dict):
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
@@ -1146,7 +1146,8 @@ class MainWindow(QMainWindow):
|
|||||||
f"Couldn't identify “{track.name}”: {result['error']}", 6000)
|
f"Couldn't identify “{track.name}”: {result['error']}", 6000)
|
||||||
elif not result["candidates"]:
|
elif not result["candidates"]:
|
||||||
self.statusBar().showMessage(
|
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:
|
else:
|
||||||
self.statusBar().clearMessage()
|
self.statusBar().clearMessage()
|
||||||
dialog = IdentifyDialog(track, result["candidates"],
|
dialog = IdentifyDialog(track, result["candidates"],
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Round 46 (2026-09-02) — The file already told you (v0.16.0)
|
||||||
|
|
||||||
|
Round 45 shipped and trav broke it in two places within the hour, both real.
|
||||||
|
"B. Clem - Zuuso [1025657891]" fingerprinted fine and matched **nothing**, and
|
||||||
|
"Snowfall (Live At The Alhambra_1961)" matched but proposed a 2002 reissue year
|
||||||
|
and ranked a compilation above the album its own filename named.
|
||||||
|
|
||||||
|
- [x] **The limit is the database, not the fingerprint.** Verified before
|
||||||
|
building anything: that Zuuso fingerprint returns zero AcoustID results,
|
||||||
|
a MusicBrainz text search for "Zuuso" returns zero, and iTunes returns
|
||||||
|
zero (and irrelevant fuzz for "B. Clem"). The track exists in no metadata
|
||||||
|
service on earth. So no smarter lookup — LLM or otherwise — could have
|
||||||
|
answered it, because there was nothing to answer with.
|
||||||
|
- [x] **But the filename knew.** New `lintunes/filename_tags.py` reads tags out
|
||||||
|
of the name yt-dlp gave the file. "B. Clem - Zuuso [1025657891]" → artist
|
||||||
|
"B. Clem", name "Zuuso", which is exactly what trav said it was. Checked
|
||||||
|
against all 474 files in the untagged folder, not against invented
|
||||||
|
examples.
|
||||||
|
- [x] **Careful about what it strips.** An 11-character trailing token is only
|
||||||
|
a YouTube id if it carries a digit, an underscore, or case that flips
|
||||||
|
repeatedly — otherwise "Cold Draft-Underground" loses a word. A hyphen
|
||||||
|
only splits artist from title when it has spaces around it, so "Jay-Z"
|
||||||
|
and "350-440-DialTone" survive. A 4-digit year is never a numeric id.
|
||||||
|
- [x] **A guess is labelled a guess.** `IdentifyCandidate.source` is
|
||||||
|
"acoustid" or "filename"; the dialog quotes a match confidence only for
|
||||||
|
the former and says "No database knows this — read from the file's name"
|
||||||
|
for the latter, rather than inventing a percentage.
|
||||||
|
- [x] **The file now ranks the results too.** `hint_for(track)` feeds tag +
|
||||||
|
filename tokens into `parse_lookup`: overlap scoring (words count double,
|
||||||
|
bare numbers single — "alhambra" identifies a release, "1961" appears in
|
||||||
|
every compilation spanning it), a coarse duration bucket against the
|
||||||
|
recording lengths AcoustID returns, and a filename year that predates
|
||||||
|
what the database knows. Alhambra now ranks the right album first with
|
||||||
|
year 1961 instead of a compilation with 2002.
|
||||||
|
- [x] **Live is no longer a demerit.** It was lumped in with Compilation, which
|
||||||
|
buried a live album for a file whose name said "Live At The Alhambra".
|
||||||
|
Only compilations, remixes, interviews and the like are demoted now.
|
||||||
|
- [x] **Why the year needed the filename at all:** MusicBrainz's own
|
||||||
|
`first-release-date` for "Ahmad Jamal's Alhambra" is *2002* — its three
|
||||||
|
original 1961 pressings are in the database undated. A second MusicBrainz
|
||||||
|
call, the obvious fix, returns the same wrong answer. 1961 exists only in
|
||||||
|
trav's filename.
|
||||||
|
- [x] Not done, deliberately: trav rejected "never propose a shorter title" —
|
||||||
|
truncating is *wanted*, because the garbage in a title is usually the
|
||||||
|
part being dropped.
|
||||||
|
|
||||||
### Round 45 (2026-09-02) — Ask the song what it is (v0.15.0)
|
### Round 45 (2026-09-02) — Ask the song what it is (v0.15.0)
|
||||||
|
|
||||||
Some files arrive with the title right and everything else missing or wrong,
|
Some files arrive with the title right and everything else missing or wrong,
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Round 46 — Identify Track stops giving up when no database knows the song.
|
||||||
|
|
||||||
|
Two real failures from trav's library drove this. "B. Clem - Zuuso
|
||||||
|
[1025657891]" fingerprinted fine and returned *zero* AcoustID results — the
|
||||||
|
track is underground, so it is in no database at all, and no amount of
|
||||||
|
cleverness can look up what does not exist. But its filename said exactly what
|
||||||
|
it was, which is how trav knew. And "Snowfall (Live At The Alhambra_1961)"
|
||||||
|
matched, yet proposed a 2002 reissue year and ranked a compilation above the
|
||||||
|
album the filename named, because nothing compared the results against what
|
||||||
|
the file already said.
|
||||||
|
|
||||||
|
So the file itself is now evidence: it seeds a proposal when the lookup finds
|
||||||
|
nothing, and it ranks the lookup's results when it finds several.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lintunes.fingerprint import (
|
||||||
|
IdentifyCandidate, candidate_from_filename, hint_for, parse_lookup,
|
||||||
|
)
|
||||||
|
from lintunes.filename_tags import (
|
||||||
|
leading_track_number, parse_filename, split_artist_title,
|
||||||
|
strip_download_id, strip_noise, year_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Track:
|
||||||
|
def __init__(self, name="", artist="", album="", location=""):
|
||||||
|
self.track_id = 1
|
||||||
|
self.name = name
|
||||||
|
self.artist = artist
|
||||||
|
self.album = album
|
||||||
|
self.location = location
|
||||||
|
|
||||||
|
|
||||||
|
UNKNOWN = "/m/Unknown Artist/Unknown Album/"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrippingDownloadIds:
|
||||||
|
@pytest.mark.parametrize("stem, expected", [
|
||||||
|
("B. Clem - Zuuso [1025657891]", "B. Clem - Zuuso"),
|
||||||
|
("Stardust-321895497", "Stardust"),
|
||||||
|
("Cold Draft-WC7gK2kgyTQ", "Cold Draft"),
|
||||||
|
("Self Esteem [EtNZnhxWLHo]", "Self Esteem"),
|
||||||
|
("Caught Up in the Rapture [Oz-b86LZ21c]", "Caught Up in the Rapture"),
|
||||||
|
])
|
||||||
|
def test_yt_dlp_ids_are_removed(self, stem, expected):
|
||||||
|
assert strip_download_id(stem) == expected
|
||||||
|
|
||||||
|
def test_a_real_word_is_not_mistaken_for_a_youtube_id(self):
|
||||||
|
""""Underground" is 11 characters too — length alone can't decide."""
|
||||||
|
assert strip_download_id("Cold Draft-Underground") == \
|
||||||
|
"Cold Draft-Underground"
|
||||||
|
|
||||||
|
def test_a_year_is_not_mistaken_for_a_numeric_id(self):
|
||||||
|
assert strip_download_id("Cross Country Tour 1958-1961") == \
|
||||||
|
"Cross Country Tour 1958-1961"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrippingNoise:
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"Dance II (Official Video)",
|
||||||
|
"Dance II (Official Music Video)",
|
||||||
|
"Dance II (Official Lyric Video)",
|
||||||
|
"Dance II [HD]",
|
||||||
|
"Dance II (Audio)",
|
||||||
|
])
|
||||||
|
def test_decorations_go(self, text):
|
||||||
|
assert strip_noise(text) == "Dance II"
|
||||||
|
|
||||||
|
def test_a_real_parenthetical_stays(self):
|
||||||
|
assert strip_noise("Take Off (ASOT 1159)") == "Take Off (ASOT 1159)"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplittingAndNumbering:
|
||||||
|
def test_artist_title_split(self):
|
||||||
|
assert split_artist_title("B. Clem - Zuuso") == ("B. Clem", "Zuuso")
|
||||||
|
|
||||||
|
def test_hyphen_inside_a_word_is_not_a_separator(self):
|
||||||
|
assert split_artist_title("350-440-DialTone") == \
|
||||||
|
(None, "350-440-DialTone")
|
||||||
|
|
||||||
|
def test_a_repeated_uploader_collapses(self):
|
||||||
|
assert split_artist_title("Sean Dream - Sean Dream - tomorrow111") == \
|
||||||
|
("Sean Dream", "tomorrow111")
|
||||||
|
|
||||||
|
def test_leading_track_and_disc_numbers(self):
|
||||||
|
assert leading_track_number("1-04 Sitting Back") == \
|
||||||
|
("Sitting Back", 4, 1)
|
||||||
|
assert leading_track_number("02 nashville cats") == \
|
||||||
|
("nashville cats", 2, None)
|
||||||
|
|
||||||
|
def test_a_number_that_is_the_whole_title_is_kept(self):
|
||||||
|
assert leading_track_number("1979") == ("1979", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestYearMining:
|
||||||
|
def test_a_single_year_is_read(self):
|
||||||
|
assert year_in("Snowfall (Live At The Alhambra_1961)") == 1961
|
||||||
|
|
||||||
|
def test_a_range_is_refused(self):
|
||||||
|
""""1958-62" and "1958-1961" name a span, not a release year."""
|
||||||
|
assert year_in("Cross Country Tour 1958-1961") is None
|
||||||
|
|
||||||
|
def test_no_year_is_none(self):
|
||||||
|
assert year_in("Zuuso") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilenameProposal:
|
||||||
|
def test_the_zuuso_case(self):
|
||||||
|
"""The track that started this: fingerprinted fine, matched nothing,
|
||||||
|
and its name said exactly what trav already knew it was."""
|
||||||
|
track = _Track(name="B. Clem - Zuuso [1025657891]",
|
||||||
|
location=UNKNOWN + "B. Clem - Zuuso [1025657891].mp3")
|
||||||
|
candidate = candidate_from_filename(track)
|
||||||
|
assert candidate.source == "filename"
|
||||||
|
assert candidate.fields() == {"name": "Zuuso", "artist": "B. Clem"}
|
||||||
|
|
||||||
|
def test_apostrophes_come_back(self):
|
||||||
|
track = _Track(location=UNKNOWN + "cehryl - That_s So Sick! [738273660].mp3")
|
||||||
|
assert candidate_from_filename(track).name == "That's So Sick!"
|
||||||
|
|
||||||
|
def test_the_folder_tree_fills_what_the_name_omits(self):
|
||||||
|
track = _Track(location="/m/Onra/Long Distance/1-04 Sitting Back.mp3")
|
||||||
|
fields = candidate_from_filename(track).fields()
|
||||||
|
assert fields["artist"] == "Onra"
|
||||||
|
assert fields["album"] == "Long Distance"
|
||||||
|
assert fields["name"] == "Sitting Back"
|
||||||
|
assert fields["track_number"] == 4
|
||||||
|
|
||||||
|
def test_placeholder_folders_are_not_treated_as_tags(self):
|
||||||
|
track = _Track(location=UNKNOWN + "blurf.mp3")
|
||||||
|
fields = candidate_from_filename(track).fields()
|
||||||
|
assert "artist" not in fields and "album" not in fields
|
||||||
|
|
||||||
|
def test_a_track_with_no_file_proposes_nothing(self):
|
||||||
|
assert candidate_from_filename(_Track()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(*recordings, score=0.9):
|
||||||
|
return {"status": "ok",
|
||||||
|
"results": [{"score": score, "recordings": list(recordings)}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _recording(title, duration=None, groups=()):
|
||||||
|
rec = {"title": title, "artists": [{"name": "The Band"}],
|
||||||
|
"releasegroups": list(groups)}
|
||||||
|
if duration is not None:
|
||||||
|
rec["duration"] = duration
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _group(title, year, type_="Album", secondary=None):
|
||||||
|
return {"title": title, "type": type_,
|
||||||
|
"secondarytypes": list(secondary or []),
|
||||||
|
"releases": [{"date": {"year": year} if year else {}}]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRankingAgainstTheFile:
|
||||||
|
def test_the_album_the_filename_names_wins(self):
|
||||||
|
payload = _payload(_recording("Snowfall", groups=[
|
||||||
|
_group("Some Other Record", 1970),
|
||||||
|
_group("Ahmad Jamal's Alhambra", 2002, secondary=["Live"]),
|
||||||
|
]))
|
||||||
|
hint = "Snowfall (Live At The Alhambra_1961)"
|
||||||
|
assert parse_lookup(payload, hint=hint)[0].album == \
|
||||||
|
"Ahmad Jamal's Alhambra"
|
||||||
|
|
||||||
|
def test_a_live_album_is_no_longer_demoted(self):
|
||||||
|
"""Live is what a live album *is*; only compilations get pushed down."""
|
||||||
|
payload = _payload(_recording("Snowfall", groups=[
|
||||||
|
_group("Greatest Hits", 1990, secondary=["Compilation"]),
|
||||||
|
_group("The Live Album", 1995, secondary=["Live"]),
|
||||||
|
]))
|
||||||
|
assert parse_lookup(payload)[0].album == "The Live Album"
|
||||||
|
|
||||||
|
def test_duration_picks_the_right_take(self):
|
||||||
|
"""Two recordings of one song; only the length tells them apart."""
|
||||||
|
payload = _payload(
|
||||||
|
_recording("Snowfall", duration=155.0,
|
||||||
|
groups=[_group("The Long Take", 2000)]),
|
||||||
|
_recording("Snowfall", duration=150.3,
|
||||||
|
groups=[_group("The Short Take", 2000)]),
|
||||||
|
)
|
||||||
|
assert parse_lookup(payload, duration=150)[0].album == "The Short Take"
|
||||||
|
|
||||||
|
def test_a_year_in_the_filename_beats_a_reissue_date(self):
|
||||||
|
"""MusicBrainz often has only the CD reissue dated, with the original
|
||||||
|
pressing undated — so the file's own 1961 is the better evidence."""
|
||||||
|
payload = _payload(_recording("Snowfall", groups=[
|
||||||
|
_group("Ahmad Jamal's Alhambra", 2002)]))
|
||||||
|
hint = "Snowfall (Live At The Alhambra_1961)"
|
||||||
|
assert parse_lookup(payload, hint=hint)[0].year == 1961
|
||||||
|
|
||||||
|
def test_a_later_filename_year_never_overrides_an_earlier_one(self):
|
||||||
|
payload = _payload(_recording("Song", groups=[_group("Album", 1967)]))
|
||||||
|
assert parse_lookup(payload, hint="Song (remaster 1999)")[0].year == 1967
|
||||||
|
|
||||||
|
def test_hint_never_invents_a_candidate(self):
|
||||||
|
assert parse_lookup({"status": "ok", "results": []},
|
||||||
|
hint="anything at all") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestHint:
|
||||||
|
def test_hint_gathers_tags_and_filename(self):
|
||||||
|
track = _Track(name="Snowfall", artist="Ahmad Jamal",
|
||||||
|
location=UNKNOWN + "Snowfall (Live At The Alhambra_1961).mp3")
|
||||||
|
hint = hint_for(track)
|
||||||
|
assert "Snowfall" in hint and "Ahmad Jamal" in hint and "1961" in hint
|
||||||
|
|
||||||
|
|
||||||
|
class TestDialogLabelsAGuessHonestly:
|
||||||
|
def test_filename_candidates_say_where_they_came_from(self, qapp):
|
||||||
|
from lintunes.gui.identify_dialog import IdentifyDialog
|
||||||
|
track = _Track(name="B. Clem - Zuuso [1025657891]")
|
||||||
|
guess = IdentifyCandidate(score=0.0, source="filename",
|
||||||
|
name="Zuuso", artist="B. Clem")
|
||||||
|
dialog = IdentifyDialog(track, [guess])
|
||||||
|
assert "file's name" in dialog._header.text().lower()
|
||||||
|
# No fabricated confidence percentage for something nothing matched.
|
||||||
|
assert "%" not in dialog._header.text()
|
||||||
|
assert "From the filename" in dialog._combo.itemText(0)
|
||||||
|
|
||||||
|
def test_acoustid_candidates_still_quote_confidence(self, qapp):
|
||||||
|
from lintunes.gui.identify_dialog import IdentifyDialog
|
||||||
|
candidate = IdentifyCandidate(score=0.98, name="Snowfall")
|
||||||
|
dialog = IdentifyDialog(_Track(name="x"), [candidate])
|
||||||
|
assert "98%" in dialog._header.text()
|
||||||
Reference in New Issue
Block a user