v2.4.0 - Jellyfin 12, and badges that point at one thing
This release runs on Jellyfin 12 and ships a build for it, and it adds the first achievements that can point at a specific show, season or track rather than an aggregate. Drop-in upgrade from v2.3.x - no schema breakage or manual migration.
Jellyfin 12 - two packages per release
Requested by @Lyxon1337 in #109; the build work and the web-client work are @camarigor's #117 and #122.
- Every release now ships two zips.
Jellyfin.Plugin.AchievementBadges_2.4.0.0.zipis thenet9.0build for Jellyfin 10.11 andJellyfin.Plugin.AchievementBadges_2.4.0.1.zipis thenet10.0build for Jellyfin 12, compiled againstJellyfin.Controller 12.0.0. The manifest carries both, withtargetAbi10.11.0.0 and 12.0.0.0. Jellyfin's catalog filters by ABI and then takes the highest version number, so a 12 server installs.1and a 10.11 server can only ever see.0; the distinct 4th segment is what makes that hold regardless of the order the manifest lists them in. - No source change was needed for the API. Everything this plugin consumes is signature-identical between 10.11 and 12.0, and the
IUserManager.UserstoGetUsers()change was already covered by the reflection shim from v2.0.1. The suite runs against both API lines in CI on every push. - Both zips are signed and attested the same way as before: md5, sha256, a cosign signature per zip, and one SLSA provenance bundle covering both.
Added - targeted badges
Requested by @unknownTGG in #107.
- Completion of one container.
ContainerCompletionPercentcounts played items over total items of one series, season, collection, playlist or album. Threshold 100 means you finished it. - Plays of one item.
ItemPlayCountreads Jellyfin's own play count for a single item. Threshold 1 covers "watched this movie"; larger thresholds cover the track you keep going back to. - Library picker in the admin page. Both metrics take their target from a search field rather than from a hand-typed ID. The stored parameter keeps the GUID and the name together, so a badge survives both a rename and an item being deleted and re-added: when the GUID goes stale the name resolves it and the parameter is rewritten with the new GUID.
- Arbitrary groupings through collections. For something the library has no container for, such as one story arc of a long-running show, make a Jellyfin collection and point a completion badge at it. That keeps the grouping in a native Jellyfin feature instead of a badge-editor episode list.
- Retroactive on creation. Authoring a targeted badge recomputes it for every profile immediately, so anyone who already finished the target unlocks it without waiting for a watch-history scan.
- Moves on manual marking, not only on playback. The trigger is Jellyfin's own played flag, so marking a season watched by hand advances the badge. That is how an old arc actually gets completed.
- Bounded cost. Only the targets that enabled badges actually reference are ever computed.
MaxTargetedBadgeTargets(default 50) caps how many, and targets past the cap are named in the log rather than dropped silently.
Added - shop bling on the shareable card
Asked by @TsunamicFlame in #42: the shop sold a custom title and a badge frame that only their owner could see.
- The equipped custom title and badge frame now show on the shareable profile card, on all three skins, and on the profile card that opens from the friends drawer. The title sits under the name, star prefixed, in the skin's accent; the frame recolours the equipped badges.
- Same privacy as the equipped badges. Both are hidden by the same toggles that hide the equipped preview (showcase off, hidden from leaderboard or compare, admin force-hide), and privacy mode hides everything. Both are resolved against the shop catalog, so a stale or unknown id shows nothing.
- And everything else the shop sells. The first thing TsunamicFlame named was "Pastel", and #119 had left the theme out. Every equipped cosmetic now reaches both surfaces: the profile theme paints the shareable card on all three skins (backdrop, surfaces and accent; Metro keeps its fixed 360 green tiles and takes the backdrop) and colours the accent of the friends-drawer card; the profile border rings both cards with the page's own effect (gold shimmer, plasma, ember, holo, crystal); the animated background plays behind the shareable card, the same video loop the achievements page uses (Starfield stays CSS); and the avatar glyph replaces the initial on both. Same privacy gate for all of them, and each id is resolved against the catalog before it becomes a class or an asset name.
Added - game achievements
Requested by @TsunamicFlame in #115.
- Games played through JellyEmu now earn achievements. JellyEmu already reported each game session to Jellyfin as a playback session; this plugin dropped them because games are
Bookitems with no runtime and two of its checks were built for video. Sessions are now measured on their own terms: the smaller of what the emulator reported and what the plugin saw on its own clock, with a floor for misclicks and a cap for a tab left open, both configurable. - Nine built-in badges under a new Games category: sessions, distinct games, hours and platforms.
- Three custom-badge metrics: distinct games on a platform, distinct games from a developer or publisher, and hours in one specific game through the target picker.
- Same abuse guards as everything else (daily credit cap, suspicious-rate flag), without the completion gate and the six-hour per-item dedupe, which do not fit a session: the same game twice in an evening is two sessions.
- Not included, on purpose: RetroAchievements sync. Nothing played in JellyEmu's browser emulator can unlock one, so it would only reward play that happens elsewhere.
Jellyfin 12 - the web client
Jellyfin 12.0 ships a new default layout and switches legacy authorization off. Nothing to configure.
- The Achievements entry moved to the avatar menu. The modern layout keeps the old drawer and header in the DOM but hidden, so the sidebar entry and the equipped badge strip were injected where nobody could see them. On 12 the entry sits right below Profile in the avatar menu and the strip sits before the avatar in the toolbar. The legacy layouts (desktop-legacy, mobile-legacy, TV) keep the drawer entry.
- Every authenticated call answered 401. The 12.0 migration
DisableLegacyAuthorizationrefusesX-Emby-Token,X-MediaBrowser-Tokenandapi_keyin the query; the plugin sent only the first. All calls now carryAuthorization: MediaBrowser Token=...next to it, which both versions accept. - The Revamp admin page fits the dashboard. It takes the viewport with
position: fixed, and the dashboard's docked drawer and app bar sit on layers above it, hiding its left 240px and top 48px. The page now measures both and insets itself; Classic is unchanged. - The tab is named Achievements while the achievements page is open, instead of the "Page not found" the SPA route underneath used to set.
- Links use
#/routes. jellyfin-web 12 still redirects#!/with a deprecation warning; every link the plugin writes now uses the current format. - The equipped badge strip is in the toolbar, not the hidden header. Found on a real 12.0.0 install: the modern layout keeps
.headerRightin the DOM inside the hidden.skinHeader, so the strip was injected there with all five badges and the toolbar fallback never ran. The visible target now wins, and a strip that mounted into the hidden header before the toolbar existed is moved rather than left there.
Dependencies
- Dependabot's September round folded into one commit by @camarigor (#128):
actions/setup-dotnet6.0.0,actions/attest-build-provenance4.2.2,Microsoft.CodeAnalysis.NetAnalyzers10.0.401,Microsoft.NET.Test.Sdk18.10.0,xunit.runner.visualstudio4.0.0, plusxunit2.9.3 (#121), every lock file regenerated.Jellyfin.Controller/Jellyfin.Modelstay pinned per target framework and Dependabot now ignores them in every project.
Changed
- The
library-completion/recomputeendpoint now also recomputes targeted progress and returns it, so a broken or unwanted scan does not leave targeted badges unreachable. - A custom badge whose criteria use a targeted metric without a target is now rejected at write time. It could never unlock, and it failed silently: the badge rendered and sat at zero with nothing in the log.
Fixed
-
~25
Users/Merequests on the login page. The standalone page polls for the current user id and, after ten tries, asks the server. That request waits up to two seconds for a token, and while it waited the poll kept ticking and queued another request every 200ms, each one a 401 while logged out. The poll now stops before asking. -
The Revamp stylesheet could be served stale for a day. Its URL carried the literal token
v=1.9.2, unchanged since 1.9.2, while the controller serves itimmutable, max-age=86400- in three places (the admin page, the achievements page and sidebar.js's early link). The token is now read off the injected bootstrap script tag, which carries the one the running DLL stamps, so a stylesheet change busts on the same event a script change does. -
"Un-hidden elements" with the Pastel theme (#42). Every gradient theme painted the page with a
backgroundshorthand whose first stops are translucent, which leaves the page's own colour transparent: Jellyfin's "Page not found" route and toolbar showed through the top of the achievements page. Reported by TsunamicFlame, not reproducible at the time because it needs a theme with a light first stop and a page underneath with content at the top. Every theme now paints over a solid base, in all three stylesheets that define them. -
Profile borders never showed under Revamp. Revamp repurposes the hero's
::beforeas the section label, which is where the border effect lived. Under Revamp the ring is now drawn with an outline and a glow, animated the same way; Classic keeps its gradient ring. -
Badge frames were nearly invisible. They applied only to the Equipped row of the My Badges tab, and under Revamp even there the effect was reduced to a 1px tinted border. The frame now rings the hero showcase pills, the header strip, the drawer showcase and the Equipped row, in both styles.
-
The shop's "Auto-unlock at N score" pill was clipped or broke mid-sentence. It was
width: 100%plus padding inside a card that clips overflow, and its label, number and unit were separate flex items. One inline run in a border-box pill, with thousands separators. -
The admin hero announced v1.9.2 on Jellyfin ABI 10.11.0.0 on every release and every server. It now reads the plugin's entry from
/Pluginsand the server version from/System/Info/Public. -
Issue #97 diagnostics.
POST users/{id}/resetanswered 200 forundefined,nulland ids of accounts that do not exist, creating an empty profile as a side effect; it now returns 400 for a non-GUID and 404 for an unknown account. The admin page chained reset -> reload and scan -> reload under one error handler, so a 400 from any of the six requests in the reload read as "Failed to reset badges", which is where the report's month of guesswork came from; failures now name the request (method and route) and the action's own failure is reported separately from the reload's. And a scan that finds no played items now says so and points at the account's library access, which is what that symptom nearly always means, in all eight languages. -
Toast position never applied (#116).
styles-revamp.csspins the toast container to top-right with!important, and the per-user placement from v2.3.0 wrote a plain inline style, which loses to a stylesheet!important. So with the Revamp style active every placement rendered top-right, not only bottom-center as reported. The placement is now written with inline!important, which is the one thing that outranks the stylesheet; the stylesheet's z-index is left in charge on purpose. Reproduced and verified in a browser against a verbatim copy of the rule: before the fix all five placements landed top-right, after it each lands where it says. -
The artist-completion ordinal test asserted that
ArtistCompletionPercentwas the last value in the metric enum. That is a proxy for the invariant it protects rather than the invariant itself, and it failed on the first correct append after it. It now pins the ordinal (70), which is the value that must never move.
Verification
- 306/306 automated tests pass on .NET 9 / Jellyfin 10.11.6 and again on .NET 10 / Jellyfin 12.0.0, with zero build warnings.
- The admin page's embedded scripts and the three injected scripts pass syntax validation.
- Jellyfin 12.0.0, net10.0 build, throwaway container, Chromium: plugin loads as 2.4.0.1;
X-Emby-Token,X-MediaBrowser-Tokenandapi_keyeach answer 401 andAuthorization: MediaBrowser Tokenanswers 200, matching #122's table; fifteen plugin endpoints answer 200; four movies marked played, the scan credits 4 withLastPlayedDateresolved through the reflection path (the watch calendar buckets on the played date, not the scan date), 14 badges unlock, library completion computes. Avatar menu shows Profile, Achievements, Settings, Dashboard, Metadata Manager, Quick Connect, Sign Out; the entry closes the menu and lands on#/achievementswith the tab named Achievements. The equipped strip sits in the MUI toolbar beside the avatar. Classic and Revamp admin pages both fit the dashboard, the Revamp page at x=240 y=48, with the stylesheet fetched under the runtime token. Console clean after login. Reset answers 400 / 404 / 200 for a non-GUID / unknown account / real account; a scan for an account with no library access returns the hint. - Jellyfin 10.11.10, net9.0 build, the same source, 22 profiles: loads clean, drawer entry, drawer showcase and header strip in place, achievements page mounts with the profile loaded.
- Deployed and exercised against a live Jellyfin 10.11.11 with a 28-profile install:
- A completion badge on a fully watched 73-episode series unlocked 0.5 seconds after being authored, with no scan.
- A completion badge on a partly watched series read 83, matching Jellyfin's own 38 of 46.
- Marking one more episode played by hand, with no playback at all, moved it 83 to 85; unmarking moved it back, and the other target's stored 100 was untouched, which is the merge-not-replace contract holding on live data.
- A play-count badge on a film with 30 plays unlocked immediately against history that predates the feature.
- A collection badge went 0 to 25 when one of its four films was marked played, confirming that linked children are read; an AncestorIds query alone returns nothing for a BoxSet.
- A badge authored with a dead GUID and a live name re-resolved by name and rewrote its own parameter with the correct GUID.
- Seeding a newly authored badge across 28 profiles took one second.