Files
Emma Thorpe 18f05d3d55
Build and publish container / build (push) Failing after 2m16s
feat: ingest a Last.fm scrobble history into a local store
First stage of a curation tool for the music library that music-mirror
mirrors. Before anything can build playlists or decide what has gone cold,
there has to be a local, queryable record of what is actually played; an API
call per question does not scale to a library-sized analysis.

Ingest is in two halves. A catch-up fetches everything scrobbled since the
newest scrobble held, and a backfill walks the history backwards until it runs
out. Both take their bounds from the database rather than from a saved cursor,
so an interrupted run resumes from what it actually has, and both windows are
bounded at each end so paging cannot shift under the fetch while new scrobbles
arrive mid-run.

Scrobbles carry no identifier, so the primary key is timestamp, artist and
track. Two plays of one track in the same second collapse into a single row:
they are indistinguishable in the data, and a surrogate key would make
re-ingest non-idempotent, which is the worse trade.

Three API behaviours are handled explicitly because each fails silently:
the currently-playing track arrives with no timestamp and would be re-ingested
on every pass; a lone result is returned as a bare object rather than a
one-item list; and MBIDs are empty strings rather than absent when unknown,
which would later look like a usable join key.

Retries cover the rate limit and the transient backend errors with an
exponential backoff. An invalid or suspended key fails immediately.

The report exists to surface one number before the next stage is built: the
share of scrobbles carrying a MusicBrainz recording id. Lidarr exposes the same
identifier per track, so those can be joined exactly and the rest must go
through name matching. That percentage bounds how far the matcher can be
trusted.

No runtime dependencies, and the tests run against a fake transport that
reproduces the service's paging and response shapes, so they need neither
network nor credentials.
2026-08-24 12:03:08 +01:00

33 lines
1.1 KiB
Docker

# Alpine because there is nothing to compile and nothing to link against: the
# application is one Python module with no dependencies outside the standard
# library, so the base image is almost the whole image.
FROM python:3.13-alpine AS runtime
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml README.md ./
COPY music_curator.py ./
RUN pip install --no-cache-dir . \
&& rm -rf build music_curator.egg-info
# The store lives here. Mount a volume over it, or the history is re-downloaded
# from Last.fm every time the container is recreated.
ENV MUSIC_CURATOR_DB=/data/curator.db
VOLUME ["/data"]
# Runs as root by default so a bind-mounted dataset of any ownership is
# writable. Override with `user:` in compose to run as the dataset's owner.
ENTRYPOINT ["music-curator"]
# Test stage: build it with `--target test`; a failing test fails the build.
# The suite talks to a fake transport, never to Last.fm, so it needs no
# credentials and no network. The published image is the `runtime` stage above
# and carries none of this.
FROM runtime AS test
RUN pip install --no-cache-dir pytest
COPY pytest.ini ./
COPY tests ./tests
RUN python -m pytest