11 Commits
Author SHA1 Message Date
lyrathorpe 88b96b8159 chore(release): v0.4.0 2026-08-24 16:57:43 +00:00
lyrathorpe 05cca3508c Merge pull request 'feat: write playlists into the mirror from the listening history' (#9) from feat/playlists into main
Build and publish container / build (push) Successful in 4m43s
Reviewed-on: #9
2026-08-24 17:53:03 +01:00
Emma Thorpe cda3d8463b feat: write playlists into the mirror from the listening history
Build and publish container / build (pull_request) Successful in 5m13s
Six rules over the scrobble history and the library index, written as extended
M3U into <mirror>/_playlists/ and rebuilt every pass. Three look at what gets
played -- heavy rotation, all-time, and things played heavily once and silent
for a year. Three look at what does not: album tracks skipped on records
otherwise played constantly, unplayed tracks by the artists played most, and
unplayed tracks from anywhere. Four fifths of the library has never been played,
so both halves are needed.

Ninety days was the obvious window for "recent" and is the wrong one. On a real
history it holds a few hundred plays spread across a twenty-thousand track
rotation, and nothing ranks meaningfully. Twelve months does.

The two rotating playlists are shuffled by week rather than by pass. A pass runs
every few hours; a playlist that reorders itself each time is one that has to be
re-imported each time, because the Music app imports a snapshot of a file rather
than tracking it.

Lidarr knows where the lossless source is, and the playlists have to point at
the MP3s music-mirror produced from it. The library root is derived from the
common parent of the indexed artist folders, so it agrees with Lidarr by
construction instead of being kept in step by hand, and can be overridden.
Entries are relative to the playlist file, so one file works from the NAS, from
a Mac over SMB and from Linux.

A track is listed only once its mirror file has been confirmed to exist: Lidarr
holding the FLAC says nothing about whether the MP3 has been encoded. When many
are missing the run says so, because that is what a misconfigured root looks
like -- every path mapping to nothing -- and silence there would present an
empty playlist as a correct one.
2026-08-24 17:50:58 +01:00
lyrathorpe 08f099aaa9 chore(release): v0.3.1 2026-08-24 16:42:06 +00:00
lyrathorpe 719a6372ad Merge pull request 'fix: distinguish a title collision from an attribution miss, and index for it' (#8) from fix/title-collisions-and-index into main
Build and publish container / build (push) Successful in 4m54s
Reviewed-on: #8
2026-08-24 17:37:12 +01:00
Emma Thorpe 61ce751ac5 fix: distinguish a title collision from an attribution miss, and index for it
Build and publish container / build (pull_request) Successful in 5m16s
Two faults in the split added last change, both visible in the first real run.

It reported 700 pairs as attribution disagreements on the strength of the
library holding the same title under a different artist. The examples show what
that actually caught: "Everyday" matched Def Leppard, "Kaleidoscope" matched
Chappell Roan, "Fight for Your Right" matched Motley Crue. Different songs that
happen to share a name. Across fifty thousand tracks that is not an edge case,
it is the common case, and presenting it as a matcher failure argues for exactly
the title-only matching tier that would produce this rubbish on purpose.

The signal for a real attribution miss is narrower: the library's own title
credits the artist the play is filed under, as in "Voodoo People (Pendulum
Remix)" against a scrobble credited to Pendulum. The normalised title has that
suffix stripped -- which is what let the two meet in the first place -- so the
raw title is searched for the name. Collisions are now counted and named
separately, as what they are.

The same query also took forty-seven seconds. lidarr_track was indexed on
(norm_artist, norm_title), which a lookup by title alone cannot use because its
leading column is the artist, so every unmatched key scanned all eighty-four
thousand tracks. Add the index on the title by itself; the query plan changes
from an automatic partial index to a covering one.
2026-08-24 17:35:32 +01:00
lyrathorpe d1c10d32d9 Merge pull request 'ci: build the image once instead of twice' (#7) from ci/one-build-not-two into main
Reviewed-on: #7
2026-08-24 17:31:47 +01:00
lyrathorpe acd042ad8d chore(release): v0.3.0 2026-08-24 16:28:00 +00:00
Emma Thorpe 45d99ff039 ci: build the image once instead of twice
Build and publish container / build (pull_request) Successful in 5m40s
A pull request took roughly eleven minutes to go green, and the log shows one
CACHED line in the whole run. The image was being built twice, in full.

The test stage is built by the runner's docker daemon. The runtime stage was
then built by docker/build-push-action, which runs under a buildx builder that
setup-buildx-action creates in its own container with its own cache. The two
share nothing, so the second build pulled the base image again, ran pip install
again, and exported the layers again -- about four and a half minutes, plus
another thirty-five seconds to boot buildkit. The comment above the test step
claimed those layers were shared, which is what made this look reasonable.

buildx earns that overhead when producing several architectures. This produces
linux/amd64 only, by an explicit decision recorded in the workflow, so it earns
nothing here. Use plain docker build against the same daemon that ran the
tests, and push with docker push. The runtime stage is a strict prefix of the
test stage, so every layer is a cache hit: measured at 1.3 seconds locally
against roughly four and a half minutes in CI.

The remaining time is the runner itself, which is slow in absolute terms --
pytest takes four seconds locally and a hundred and two in CI. That is not
something the workflow can fix.
2026-08-24 17:24:56 +01:00
lyrathorpe 54e19992e4 Merge pull request 'feat: split unmatched listening by whether the library holds the title' (#6) from diag/split-misses-by-ownership into main
Build and publish container / build (push) Successful in 9m7s
Reviewed-on: #6
2026-08-24 17:19:09 +01:00
Emma Thorpe 2886c02a2e feat: split unmatched listening by whether the library holds the title
Build and publish container / build (pull_request) Successful in 8m41s
"Unmatched by an artist the library holds" was presented as the matcher's
misses. On real data it is not: owning one album by an artist says nothing about
owning a particular single of theirs, and most of that figure turned out to be
drum and bass tracks streamed but never bought.

Split it in two. A title the library holds under some other artist is an
attribution disagreement -- a remixer credited as the artist, a guest billed as
one -- and is a genuine miss worth fixing; the report now names the artist the
library files it under, which is the information needed to judge it. A title the
library does not hold at all under any artist was never bought, and no
improvement to matching will conjure it.

The distinction matters beyond presentation. That figure is the gate on the
cull, and a gate computed from a number that overstates the failure rate blocks
work that is actually safe to do.
2026-08-24 17:15:56 +01:00
6 changed files with 707 additions and 38 deletions
+29 -19
View File
@@ -45,7 +45,8 @@ jobs:
# The suite runs inside the image, against the interpreter that ships,
# rather than against whatever the runner happens to provide. A failing
# test fails the build. Layers are shared with the push build below.
# test fails the build. The runtime stage below is built from the same
# daemon afterwards, so its layers are already in cache.
- name: Run the test suite inside the image
run: docker build --target test -t music-curator:test .
@@ -124,9 +125,6 @@ jobs:
echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "Computed bump=${bump}, release=${release}, base=${base}"
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Log in to the Gitea container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
@@ -135,21 +133,33 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
# Without this the last stage in the Dockerfile -- the test stage --
# would be what gets published.
target: runtime
# The NAS is the only host this runs on. Building arm64 as well would
# mean emulating it under QEMU for no consumer.
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.version.outputs.tags }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
# Plain `docker build` rather than buildx. buildx boots its own buildkit
# in a container with a cache of its own, so it shared nothing with the
# test build above and rebuilt the image from the base image up -- two
# full builds per run. It earns that cost when building for several
# platforms; this only ever targets the amd64 NAS, so it does not.
#
# `--target runtime` is a strict prefix of the test stage, so every layer
# is already in the daemon's cache and this resolves in seconds.
- name: Build the runtime image
run: |
set -euo pipefail
tags=()
while IFS= read -r tag; do
[ -n "$tag" ] && tags+=(-t "$tag")
done <<< "${{ steps.version.outputs.tags }}"
docker build --target runtime \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
"${tags[@]}" .
- name: Push
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -n "$tag" ] && docker push "$tag"
done <<< "${{ steps.version.outputs.tags }}"
# Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release
+70 -8
View File
@@ -7,9 +7,10 @@ which keeps an MP3 copy of a lossless library for an iPod. This one answers the
question that mirror cannot: which of it is worth carrying, and which of it has
not been played in years.
**This is stage two.** It ingests the scrobble history, indexes the library from
Lidarr, and matches one to the other. There are no playlists yet, and it writes
nothing back — every Lidarr call is a `GET`. See "Where this is going" below.
**This is stage three.** It ingests the scrobble history, indexes the library
from Lidarr, matches one to the other, and writes playlists into the mirror.
Nothing is written back to Lidarr — every call there is a `GET`. See "Where this
is going" below.
## What it does today
@@ -18,6 +19,7 @@ nothing back — every Lidarr call is a `GET`. See "Where this is going" below.
- Indexes every artist, album and track Lidarr knows about, with file paths and
the date each file landed.
- Ties the two together and reports how well it managed.
- Writes M3U playlists into the mirror, from the listening history.
## Matching
@@ -109,10 +111,66 @@ matcher. The line to watch is:
unmatched by an artist the library holds: N pairs, M plays
```
That is a track that was played, sitting beside a file it should have matched.
Those are the matcher's real misses, and every one is a candidate for being
wrongly called cold in stage four. The report lists the worst fifteen by play
count so they can be eyeballed.
That is a track that was played by an artist the library holds. It is then
split three ways, because owning an artist is a weak proxy for owning a track
and a shared title is a weak proxy for a shared song:
- **the library's own title credits the scrobbled artist** — `Voodoo People
(Pendulum Remix)` against a play credited to Pendulum. Same song, filed
under the original artist. These are the genuine misses.
- **the same title under an unrelated artist** — a collision, not a miss.
Across fifty thousand tracks these are constant: `Everyday` is Rusko and
also Def Leppard, `Kaleidoscope` is Delta Heavy and also Chappell Roan.
Matching on title alone would be far worse than missing them, which is why
there is no such tier.
- **the title is nowhere in the library** — never bought.
Only the first is worth chasing. Counting all three as matcher failures
overstates the problem and would over-block the cull.
## Playlists
Written into `<mirror>/_playlists/` as extended M3U, rebuilt every pass. Six
rules, capped at `--playlist-limit` tracks each:
| Playlist | Rule |
| -------------------- | --------------------------------------------------------- |
| `heavy-rotation` | Most played over the last twelve months |
| `all-time` | Most played ever |
| `neglected` | Played heavily once, silent for twelve months |
| `deep-cuts` | Never played, from albums whose other tracks you play constantly |
| `unheard-favourites` | Never played, by the artists you play most |
| `unheard` | Never played, anywhere in the library |
Ninety days was the obvious window for "recent" and is the wrong one: on a real
history it holds a few hundred plays spread thinly across a twenty-thousand
track rotation, so nothing ranks meaningfully. Twelve months does.
The two `unheard` playlists rotate **weekly**, not per pass. A pass runs every
few hours, and a playlist that reorders itself each time is one that has to be
re-imported each time — the Music app imports a snapshot of a file, it does not
track it.
### Paths
Lidarr knows where the lossless source is; the playlists have to point at the
MP3s music-mirror made from it. The mapping strips a library root from Lidarr's
track paths and re-roots them under the mirror, with the suffix changed.
`--library-root` is derived from the common parent of the indexed artist folders
when unset, so it agrees with Lidarr by construction rather than by being kept
in step by hand. Override it if that guess is wrong.
Entries are written **relative to the playlist file**, so one playlist works
from the NAS, from a Mac over SMB, and from Linux, without rewriting.
A track is only listed once its mirror file has been confirmed to exist. Lidarr
holding the FLAC says nothing about whether the MP3 has been encoded yet. If a
large number are missing, the run says so — that is what a wrong `--library-root`
or `--mirror` looks like, since the paths then map to nothing at all.
`_playlists/` survives music-mirror's prune: it only deletes `*.mp3`, and its
empty-directory sweep skips a directory holding M3Us.
## How the ingest works
@@ -168,6 +226,9 @@ music-curator --report-only # report on the store, fetch nothing
| `--backfill-limit` | `MUSIC_CURATOR_BACKFILL_LIMIT` | `0` | Cap backfill requests per pass; 0 for no cap |
| `--lidarr-url` | `MUSIC_CURATOR_LIDARR_URL` | unset | Lidarr base URL, e.g. `http://lidarr:8686` |
| `--lidarr-api-key` | `MUSIC_CURATOR_LIDARR_API_KEY` | unset | Lidarr API key |
| `--mirror` | `MUSIC_CURATOR_MIRROR` | unset | Root of the MP3 mirror; playlists go here |
| `--library-root` | `MUSIC_CURATOR_LIBRARY_ROOT` | derived | Prefix to strip from Lidarr's paths |
| `--playlist-limit` | `MUSIC_CURATOR_PLAYLIST_LIMIT` | `100` | Most tracks in any one playlist |
| `--skip-index` | — | off | Match against the index already held |
| `--report-only` | — | off | Report without fetching |
@@ -216,7 +277,8 @@ nix shell nixpkgs#python3Packages.pytest -c pytest
| ------------------------------------------------ | ------------ |
| Last.fm ingest and store | done |
| Lidarr index and the scrobble-to-track matcher | done |
| M3U playlists written into the mirror | next |
| M3U playlists from the listening history | done |
| Genre and mood playlists from Last.fm tags | next |
| Cold-music report, unmonitoring what is not played | last |
The cull will unmonitor cold albums in Lidarr and tag their artists. It will
+6
View File
@@ -30,5 +30,11 @@ services:
# Cap the backfill at this many requests per pass. Unlimited by default,
# which finishes a long history in one go.
# MUSIC_CURATOR_BACKFILL_LIMIT: "0"
# The MP3 mirror music-mirror maintains. Playlists are written into
# _playlists/ inside it; leave unset to skip them.
MUSIC_CURATOR_MIRROR: /mirror
# Most tracks in any one playlist.
# MUSIC_CURATOR_PLAYLIST_LIMIT: "100"
volumes:
- /mnt/tank/apps/music-curator:/data
- /mnt/tank/media/music-mp3:/mirror
+344 -4
View File
@@ -26,6 +26,7 @@ import re
import signal
import sqlite3
import sys
import tempfile
import time
import unicodedata
import urllib.error
@@ -62,6 +63,16 @@ REQUEST_DELAY_SECONDS = 0.25
BACKOFF_SECONDS = 2.0
BACKOFF_CEILING_SECONDS = 60.0
# What music-mirror names its output, and where playlists are written inside the
# mirror. music-mirror's prune only deletes `*.mp3` and only removes directories
# it finds empty, so a directory of M3Us survives it untouched.
MIRROR_SUFFIX = ".mp3"
PLAYLIST_DIRECTORY = "_playlists"
# Group-readable, for the same reason music-mirror sets it: the mirror is read
# back by whatever serves it, and a playlist nobody can read is not a playlist.
GROUP_READ = 0o040
SCHEMA_VERSION = "2"
# Versions this build upgrades in place. Everything added since version 1 is a
@@ -141,6 +152,11 @@ CREATE TABLE IF NOT EXISTS lidarr_track (
);
CREATE INDEX IF NOT EXISTS lidarr_track_recording ON lidarr_track (recording_mbid);
CREATE INDEX IF NOT EXISTS lidarr_track_norm ON lidarr_track (norm_artist, norm_title);
-- Separate from the composite above, which a lookup by title alone cannot use:
-- its leading column is the artist. The report searches by title on its own, and
-- without this it scans every track for every unmatched key -- forty-seven
-- seconds on a library of eighty-four thousand.
CREATE INDEX IF NOT EXISTS lidarr_track_title ON lidarr_track (norm_title);
CREATE INDEX IF NOT EXISTS lidarr_track_album ON lidarr_track (album_id);
-- One row per distinct thing listened to, with the verdict on whether it could
@@ -1041,6 +1057,225 @@ def format_time(uts):
return datetime.fromtimestamp(uts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
# Playlists written into the mirror. Each is a rule over the listening history
# and the library index; none of them look at the audio.
#
# The rotating ones are shuffled by week rather than by pass. A pass runs every
# few hours, and a playlist that reorders itself every time is one that has to
# be re-imported every time -- the Music app does not track a file, it imports a
# snapshot of one.
ROTATION_PERIOD_SECONDS = 7 * 86400
SHUFFLE_MULTIPLIER = 2654435761
SHUFFLE_MODULUS = 104729
# Common table expressions the rules share. `played` is every library track that
# has ever been matched to a scrobble, with its count and the last time it was
# heard; `recent` is the same restricted to a window.
PLAYED_CTE = """
WITH played AS (
SELECT sk.track_id AS track_id, COUNT(*) AS plays, MAX(s.uts) AS last_uts
FROM scrobble s
JOIN scrobble_key sk ON sk.artist = s.artist AND sk.track = s.track
WHERE sk.track_id IS NOT NULL
GROUP BY sk.track_id
)
"""
RECENT_CTE = """
WITH recent AS (
SELECT sk.track_id AS track_id, COUNT(*) AS plays
FROM scrobble s
JOIN scrobble_key sk ON sk.artist = s.artist AND sk.track = s.track
WHERE sk.track_id IS NOT NULL AND s.uts >= :year_ago
GROUP BY sk.track_id
)
"""
SELECT_TRACK = """
SELECT t.id AS id, a.name AS artist, t.title AS title,
t.duration AS duration, t.path AS path
FROM lidarr_track t
JOIN lidarr_artist a ON a.id = t.artist_id
"""
PLAYABLE = " t.has_file = 1 AND t.path IS NOT NULL AND t.path <> '' "
SHUFFLE = f" ((t.id * {SHUFFLE_MULTIPLIER}) + :week) % {SHUFFLE_MODULUS} "
PLAYLISTS = (
(
"heavy-rotation",
"most played over the last twelve months",
RECENT_CTE + SELECT_TRACK + f"""
JOIN recent r ON r.track_id = t.id
WHERE {PLAYABLE}
ORDER BY r.plays DESC, a.name, t.title
LIMIT :limit
""",
),
(
"all-time",
"most played ever",
PLAYED_CTE + SELECT_TRACK + f"""
JOIN played p ON p.track_id = t.id
WHERE {PLAYABLE}
ORDER BY p.plays DESC, a.name, t.title
LIMIT :limit
""",
),
(
"neglected",
"played heavily once, silent for twelve months",
PLAYED_CTE + SELECT_TRACK + f"""
JOIN played p ON p.track_id = t.id
WHERE {PLAYABLE} AND p.last_uts < :year_ago
ORDER BY p.plays DESC, a.name, t.title
LIMIT :limit
""",
),
(
"deep-cuts",
"never played, from albums whose other tracks you play constantly",
PLAYED_CTE + """,
album_plays AS (
SELECT t.album_id AS album_id, SUM(p.plays) AS plays
FROM played p JOIN lidarr_track t ON t.id = p.track_id
GROUP BY t.album_id
)
""" + SELECT_TRACK + f"""
JOIN album_plays ap ON ap.album_id = t.album_id
LEFT JOIN played p ON p.track_id = t.id
WHERE {PLAYABLE} AND p.track_id IS NULL
ORDER BY ap.plays DESC, t.id
LIMIT :limit
""",
),
(
"unheard-favourites",
"never played, by the artists you play most; rotates weekly",
PLAYED_CTE + """,
artist_plays AS (
SELECT t.artist_id AS artist_id, SUM(p.plays) AS plays
FROM played p JOIN lidarr_track t ON t.id = p.track_id
GROUP BY t.artist_id
)
""" + SELECT_TRACK + f"""
LEFT JOIN played p ON p.track_id = t.id
WHERE {PLAYABLE} AND p.track_id IS NULL
AND t.artist_id IN (SELECT artist_id FROM artist_plays
ORDER BY plays DESC LIMIT 50)
ORDER BY {SHUFFLE}
LIMIT :limit
""",
),
(
"unheard",
"never played, anywhere in the library; rotates weekly",
PLAYED_CTE + SELECT_TRACK + f"""
LEFT JOIN played p ON p.track_id = t.id
WHERE {PLAYABLE} AND p.track_id IS NULL
ORDER BY {SHUFFLE}
LIMIT :limit
""",
),
)
def library_root_of(store):
"""Return the directory Lidarr's artist folders sit under.
Derived rather than configured, because it has to agree with what Lidarr
reports and no one wants to keep a second copy of that in step by hand.
"""
paths = [
row["path"]
for row in store.connection.execute(
"SELECT path FROM lidarr_artist WHERE path IS NOT NULL AND path <> ''"
)
]
if not paths:
return None
if len(paths) == 1:
return str(Path(paths[0]).parent)
try:
return os.path.commonpath(paths)
except ValueError:
return None
def mirror_path_for(source, library_root, mirror_root):
"""Return where music-mirror would have put the MP3 for a source file."""
try:
relative = Path(source).relative_to(library_root)
except ValueError:
return None
return (Path(mirror_root) / relative).with_suffix(MIRROR_SUFFIX)
def write_playlist(path, entries):
"""Write one extended M3U, atomically.
Paths are relative to the playlist file, so the same playlist works from the
NAS, from a Mac over SMB and from Linux without rewriting.
"""
lines = ["#EXTM3U"]
for entry in entries:
seconds = round((entry["duration"] or 0) / 1000)
lines.append(f"#EXTINF:{seconds},{entry['artist']} - {entry['title']}")
lines.append(os.path.relpath(entry["mirror"], path.parent))
path.parent.mkdir(parents=True, exist_ok=True)
handle, temporary = tempfile.mkstemp(dir=path.parent, suffix=".m3u.part")
os.close(handle)
temporary = Path(temporary)
try:
temporary.write_text("\n".join(lines) + "\n", encoding="utf-8")
# The mirror is read back by something else; see music-mirror, which had
# to learn this the hard way.
mode = temporary.stat().st_mode
if not mode & GROUP_READ:
temporary.chmod(mode | GROUP_READ)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def build_playlists(store, mirror_root, library_root, limit, now):
"""Write every playlist into the mirror. Returns how many tracks were listed.
A track is only listed once its mirror file has been confirmed to exist. The
index knows what Lidarr holds, which is the lossless source; whether the MP3
beside it has been encoded yet is music-mirror's business and is checked
rather than assumed.
"""
directory = Path(mirror_root) / PLAYLIST_DIRECTORY
parameters = {
"limit": limit,
"year_ago": now - 365 * 86400,
"week": now // ROTATION_PERIOD_SECONDS,
}
total = 0
missing = 0
for name, description, sql in PLAYLISTS:
entries = []
for row in store.connection.execute(sql, parameters):
mirror = mirror_path_for(row["path"], library_root, mirror_root)
if mirror is None or not mirror.is_file():
missing += 1
continue
entries.append({**dict(row), "mirror": mirror})
write_playlist(directory / f"{name}.m3u", entries)
total += len(entries)
logger.info("playlist %-20s %4d tracks -- %s", name, len(entries), description)
if missing:
logger.warning(
"%d selected tracks had no file in the mirror and were left out."
" A handful means music-mirror has not encoded them yet; a large"
" number means --library-root or --mirror is pointing at the wrong"
" place, since the paths are then being mapped to nothing.",
missing,
)
return total
def coverage_report(store):
"""Log how much of the listening history could be tied to the library.
@@ -1119,12 +1354,76 @@ def coverage_report(store):
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
).fetchone()
logger.info(
"unmatched by an artist the library holds: %d pairs, %d plays -- these are the"
" matcher's misses, not music you do not own",
"unmatched by an artist the library holds: %d pairs, %d plays",
suspect["pairs"],
suspect["plays"],
)
# Owning an artist is a weak proxy for owning a track, so that figure alone
# overstates the matcher's failings. Split it -- but not on the title alone.
# Across fifty thousand tracks, titles collide constantly: "Everyday" is
# Rusko and also Def Leppard, "Kaleidoscope" is Delta Heavy and also
# Chappell Roan. Matching those would be worse than missing them.
#
# The signal for a genuine attribution miss is that the library's own title
# credits the artist the scrobble is filed under -- "Voodoo People (Pendulum
# Remix)" against a play credited to Pendulum. The normalised title has that
# suffix stripped, which is exactly what let them meet, so the raw one has to
# be searched for the name.
attribution = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays FROM scrobble_key k"
" WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
).fetchone()
collision = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays FROM scrobble_key k"
" WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)"
).fetchone()
logger.info(
" the library's title credits the scrobbled artist: %d pairs, %d plays"
" -- remixes and guest spots, and the genuine misses",
attribution["pairs"],
attribution["plays"],
)
logger.info(
" same title under an unrelated artist: %d pairs, %d plays"
" -- title collisions, not misses; matching these would be a mistake",
collision["pairs"] - attribution["pairs"],
collision["plays"] - attribution["plays"],
)
logger.info(
" the rest, %d pairs, %d plays: you own the artist but not the track",
suspect["pairs"] - collision["pairs"],
suspect["plays"] - collision["plays"],
)
mismatched = store.connection.execute(
"SELECT k.artist, k.track, k.plays,"
" (SELECT t.title FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0 LIMIT 1) AS library_title"
" FROM scrobble_key k"
" WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
" ORDER BY k.plays DESC, k.artist LIMIT 10"
).fetchall()
for position, row in enumerate(mismatched, start=1):
logger.info(
" attribution %2d: %-45s %4d plays, library has %r",
position,
f"{row['artist']} - {row['track']}"[:45],
row["plays"],
row["library_title"],
)
with_files = store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE has_file = 1")
played = store.scalar(
"SELECT COUNT(DISTINCT k.track_id) FROM scrobble_key k"
@@ -1193,7 +1492,10 @@ def report(store, now):
logger.info(" top track 90d %2d: %-50s %d", position, label[:50], row["plays"])
def run_once(client, store, user, now, backfill_limit, lidarr=None):
def run_once(
client, store, user, now, backfill_limit, lidarr=None, mirror=None,
library_root=None, playlist_limit=100,
):
"""Run a single pass. Returns the number of scrobbles added."""
started = time.monotonic()
added = catch_up(client, store, user, now)
@@ -1206,6 +1508,15 @@ def run_once(client, store, user, now, backfill_limit, lidarr=None):
# arrived, and they need a verdict too.
if store.scalar("SELECT COUNT(*) FROM lidarr_track"):
match_library(store)
if mirror is not None:
root = library_root or library_root_of(store)
if root is None:
logger.warning(
"cannot work out where Lidarr's music lives, so no playlists were"
" written; set --library-root"
)
else:
build_playlists(store, mirror, root, playlist_limit, now)
logger.info(
"pass complete in %.1fs: %d scrobbles added, %d loved",
@@ -1278,6 +1589,25 @@ def build_parser():
default=os.getenv("MUSIC_CURATOR_LIDARR_API_KEY"),
help="Lidarr API key (env MUSIC_CURATOR_LIDARR_API_KEY)",
)
parser.add_argument(
"--mirror",
default=os.getenv("MUSIC_CURATOR_MIRROR"),
help="root of the MP3 mirror; playlists are written into it"
" (env MUSIC_CURATOR_MIRROR)",
)
parser.add_argument(
"--library-root",
default=os.getenv("MUSIC_CURATOR_LIBRARY_ROOT"),
help="prefix to strip from Lidarr's track paths when mapping them into the"
" mirror; derived from the indexed artist folders when unset"
" (env MUSIC_CURATOR_LIBRARY_ROOT)",
)
parser.add_argument(
"--playlist-limit",
type=int,
default=int(os.getenv("MUSIC_CURATOR_PLAYLIST_LIMIT", "100")),
help="most tracks to put in any one playlist (env MUSIC_CURATOR_PLAYLIST_LIMIT)",
)
parser.add_argument(
"--skip-index",
action="store_true",
@@ -1342,7 +1672,17 @@ def main(argv=None, clock=time.time):
while True:
now = int(clock())
try:
run_once(client, store, args.user, now, args.backfill_limit, lidarr)
run_once(
client,
store,
args.user,
now,
args.backfill_limit,
lidarr,
args.mirror,
args.library_root,
args.playlist_limit,
)
except (LastfmError, LidarrError) as error:
logger.error("%s", error)
if interval is None:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "music-curator"
version = "0.2.4"
version = "0.4.0"
description = "Ingest a Last.fm listening history and curate a music library from it"
readme = "README.md"
requires-python = ">=3.11"
+257 -6
View File
@@ -1,5 +1,7 @@
import json
import stat
import urllib.error
from pathlib import Path
import pytest
from conftest import FakeLastfm, FakeLidarr, make_loved, make_tracks
@@ -55,9 +57,23 @@ LIBRARY = [
{
"name": "The Prodigy",
"albums": [
{"title": "The Fat of the Land", "tracks": [{"title": "Breathe (Remastered)"}]}
{
"title": "The Fat of the Land",
"tracks": [
{"title": "Breathe (Remastered)"},
# Credits its remixer in the title, which is the only signal
# separating a real attribution miss from a title collision.
{"title": "Voodoo People (Pendulum Remix)"},
],
}
],
},
# Held by the library in its own right, which is what puts its scrobbles
# inside the "artist the library holds" filter at all.
{
"name": "Pendulum",
"albums": [{"title": "Immersion", "tracks": [{"title": "Watercolour"}]}],
},
]
@@ -393,7 +409,7 @@ def test_keys_carry_the_play_count_and_the_span(tmp_path):
def test_re_indexing_drops_what_lidarr_no_longer_has(tmp_path):
"""The index is Lidarr's mirror, not an accumulation of everything ever seen."""
store = indexed(tmp_path, [scrobble_of("AC/DC", "Hells Bells")])
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 3
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 4
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(LIBRARY[:1])), store
@@ -512,7 +528,7 @@ def test_albums_come_from_the_unfiltered_endpoint(tmp_path):
album_calls = [query for path, query in api.calls if path == "album"]
assert album_calls == [{}]
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 3
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 4
def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path):
@@ -526,7 +542,7 @@ def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path):
assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE artist_id = 2") == 0
# The other two artists keep their albums.
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 2
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 3
assert store.get_state("index_albums_skipped") == "1"
@@ -549,10 +565,10 @@ def test_an_artist_lidarr_cannot_serve_does_not_kill_the_index(tmp_path):
music_curator.index_library(music_curator.Lidarr("http://lidarr", "key", transport=api), store)
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 3
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 4
assert store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE artist_id = 2") == 0
# The other two artists are indexed in full.
assert store.scalar("SELECT COUNT(*) FROM lidarr_track") == 3
assert store.scalar("SELECT COUNT(*) FROM lidarr_track") == 5
assert store.get_state("index_skipped") == "1"
@@ -732,3 +748,238 @@ def test_a_bracketed_guest_credit_matches_the_bare_tag(tmp_path):
method, track_id = verdict(store, "Yellowcard", "Here I Am Alive (feat. Someone)")
assert method == "name"
assert track_id is not None
def test_misses_are_split_by_whether_the_library_holds_the_title(tmp_path):
"""Owning an artist is a weak proxy for owning a track. Counting both as
matcher failures overstates the problem and would over-block the cull."""
store = indexed(
tmp_path,
[
# The library holds "Hells Bells", but under AC/DC, not Yellowcard:
# an attribution disagreement, and a real miss.
scrobble_of("Yellowcard", "Hells Bells"),
# Yellowcard is in the library; this track is not, under any artist.
scrobble_of("Yellowcard", "A Single She Never Bought"),
],
)
def count(extra):
return store.connection.execute(
"SELECT COUNT(*) FROM scrobble_key k WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
f" {extra}"
).fetchone()[0]
assert count("") == 2
assert count("AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)") == 1
def test_the_report_survives_the_attribution_split(tmp_path):
store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")])
music_curator.report(store, NOW)
def attribution_pairs(store):
"""Unmatched pairs where the library's own title credits the scrobbled artist."""
return [
row["artist"]
for row in store.connection.execute(
"SELECT k.artist FROM scrobble_key k WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
)
]
def test_a_shared_title_is_not_an_attribution_miss(tmp_path):
"""Across fifty thousand tracks, titles collide constantly: "Everyday" is
Rusko and also Def Leppard. Matching those would be worse than missing."""
store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")])
# The library holds "Hells Bells", by AC/DC, and its title says nothing
# about Yellowcard. A collision, not a miss.
assert verdict(store, "Yellowcard", "Hells Bells") == ("none", None)
assert attribution_pairs(store) == []
def test_a_remix_credited_in_the_library_title_is_an_attribution_miss(tmp_path):
"""The library has "Voodoo People (Pendulum Remix)" under The Prodigy; the
scrobble credits Pendulum. Same song, different filing."""
store = indexed(tmp_path, [scrobble_of("Pendulum", "Voodoo People")])
assert attribution_pairs(store) == ["Pendulum"]
def test_the_title_index_is_used_for_the_report_lookup(tmp_path):
"""Without it the report scans every track for every unmatched key: forty-
seven seconds on a real library."""
store = indexed(tmp_path, [])
plan = "\n".join(
row[-1]
for row in store.connection.execute(
"EXPLAIN QUERY PLAN SELECT 1 FROM scrobble_key k WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)"
)
)
assert "lidarr_track_title" in plan, plan
def playlist_library(tmp_path):
"""A library on disk, with mirror MP3s beside the Lidarr source paths."""
source = tmp_path / "music"
mirror = tmp_path / "mirror"
library = [
{
"name": "Played Band",
"albums": [
{
"title": "Known",
"tracks": [{"title": "Hit"}, {"title": "Album Track"}],
}
],
},
{
"name": "Silent Band",
"albums": [{"title": "Unknown", "tracks": [{"title": "Never Heard"}]}],
},
]
api = FakeLidarr(library)
# FakeLidarr invents /music/<artist>/<album>/<title>.flac; put the mirror
# MP3s at the paths music-mirror would have produced from those.
for handle in api.files:
relative = Path(handle["path"]).relative_to("/music")
handle["path"] = str(source / relative)
mp3 = (mirror / relative).with_suffix(".mp3")
mp3.parent.mkdir(parents=True, exist_ok=True)
mp3.write_bytes(b"not really an mp3")
for artist in api.artists:
artist["path"] = str(source / Path(artist["path"]).name)
return api, source, mirror
def test_playlists_are_written_into_the_mirror(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")] * 1))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
written = sorted(p.name for p in (mirror / "_playlists").glob("*.m3u"))
assert written == [
"all-time.m3u",
"deep-cuts.m3u",
"heavy-rotation.m3u",
"neglected.m3u",
"unheard-favourites.m3u",
"unheard.m3u",
]
played = (mirror / "_playlists" / "all-time.m3u").read_text().splitlines()
assert played[0] == "#EXTM3U"
assert played[1].startswith("#EXTINF:")
assert "Played Band - Hit" in played[1]
# Relative to the playlist file, so the same file works from any mount.
assert played[2] == "../Played Band/Known/Hit.mp3"
assert (mirror / "_playlists" / "all-time.m3u").parent.joinpath(played[2]).resolve().is_file()
def test_an_unplayed_track_lands_in_the_unheard_playlist(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")]))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
unheard = (mirror / "_playlists" / "unheard.m3u").read_text()
assert "Never Heard" in unheard
assert "Album Track" in unheard
# The one thing that was played must not be in it.
assert "Played Band - Hit\n" not in unheard
def test_a_track_with_no_mirror_file_is_left_out(tmp_path):
"""The index knows what Lidarr holds; whether music-mirror has encoded the
MP3 yet is a different question, and is checked rather than assumed."""
api, source, mirror = playlist_library(tmp_path)
for mp3 in mirror.rglob("*.mp3"):
mp3.unlink()
store = store_at(tmp_path)
ingest(store, FakeLastfm([scrobble_of("Played Band", "Hit")]))
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
total = music_curator.build_playlists(store, mirror, str(source), 100, NOW)
assert total == 0
assert (mirror / "_playlists" / "all-time.m3u").read_text() == "#EXTM3U\n"
def test_the_library_root_is_derived_from_the_artist_folders(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
assert music_curator.library_root_of(store) == str(source)
def test_the_playlist_limit_is_honoured(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 1, NOW)
unheard = (mirror / "_playlists" / "unheard.m3u").read_text().splitlines()
assert len([line for line in unheard if line.startswith("#EXTINF")]) == 1
def test_the_rotation_moves_weekly_not_every_pass(tmp_path):
"""A playlist that reorders on every pass is one that has to be re-imported
on every pass; the Music app imports a snapshot, it does not track a file."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
def unheard_at(when):
music_curator.build_playlists(store, mirror, str(source), 100, when)
return (mirror / "_playlists" / "unheard.m3u").read_text()
same_week = unheard_at(NOW), unheard_at(NOW + 3600)
assert same_week[0] == same_week[1]
def test_playlists_are_group_readable(tmp_path):
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
for playlist in (mirror / "_playlists").glob("*.m3u"):
assert playlist.stat().st_mode & stat.S_IRGRP, playlist