Skip to content

Releases: sredevopsorg/metaedit

v0.2.1 — documentation that matches the code

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 17 Oct 07:52
c294bbe

Every environment variable, verified

docs/configuration.md is new: every variable the stack reads, its default, and what it changes — including the ones that were documented nowhere before: the Last.fm rate limiter (LASTFM_MAX_RPS, LASTFM_BURST), JELLYFIN_USER_ID, ARCHIVE_SIMILAR_FRESHNESS_TTL_S, and METAEDIT_BROWSE_SCAN_CAP. Every row was checked against config.py, the compose file and the test conftest, because a configuration table that has drifted is worse than none: it gets trusted and copied.

The API reference now lists every endpoint

The harvest endpoints (POST /api/items/{id}/harvest, and the batch POST /api/harvest that streams one event per item) were implemented, mounted and shipping — and absent from a page that opens with "Every endpoint metaedit serves." They are documented now, with the load-bearing rule stated: harvest spends Last.fm requests and writes to the archive, but never writes to Jellyfin.

A design doc that no longer says reindex is missing

The phase-3 design doc still claimed metaedit reindex raises NotImplementedError — the kind of sentence a reader cannot distrust, because it sounds authoritative. Reindex has shipped since 0.1.0. The status is corrected; the document remains as the contract the implementation must keep satisfying, including the recorded --since deviation.

Small repairs that were quietly wrong

  • The compose default for the Last.fm user agent said metaedit/0.1.0 while the app default said 0.2.0. Both now say 0.2.1.
  • The development layout omitted the service/ package, where the orchestration actually lives.
  • docs/development.md documents all four pnpm scripts, and notes that integration tests skip cleanly without Postgres — an ordinary uv run pytest on a fresh checkout reports skips, not failures.
  • The README's requirements are a table; the feature list covers narrowing and bulk harvest; the doc index gains Configuration and Changelog rows.

No code behaviour changes. The metadata write contract — ADR 0003 (full overwrite), 0004 (snapshot before every write), 0007 (optimistic concurrency) — is untouched.

v0.2.0 — filters that cannot silently widen

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 19:35
7607e57

Four features on the screens where a selection feeds a write, and one of them changed a rule that was quietly wrong.

Narrowing that cannot silently widen

ArtistIds=<32hex>          albums=2     songs=21
ArtistIds=abc              albums=502   songs=5442   ← filter vanished
ArtistIds=<32hex>|<32hex>  albums=502   songs=5442   ← filter vanished
ArtistIds=<40hex>          albums=502   songs=5442   ← filter vanished

That is a live Jellyfin 12.2.0. An id list it cannot parse is discarded in full, and the server answers with the unfiltered library. A single typo turns "narrow to these three artists" into "the entire library" — and on these screens that set is what feeds a write, so the failure is a batch you did not mean to run.

Ids are now checked against the shape Jellyfin actually parses (32 hex characters, or a dashed GUID) and refused with a 422 that says why. The browser drops a bad id from a URL instead, deliberately: a link you edited should show the unfiltered list, not an error page.

Related, and the same class of quiet wrongness: ArtistIds on an artist query returns zero, not an error. An empty table reads as a library with nothing in it, so an inapplicable filter is now refused rather than answered.

Album and artist filters

Filter albums by artist, and songs by artist and/or album. These are Jellyfin's own parameters, so they are free: nothing is scanned, and total is genuinely the filtered count. ArtistIds=<ABBA> returns 2 of 502 albums and 21 of 5,442 songs; the two parameters union within themselves and intersect across each other.

Exclusion patterns

A pattern is a case-insensitive glob (*, ?) — not a substring, and not a regex.

It matches the item's name, its album and its album artist. All three, because Various Artists is a compilation marker that lives in the album artist and appears in no track or album name, so a matcher reading only the name would leave every compilation in the selection.

Literal, so live hides exactly live and *live* is what hides a substring. Implicit substring matching is how Rock comes to swallow Rockabilly one field over, and the same mistake here would drop items nobody meant to drop. Glob rather than regex because the input is typed into a box by hand, and fnmatch escapes everything but the wildcards — so a pathological pattern cannot become a catastrophic-backtracking regex pointed at your own server.

Jellyfin has no parameter for this, so it costs a read, and the response says what the read covered: scanned, matched, excluded, truncated, limit. A truncated scan is reported as a lower bound rather than presented as a total.

Pagination above the table

As well as below it. A long page is read from the middle, and an operator who has scrolled to the last row should not have to travel back up to move on. The two controls carry distinct accessible names, so they are not ambiguous to a screen reader.

Also

  • A filtered batch or removal now fills its limit. limit: 25 with missing=genres previously returned however many of the first 25 happened to lack genres — maybe four — with nothing to explain the difference. The selection keeps reading until it has 25 that pass, and excluded explains a short result.
  • The same three narrowings apply to batch diffs, genre removals and harvests, so one selection means one thing across the app.
  • Patterns narrow a view or a selection. They never delete anything — a pattern that hides an album from the browse screen does not remove a genre from it. Deleting is still the separate, reviewed, revertible Remove genre tool.
  • Changing any filter clears the current selection, because that selection feeds a write.

Upgrading

No migration and no configuration change. METAEDIT_BROWSE_SCAN_CAP (default 2,000) bounds how much a read-backed filter will look at, and is now documented as an operator knob rather than a test hook — exclusion patterns make scans routine. Raise it on a large library and the filter gets slower rather than wrong, which is the direction to prefer.

Verification

Check Result
Backend suite (CI) 1035 passed, 20 deselected
e2e, clean stack via scripts/e2e.sh 52 passed (12 new)
Frontend unit 28 passed
ruff / mypy / contract --check clean
CodeQL, image build and health pass

Both new mechanisms are mutation-tested rather than trusted. Deleting the facet parameter fails exactly the five facet specs and leaves the seven exclusion specs green, so they are covered independently; removing patterns from the selection key fails exactly the "selection is cleared" spec.

Five defects were found and fixed during this work, all in the author's own code — including a spec that asserted toHaveCount(0) on text that never renders while a selection exists, so it passed whatever the screen did. It had passed twice before anyone noticed. Reasoning for all of the above is in ADR 0017.

Known gap, stated rather than implied: the end-to-end suite is not a CI job. Both features are things you click, and the central guarantee — that filtering narrows rather than widens — lives in specs no CI run executes.

v0.1.0 — genre blacklist and batch genre removal

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 16:24
04a5eb8

Two features, one problem. Both edit genres, and both are destructive when they guess
wrong — so both are built on the same rule.

A genre blacklist you can edit while the app is running

Settings → Blacklisted genres takes the genres this tool must never propose for
writing. Entries live in Postgres and take effect on the very next request, instead of
after a container restart. TAG_BLACKLIST_EXTRA still works and merges in, so an existing
deployment is unchanged.

The screen shows what each entry matches before you save it — "this will match 3 genre
values in your library" — because the input format hides a real ambiguity. A comma
separates entries as you type, and is also a legal character inside a genre value. A line
containing one is reported with both readings rather than guessed at, and splitting is a
separate labelled action.

A batch tool that removes one genre from a whole library

Remove genre finds every item carrying a value, shows exactly what would change, and
applies it as one revertible batch. It reuses the existing bulk write path rather than
adding a second one, so it inherits payload completeness, snapshot-before-write and the
etag check without restating any of them. Failures are isolated per item, and the whole
run reverts through the same endpoint as any other batch.

The comma problem, and why matching is exact

A comma separates entries in rock, reggae — and it is a legal character inside a genre
value. Both are true at once, and both are in this library:

'Rock, Reggae'   'EBM, Synth-pop'   'Reggae; Ska'
'Hip Hop, Rock, Latin, Funk / Soul, Pop, Children's, Folk, World, & Country'

This is not cosmetic. Measured against Jellyfin 12.2.0:

Query Result
Genres=Rock 67 albums
Genres=Rock, Reggae 1 album

A comma-split matcher would delete 67 albums' worth of curation for a request naming a
genre that occurs once. So matching is exact and case-insensitive, an empty target is
refused rather than treated as a wildcard, and removing part of a packed value is an
explicit opt-in that rebuilds using the separator the value already used — Reggae; Ska
does not become Reggae, Ska.

Verified on 100 real artists: removing rock took Rock/rock and left Gothic Rock,
indie rock, punk rock, Rock en Espanol, rock en español and rock mexicano alone.

Blacklisting is not deleting

Blacklisting a genre stops it being added. It does not remove one an item already has:
merge mode carries existing values through by design, and a blacklist that silently pruned
curated data on the next unrelated edit would be a destructive surprise. Deleting a stored
genre is the separate Remove genre tool, which is reviewed and revertible.

Upgrading

One migration, forward-only, adding a single table. alembic upgrade head is all that is
required, and no existing behaviour changes.

Never apply an autogenerated Alembic revision here without reading it. Autogenerate
emits op.drop_table for every lastfm_request_YYYY_MM partition, because those are
created at runtime rather than declared on the model — applying its output verbatim would
delete the archived request log. For the same reason alembic check cannot be used as a
CI gate on this repository. This is recorded in the migration, in docs/operations.md and
in ADR 0016, because I hit it while building this release.

Two pre-existing defects, recorded rather than quietly fixed

  • Jellyfin rounds PremiereDate to midnight on every write. Verified on 12.2.0, and
    documented in ADR 0014. Affects the 9 artists and 5,907 albums/songs that carry a date.
  • Any merge write decomposes a stored comma-bearing genre. An item holding the curated
    genre Rock, Reggae ends up with two genres after an unrelated edit. 8 of 502 albums
    carry such a value. Pinned in a test so a fix is deliberate rather than silent.

Both predate this release. Neither is introduced or worked around by it, and fixing the
second changes existing write behaviour, so it is called out instead of folded in.

Notes

  • 945 backend tests, 17 frontend, 39 end-to-end, all passing. Guarantees were
    mutation-tested rather than trusted to counts.
  • Version 0.1.0: new table, seven endpoints, two screens.
  • ADR 0015
    and 0016
    record the decisions and the alternatives rejected.

v0.0.5 — PremiereDate broke every edit on any item that has one

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 14:34
d35ca56

Fixes editing any item that has a PremiereDate — which, measured against the reporting
library, is 5,916 of 6,583 items: 470 of 502 albums and 5,437 of 5,442 songs.

The report was Internal error. Reference abb6b550a3d3 while editing an artist from the third
page. It was not the page, and it was not the artist as such — that artist was one of only 9 of
639 with a PremiereDate set. Artists are the one kind where this mostly works, which is why
clicking one was the least likely way to find it.

TypeError: Object of type datetime is not JSON serializable

One wrong representation, three boundaries

NormalizedItem.fields held PremiereDate as a datetime; only fields present in changes
were converted to an ISO string on the way out. Fixing each destination separately is what
turned one bug into three reports:

# Boundary Symptom
1 snapshot → JSONB column the reported failure — after review and confirmation, before any write
2 write payload → httpx json= invisible until (1) was fixed, because (1) always failed first
3 error body → JSONResponse fixed earlier, in v0.0.4

The fix is at the representation, not the exits: _normalize, the single door into fields,
now emits the wire form for every field. "Nothing in fields is unstorable or unsendable" is an
invariant now, rather than three conversions that each have to be remembered.

Why this was worse than a 500

The failure landed between the confirm button and the write. A change set would be reviewed,
approved, and then fail with an internal error — with no snapshot written to explain what had
been about to happen. The guardrails held (nothing was half-written), but the error gave no clue
that the date was the problem.

Also fixed

An unanticipated failure on one item killed the whole batch. apply_job caught only
MetaeditError, so anything else escaped with the session still dirty, the next item's flush
raised PendingRollbackError, and the run stopped with the real cause buried under a second
error. Items are committed independently so one failure does not discard the rest; that
guarantee now covers failures we did not predict, not just the ones we did.

What to know before upgrading

Jellyfin rounds PremiereDate to midnight on every write. Verified on 12.2.0: writing back
a value the item already held returns midnight instead. So editing any field on a dated item
rounds its date. This is Jellyfin's behaviour, not this release's — but until now you could not
reach a write on a dated item at all, so this release is when you can first meet it.

Six artists in the reference library are affected, where the time component is a timezone offset
and the date itself is unchanged. It is not worked around: the endpoint is a full overwrite, so
omitting the field would null it, and refusing every edit to a dated item would make most of the
library uneditable. Recorded in ADR 0014 because the alternative is finding it in your own library.

GET /items/{id}/state returns PremiereDate as an ISO string rather than a datetime. The
old shape was a latent 500 for any handler that echoed it; the new one is the shape that always
worked. The published contract is unchanged.

Verification

Check Result
Backend tests 799 passed (20 deselected)
End-to-end (Playwright) 27 passed
Contract the committed contract matches the app
Frontend unit # pass 17
Lint / format / mypy clean
Live apply and revert artist, album, song — all three kinds

Every new test was confirmed to fail against the old code by reverting the fix. The snapshot
tests assert the property that matters — the stored body and the sent payload must both survive
encoding — rather than the shape of the fix.

Not covered by CI: the 27 e2e specs run locally only. Given that this release's headline bug
was reachable from the browser, that is worth knowing when judging "CI green".

Upgrading

docker compose build && docker compose up -d

No migration. The PremiereDate change is internal to fields; existing snapshot rows read
back unchanged, and the reference library's 658 prior snapshots were verified to still load.

v0.0.4 — filtering, pagination and sorting

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 12:00
113f229

v0.0.4 — filtering, pagination and sorting

Browse screens you can actually narrow down. Library and Archive gain real paging, sortable headers, filter chips and a scan note, and /api/items grows sort, order and a per-media-type missing filter.

The feature is the smaller half. Five of the six commits in this release fix something that was already broken, and all five were found by using the tool rather than by its tests.

The archive was losing a row while showing another twice

ORDER BY name is not a total order. 8 album rows and 2 track rows share a name with another row, and OFFSET paging over a non-total order is incorrect by construction. Measured against a real archive:

109 rows fetched, 108 distinct
duplicated across pages: [90]   'Corazones' — Jorge González
never appearing:         [8]    'Corazones' — Los Prisioneros

The count said 109. The walk yielded 109 entries. One of them was a duplicate — so the missing row was invisible from the UI, and no amount of looking at the screen would have found it.

Every ordering now ends with id, which the reindex assigns deterministically, so the tiebreaker survives a rebuild. Confirmed deterministic rather than flaky, five runs of each:

ORDER BY name       108 distinct, duplicate id 90 in EVERY run
ORDER BY name, id   109 distinct, no duplicates in ANY run

The missing-metadata filter was useless for songs

MusicArtist  500 items: overview in 254, provider ids in 414, genres in 392
Audio        500 items: overview in   1, provider ids in  69, genres in 314

A song without an Overview is the normal state of a song. The old filter counted that as missing metadata, so over a song library it reported 5,441 of 5,442 items incomplete — a wall of red that buried the 1,466 songs whose missing genres were actually fixable. It could not be fixed by filtering harder; it needed a per-media-type definition.

Jellyfin cannot express this for music (Filters=IsMissing is accepted and ignored), so matching items means reading them. That scan is capped, and the cap is reported:

?kind=song&missing=genres  →  679 matched from 2,000 checked, truncated, 1.8s

A labelled lower bound, rather than a total that looks complete and is not.

The bulk and harvest selections had kept their own copy of the same broken test — on a screen whose output is a write. All three surfaces now share one definition.

Two more that shaped how the screens behave

  • The library's total described a set the table could not show. It fetched one page of 200 and filtered in the browser, so the header read "641 artists" above 200 rows. With 5,442 songs, 96% of the library was unreachable and nothing said so.
  • "Select all" meant "select the first page", on a path that feeds a write. It now says "Select these 50", reports how many of the selection are on the current page, and keeps a selection across pages — because selecting across pages is why paging exists — while still clearing it when the query changes.

Also in this release

  • NULLS LAST, argued from the data rather than from theory: all 383 artist rows have a NULL listeners, against 0 of 109 albums. Postgres defaults to NULLS FIRST on DESC, so "sort artists by listeners, descending" would have led with every row that has no listener count.
  • The end-to-end specs are typechecked for the first time. tsconfig.json includes only src and Playwright transpiles without checking, so every spec was the only TypeScript in the app that nothing verified. Adding the config found seven strict-mode errors waiting in the pre-existing specs.
  • The frontend has unit tests, run by node's native TypeScript stripping so the paging arithmetic needs no test runner. The runner treats "no test files matched" as a failure, because node --test exits 0 on an empty glob — a renamed file would otherwise have left the build green while checking nothing.
  • The e2e stub can now fail. It held 5 items, ignored sortBy entirely, and computed TotalRecordCount after slicing — so it reported the wrong total for the one number this release is about. Rebuilt: 65 items, two artists named "Twin Peaks", sort applied before the window, real filters. The suite went from 19 to 27 passing specs.

Verification

Backend 790 passed, 20 deselected
Frontend unit 17 passed
End-to-end 27 passed (was 19)
Types app + test + e2e configs, strict
Lint ruff check, ruff format --check, mypy
Contract export_contract.py --check passes

Every fix was verified by reintroducing the defect and confirming the test fails — sort translation, filter passing, truncation reporting, the tiebreaker, NULLS LAST, per-kind aspects, list query encoding, and four paging arithmetic cases.

Six of the new tests initially passed for the wrong reason and are now fixed: a paging walk that clicked Next and re-read without waiting (collecting page 1 fifty times), an assertion comparing a page's distinct names to the library total, a sort injection the fixture could not detect because every row shared one listeners value, two truncation tests with the cap above the fixture size, and a query test that tested a copy of the function rather than the function.

Known limitations

  • The end-to-end suite is not run in CI. It needs a browser and a compose stack. It was run locally six times and the final run is green, but a future change can break it without CI noticing.
  • No sort tiebreaker is sent to Jellyfin. A second key could not be verified to do anything — appending Id, or even a nonsense key, produced byte-identical responses on a live 12.2.0 server. Adding a key that does nothing would read as a guarantee it is not. The browse is safe today (all 6,583 items walk without loss, and ten identical requests return one identical order), but that guarantee is the server's, not ours. Recorded in domain/browse.py.

v0.0.3 — songs are selectable, and a 500 you can report

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 09:33
0367704

v0.0.3 — the release where using the tool found what testing it missed

Three fixes, and all three were found by using metaedit rather than by its tests. That is worth naming, because it is the second time in this project it has been true.

Bulk editing over songs never worked

unknown selection kind 'song'; expected one of ['album', 'artist', 'track']

There are two names for one thing here, and they are deliberately different:

vocabulary values who reads it
query artist / album / song every human-facing surface: the library browse, the bulk selection, the harvest selection, the labels in the UI
archive artist / album / track the derived tables — lastfm_track, ReindexKind — someone writing SQL

The bulk selection took its enum from one side and its lookup table from the other. The UI offered "Songs", the schema accepted "song", and the service rejected it — every time.

Only songs broke, because it is the one word the two vocabularies spell differently. The same conflation for artists and albums happened to agree, which is exactly why it survived so long. Harvest had the identical bug through the same shared path.

Fixed by putting both mappings next to each other in one module with the distinction written down, and pinning the relationship in test_kind_vocabularies.py rather than the values — so adding a media type now fails in a test instead of in someone's browser.

A timestamp in an error detail turned the error into a 500

An exception handler builds its own response, so JSONResponse(content=...) runs plain json.dumps rather than FastAPI's jsonable_encoder. PremiereDate lives in the item state as a real datetime — deliberately, so date comparisons work — and that state travels in ConflictError.current so a stale-Etag 409 can show what changed. The body then failed to serialise.

The one error whose job is to explain a conflict was the one that could not render.

Songs were where it surfaced because a song usually carries a PremiereDate and an artist usually does not — verified against a real library, not assumed.

The bug was never a missing conversion. It was conversion being remembered at four of five places, so api/serialization.py now holds the single definition of "can this go in a response body", applied where the body is built.

A 500 you can report

before   {"error": {"code": "internal_error", "message": "Unexpected server error."}}
after    {"error": {"code": "internal_error",
                    "message": "Internal error. Reference 8d898cf2128d -- see the server log ...",
                    "reference": "8d898cf2128d",
                    "occurred": "TypeError",
                    "path": "GET /api/items/x/diff",
                    "retryable": false}}

The reference appears in the server log, so a user's screenshot is something an operator can grep. It is a structured field on SSE error events too, not only inside a sentence that is ours to reword.

The disclosure policy is unchanged: a message we wrote for a reader still passes through, and anything else still gets a reference instead of its str().

Also: harvest reported an unexpected failure as harvest_failed while bulk reported internal_error for the same RuntimeError — a label for the route being used as a code for the failure. Both report internal_error now.

Also in this release

  • The test suite runs in 38 s instead of 149 s. Every Postgres-backed test was replaying alembic upgrade head in a subprocess — 119 s of the 139 s the integration suite took. The migration chain now runs once per session into a template database, and each test's database is a CREATE DATABASE ... TEMPLATE copy. Every test still gets a private, fully migrated database; the isolation guarantee is now asserted rather than assumed.
  • The README is a landing page, with the detail split into docs/development.md, docs/api.md, docs/operations.md and docs/architecture.md. Two paragraphs were deleted rather than moved, because they had described the project as it was five phases earlier.
  • Every CI job runs on ubuntu-latest, and the pytest steps no longer append -q — addopts already sets one, and a second suppressed the summary line, so a green run never said how many tests ran.
  • .github/CODEOWNERS exists, so the ruleset's code-owner-review requirement names a real person instead of enforcing nothing.
  • .env.example no longer ships a two-release-old User-Agent naming a placeholder repository.

Verification

  • 703 tests pass (was 694), 20 deselected.
  • Ruff, format and strict mypy clean.
  • export_contract.py --check passes — the version is bumped in all five places it lives, including the OpenAPI document, which is why the contract check is part of the release rather than an afterthought.
  • Every fix has a test that fails without it, checked by reintroducing the defect rather than by asserting the fix is present.

Known limitations

  • The Playwright end-to-end suite is not run in CI and was not run for this release. Fifteen specs need a browser and a compose stack. A green CI run says nothing about them.
  • One JSONResponse site remains unconverted — the httpx.HTTPError handler builds a literal body and is safe today, but is one edit away from the same class of bug.

v0.0.2 — internal error detail no longer reaches the browser

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 03:56

What changed

A fix release for two information-disclosure defects in the streaming endpoints, found
by CodeQL after it was enabled on this repository.

If you run this app, this release is worth taking. No credential was ever reachable,
but internal error detail (driver names, host and port) could reach a browser. Details,
including what was not affected and why, are in the changelog.

Nothing in the metadata contract changed: what gets written to your Jellyfin library is
identical to v0.0.1.


Fixed

Internal error text no longer reaches the browser. CodeQL found this
(py/stack-trace-exposure), and it found it twice — which is the interesting part,
because the second instance was still there after the first fix. Each place that reported
a failure was answering "is this exception safe to quote?" for itself, and one of them
answered wrong.

  • The SSE catch-all put str(exc) straight into the error frame. An unexpected failure
    now reports a code and a short reference id; the traceback goes to the server log
    under that same reference, so a report from a user still leads to the cause.
  • The same expression appeared a second time in the per-item failure payloads for bulk
    apply and revert. Both now go through one function.
  • domain.errors.public_error_text is that function, and the single definition of what a
    client may be told. The distinction it draws is provenance, not severity: a
    MetaeditError message was written by us for a reader and passes through; anything
    else gets a reference.
  • No credential was ever reachable, which was checked rather than assumed: Last.fm's
    httpx failures collapse to a type name before escaping, so the key in the query string
    never reached a message; the Jellyfin key travels in a header and its bodies already
    pass through _sanitise_body; and a failing Postgres connection renders with the
    password masked. What leaked was internal topology — driver names, host and port.
  • SelectionError now subclasses MetaeditError instead of ValueError. It was an
    expected failure with a person-readable message all along, and being a bare ValueError
    meant the write path had two hierarchies to catch and its HTTP status lived in a handler
    in another layer. The status (422) and code (invalid_request) are unchanged, but they
    now sit next to the failure that causes them, and the per-item failure payload no longer
    needs to special-case it.

domain/errors.py had promised this from the start — "no upstream payload ever reaches a
client verbatim" — so both leaks were omissions, not decisions.

Found by CodeQL code scanning,
which was enabled on this repository between the two releases.

Notes

  • GitHub Actions were brought to current majors (actions/checkout v7,
    actions/upload-artifact v7, actions/setup-node v7) and astral-sh/setup-uv to
    v10.1.0, pinned to a commit SHA so a moved tag cannot change what CI runs.
  • This is a fix release, so nothing in the metadata contract changed. The API version
    reported by /openapi.json is the only interface difference.

Full changelog: CHANGELOG.md
Compare: v0.0.1...v0.0.2

v0.0.1 — first release

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 02:35

The first release: a working Last.fm → Jellyfin metadata editor with a persistent local
archive, a review step before every write, and one-click undo.

Added

Editing flow. Search the Jellyfin library, select one or many items, fetch their
Last.fm data, review the proposed changes field by field, confirm, and write. Every write
is snapshotted first and can be reverted, individually or as a batch.

  • Library browser over artists, albums and songs, with search, a "missing metadata"
    filter, and multi-select.
  • Fetch from Last.fm — derives a query per item (preferring a MusicBrainz id over a
    name), fetches getInfo, getTopTags and getSimilar, archives every response, and
    rebuilds the derived layer. A miss is a reported outcome with close matches, not an
    error, so a library-wide fetch does not abort on the first obscure item.
  • Review screen showing each item's proposed changes with an independent tick per
    field, starting from the policy's safe default. An item nobody reviewed writes nothing.
  • Apply and revert with a snapshot taken before every write, per-item audit entries
    carrying Last.fm provenance, and optimistic concurrency on the item's Etag.
  • Bulk editing over Server-Sent Events — review a selection, apply it, watch per-item
    progress, and revert the whole batch. A failure on one item does not roll back or
    abandon the rest.
  • Archive explorer — storage-cap headroom, the derived layer's counts, and stored
    entities with tags by real popularity and their similar artists.

Last.fm archive. A persistent, incremental, structured store of everything fetched,
so the same request is never paid for twice.

  • Content-addressed responses: identical bodies are stored once while every request
    against them is logged, so observations normally exceed distinct bodies.
  • Append-only request log in monthly partitions, with the storage cap measured and
    surfaced rather than silently pruned (ADR 0011).
  • Three layers — raw, derived current state, derived graph — where the derived layer is a
    pure function of the raw one. A rebuild is reproducible: the md5 over all seven derived
    tables is identical across a wipe and rebuild, and the raw layer is untouched.

Domain layer. Tag policy (genre/style limits, minimum counts, blacklist, and
merge/replace/fill-if-empty modes per field), HTML-to-text sanitisation of biographies,
weighted confidence scoring with auto/review/reject verdicts, and a diff that only ever
proposes what policy permits.

API and contract. Typed response models for every endpoint, so the OpenAPI document
describes what the server actually returns. The SPA's types — including query parameters —
are generated from that document rather than hand-written, and CI fails if a schema change
was not regenerated.

Operations. Multi-stage Dockerfile running as a non-root user, docker-compose with
docker.io/postgres:18-trixie, migrations as a separate idempotent step, health and
readiness endpoints, and structured logging.

Fixed

Bugs found only by running against real Jellyfin 12.2.0 and the live Last.fm API. Each is
recorded because the class of mistake is more instructive than the instance.

  • Every single-item read returned 400. GET /Items/{itemId} needs a user context, and
    a userless API key must pass userId explicitly — for every id, including ones the
    server itself had just returned from /Artists. Because apply reads an item before
    writing, the whole edit path was dead and surfaced as a write failure that never
    attempted a write.
  • /api/info reported a working API key as unusable, so the UI hid the entire editor
    on a correctly configured deployment.
  • Tag popularity never reached the tag policy. artist.getTopTags is envelope-free, so
    the entity derivation skipped it and every tag count was null — silently disabling
    min_count and reducing ranking to list order everywhere.
  • Authentication did not work on Jellyfin 12, which removed the legacy auth channels.
    A bare Authorization: <key> returns 401; the MediaBrowser scheme is required.
  • /Artists returned nothing when given includeItemTypes — a filter that looks
    harmless and emptied the browse.
  • Album overviews were discarded. The code asserted album getInfo carries no wiki;
    it does, and the text was being thrown away.
  • AlbumArtist, Artists and AlbumArtists are three spellings and only the array
    was read, leaving some albums with no artist and therefore no possible Last.fm lookup.
  • Last.fm spells one album with a U+2026 ellipsis and the same album with three dots
    across its own endpoints, which silently dropped that album's popularity.
  • A miss was silent when the fallback search was disabled, and a rejected API key was
    converted into a per-item miss instead of a fatal error.
  • Archive reads were logged as Last.fm requests; a field-level mode override had no
    effect; warn could be configured past refuse; prune-raw exited non-zero on a
    successful dry run; and an album-only AttributeError reached the API as a 500.

Security

  • Security headers applied in the app: a restrictive CSP (the SPA shares an origin with the
    write endpoints, so injected script could edit metadata), nosniff,
    frame-ancestors 'none', Referrer-Policy: no-referrer, and a Permissions-Policy
    denying camera, microphone and geolocation.
  • A 2 MiB request-body cap, checked against the declared Content-Length.
  • Both credentials are SecretStr, so model_dump() and repr() cannot leak them, and
    upstream error bodies are redacted before being surfaced.

Documentation

  • 13 ADRs covering the decisions that constrain the design: writing fields directly
    rather than through Jellyfin's provider pipeline, the full-overwrite hazard, snapshotting
    before every write, related artists staying in our database, optimistic concurrency,
    the persistent archive, content addressing, measuring rather than pruning, read-only
    Last.fm, and the single-container deployment.
  • docs/design/0003 specifying the derivation contract, including the deliberate
    deviations from the original plan.
  • A README recording every behaviour verified against live servers, so the quirks are
    recoverable rather than folklore.

Testing

  • Unit, integration (against a real Postgres, freshly created and migrated per test),
    contract, live and end-to-end suites.
  • Faithful upstream mocks: every bug above was found by talking to the real servers and
    none by the test suite, because the mocks agreed with the client's assumptions rather
    than the servers' behaviour. The mocks in tests/support/ now reproduce what the servers
    do — including a Jellyfin that really performs the full overwrite, so a payload that
    drops a field visibly destroys data.
  • The end-to-end suite runs the whole journey against a stub Jellyfin and a stub Last.fm in
    a separate compose file, because the journey writes and a flag that could point it at a
    real library is a flag somebody will eventually set.

Notes

  • Last.fm is used read-only; only an API key is needed and no user authentication is
    performed (ADR 0012).
  • Related-artist data is kept in this application's database and is never written to
    Jellyfin (ADR 0005).
  • The Last.fm Terms of Service cap stored Last.fm Data at 100 MB. Usage is measured and
    shown; nothing is deleted automatically, and reaching the cap refuses new writes instead.