35 Commits
Author SHA1 Message Date
lyrathorpe 494ac950d7 chore(release): v0.7.1 2026-08-26 19:41:43 +00:00
lyrathorpe 61761c7fac Merge pull request 'fix: remove playlists left behind by the switch to .m3u8' (#15) from fix/prune-superseded-playlists into main
Build and publish container / build (push) Successful in 2m41s
Reviewed-on: #15
2026-08-26 20:39:09 +01:00
Emma Thorpe 8644184caa fix: remove playlists left behind by the switch to .m3u8
Build and publish container / build (pull_request) Successful in 3m40s
Every playlist appeared twice on the device. Pruning is driven by a record of
what was written last time, deliberately, so that a playlist put in that
directory by hand is never touched. But the record cannot reach back before it
existed: the .m3u files were written by a version that kept none, so when the
extension changed there was nothing to prune them by, and rsync carried both
copies across.

A file with the same name as one being written now, under an extension this
tool used to write, is removed as well. That is narrow enough to match only its
own leavings -- a hand-made playlist has a name this tool never produces, so it
survives whatever its extension, and there is a test for that alongside the one
for the duplicate.
2026-08-26 18:06:47 +01:00
lyrathorpe 43d04bf311 chore(release): v0.7.0 2026-08-25 10:11:36 +00:00
lyrathorpe 9b7bb1e9fd Merge pull request 'feat: cold report, playlist pruning, and .m3u8 playlists' (#13) from feat/cold-report into main
Build and publish container / build (push) Successful in 2m21s
Reviewed-on: #13
2026-08-25 11:09:15 +01:00
Emma Thorpe 35d5e98642 fix: write playlists as .m3u8 so Rockbox reads them as UTF-8
Build and publish container / build (pull_request) Successful in 2m42s
Rockbox's is_m3u8_name() treats every playlist extension as UTF-8 except an
explicit ".m3u", which it instead decodes through the user's configured
codepage:

    /* Default to M3U8 unless explicitly told otherwise. */
    return (!dot || strcasecmp(dot, ".m3u") != 0);

The one extension being used was therefore the only one that mangles accented
filenames, and this library holds Motley Crue, Beyonce and Sigur Ros. Renaming
the output is the whole fix.

No byte order mark is written. One would promote a .m3u file to UTF-8 as well,
but it is unnecessary at this extension and upsets players that do not expect
to find one.

Kept with the pruning change rather than raised separately, because the rename
depends on it: without pruning, seventeen dead .m3u files would sit on the
device for ever, and every one of them full of paths that still resolve.
2026-08-25 10:43:43 +01:00
Emma Thorpe 9fdd61b648 feat: report which albums have never been played
Build and publish container / build (pull_request) Successful in 6m37s
Stage four, read-only. An album is cold when it has files, not one of its tracks
has ever been scrobbled across the whole nine-year history, and its newest file
landed over a year ago. The age floor is measured from the file rather than the
release date: what matters is how long a record has been available to play, not
how old it is.

Album-level rather than track-level. A record with two played tracks is a record
that gets played, and picking the other ten off it leaves gaps in an album
rather than reclaiming anything worth having.

Track file sizes are now indexed so the report can rank by the disk actually
recoverable, which is the point of the exercise. That needed a column on a table
that already exists, so the store gained an in-place column migration; a full
rebuild would mean re-downloading sixty thousand scrobbles.

The report refuses to produce anything when the backfill is unfinished, when any
artist failed to index, when any artist has no albums, or when there is no
history to judge against. Each of those makes played music look unplayed, which
is the one failure that costs a library, and they are checked rather than
trusted because the report they gate is the one that ends in deletion.

Nothing is written to Lidarr. Every call there remains a GET, and unmonitoring
waits until the list this produces has been looked at -- no flag protects
against a list that is wrong.

Also prunes playlists no longer produced, which #12 made necessary by renaming
moods: high-energy-rock would otherwise have stayed on the device for ever. It
is driven by a record of what was written last time rather than by deleting
every M3U that is not currently ours, so a playlist put in that directory by
hand is left alone.
2026-08-24 19:21:57 +01:00
lyrathorpe 7b6f6e0016 chore(release): v0.6.0 2026-08-24 17:49:48 +00:00
lyrathorpe 0ae2630a79 Merge pull request 'feat: rebuild the moods from real tags, add exclusions, fix playlist ownership' (#12) from feat/moods-from-real-tags into main
Build and publish container / build (push) Successful in 1m9s
Reviewed-on: #12
2026-08-24 18:48:43 +01:00
Emma Thorpe a7d16ca0b2 fix: give playlists the mirror's ownership rather than root's
Build and publish container / build (pull_request) Successful in 2m40s
Playlists were being written owned by root. The image runs as root by default,
deliberately, so that a bind-mounted dataset of any ownership stays writable --
but everything it writes then comes out root-owned, and a root-owned playlist
inside a mirror owned by the apps account is unreadable to whatever serves it.
The group-read bit does not help when the group is also root.

Copy the ownership of the mirror the playlist is being written into. That needs
no configuration, cannot drift from whatever the mirror actually is, and does
nothing at all when the two already agree -- which is the case whenever the
container is run with an explicit user.

Applied to the temporary file before the rename, so a playlist is never briefly
visible owned by the wrong account, and to the _playlists directory when this is
the run that creates it. A chown that is refused is ignored rather than fatal:
that only happens when not running as root, which is precisely the case where
the ownership was already right.
2026-08-24 18:45:25 +01:00
Emma Thorpe 8c4da6e14e feat: let a mood exclude tags, and widen the eighties one to the canon
Build and publish container / build (pull_request) Successful in 2m52s
The eighties mood selected on synth-specific tags only, on the grounds that the
bare `80s` tag drags in Def Leppard and Bon Jovi. Checking the canon against
live Last.fm pages shows what that costs. Eurythmics is tagged 80s, new wave,
pop, female vocalists, synth pop -- four of those five are tags no mood may use.
Frankie Goes to Hollywood is tagged 80s, new wave, pop, british, dance, and
survives only on new wave. An act tagged purely 80s, pop and dance, which is a
great deal of eighties pop, was missed outright.

Two spellings also matter more than the census suggested. Of Depeche Mode, Duran
Duran, Eurythmics and Frankie Goes to Hollywood, three carry "synth pop" with a
space and only one carries "synthpop" without. The census could not show this
because it only sees artists already in the library; the spelling that was kept
was the rarer one.

Add an `exclude` list to a mood. An excluded tag drops the artist outright
rather than docking their score, which is the only construction that expresses
"the eighties, but not the stadium rock" -- weighting cannot do it, because the
tag it would weight is the one both share. The synth acts do not carry hard rock
or hair metal, and that is the whole of the difference.

A mood may not both select on and exclude the same tag; that is rejected when
the definitions are read rather than silently producing nothing.
2026-08-24 18:39:59 +01:00
Emma Thorpe 41f6b290d8 feat: rebuild the moods from the library's measured tag distribution
Build and publish container / build (pull_request) Successful in 2m40s
The first set of moods was guessed at, and a tag census of the real library
shows how badly. Four tags in the dance mood -- edm, big room, hardstyle,
electronic dance music -- carry nothing whatsoever, while techno, electro, big
beat, rave and progressive house together carry over seven thousand plays and
were all absent. The drum and bass mood missed four of the six spellings the
taggers actually use, plus techstep, darkstep and hospital records.

The eighties mood was the worst of it. It leaned on the bare `80s` tag, which is
the eleventh most-played tag here and sits on Def Leppard, Bon Jovi and AC/DC
rather than on anything with a synthesiser in it. Removing it and adding a
hair-metal mood -- glam rock, hair metal, glam metal, arena rock, AOR, windowed
to 1975-1994 -- puts roughly eight thousand plays somewhere sensible and stops
the synth playlist being a stadium rock playlist.

Whole clusters had no mood at all: metal at around twenty-five thousand plays
across heavy, thrash, speed, power, death and NWOBHM, and pop punk and emo at a
similar figure. Both now exist, along with bass, nu-metal and indie.

Three kinds of tag are excluded on principle, with a test enforcing it, because
each produces a playlist that looks reasonable and is not. Nationality describes
a passport rather than a sound, and `american` alone spans 241 artists. `rock`
and `electronic` span 340 and 275, which is most of the collection. And
Last.fm's most popular tag for an artist is frequently the artist's own name, so
selecting on `green day` yields a Green Day playlist wearing a genre's clothes.
2026-08-24 18:34:34 +01:00
lyrathorpe fbce764dc5 chore(release): v0.5.1 2026-08-24 17:17:33 +00:00
lyrathorpe 71c7115507 Merge pull request 'fix: look tags up by artist name rather than by MusicBrainz id' (#11) from fix/tag-lookup-by-name into main
Build and publish container / build (push) Successful in 2m48s
Reviewed-on: #11
2026-08-24 18:14:41 +01:00
Emma Thorpe 40dfce8a4c fix: look tags up by artist name rather than by MusicBrainz id
Build and publish container / build (pull_request) Successful in 2m53s
Tag lookups asked by MusicBrainz id whenever Lidarr had one, which is always.
The reasoning was that an id cannot be ambiguous the way a name can. In practice
Last.fm's mbid index is stale and partial, and it answered "the artist you
supplied could not be found" for Devo, Escape the Fate, Blasterjaxx, Frank
Carter & the Rattlesnakes and a long tail of others -- artists whose Last.fm
pages plainly exist and carry precisely the tags the moods are built from. Devo
is tagged new wave, post-punk and 80s; Escape the Fate is tagged post-hardcore,
screamo and emocore. Both were skipped.

Ask by name first, which is the index Last.fm's own site runs on, and keep the
id only as a fallback for a name Lidarr spells differently.

Failures were also being dropped without recording the attempt, so every one of
those artists was re-queried on every subsequent pass, indefinitely. They are now
distinguished: an artist that neither key resolves is recorded as fetched with
no tags and not asked about again, while a genuine failure -- a rate limit, a
bad key -- is deliberately left unrecorded so the next pass retries it. Telling
the two apart needed the service's own error number, so LastfmError now carries
it.
2026-08-24 18:13:23 +01:00
lyrathorpe ddd126cc0d chore(release): v0.5.0 2026-08-24 17:04:05 +00:00
lyrathorpe 97b27f8daa Merge pull request 'feat: mood playlists from Last.fm crowd tags' (#10) from feat/tag-vibes into main
Build and publish container / build (push) Successful in 4m22s
Reviewed-on: #10
2026-08-24 17:59:43 +01:00
lyrathorpe 88b96b8159 chore(release): v0.4.0 2026-08-24 16:57:43 +00:00
Emma Thorpe 15e5ee5aea feat: mood playlists from Last.fm crowd tags
Build and publish container / build (pull_request) Successful in 3m50s
A second set of playlists selecting by genre and mood rather than by play
history: eighties synths, high energy rock, screamo, drum and bass, dance,
classic rock.

The tags come from Last.fm rather than MusicBrainz. MusicBrainz genres arrive
free with the Lidarr index, which makes them the obvious choice and the wrong
one: they are sparse and formal, and will not tell you a record is screamo or
synthwave. Crowd tags will, because people typed them.

One request per artist, by MusicBrainz id where Lidarr has one, refreshed every
ninety days. An artist Last.fm has never heard of is recorded as fetched with no
tags rather than left unmarked, so it is not asked about again on every pass
forever. The tag table is keyed on the normalised artist name, not the Lidarr
id, so it survives an artist being removed and re-added there.

Weights are taken from the response's count where it has one. The documented
sample carries only a name and a URL, a live response also carries a 0-100
count, and depending on either alone would be a guess -- so the count is used
when present and the documented ordering by popularity stands in when it is not.

An artist qualifies for a mood when their weights inside it sum to at least
thirty. A single low-weight tag is not a genre, it is somebody's stray opinion.
A mood may also restrict release years, which is what separates eighties synth
records from everything else a synthpop tag drags in.

The built-in set is chosen for this library rather than as a taxonomy, and
--vibes replaces it wholesale with a JSON file so a new mood does not need a new
release. Names are validated when that file is read: an invalid one would
otherwise only surface as a playlist written somewhere unintended.
2026-08-24 17:56:35 +01: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
lyrathorpe 56a06a6cd5 chore(release): v0.2.4 2026-08-24 16:09:38 +00:00
lyrathorpe a3d0689c0a Merge pull request 'fix: match bracketed guest credits and hyphenated version suffixes' (#5) from fix/normalise-bracketed-credits-and-dash-suffixes into main
Build and publish container / build (push) Successful in 11m42s
Reviewed-on: #5
2026-08-24 16:58:05 +01:00
Emma Thorpe 7588fee302 fix: match bracketed guest credits and hyphenated version suffixes
Build and publish container / build (pull_request) Successful in 10m45s
Two normalisation faults, both found by running a real coverage report's
unmatched list back through the normaliser. Between them they account for five
of the fifteen worst misses by play count.

The guest-credit pattern required whitespace immediately before the word, so it
caught "Yellowcard feat. Tay Jardine" but missed "Self vs Self (feat. In
Flames)" -- and the bracketed form is the more common of the two. An opening
bracket is now allowed in that position.

The trailing-version pattern matched the suffix as a run of non-hyphens, which
cannot cross a hyphen inside the suffix itself: "Gold Dust - Shy FX Re-Edit" and
"Back To Your Roots - Friction & K-Tee Remix" both survived untouched. Matched
lazily instead.

Four version words are added for how drum and bass marks its variants: vip,
bootleg, rework, extended. They only apply inside a bracket or after a trailing
dash, so the exposure is small, and ordinary titles carrying those words --
Editors, Mixed Emotions, Radio Ga Ga, Live and Let Die -- are pinned as tests
against exactly that.

The report also gains the figures that explain why the MBID tier contributes so
little. Two thirds of scrobbles carry a recording id, and only a twentieth of
them join on one: MusicBrainz holds a separate recording per release, and the
two sides rarely choose the same one. Counting the pairs that carried an id and
matched on name anyway measures that disagreement directly, and settles that the
weakness is not a bug in the join.
2026-08-24 16:56:13 +01:00
lyrathorpe cd66559b55 chore(release): v0.2.3 2026-08-24 13:47:36 +00:00
lyrathorpe fef082a783 Merge pull request 'fix: hold one connection to Lidarr open, and retry what deserves retrying' (#4) from fix/lidarr-connection-reuse into main
Build and publish container / build (push) Successful in 6m39s
Reviewed-on: #4
2026-08-24 14:41:02 +01:00
Emma Thorpe edebecc8ea fix: hold one connection to Lidarr open, and retry what deserves retrying
Build and publish container / build (pull_request) Successful in 6m0s
Indexing makes two requests per artist, and more when the album fallback fires.
urllib opens a new TCP connection and performs a new DNS lookup for every one of
them, so a large library becomes thousands of lookups inside a few minutes. That
is enough to exhaust a container's resolver, and the result is
"[Errno -3] Try again" on every artist at once -- a failure caused entirely by
how the requests were made rather than by anything wrong with Lidarr.

Add a transport that keeps one connection open per host, so the name is resolved
once and the socket is reused. It retries once on a connection the server has
already closed, since a stale keep-alive announces itself only on use.

Retries were previously declined on the grounds that Lidarr is on the same LAN.
That is not a safe assumption -- it may sit behind a public hostname and a
reverse proxy -- and a transient failure currently costs an artist their entire
entry for that pass. Transient failures are now retried with a backoff. HTTP 500
is deliberately excluded: it is an exception inside Lidarr's serialisation, not
a busy server, and three attempts only delay finding that out.

The same distinction gates the album probe added alongside this. Naming the
offending album costs one request per album of that artist, which is worth it
for a deterministic fault and actively harmful during a network-wide one, where
every artist fails and probing each of them multiplies the load responsible.

The keep-alive transport is tested against a real local HTTP server rather than
a fake, because connection reuse and status mapping are exactly the properties a
fake would assume rather than demonstrate.
2026-08-24 14:40:00 +01:00
7 changed files with 2522 additions and 75 deletions
+29 -19
View File
@@ -45,7 +45,8 @@ jobs:
# The suite runs inside the image, against the interpreter that ships, # The suite runs inside the image, against the interpreter that ships,
# rather than against whatever the runner happens to provide. A failing # 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 - name: Run the test suite inside the image
run: docker build --target test -t music-curator:test . run: docker build --target test -t music-curator:test .
@@ -124,9 +125,6 @@ jobs:
echo "release=${release}" >> "$GITHUB_OUTPUT" echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "Computed bump=${bump}, release=${release}, base=${base}" 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 - name: Log in to the Gitea container registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
@@ -135,21 +133,33 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.PACKAGES_TOKEN }} password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push # Plain `docker build` rather than buildx. buildx boots its own buildkit
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 # in a container with a cache of its own, so it shared nothing with the
with: # test build above and rebuilt the image from the base image up -- two
context: . # full builds per run. It earns that cost when building for several
# Without this the last stage in the Dockerfile -- the test stage -- # platforms; this only ever targets the amd64 NAS, so it does not.
# would be what gets published. #
target: runtime # `--target runtime` is a strict prefix of the test stage, so every layer
# The NAS is the only host this runs on. Building arm64 as well would # is already in the daemon's cache and this resolves in seconds.
# mean emulating it under QEMU for no consumer. - name: Build the runtime image
platforms: linux/amd64 run: |
push: ${{ github.event_name != 'pull_request' }} set -euo pipefail
tags: ${{ steps.version.outputs.tags }} tags=()
labels: | while IFS= read -r tag; do
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} [ -n "$tag" ] && tags+=(-t "$tag")
org.opencontainers.image.revision=${{ github.sha }} 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 # Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release # commit and tag it, so the packaging metadata always matches the release
+247 -10
View File
@@ -7,9 +7,11 @@ 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 question that mirror cannot: which of it is worth carrying, and which of it has
not been played in years. not been played in years.
**This is stage two.** It ingests the scrobble history, indexes the library from **This is stage four, read-only.** It ingests the scrobble history, indexes the library
Lidarr, and matches one to the other. There are no playlists yet, and it writes from Lidarr, matches one to the other, and writes playlists into the mirror —
nothing back — every Lidarr call is a `GET`. See "Where this is going" below. by listening history and by mood.
Nothing is written back to Lidarr — every call there is a `GET`. See "Where this
is going" below.
## What it does today ## What it does today
@@ -18,6 +20,8 @@ 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 - Indexes every artist, album and track Lidarr knows about, with file paths and
the date each file landed. the date each file landed.
- Ties the two together and reports how well it managed. - Ties the two together and reports how well it managed.
- Writes M3U playlists into the mirror, from the listening history and by mood.
- Reports which albums have never been played. It does not act on that.
## Matching ## Matching
@@ -29,7 +33,14 @@ Two tiers, and no third.
| `name` | Normalised artist and title | Everything the first tier could not carry | | `name` | Normalised artist and title | Everything the first tier could not carry |
| `none` | — | Recorded as a miss, never guessed at | | `none` | — | Recorded as a miss, never guessed at |
The normalisation is the load-bearing part, because the two sides disagree in The name tier does almost all of the work. A recording MBID is exact when it
lands, but MusicBrainz holds a separate recording per release, and Last.fm and
Lidarr rarely pick the same one: on a real library, two thirds of scrobbles
carry a recording id and barely a twentieth of them join on it. The report
counts how many carried an id and matched on name anyway, which is the measure
of that disagreement.
So the normalisation is the load-bearing part, because the two sides disagree in
predictable ways. It folds case and accents, drops guest credits (`Yellowcard predictable ways. It folds case and accents, drops guest credits (`Yellowcard
feat. Tay Jardine` against a tag of `Yellowcard`), strips a trailing feat. Tay Jardine` against a tag of `Yellowcard`), strips a trailing
version suffix (`(Remastered 2011)`, `- Live`), expands `&`, and removes a version suffix (`(Remastered 2011)`, `- Live`), expands `&`, and removes a
@@ -67,6 +78,25 @@ Losing an artist's albums does not cost their tracks, which come from a
different endpoint with a different mapper, so matching is unaffected. A cull different endpoint with a different mapper, so matching is unaffected. A cull
would not be, and the report says so. would not be, and the report says so.
### Talking to Lidarr
Indexing is two requests per artist, and more when the album fallback fires. On
a large library that is thousands of requests in a few minutes. `urllib` opens a
new TCP connection and performs a new DNS lookup for every one of them, which is
enough to exhaust a container's resolver and produce `[Errno -3] Try again` on
everything at once. The client therefore holds one connection open per host and
resolves once.
Transient failures — a dropped connection, a resolver hiccup, `429`, `502`,
`503`, `504` — are retried with a backoff. An HTTP `500` is not: it is an
unhandled exception inside Lidarr's own serialisation and will be raised again
identically. That distinction also decides whether a failure is worth
investigating; a library-wide outage is not probed artist by artist, because
doing so multiplies the load that caused it.
A local address is preferable to a public hostname here. It removes DNS, the
reverse proxy and its timeouts from a path that needs none of them.
Tracks and files have no unfiltered endpoint — Lidarr rejects a call with no Tracks and files have no unfiltered endpoint — Lidarr rejects a call with no
filter — so they stay per artist. If one artist cannot be served, that artist is filter — so they stay per artist. If one artist cannot be served, that artist is
skipped and the run continues, but the count is recorded and the coverage report skipped and the run continues, but the count is recorded and the coverage report
@@ -83,10 +113,209 @@ matcher. The line to watch is:
unmatched by an artist the library holds: N pairs, M plays 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. That is a track that was played by an artist the library holds. It is then
Those are the matcher's real misses, and every one is a candidate for being split three ways, because owning an artist is a weak proxy for owning a track
wrongly called cold in stage four. The report lists the worst fifteen by play and a shared title is a weak proxy for a shared song:
count so they can be eyeballed.
- **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.
The extension is **`.m3u8`**, not `.m3u`. Rockbox's `is_m3u8_name()` treats
every extension as UTF-8 *except* an explicit `.m3u`, which it decodes through
the user's configured codepage instead — so a plain `.m3u` mangles every
accented filename. No byte order mark is written: Rockbox does not need one at
this extension, and a BOM upsets players that do not expect it. 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.
### Moods
A second set of playlists selects by **Last.fm's crowd tags** rather than by
listening history. MusicBrainz genres arrive free with the Lidarr index and are
no use for this: they are sparse and formal, and will not tell you a record is
screamo or synthwave. People typing tags will.
Tags are fetched once per artist — one `artist.getTopTags` call each — and
refreshed every ninety days. `--tag-limit` spreads the first sweep over several
passes.
Artists are looked up **by name**, not by MusicBrainz id, despite Lidarr having
an id for every one of them. Last.fm's mbid index is stale and partial: it
answers "the artist you supplied could not be found" for Devo, Escape the Fate,
Blasterjaxx and a few hundred others whose pages plainly exist and carry exactly
the tags wanted. Its name index is the one its own site runs on. The id is kept
only as a fallback, for a name Lidarr spells differently.
An artist neither key resolves is recorded as fetched with no tags, so the next
pass does not spend a request on it again. A genuine failure — a rate limit, a
bad key — is *not* recorded, so that one is retried.
The built-in moods are built from the **tag distribution of this library**,
measured, rather than from a general taxonomy:
| Mood | Selected on |
| --------------- | -------------------------------------------------------- |
| `drum-and-bass` | drum and bass and its six spellings, liquid funk, neurofunk, jungle, techstep, hospital records |
| `bass` | dubstep, brostep, grime, trip-hop, big beat |
| `dance` | house and its variants, trance, techno, electro, rave |
| `pop-punk` | pop punk, punk, emo, emocore, easycore, power pop |
| `screamo` | screamo, post-hardcore, metalcore, melodic hardcore, trancecore |
| `heavy-metal` | heavy metal, thrash, speed, power, death, prog, NWOBHM |
| `hair-metal` | hair metal, glam metal, glam rock, arena rock, AOR — 1975-1994 |
| `nu-metal` | nu metal, alternative metal, rapcore, industrial |
| `classic-rock` | classic rock, prog, psychedelic, blues rock, 70s, 60s |
| `80s-synths` | 80s, new wave, synth pop, electropop, post-punk — 1975-1992, rock excluded |
| `indie` | indie, indie rock, indie pop, britpop, singer-songwriter |
Measuring first mattered. `synthwave`, `edm`, `big room` and `hardstyle` are
plausible tags that carry **nothing at all** here, while `techstep`, `easycore`
and `hospital records` carry real weight. Guessing produces the first list.
Three kinds of tag are never used, and there is a test enforcing it:
- **Nationality** — `american` alone spans 241 artists. A passport is not a
sound.
- **`rock` and `electronic`** — 340 and 275 artists, most of the library. A
mood that matches everything is not a mood.
- **Artist names** — Last.fm's most popular tag for an artist is frequently
their own name. `green day`, `paramore` and `queen` are single-artist
playlists waiting to happen.
### Exclusions
A mood may also list `exclude`. An excluded tag drops the artist outright rather
than docking their score, and it exists because `80s-synths` cannot be written
any other way.
`80s` is the eleventh most-played tag here, and it sits on Def Leppard and Bon
Jovi exactly as heavily as on Eurythmics. Weighting cannot separate them,
because the tag it would weight is the one they share. What does separate them
is that the stadium rock also carries `hard rock` and `hair metal`, and the
synth acts do not.
Checked against live Last.fm pages, since the tag census only sees artists
already in the library:
| Artist | Tags |
| --- | --- |
| Eurythmics | `80s`, `new wave`, `pop`, `female vocalists`, `synth pop` |
| Frankie Goes to Hollywood | `80s`, `new wave`, `pop`, `british`, `dance` |
| Depeche Mode | `electronic`, `synthpop`, `new wave`, `80s`, `synth pop` |
| Duran Duran | `new wave`, `80s`, `pop`, `synth pop`, `rock` |
Four of Eurythmics' five tags are ones no mood may use. Three of the four
artists spell it **`synth pop`** with a space; only one spells it `synthpop`.
Guessing one spelling would have missed most of the canon.
An artist qualifies when their tag weights inside a mood sum to at least 30 out
of Last.fm's 0-100 scale. One low-weight tag is not a genre, it is somebody's
stray opinion.
`years` filters on the album's release date, which is what separates eighties
synth records from everything else a synthpop tag drags in.
`--vibes` replaces the whole set with a JSON file of the same shape, so a new
mood does not need a new release:
```json
[{ "name": "shoegaze", "tags": ["shoegaze", "dream pop"], "min_score": 40 }]
```
Names are validated when the file is read, not when the file is written. A bad
one would otherwise surface as a playlist created somewhere unintended.
### 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.
Each playlist is given the **owner and group of the mirror** it is written
into. The image runs as root by default so that a bind mount of any ownership
stays writable, and the cost of that is output owned by root — which the account
serving the share cannot read, group bit or no group bit, because the group is
also root. Copying the mirror's own ownership avoids having to be told what it
should be, and does nothing when the two already agree.
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.
## The cold report
Which albums have files, have never had a single track played in the whole
history, and have sat there long enough to have had the chance. Ranked by the
disk they occupy, because that is the point of the exercise.
**Album-level, not track-level.** A record with two played tracks is a record
that gets played; picking the other ten off it leaves gaps rather than
reclaiming anything worth having.
The age floor is measured from the newest file in the album, not from the
release date — what matters is how long it has been available to play, not how
old the record is. `--cold-after` sets it, defaulting to a year.
Artists whose *every* album is cold are counted separately. That is a different
proposition from one cold record by somebody otherwise played, and Lidarr can
only tag at artist level anyway.
### What stops it
The report refuses to produce anything at all when:
- the scrobble backfill is unfinished
- any artist failed to index, or any artist has no albums indexed
- the library has not been indexed, or there is no history to judge against
Each of those makes played music look unplayed, which is the single failure that
costs a library. They are checked rather than trusted, because the report they
gate is the one that ends in deletion.
**Nothing is written to Lidarr.** Every call there is still a `GET`. Unmonitoring
comes once the list has been looked at, because no flag protects against a list
that is wrong.
## How the ingest works ## How the ingest works
@@ -142,6 +371,12 @@ 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 | | `--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-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 | | `--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 |
| `--vibes` | `MUSIC_CURATOR_VIBES` | built-in | JSON file of mood definitions |
| `--tag-limit` | `MUSIC_CURATOR_TAG_LIMIT` | `0` | Cap artist tag lookups per pass |
| `--cold-after` | `MUSIC_CURATOR_COLD_AFTER` | `365` | Days a file must sit unplayed to count as cold |
| `--skip-index` | — | off | Match against the index already held | | `--skip-index` | — | off | Match against the index already held |
| `--report-only` | — | off | Report without fetching | | `--report-only` | — | off | Report without fetching |
@@ -190,8 +425,10 @@ nix shell nixpkgs#python3Packages.pytest -c pytest
| ------------------------------------------------ | ------------ | | ------------------------------------------------ | ------------ |
| Last.fm ingest and store | done | | Last.fm ingest and store | done |
| Lidarr index and the scrobble-to-track matcher | done | | Lidarr index and the scrobble-to-track matcher | done |
| M3U playlists written into the mirror | next | | M3U playlists from the listening history | done |
| Cold-music report, unmonitoring what is not played | last | | Genre and mood playlists from Last.fm tags | done |
| Cold-music report | done |
| Unmonitoring what is not played | last |
The cull will unmonitor cold albums in Lidarr and tag their artists. It will The cull will unmonitor cold albums in Lidarr and tag their artists. It will
never delete files: Lidarr's `AlbumResource` has no tags at all, so tagging never delete files: Lidarr's `AlbumResource` has no tags at all, so tagging
+6
View File
@@ -30,5 +30,11 @@ services:
# Cap the backfill at this many requests per pass. Unlimited by default, # Cap the backfill at this many requests per pass. Unlimited by default,
# which finishes a long history in one go. # which finishes a long history in one go.
# MUSIC_CURATOR_BACKFILL_LIMIT: "0" # 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: volumes:
- /mnt/tank/apps/music-curator:/data - /mnt/tank/apps/music-curator:/data
- /mnt/tank/media/music-mp3:/mirror
+1204 -38
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "music-curator" name = "music-curator"
version = "0.2.2" version = "0.7.1"
description = "Ingest a Last.fm listening history and curate a music library from it" description = "Ingest a Last.fm listening history and curate a music library from it"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+77 -1
View File
@@ -1,7 +1,9 @@
import http.server
import io import io
import json import json
import os import os
import sys import sys
import threading
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@@ -54,7 +56,9 @@ class FakeLastfm:
is prepended to the first page with no `date`. is prepended to the first page with no `date`.
""" """
def __init__(self, tracks=(), loved=(), nowplaying=None, outcomes=()): def __init__(self, tracks=(), loved=(), nowplaying=None, outcomes=(), tags=None):
# Keyed by mbid or by artist name, whichever the caller asked with.
self.tags = tags or {}
self.tracks = sorted(tracks, key=lambda track: int(track["date"]["uts"]), reverse=True) self.tracks = sorted(tracks, key=lambda track: int(track["date"]["uts"]), reverse=True)
self.loved = list(loved) self.loved = list(loved)
self.nowplaying = nowplaying self.nowplaying = nowplaying
@@ -80,6 +84,15 @@ class FakeLastfm:
return json.dumps(self._recent(query)) return json.dumps(self._recent(query))
if method == "user.getlovedtracks": if method == "user.getlovedtracks":
return json.dumps(self._loved(query)) return json.dumps(self._loved(query))
if method == "artist.gettoptags":
key = query.get("mbid") or query.get("artist", "")
if key not in self.tags:
# What the real service says for a key it cannot resolve, which
# for mbids is a great many artists whose pages plainly exist.
return json.dumps(
{"error": 6, "message": "The artist you supplied could not be found"}
)
return json.dumps({"toptags": {"tag": self.tags[key]}})
raise AssertionError(f"unexpected method {method}") raise AssertionError(f"unexpected method {method}")
def _recent(self, query): def _recent(self, query):
@@ -201,6 +214,19 @@ class FakeLidarr:
url, 500, "Internal Server Error", {}, io.BytesIO(b'{"message": "boom"}') url, 500, "Internal Server Error", {}, io.BytesIO(b'{"message": "boom"}')
) )
album_ids = query.get("albumIds")
if path == "album" and album_ids:
album_id = int(album_ids)
if ("albumid", album_id) in self.fail:
raise urllib.error.HTTPError(
url,
500,
"Internal Server Error",
{},
io.BytesIO(b'{"message": "Sequence contains more than one element"}'),
)
return json.dumps([row for row in self.albums if row["id"] == album_id])
if path == "artist": if path == "artist":
return json.dumps(self.artists) return json.dumps(self.artists)
source = {"album": self.albums, "track": self.tracks, "trackfile": self.files}[path] source = {"album": self.albums, "track": self.tracks, "trackfile": self.files}[path]
@@ -228,3 +254,53 @@ def now_playing():
"mbid": "", "mbid": "",
"@attr": {"nowplaying": "true"}, "@attr": {"nowplaying": "true"},
} }
class _CountingServer(http.server.ThreadingHTTPServer):
"""Counts accepted connections, which is what connection reuse is about."""
daemon_threads = True
def __init__(self, *args, **kwargs):
self.connections = 0
super().__init__(*args, **kwargs)
def process_request(self, request, client_address):
self.connections += 1
super().process_request(request, client_address)
class _Handler(http.server.BaseHTTPRequestHandler):
# Without HTTP/1.1 the server closes after every response and no client
# could reuse anything, which would make the test prove nothing.
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path.startswith("/boom"):
body, status = b'{"message": "boom"}', 500
else:
body = json.dumps(
{"path": self.path, "key": self.headers.get("X-Api-Key")}
).encode()
status = 200
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def http_server():
"""A real local HTTP server, for the one component that talks sockets."""
server = _CountingServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()
File diff suppressed because it is too large Load Diff