Compare commits
26
Commits
v0.2.4
...
43d04bf311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d04bf311 | ||
|
|
9b7bb1e9fd | ||
|
|
35d5e98642 | ||
|
|
9fdd61b648 | ||
|
|
7b6f6e0016 | ||
|
|
0ae2630a79 | ||
|
|
a7d16ca0b2 | ||
|
|
8c4da6e14e | ||
|
|
41f6b290d8 | ||
|
|
fbce764dc5 | ||
|
|
71c7115507 | ||
|
|
40dfce8a4c | ||
|
|
ddd126cc0d | ||
|
|
97b27f8daa | ||
|
|
88b96b8159 | ||
|
|
15e5ee5aea | ||
|
|
05cca3508c | ||
|
|
cda3d8463b | ||
|
|
08f099aaa9 | ||
|
|
719a6372ad | ||
|
|
61ce751ac5 | ||
|
|
d1c10d32d9 | ||
|
|
acd042ad8d | ||
|
|
45d99ff039 | ||
|
|
54e19992e4 | ||
|
|
2886c02a2e |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -109,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
|
||||||
|
|
||||||
@@ -168,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 |
|
||||||
|
|
||||||
@@ -216,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+985
-16
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "music-curator"
|
name = "music-curator"
|
||||||
version = "0.2.4"
|
version = "0.7.0"
|
||||||
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"
|
||||||
|
|||||||
+12
-1
@@ -56,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
|
||||||
@@ -82,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):
|
||||||
|
|||||||
+778
-6
@@ -1,5 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from conftest import FakeLastfm, FakeLidarr, make_loved, make_tracks
|
from conftest import FakeLastfm, FakeLidarr, make_loved, make_tracks
|
||||||
@@ -55,8 +59,22 @@ LIBRARY = [
|
|||||||
{
|
{
|
||||||
"name": "The Prodigy",
|
"name": "The Prodigy",
|
||||||
"albums": [
|
"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 +411,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):
|
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."""
|
"""The index is Lidarr's mirror, not an accumulation of everything ever seen."""
|
||||||
store = indexed(tmp_path, [scrobble_of("AC/DC", "Hells Bells")])
|
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.index_library(
|
||||||
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(LIBRARY[:1])), store
|
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(LIBRARY[:1])), store
|
||||||
@@ -512,7 +530,7 @@ def test_albums_come_from_the_unfiltered_endpoint(tmp_path):
|
|||||||
|
|
||||||
album_calls = [query for path, query in api.calls if path == "album"]
|
album_calls = [query for path, query in api.calls if path == "album"]
|
||||||
assert album_calls == [{}]
|
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):
|
def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path):
|
||||||
@@ -526,7 +544,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
|
assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE artist_id = 2") == 0
|
||||||
# The other two artists keep their albums.
|
# 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"
|
assert store.get_state("index_albums_skipped") == "1"
|
||||||
|
|
||||||
|
|
||||||
@@ -549,10 +567,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)
|
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
|
assert store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE artist_id = 2") == 0
|
||||||
# The other two artists are indexed in full.
|
# 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"
|
assert store.get_state("index_skipped") == "1"
|
||||||
|
|
||||||
|
|
||||||
@@ -732,3 +750,757 @@ def test_a_bracketed_guest_credit_matches_the_bare_tag(tmp_path):
|
|||||||
method, track_id = verdict(store, "Yellowcard", "Here I Am Alive (feat. Someone)")
|
method, track_id = verdict(store, "Yellowcard", "Here I Am Alive (feat. Someone)")
|
||||||
assert method == "name"
|
assert method == "name"
|
||||||
assert track_id is not None
|
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("*.m3u8"))
|
||||||
|
assert written == [
|
||||||
|
"all-time.m3u8",
|
||||||
|
"deep-cuts.m3u8",
|
||||||
|
"heavy-rotation.m3u8",
|
||||||
|
"neglected.m3u8",
|
||||||
|
"unheard-favourites.m3u8",
|
||||||
|
"unheard.m3u8",
|
||||||
|
]
|
||||||
|
|
||||||
|
played = (mirror / "_playlists" / "all-time.m3u8").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.m3u8").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.m3u8").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, produced = music_curator.build_playlists(store, mirror, str(source), 100, NOW)
|
||||||
|
|
||||||
|
assert total == 0
|
||||||
|
assert produced, "the playlists are still written, they are simply empty"
|
||||||
|
assert (mirror / "_playlists" / "all-time.m3u8").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.m3u8").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.m3u8").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("*.m3u8"):
|
||||||
|
assert playlist.stat().st_mode & stat.S_IRGRP, playlist
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_weights_fall_back_to_rank_when_no_count_is_sent():
|
||||||
|
"""The documented sample carries only a name and a URL; a live response also
|
||||||
|
carries a count. Neither may be relied on alone."""
|
||||||
|
with_count = music_curator.parse_tags(
|
||||||
|
{"toptags": {"tag": [{"name": "Screamo", "count": 100}, {"name": "emo", "count": 40}]}}
|
||||||
|
)
|
||||||
|
assert with_count == [("screamo", 100), ("emo", 40)]
|
||||||
|
|
||||||
|
without = music_curator.parse_tags(
|
||||||
|
{"toptags": {"tag": [{"name": "screamo"}, {"name": "emo"}]}}
|
||||||
|
)
|
||||||
|
assert [tag for tag, _ in without] == ["screamo", "emo"]
|
||||||
|
assert without[0][1] > without[1][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lone_tag_is_not_a_list():
|
||||||
|
assert music_curator.parse_tags({"toptags": {"tag": {"name": "dnb", "count": 90}}}) == [
|
||||||
|
("dnb", 90)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_artist_lastfm_cannot_answer_for_is_not_asked_again(tmp_path):
|
||||||
|
"""Recording the fetch even when it returns nothing is what stops a pass
|
||||||
|
spending a request per unknown artist, forever."""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(tags={})
|
||||||
|
|
||||||
|
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 2
|
||||||
|
before = len(lastfm.calls)
|
||||||
|
|
||||||
|
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 0
|
||||||
|
assert len(lastfm.calls) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_are_refetched_once_they_go_stale(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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(tags={})
|
||||||
|
music_curator.sync_tags(client_for(lastfm), store, NOW)
|
||||||
|
|
||||||
|
later = NOW + music_curator.TAG_REFRESH_SECONDS + 1
|
||||||
|
assert music_curator.sync_tags(client_for(lastfm), store, later) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def tagged_store(tmp_path, tags):
|
||||||
|
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.sync_tags(client_for(FakeLastfm(tags=tags)), store, NOW)
|
||||||
|
return store, source, mirror
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vibe_selects_by_tag(tmp_path):
|
||||||
|
store, source, mirror = tagged_store(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"Played Band": [{"name": "screamo", "count": 100}],
|
||||||
|
"Silent Band": [{"name": "classic rock", "count": 100}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
vibes = [{"name": "screamo", "tags": ["screamo"]}]
|
||||||
|
|
||||||
|
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||||
|
|
||||||
|
written = (mirror / "_playlists" / "screamo.m3u8").read_text()
|
||||||
|
assert "Played Band" in written
|
||||||
|
assert "Silent Band" not in written
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_weakly_tagged_artist_is_below_the_threshold(tmp_path):
|
||||||
|
"""A single low-weight tag is not a genre, it is somebody's stray opinion."""
|
||||||
|
store, source, mirror = tagged_store(
|
||||||
|
tmp_path, {"Played Band": [{"name": "screamo", "count": 3}]}
|
||||||
|
)
|
||||||
|
vibes = [{"name": "screamo", "tags": ["screamo"]}]
|
||||||
|
|
||||||
|
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||||
|
|
||||||
|
assert (mirror / "_playlists" / "screamo.m3u8").read_text() == "#EXTM3U\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vibe_can_be_restricted_by_release_year(tmp_path):
|
||||||
|
"""What separates eighties synth records from everything else a synthpop
|
||||||
|
tag drags in."""
|
||||||
|
store, source, mirror = tagged_store(
|
||||||
|
tmp_path, {"Played Band": [{"name": "synthpop", "count": 100}]}
|
||||||
|
)
|
||||||
|
inside = [{"name": "eighties", "tags": ["synthpop"], "years": [1975, 1992]}]
|
||||||
|
outside = [{"name": "nineties", "tags": ["synthpop"], "years": [1993, 1999]}]
|
||||||
|
|
||||||
|
# FakeLidarr dates every album 2019, so neither window should catch it.
|
||||||
|
music_curator.build_vibe_playlists(store, inside, mirror, str(source), 100, NOW)
|
||||||
|
music_curator.build_vibe_playlists(store, outside, mirror, str(source), 100, NOW)
|
||||||
|
assert (mirror / "_playlists" / "eighties.m3u8").read_text() == "#EXTM3U\n"
|
||||||
|
assert (mirror / "_playlists" / "nineties.m3u8").read_text() == "#EXTM3U\n"
|
||||||
|
|
||||||
|
modern = [{"name": "modern", "tags": ["synthpop"], "years": [2000, 2030]}]
|
||||||
|
music_curator.build_vibe_playlists(store, modern, mirror, str(source), 100, NOW)
|
||||||
|
assert "Played Band" in (mirror / "_playlists" / "modern.m3u8").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_built_in_vibes_are_all_usable_filenames():
|
||||||
|
for vibe in music_curator.DEFAULT_VIBES:
|
||||||
|
assert music_curator.SAFE_VIBE_NAME.fullmatch(vibe["name"]), vibe["name"]
|
||||||
|
assert vibe["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vibes_file_replaces_the_built_in_set(tmp_path):
|
||||||
|
path = tmp_path / "vibes.json"
|
||||||
|
path.write_text(json.dumps([{"name": "mine", "tags": ["shoegaze"]}]))
|
||||||
|
|
||||||
|
assert music_curator.load_vibes(str(path)) == ({"name": "mine", "tags": ["shoegaze"]},)
|
||||||
|
assert music_curator.load_vibes(None) is music_curator.DEFAULT_VIBES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"content",
|
||||||
|
[
|
||||||
|
'{"not": "a list"}',
|
||||||
|
'[{"tags": ["x"]}]',
|
||||||
|
'[{"name": "../escape", "tags": ["x"]}]',
|
||||||
|
'[{"name": "ok"}]',
|
||||||
|
"not json at all",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_bad_vibes_file_is_refused_up_front(tmp_path, content):
|
||||||
|
"""A bad name would otherwise surface as a file written somewhere
|
||||||
|
unintended, which is a poor way to learn about a typo."""
|
||||||
|
path = tmp_path / "vibes.json"
|
||||||
|
path.write_text(content)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
music_curator.load_vibes(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_are_looked_up_by_name_not_by_mbid(tmp_path):
|
||||||
|
"""Last.fm's mbid index is stale: it cannot find Devo or Escape the Fate by
|
||||||
|
one, though their pages plainly exist. Its name index can."""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(tags={"Played Band": [{"name": "screamo", "count": 90}]})
|
||||||
|
|
||||||
|
music_curator.sync_tags(client_for(lastfm), store, NOW)
|
||||||
|
|
||||||
|
asked = [call for call in lastfm.calls if call.get("method") == "artist.getTopTags"]
|
||||||
|
# The name is always tried first; the mbid only appears as a fallback for
|
||||||
|
# the artist that the name could not resolve.
|
||||||
|
assert "artist" in asked[0]
|
||||||
|
assert [call for call in asked if call.get("artist") == "Played Band"]
|
||||||
|
assert not [call for call in asked if call.get("mbid") == "artist-mbid-1"]
|
||||||
|
assert store.scalar("SELECT COUNT(*) FROM artist_tag WHERE tag = 'screamo'") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mbid_is_tried_when_the_name_is_not_found(tmp_path):
|
||||||
|
"""Kept only for a name Lidarr spells differently to Last.fm."""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(tags={"artist-mbid-1": [{"name": "dnb", "count": 80}]})
|
||||||
|
|
||||||
|
music_curator.sync_tags(client_for(lastfm), store, NOW)
|
||||||
|
|
||||||
|
assert store.scalar("SELECT COUNT(*) FROM artist_tag WHERE tag = 'dnb'") == 1
|
||||||
|
asked = [call for call in lastfm.calls if call.get("method") == "artist.getTopTags"]
|
||||||
|
assert any("mbid" in call for call in asked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_artist_neither_key_resolves_is_recorded_and_not_retried(tmp_path):
|
||||||
|
"""Otherwise every pass spends a request on it again, for ever."""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(tags={})
|
||||||
|
|
||||||
|
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 2
|
||||||
|
spent = len(lastfm.calls)
|
||||||
|
|
||||||
|
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 0
|
||||||
|
assert len(lastfm.calls) == spent
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_failure_is_not_recorded_so_the_next_pass_retries(tmp_path):
|
||||||
|
"""A rate limit is not the same as an artist not existing."""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
lastfm = FakeLastfm(
|
||||||
|
tags={"Played Band": [], "Silent Band": []},
|
||||||
|
outcomes=[{"error": 10, "message": "Invalid API key"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
music_curator.sync_tags(client_for(lastfm), store, NOW)
|
||||||
|
|
||||||
|
# One artist failed hard and must still be pending.
|
||||||
|
assert store.scalar("SELECT COUNT(*) FROM artist_tag_fetched") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# Tags that carry real weight in a real library, against tags that describe a
|
||||||
|
# passport, span most of the collection, or are somebody's artist name.
|
||||||
|
USELESS_TAGS = {
|
||||||
|
"american", "british", "australian", "canadian", "swedish", "dutch", "german",
|
||||||
|
"scottish", "english", "uk", "usa", "canada",
|
||||||
|
"rock", "electronic", "pop", "alternative", "metal ", "all", "heavy",
|
||||||
|
"female vocalists", "male vocalists", "female vocalist",
|
||||||
|
"my top songs", "cover", "covers", "not emo",
|
||||||
|
"green day", "paramore", "queen", "bon jovi", "shinedown", "aerosmith",
|
||||||
|
"journey", "fleetwood mac",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_mood_selects_on_a_useless_tag():
|
||||||
|
"""Nationality is not a sound; `rock` and `electronic` span most of the
|
||||||
|
library; and Last.fm's top tag for an artist is often their own name."""
|
||||||
|
for vibe in music_curator.DEFAULT_VIBES:
|
||||||
|
overlap = {tag.casefold() for tag in vibe["tags"]} & USELESS_TAGS
|
||||||
|
assert not overlap, f"{vibe['name']} selects on {overlap}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mood_names_are_unique():
|
||||||
|
names = [vibe["name"] for vibe in music_curator.DEFAULT_VIBES]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
|
||||||
|
|
||||||
|
ROCK_TAGS = {
|
||||||
|
"classic rock", "hard rock", "blues rock", "southern rock", "arena rock",
|
||||||
|
"glam rock", "hair metal", "glam metal", "heavy metal", "metal", "art rock",
|
||||||
|
"psychedelic rock", "progressive rock", "rock and roll", "rock n roll",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_decade_tag_in_a_non_rock_mood_must_exclude_the_rock():
|
||||||
|
"""`80s` sits on Def Leppard and Bon Jovi as heavily as on Eurythmics, so a
|
||||||
|
mood that reaches for a decade without wanting rock has to say so. A mood
|
||||||
|
that does want it -- classic-rock reaching for 70s -- is exempt."""
|
||||||
|
for vibe in music_curator.DEFAULT_VIBES:
|
||||||
|
tags = {tag.casefold() for tag in vibe["tags"]}
|
||||||
|
decades = {"60s", "70s", "80s", "90s"} & tags
|
||||||
|
if not decades or tags & ROCK_TAGS:
|
||||||
|
continue
|
||||||
|
excluded = {tag.casefold() for tag in vibe.get("exclude", [])}
|
||||||
|
missing = {"hard rock", "hair metal"} - excluded
|
||||||
|
assert not missing, f"{vibe['name']} selects on {decades} without excluding {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_eighties_mood_covers_the_canon():
|
||||||
|
"""Depeche Mode, Duran Duran, Eurythmics and Frankie Goes to Hollywood --
|
||||||
|
checked against their live Last.fm tags. Three of the four carry "synth pop"
|
||||||
|
with a space; only one carries it without."""
|
||||||
|
synths = next(v for v in music_curator.DEFAULT_VIBES if v["name"] == "80s-synths")
|
||||||
|
tags = {t.casefold() for t in synths["tags"]}
|
||||||
|
for artist_tags in (
|
||||||
|
{"80s", "new wave", "pop", "female vocalists", "synth pop"}, # Eurythmics
|
||||||
|
{"80s", "new wave", "pop", "british", "dance"}, # Frankie
|
||||||
|
{"electronic", "synthpop", "new wave", "80s", "synth pop"}, # Depeche Mode
|
||||||
|
{"new wave", "80s", "pop", "synth pop", "rock"}, # Duran Duran
|
||||||
|
):
|
||||||
|
assert tags & artist_tags, artist_tags
|
||||||
|
assert synths["years"] == [1975, 1992]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_excluded_tag_drops_the_artist(tmp_path):
|
||||||
|
"""Weighting cannot separate the eighties synth acts from the eighties
|
||||||
|
stadium rock, because the tag they would be weighted on is the one they
|
||||||
|
share."""
|
||||||
|
store, source, mirror = tagged_store(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"Played Band": [{"name": "80s", "count": 100}, {"name": "hard rock", "count": 90}],
|
||||||
|
"Silent Band": [{"name": "80s", "count": 100}, {"name": "synth pop", "count": 90}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
vibes = [{"name": "eighties", "tags": ["80s", "synth pop"], "exclude": ["hard rock"]}]
|
||||||
|
|
||||||
|
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||||
|
|
||||||
|
written = (mirror / "_playlists" / "eighties.m3u8").read_text()
|
||||||
|
assert "Silent Band" in written
|
||||||
|
assert "Played Band" not in written
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vibe_cannot_both_select_and_exclude_a_tag(tmp_path):
|
||||||
|
path = tmp_path / "vibes.json"
|
||||||
|
path.write_text(json.dumps([{"name": "x", "tags": ["80s"], "exclude": ["80s"]}]))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="selects on and excludes"):
|
||||||
|
music_curator.load_vibes(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_ownership_is_a_no_op_when_it_already_agrees(tmp_path):
|
||||||
|
target = tmp_path / "file"
|
||||||
|
target.write_text("x")
|
||||||
|
|
||||||
|
assert music_curator.match_ownership(target, tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ownership_failure_is_tolerated(tmp_path):
|
||||||
|
"""Not permitted unless running as root -- which is exactly the case where
|
||||||
|
the ownership is already whatever the caller runs as."""
|
||||||
|
target = tmp_path / "file"
|
||||||
|
target.write_text("x")
|
||||||
|
|
||||||
|
# uid 0 from a non-root test process: refused, and must not raise.
|
||||||
|
assert music_curator.set_ownership(target, 0, 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_playlist_is_chowned_to_match_the_mirror(tmp_path, monkeypatch):
|
||||||
|
"""The image runs as root, so its output is root-owned, and a root-owned
|
||||||
|
playlist in an apps-owned mirror is unreadable to whatever serves it."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
attempted = []
|
||||||
|
real_stat = music_curator.Path.stat
|
||||||
|
|
||||||
|
def pretend_mirror_is_owned_by_568(self, *args, **kwargs):
|
||||||
|
info = real_stat(self, *args, **kwargs)
|
||||||
|
if self == mirror:
|
||||||
|
return os.stat_result(
|
||||||
|
(info.st_mode, info.st_ino, info.st_dev, info.st_nlink, 568, 568,
|
||||||
|
info.st_size, int(info.st_atime), int(info.st_mtime), int(info.st_ctime))
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
monkeypatch.setattr(music_curator.Path, "stat", pretend_mirror_is_owned_by_568)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
music_curator.os, "chown", lambda p, u, g: attempted.append((str(p), u, g))
|
||||||
|
)
|
||||||
|
|
||||||
|
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
|
||||||
|
|
||||||
|
assert attempted, "no ownership was applied"
|
||||||
|
assert all(tuple(owner) == (568, 568) for _, *owner in attempted)
|
||||||
|
# The temporary file, before the rename, never the finished playlist.
|
||||||
|
assert all(path.endswith(".part") or path.endswith("_playlists") for path, *_ in attempted)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_renamed_mood_does_not_leave_its_old_playlist_behind(tmp_path):
|
||||||
|
"""Otherwise the dead file stays on the device for ever."""
|
||||||
|
api, source, mirror = playlist_library(tmp_path)
|
||||||
|
store = store_at(tmp_path)
|
||||||
|
directory = mirror / "_playlists"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "high-energy-rock.m3u").write_text("#EXTM3U\n")
|
||||||
|
(directory / "screamo.m3u8").write_text("#EXTM3U\n")
|
||||||
|
store.set_state(
|
||||||
|
"playlist_files", json.dumps(["high-energy-rock.m3u", "screamo.m3u8"])
|
||||||
|
)
|
||||||
|
|
||||||
|
music_curator.prune_playlists(directory, ["screamo.m3u8"], store)
|
||||||
|
|
||||||
|
assert not (directory / "high-energy-rock.m3u").exists()
|
||||||
|
assert (directory / "screamo.m3u8").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_leaves_a_playlist_it_never_wrote(tmp_path):
|
||||||
|
"""A hand-made playlist in that directory is not ours to delete."""
|
||||||
|
api, source, mirror = playlist_library(tmp_path)
|
||||||
|
store = store_at(tmp_path)
|
||||||
|
directory = mirror / "_playlists"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "lyras-own-mix.m3u").write_text("#EXTM3U\n")
|
||||||
|
store.set_state("playlist_files", json.dumps(["screamo.m3u8"]))
|
||||||
|
|
||||||
|
music_curator.prune_playlists(directory, [], store)
|
||||||
|
|
||||||
|
assert (directory / "lyras-own-mix.m3u").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def cold_store(tmp_path, played=(), added="2020-05-01T12:00:00Z"):
|
||||||
|
"""A library where nothing is played unless named, indexed and matched."""
|
||||||
|
library = [
|
||||||
|
{
|
||||||
|
"name": "Played Band",
|
||||||
|
"albums": [{"title": "Known", "tracks": [{"title": "Hit", "added": added}]}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silent Band",
|
||||||
|
"albums": [
|
||||||
|
{"title": "Unknown", "tracks": [{"title": "Never Heard", "added": added}]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
store = store_at(tmp_path)
|
||||||
|
ingest(store, FakeLastfm([scrobble_of(a, t) for a, t in played]))
|
||||||
|
music_curator.index_library(
|
||||||
|
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(library)), store
|
||||||
|
)
|
||||||
|
music_curator.match_library(store)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_album_with_no_plays_is_cold(tmp_path):
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
|
||||||
|
|
||||||
|
cold = music_curator.cold_report(store, NOW)
|
||||||
|
|
||||||
|
assert [row["artist"] for row in cold] == ["Silent Band"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_album_with_any_play_is_not_cold(tmp_path):
|
||||||
|
"""A record with one played track is a record that gets played; picking the
|
||||||
|
rest off it leaves gaps rather than reclaiming anything."""
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
|
||||||
|
|
||||||
|
cold = music_curator.cold_report(store, NOW)
|
||||||
|
|
||||||
|
assert "Played Band" not in [row["artist"] for row in cold]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_recent_arrival_is_too_young_to_judge(tmp_path):
|
||||||
|
"""It has not had a chance to be played yet."""
|
||||||
|
recent = datetime.fromtimestamp(NOW - 30 * 86400, tz=timezone.utc).isoformat()
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")], added=recent)
|
||||||
|
|
||||||
|
assert music_curator.cold_report(store, NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cull_refuses_to_run_on_an_incomplete_index(tmp_path):
|
||||||
|
"""A missing artist makes their played music look unplayed, which is
|
||||||
|
precisely how this would delete something you like."""
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
|
||||||
|
store.set_state("index_skipped", "1")
|
||||||
|
|
||||||
|
assert music_curator.cull_is_safe(store) is not None
|
||||||
|
assert music_curator.cold_report(store, NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cull_refuses_while_the_backfill_is_unfinished(tmp_path):
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
|
||||||
|
store.set_state("backfill_complete", "no")
|
||||||
|
|
||||||
|
assert music_curator.cold_report(store, NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cold_report_writes_nothing_to_lidarr(tmp_path):
|
||||||
|
"""Stage four is read-only until the list it produces has been looked at."""
|
||||||
|
store = cold_store(tmp_path, played=[("Played Band", "Hit")])
|
||||||
|
before = store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE monitored = 1")
|
||||||
|
|
||||||
|
music_curator.cold_report(store, NOW)
|
||||||
|
|
||||||
|
assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE monitored = 1") == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_bytes_reads_at_a_glance():
|
||||||
|
assert music_curator.human_bytes(0) == "0.0 B"
|
||||||
|
assert music_curator.human_bytes(1536) == "1.5 KiB"
|
||||||
|
assert music_curator.human_bytes(3 * 1024**3) == "3.0 GiB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlists_are_written_as_m3u8(tmp_path):
|
||||||
|
"""Rockbox's is_m3u8_name() treats every extension as UTF-8 except an
|
||||||
|
explicit ".m3u", which it decodes through the configured codepage instead.
|
||||||
|
A library with accented names needs the other extension."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
written = sorted(p.suffix for p in (mirror / "_playlists").iterdir())
|
||||||
|
assert set(written) == {".m3u8"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlists_are_written_as_utf8(tmp_path):
|
||||||
|
playlist = tmp_path / "_playlists" / "accents.m3u8"
|
||||||
|
music_curator.write_playlist(
|
||||||
|
playlist,
|
||||||
|
[{"artist": "Mötley Crüe", "title": "Kickstart My Heart",
|
||||||
|
"duration": 283000, "mirror": tmp_path / "x.mp3"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decodes as UTF-8, and carries no BOM: Rockbox does not need one at this
|
||||||
|
# extension, and a BOM confuses players that do not expect it.
|
||||||
|
raw = playlist.read_bytes()
|
||||||
|
assert not raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
assert "Mötley Crüe" in raw.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_old_m3u_playlists_are_pruned_after_the_rename(tmp_path):
|
||||||
|
"""Without this the seventeen dead .m3u files stay on the device for ever."""
|
||||||
|
api, source, mirror = playlist_library(tmp_path)
|
||||||
|
store = store_at(tmp_path)
|
||||||
|
directory = mirror / "_playlists"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "screamo.m3u").write_text("#EXTM3U\n")
|
||||||
|
store.set_state("playlist_files", json.dumps(["screamo.m3u"]))
|
||||||
|
|
||||||
|
music_curator.prune_playlists(directory, ["screamo.m3u8"], store)
|
||||||
|
|
||||||
|
assert not (directory / "screamo.m3u").exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user