A Jellyfin plugin that adds achievement-style badges to user profiles based on viewing activity. Users unlock badges for milestones such as first watch, binge sessions and late-night viewing. Designed to gamify the Jellyfin experience and encourage engagement across libraries.
HTML
70
386 commits
updated Oct 1, 2026
█████╗ ██████╗██╗ ██╗██╗███████╗██╗ ██╗███████╗███╗ ███╗███████╗███╗ ██╗████████╗
██╔══██╗██╔════╝██║ ██║██║██╔════╝██║ ██║██╔════╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝
███████║██║ ███████║██║█████╗ ██║ ██║█████╗ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║
██╔══██║██║ ██╔══██║██║██╔══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║
██║ ██║╚██████╗██║ ██║██║███████╗ ╚████╔╝ ███████╗██║ ╚═╝ ██║███████╗██║ ╚████║ ██║
╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝
A full progression, gamification and achievement system for Jellyfin that rewards users based on real viewing activity. Think Xbox Gamerscore meets Letterboxd meets Steam profile customization, built natively into your media server.
Status: Active development — v2.4.1 is live: everything reported against 2.4.0, including two faults that made a correct install look broken on Jellyfin 12 (an uninstall that failed outright, and a page that only worked in a private window), plus a targeted badge cap you can raise to 1000, a top-center toast placement with a server default, an option to keep login-hidden accounts out of other users' views, and a fix for a collision that had been breaking your server's whole API document since 2.1.0. See What's new in v2.4.1. v2.4.0 brought Jellyfin 12 support (a dedicated 12.0 build in every release, and the web side reworked for the new layout and authorization), targeted badges that point at one series, season, collection, playlist, album or item, JellyEmu game achievements, and your shop bling on the shareable card. Before that, v2.3.1 fixed the music feature and cleaned up the leaderboards, and 2.3.0 brought friend profile cards, three shareable card skins (Console / Metro / Aurora), Tracearr history crediting, and the profile data-loss + gzip injection fixes every published build needs. Built on v2.2 "Your Screen, Your Rules", the v2.1 "Open Library" expansion, and v2.0 "Choose Your Loadout".
Over 200 built-in achievements across 35+ categories, a 10-tier rank ladder from Rookie to Immortal, a full score economy with combos, prestige, daily/weekly quests, a Score Shop with 70+ cosmetics, power-up consumables, a Friends drawer with messaging, plus admin power features like custom badges, seasonal challenges, webhook notifications, and a full audit log.
Designed to integrate cleanly with modern Jellyfin setups and themes like NetFin, ElegantFin, or StarTrack.
Everything reported against 2.4.0, including two faults that made a correct install look broken on Jellyfin 12, plus a cap the people who author targeted badges by the hundred can now raise. Drop-in upgrade from v2.4.0 — no schema change, no migration. Full notes in docs/release-notes/v2.4.1.md.
x.y.z.0. Jellyfin then read two different numbers for one install — the dashboard shows the assembly's version, uninstall and the image endpoint resolve through the manifest's — so on Jellyfin 12 the plugin installed as 2.4.0.1, displayed as 2.4.0.0, and could not be removed. Each package now stamps the version it ships as. Stuck on 2.4.0.1? Updating through the catalogue is enough; the restart that loads the new build deletes the old folder.304 Not Modified from the file on disk, and there is no page body for the plugin to inject into — so the browser keeps using a copy with no plugin in it, forever. Worst on servers with a read-only web directory, where the on-disk patch can never take either. The middleware now forces a full page while the on-disk patch has not taken. Nobody needs to clear their browsing data — a normal reload after updating is enough.<body>; they are now anchored to the plugin's own two surfaces, and a test refuses any new rule of that shape.Found while testing this release, not reported by anyone, and present since 2.1.0 on both Jellyfin 10.11 and 12: GET /api-docs/openapi.json answered 500 for the whole server.
Jellyfin builds one OpenAPI document from itself and every installed plugin, and each schema in it is keyed by the bare type name. This plugin's media-type enum was called MediaType, which is also the name of one of Jellyfin's own enums — and a duplicate name is not resolved, it throws, taking the entire document down. Swagger UI, the dashboard's API browser and every generator that reads the spec were all broken, by a plugin that started cleanly and logged nothing about it.
The enum is now BadgeMediaType. Its numbers and the property carrying it are unchanged, so there is nothing to migrate. With the collision gone the document builds, and this plugin's 126 routes appear in it for the first time. A test now walks the plugin's public types against Jellyfin's and fails on any shared name, because nothing else could catch this: the break was in the host's document, not in this plugin's.
If your API docs page is still broken after updating, another plugin is colliding the same way — the server log names both types.
Big thanks to @camarigor for the cap and the per-play rework (#130), the Custom Tabs repair (#132), the Revamp scoping (#134), the toast work (#137), the hidden-accounts option (#139), and for catching what the first pass at #140/#141 got wrong (#144); to @Roboatlas21 for diagnosing both Jellyfin 12 faults from his logs (#140, #141); to @Tschiyo for the cap request (#129); to @Borededdy for the blank Custom Tab (#131); to @clarjon1 for the Revamp leak (#133); to @Verdancy-Rin for the top-center placement (#136); and to @Digital-Yeti for the hidden-accounts request (#138).
??? until unlockedStandout sub-collections:
"anime", case-insensitive). Anime Curious / Anime Fan / Otaku / Anime Veteran / All-Otaku at 5 / 15 / 50 / 200 / 500 anime items.BaseItem.Studios:| Badge | Studio | Threshold |
|---|---|---|
| Spirited Away | Studio Ghibli | 5 |
| A24 Acolyte | A24 | 15 |
| It's Not TV | HBO | 25 |
| Netflix and Watch | Netflix | 50 |
| Auntie Beeb | BBC | 25 |
| House of Mouse | Disney | 50 |
Backfill credits these too — the watch-history backfill task passes
Studios,SeriesId,SeasonNumber,EpisodeNumberthrough to the achievement service, so historical episodes credit anime / studio / pilot badges. Time-of-day buckets only count NEW sessions.
/dashboard + /plugins pages and during media playback; reappears as soon as you leave either stateISessionManager, with a 15-minute grace window so casual browsing still counts as online (not just active playback)IUserDataManager with reflection-based LastPlayedDate lookupFriendsService.BuildFriendRow — can't be bypassed by client tamperingbody[data-ab-style="revamp"] so the same tokens apply globallyXbox-Guide-style chat built into the Friends drawer. No external service, no WebSockets, everything stored on your own server.
.exe renamed to .png. Click-to-zoom lightboxreadBy map so group receipts work tooUserAchievementProfile.Preferences.BlockedUsers[Authorize]-gatedFriendsSimpleMode treats the whole server as one friend list for messaging too (with the #138 option on, only between users who can see each other)messages.json + attachments.json + attachments/<id>.<ext> on disk under plugins/configurations/achievementbadges/. Atomic writes via temp file + File.Move so a crash mid-send can't corrupt the store. Messages survive server restarts/badges/rarity-stats endpoint with a 5-minute server-side cache so it doesn't re-scan every profile on every page loadLoad() walks primary badges.json → .bak → .recovery before giving up. A flaky primary file with a clean backup recovers silently.bak is rotated on every successful save.recovery captures in-session state when the primary is quarantined, so restart never feels like a fresh resetLastLoadSummary exposed via the /test endpoint so admins can see recovery activitybadges.json.corrupt-<timestamp> (not deleted) for manual recovery#/achievements with the new Loadout tab (v2.0) for managing power-ups, shop, and cosmetics/Plugins/AchievementBadges/users/{id}/profile-card, in Console (default), Metro, or Aurora Spine, each drawn with the owner's rank colour as the accent. Users pick their skin in preferences; ?style= overrides per link, and a request with no style falls back to the owner's choiceab-style-pref localStorage):
01 / MY BADGES, 02 / QUESTS…), control-panel filter strip, ambient drift orb, film grain overlay, day-streak pulse, rank-name shimmer, full page entrance cascadeA gear icon on the achievements page opens a full settings panel with auto-save:
BadgeLocalizer) so both UI chrome and badge titles on the leaderboard / showcase / admin grid / equipped showcase localise together/Plugins/AchievementBadges/custom-badges API. See Custom badgesWebhookSigningSecret, every outbound POST carries X-AchievementBadges-Signature: sha256=<hex> + X-AchievementBadges-Timestamp: <unix>. Receivers verify with HMAC(secret, timestamp + "." + raw_body) and reject stale timestamps to prevent replay. Same envelope as Stripe and GitHub. Empty secret = legacy unsigned behaviour (backward compatible).AdminAuditLogFilter writes an entry on every RequiresElevation action — answer "who unlocked X for whom last Tuesday" without grepping runtime logs.SuspiciousRatePerHour audit flag (default 30/h, configurable) for soft anti-abuseILibraryManager.GetPeople() for directors/actorsAdded in v2.1.0 "Open Library". Admin-only. All endpoints require admin elevation.
Define your own badges with compound AND/OR criteria the built-in catalog can't express. The plugin config page (Dashboard → Plugins → Achievement Badges → Custom Badges) has a form for simple badges (single metric + threshold). For compound criteria, icon control, and import/export, use the API below.
A badge's criteria is a tree. A node is either:
{ "metric": "<Metric>", "threshold": <int>, "metricParameter": "<optional>" } — true when the metric value ≥ threshold.{ "operator": "And" | "Or", "children": [ <node>, ... ] } — true when And(all)/Or(any) of children are true.Limits: max depth 5, max 64 nodes per badge, max 500 badges per instance.
Added in v2.4.0, from #107.
Two metrics point at one specific thing in your library rather than at an aggregate:
ContainerCompletionPercent — played items over total items of one series, season, collection, playlist or album. Threshold 100 means "you finished it".ItemPlayCount — how many times one specific item has been played. Threshold 1 is "you watched this movie"; larger thresholds are for the track you keep going back to.Both take their target in metricParameter, formatted "{guid}|{name}". The config page has a library picker that writes it for you. Both halves are stored on purpose: the GUID survives a rename, and the name survives an item being deleted and re-added, so the badge re-resolves itself and rewrites the GUID.
For an arbitrary group of episodes, such as one story arc of a long-running show, make a Jellyfin collection and point a ContainerCompletionPercent badge at it. That keeps the grouping in a native Jellyfin feature instead of a badge-editor episode list.
Targeted badges are evaluated against your existing history, so a badge you author today unlocks immediately for anyone who already finished the target, with no scan needed. How many distinct targets all badges may reference is set on the config page, under Custom badges, as Targeted badge cap (1 to 1000, default 50). The page shows how many targets the enabled badges reference against the cap and names the ones past it, which are not computed. Every play checks each target with a set lookup: series, seasons and folders against the played item's ancestors, collections and playlists against a cached member list that is refreshed when the container changes, so hundreds of targets of any kind are fine on any server.
// POST /Plugins/AchievementBadges/custom-badges
{
"name": "Straw Hat",
"description": "Finish One Piece: Alabasta.",
"rarity": "Epic",
"media": "TV",
"criteria": {
"metric": "ContainerCompletionPercent",
"metricParameter": "8f4e2a1b9c3d4e5f6a7b8c9d0e1f2a3b|One Piece: Alabasta",
"threshold": 100
}
}
Added in v2.4.0, from #115.
Games played through JellyEmu earn achievements with no setup on the JellyEmu side. JellyEmu reports every game session to Jellyfin as a playback session and tags each game with JellyEmu, Game and its platform; this plugin reads those and measures each session as the smaller of what the emulator reported and what the plugin saw on its own clock, with a floor (MinGameSessionSeconds, default 60) so a misclick is not a play and a cap (MaxGameSessionMinutes, default 360) so a tab left open is not a night of play.
Built in, under the Games category: session ladders (1, 25, 100), distinct games (10, 50), hours (10, 100) and platforms (3, 8). For custom badges, the builder offers:
GamePlatformGames with the platform tag as parameter (SNES, SegaGenesis, PlayStation; the spelling is JellyEmu's, matched without regard to case): "play 10 Sega Genesis games".GameStudioGames with the developer or publisher as parameter: "play 5 games by Capcom".GameHours with a specific game as target, chosen in the picker like any other targeted badge: "30 hours in this game".Two limits worth knowing: play time counts from the moment this version is installed, because games carry no played flag to rebuild history from; and nothing here reads RetroAchievements, whose unlocks cannot happen in JellyEmu's browser emulator (see the discussion on #115).
// POST /Plugins/AchievementBadges/custom-badges
{
"name": "Cinephile",
"description": "Watch 100 films.",
"rarity": "Rare", // Common | Uncommon | Rare | Epic | Legendary | Mythic
"media": "Film", // Film | TV | Music | Book | Anime | Multi
"iconUrl": "", // optional external https URL; blank = default trophy
"enabled": true,
"criteria": { "metric": "MoviesWatched", "threshold": 100 }
}
{
"name": "Renaissance Viewer",
"description": "Watch 100 films AND listen to 50 music tracks.",
"rarity": "Epic",
"media": "Multi",
"enabled": true,
"criteria": {
"operator": "And",
"children": [
{ "metric": "MoviesWatched", "threshold": 100 },
{ "metric": "MusicPlaysTotal", "threshold": 50 }
]
}
}
Nested example — "(finish 5 horror series OR 10h of audiobooks) AND watch 25 anime items":
{
"name": "Eclectic",
"description": "Genre-spanning dedication.",
"rarity": "Legendary",
"media": "Multi",
"enabled": true,
"criteria": {
"operator": "And",
"children": [
{
"operator": "Or",
"children": [
{ "metric": "SeriesCompleted", "threshold": 5, "metricParameter": "Horror" },
{ "metric": "AudiobookListeningHours", "threshold": 10 }
]
},
{ "metric": "AnimeItemsWatched", "threshold": 25 }
]
}
}
| Method | Route | Purpose |
|---|---|---|
GET | /Plugins/AchievementBadges/custom-badges | List all |
GET | /Plugins/AchievementBadges/custom-badges/{id} | Fetch one |
POST | /Plugins/AchievementBadges/custom-badges | Create (fresh id assigned) |
PUT | /Plugins/AchievementBadges/custom-badges/{id} | Update |
DELETE | /Plugins/AchievementBadges/custom-badges/{id} | Delete |
GET | /Plugins/AchievementBadges/custom-badges/export | Export all as JSON |
POST | /Plugins/AchievementBadges/custom-badges/import | Bulk import (fresh ids) |
GET | /Plugins/AchievementBadges/custom-badges/targets | Targeted badge cap: configured value, applied cap, bounds, targets observed, and the names past it (#129) |
POST | /Plugins/AchievementBadges/custom-badges/targets | Set the cap (1-1000, clamped); answers with the fresh summary (#129) |
Every value AchievementMetric exposes, 71 of them. Entries marked * accept a metricParameter.
Watching — TotalItemsWatched, MoviesWatched, SeriesCompleted, LongSeriesCompleted, VeryLongSeriesCompleted, RewatchCount, ShortItemsWatched, LongestItemMinutes, TotalMinutesWatched, SeriesSampledOnly, SeriesBingedAfterPilot
Streaks and days — DaysWatched, CurrentWatchStreak, BestWatchStreak, DaysLoggedIn, CurrentLoginStreak, BestLoginStreak, MaxEpisodesInSingleDay, MaxMoviesInSingleDay, MaxMinutesInSingleDay
Time of day — LateNightSessions, EarlyMorningSessions, AfternoonSessions, PrimeTimeSessions, WeekendSessions
Variety — UniqueGenresWatched, UniqueCountriesWatched, UniqueLanguagesWatched, UniqueDecadesWatched, UniqueLibrariesVisited, TopDirectorCount, TopActorCount, MaxLibraryItemCount
Completion — LibraryCompletionPercent *, LibrariesAt100Percent, BadgesUnlockedPercent, ArtistCompletionPercent *
Music — MusicPlaysTotal, MusicListeningHours, UniqueMusicAlbums, UniqueMusicArtists, UniqueMusicGenres, UniqueMusicDecades, MusicGenrePlays *, MusicGenreListeningHours *
Books — BooksCompleted, AudiobookListeningHours, UniqueBookSeriesCompleted
Score and prestige — PrestigeLevel, LifetimeScore, BestComboCount
Parameterized by name — GenreItemsWatched *, PersonItemsWatched *, StudioItemsWatched *, DecadeItemsWatched *, DayOfWeekItemsWatched *
Holidays and dates — WatchedOnChristmas, WatchedOnNewYear, WatchedOnHalloween, WatchedOnEid, WatchedOnValentines, WatchedOnEaster, WatchedOnLunarNewYear, WatchedOnDiwali, WatchedOnThanksgiving, WatchedOnIndependenceDayUS, WatchedOnBonfireNight, WatchedOnBoxingDay, WatchedOnMothersDay, WatchedOnFathersDay
Anime — AnimeItemsWatched
Parameterized metrics match their parameter case-insensitively. GenreItemsWatched with metricParameter of "horror" counts only horror items; LibraryCompletionPercent with "Movies" reads that one library, and without a parameter it reads whichever library the user is furthest through. The same applies to ArtistCompletionPercent, which without a parameter reads the user's best artist.
Optional. Set Tracearr URL and Tracearr API token in the plugin settings, generate the token in Tracearr under Settings, and a watch history scan will also read your Tracearr history. Leave either field empty and nothing changes.
It exists because a library scan has two blind spots it cannot fix on its own:
IsPlayed on items that still exist, so a film you watched and later removed is invisible to it. Tracearr recorded the play when it happened and still has it.IsPlayed is a boolean, so no number of viewings can produce a rewatch count. That leaves the Rewatch badges unreachable for anyone who installed the plugin after they had already been watching.Only plays Tracearr considers finished are credited, each on the date it happened rather than today, so an old viewing cannot manufacture a streak. A play the library already accounted for is not counted again: the first viewing of a known item is skipped, and only repeats become rewatches.
Nothing is required on the Tracearr side. It uses the existing public v2 API.
user-60-per-min) on every controller route, with stricter overrides preserved on cooldown routes. Static-asset routes (CSS / JS / translations / video bgs) opt out via [DisableRateLimiting] so multi-user households behind a shared NAT don't collectively exhaust the limit.X-Content-Type-Options + X-Frame-Options + Referrer-Policy + Permissions-Policy on the anonymous profile-card endpoint<animate> / <set> SMIL elements that could mutate attributes mid-render[1, 1000] at controllerMessagingService + AchievementBadgeService are IDisposable so the debounced-save Timer is released on plugin reload<use href> rejectiondotnet list package --vulnerable + gitleaks on every push, every PR, and weekly cronSECURITY.md with full threat model, trust boundaries, defences-in-place inventory, continuous verification matrix, disclosure SLA, and safe-harbour for researcherscosign verify-blob or gh attestation verifyRestorePackagesWithLockFile, CI runs in locked mode so drift fails fastSave() in AchievementBadgeService and MessagingService — coalesces back-to-back disk writes from playback and messaging hot paths into one flush per 1.5sFriendsService.LastWatched cache — 90s TTL, invalidated on play, eliminates per-friend 50-item DB query on every friends-list callWriteIndented = false on production stores (~50% smaller badges.json)client-script + asset routes — one read per processCache-Control: public, max-age=86400, immutable on assets with version-only cache busting; video backgrounds use HTTP Range so the browser only streams the bytes it needs to start playingCache-Control: no-cache, must-revalidate on translations (v2.0) so users always get fresh strings after a plugin update without hard-refreshinghttps://raw.githubusercontent.com/ZL154/AchievementBadges_for_Jellyfin/main/manifest.json
#/achievements — and try the Loadout tabx.y.z.0 is the 10.11 build, x.y.z.1 is the 12 build. Installing by hand? Take the zip whose 4th version segment matches your server. Upgrading the server from 10.11 to 12 will offer the .1 build as a plugin update afterwards.item.People will stay empty if your library doesn't have people scrapeddata-ab-repaired-panel="true". Other Custom Tabs tabs are still empty on such a server, since only this one is ours to repair.| Feature | Depends on |
|---|---|
| Sidebar + header injection | Nothing (works standalone) |
| Custom Tabs page host | Custom Tabs plugin + enable the integration, save, and restart Jellyfin |
| Plugin Pages page host | Plugin Pages plugin + Jellyfin restart after enabling integration |
| Watch history backfill | Played flag on items (Jellyfin default) |
| Genre badges | Items with Genres metadata |
| Director/Actor badges | Items with People metadata (TMDb/OMDb scrape) |
| Era / decade badges | Items with ProductionYear metadata |
| Country badges | Items with ProductionLocations metadata |
| Language badges | Items with OriginalLanguage metadata |
| Runtime badges | Items with RunTimeTicks populated |
| Library completion | At least one library folder with items |
| Webhook notifications | A webhook URL (Discord, Slack, or generic) |
| Animated video backgrounds | Modern browser with H.264 + <video> autoplay-muted support (all evergreen browsers) |
The plugin injects its scripts into Jellyfin's index.html at startup. If the web directory isn't writable, the injection fails silently and no UI loads (no sidebar entry, no toasts, no achievements page).
Diagnose: visit https://your-server/Plugins/AchievementBadges/test — the JSON response shows:
DiagIndexFound — whether index.html was locatedDiagIndexPatched — whether the script tags were successfully writtenDiagLastError — the exact error if patching failed (usually Unauthorized: Access denied)Common cause: on Docker or Linux installs, Jellyfin doesn't have write access to /usr/share/jellyfin/web/. Fix by granting write permission:
# Docker: run inside the container
chmod -R a+w /usr/share/jellyfin/web/
# Systemd: fix ownership
sudo chown -R jellyfin:jellyfin /usr/share/jellyfin/web/
Then restart Jellyfin. The plugin will patch index.html on the next startup.
Can't (or won't) make the web dir writable? Use the JavaScript Injector plugin (v2.1.0, #26). On bare-metal Linux where /usr/share/jellyfin/web is owned by root, install the JavaScript Injector plugin. On the next startup, when the on-disk patch fails, Achievement Badges detects that plugin and writes the exact script URLs you need into the /Plugins/AchievementBadges/test diagnostics (DiagJsInjectorGuidance) and the server log. Paste these three into JS Injector's settings — one per entry:
/Plugins/AchievementBadges/client-script/sidebar
/Plugins/AchievementBadges/client-script/standalone
/Plugins/AchievementBadges/client-script/enhance
Restart Jellyfin and the UI loads through JS Injector — no writable web directory required. (The plugin doesn't call JS Injector's API directly, so this keeps working across JS Injector versions; the on-disk patch remains the default when the dir is writable.)
Still broken? The plugin has a middleware fallback that rewrites index.html at runtime (no disk write needed). If that's also failing, check whether a reverse proxy (nginx/Caddy) is caching a stale index.html from before the plugin was installed. Clear the proxy cache or restart it.
/nix/store)NixOS serves Jellyfin's web files from the immutable Nix store, so neither the disk patcher nor the middleware can modify index.html. Use a NixOS overlay to inject the script tags at build time:
nixpkgs.overlays = [
(final: prev: {
jellyfin-web = prev.jellyfin-web.overrideAttrs (finalAttrs: previousAttrs: {
installPhase = ''
runHook preInstall
sed -i 's#</body>#<!-- achievementbadges-bootstrap --><script src="/Plugins/AchievementBadges/client-script/sidebar"></script><script src="/Plugins/AchievementBadges/client-script/standalone" defer></script><script src="/Plugins/AchievementBadges/client-script/enhance" defer></script></body>#' dist/index.html
mkdir -p $out/share
cp -a dist $out/share/jellyfin-web
runHook postInstall
'';
});
})
];
The plugin DLL serves the JS files from embedded resources — the three <script> tags just tell the browser to load them. Rebuild your NixOS config after adding the overlay and restart Jellyfin.
/api-docs/openapi.json returns 500)Jellyfin builds one OpenAPI document from the server and every installed plugin, and each type in it is keyed by its bare class name. If two loaded types want the same name the document does not pick one — it fails, and the whole document 500s. Badges keep working, nothing is logged by the plugin, and the only visible symptom is the API docs page.
This plugin caused it from 2.1.0 to 2.4.0 (MediaType, colliding with Jellyfin's own) and no longer does. If the page is still broken on v2.4.1+, another plugin is colliding — the server log names both types:
Can't use schemaId "$PluginConfiguration" for type "$Jellyfin.Plugin.A.PluginConfiguration".
The same schemaId is already used for type "$Jellyfin.Plugin.B.Configuration.PluginConfiguration"
PluginConfiguration is the usual culprit, since most plugins name their config class that. Report it to whichever plugin appears there; there is nothing to change on your server.
The 4 HD video cosmetics need browser MP4/H.264 + autoplay-muted support. Every evergreen browser handles this fine, but if you see a static page where there should be motion:
chrome://flags/#autoplay-policy)muted + playsinline so it should autoplay everywhere; if your browser still blocks it, click anywhere on the page onceGET /Plugins/AchievementBadges/users/{userId} — full badge list
GET /Plugins/AchievementBadges/users/{userId}/summary — unlocked/total/score
GET /Plugins/AchievementBadges/users/{userId}/rank — rank tier + next tier
GET /Plugins/AchievementBadges/users/{userId}/equipped — equipped badges
POST /Plugins/AchievementBadges/users/{userId}/equipped/{badgeId}
DELETE /Plugins/AchievementBadges/users/{userId}/equipped/{badgeId}
GET /Plugins/AchievementBadges/users/{userId}/recap?period=week|month|year
GET /Plugins/AchievementBadges/users/{userId}/watch-calendar?days=90
GET /Plugins/AchievementBadges/users/{userId}/quests — daily + weekly + reroll state
GET /Plugins/AchievementBadges/users/{userId}/bank — score bank + prestige
POST /Plugins/AchievementBadges/users/{userId}/prestige
POST /Plugins/AchievementBadges/users/{userId}/buy-badge/{badgeId}
POST /Plugins/AchievementBadges/users/{userId}/gift/{toUserId}?amount=N
GET /Plugins/AchievementBadges/users/{userId}/chase/{badgeId} — items to watch to finish a badge
GET /Plugins/AchievementBadges/users/{userId}/recommendations — top 3 closest-to-unlock
GET /Plugins/AchievementBadges/users/{userId}/profile-card — HTML profile card
GET /Plugins/AchievementBadges/users/{userId}/unlocks-since?since=ISO&deviceId=ID
GET /Plugins/AchievementBadges/users/{userId}/library-completion
POST /Plugins/AchievementBadges/users/{userId}/login-ping
GET /Plugins/AchievementBadges/users/{userId}/directory (#138) users this user may see, for friend search and compare
GET /Plugins/AchievementBadges/leaderboard?limit=10
GET /Plugins/AchievementBadges/leaderboard/{category}?limit=10 — score|movies|episodes|hours|streak|series
GET /Plugins/AchievementBadges/embedded-page — Plugin Pages host fragment
GET /Plugins/AchievementBadges/server/stats
GET /Plugins/AchievementBadges/users/{userId}/powerups — inventory + active state + ScoreBank
POST /Plugins/AchievementBadges/users/{userId}/powerups/use/{type} — XpBoost | DoubleCredit (StreakFreeze auto-only)
GET /Plugins/AchievementBadges/shop/catalog — full shop catalog
POST /Plugins/AchievementBadges/users/{userId}/shop/purchase — body: {"ItemId":"..."}
GET /Plugins/AchievementBadges/users/{userId}/cosmetics — owned + equipped state + LifetimeScore
POST /Plugins/AchievementBadges/users/{userId}/cosmetics/equip — body: {"CosmeticId":"..."}
POST /Plugins/AchievementBadges/users/{userId}/cosmetics/unequip?kind=ProfileTheme|BadgeFrame|RankTitle|Avatar|Background|ProfileBorder
POST /Plugins/AchievementBadges/users/{userId}/quests/daily/reroll
POST /Plugins/AchievementBadges/users/{userId}/quests/weekly/reroll
GET /Plugins/AchievementBadges/asset/{name} — animated background mp4s (range-enabled)
RequiresElevation)POST /Plugins/AchievementBadges/users/{userId}/backfill
POST /Plugins/AchievementBadges/backfill-all
POST /Plugins/AchievementBadges/users/{userId}/reset
POST /Plugins/AchievementBadges/users/{userId}/reset-badge/{badgeId}
POST /Plugins/AchievementBadges/users/{userId}/library-completion/recompute
POST /Plugins/AchievementBadges/users/{userId}/import
GET /Plugins/AchievementBadges/users/{userId}/export
GET/POST /Plugins/AchievementBadges/admin/badge-catalog — enable/disable badges
GET/POST /Plugins/AchievementBadges/admin/custom-badges — custom badge definitions
GET/POST /Plugins/AchievementBadges/admin/challenges — seasonal challenges
GET /Plugins/AchievementBadges/admin/challenge-templates — one-click templates
GET/POST /Plugins/AchievementBadges/admin/webhook — webhook config (incl. HMAC secret)
GET/POST /Plugins/AchievementBadges/admin/ui-features — UI feature toggles
GET /Plugins/AchievementBadges/admin/audit-log?limit=200
POST /Plugins/AchievementBadges/admin/users/{userId}/inject-counters
GET/POST /Plugins/AchievementBadges/admin/feature-config — feature kill switches + admin controls
DELETE /Plugins/AchievementBadges/admin/users/{userId}/reset — wipe user's achievement progress
POST /Plugins/AchievementBadges/admin/users/{userId}/test/inject-playbacks — verify integrity caps end-to-end (v1.9.8)
DELETE /Plugins/AchievementBadges/admin/users/{userId}/badges/{badgeId} — manual badge revoke (v1.9.8)
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-score — v2.0 testing
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-powerup/{type} — v2.0 testing
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-cosmetic — v2.0 testing
POST /Plugins/AchievementBadges/admin/backfill-milestones — v2.0: retroactively unlock title milestones across all profiles
Pops up during playback when a badge unlocks. Xbox circle pops in with pulse rings, expands into a banner, trophy rotates (or diamond spritesheet for rare unlocks), text slides up, shimmer sweeps across, then everything collapses. Per-rarity color, glow, and sound.
Live demo: download
achievement-combined.html(regular) orachievement-combined-rare.html(rare with diamond) and open in a browser. Click anywhere to start the sound. Loops every 10.5s.
The full profile view, shown in the Jellyfin sidebar. Rank progress bar, day streak, score, completion percentage, and the tab bar (My Badges, Quests, Recap, Leaderboard, Compare, Activity, Wrapped, Stats, and the new Loadout tab from v2.0).
200+ badges across 35+ categories, each with live progress bars and an Equip button. Unlocked badges show in color with a green status tag; locked badges dim. Rarity-colored borders let you scan the grid visually.
Genre specialist badges and streak extremes across all six rarity colors — Common, Uncommon, Rare, Epic, Legendary, Mythic.
Rotating quests from a template pool. Everyone on the server gets the same daily + weekly challenges so people can race each other. Completing them pays into the score bank. In v2.0, each section header has a reroll button.
Weekly, monthly and yearly breakdowns of what you've actually watched — total items, active days, top genres, top directors, and top actors.
Spotify-style end-of-year recap with a big gradient hero, "your numbers" (movies, episodes, active days, best streak, total hours), "your highlights" (biggest day, biggest month, most-watched weekday) and "your favorites" (top genres/directors/actors).
Podium view for the top 3, ranked list below. Switch categories with the tab row: Score, Movies, Episodes, Hours, Best Streak, Series. (Usernames blurred as User 1–10.)
Head-to-head profile comparison between any two users on your server. Gradient bars show the relative values on 12 core metrics, and the bottom pills break down how many badges each user has that the other doesn't. (Usernames blurred as User 1 / User 2.)
GitHub-style year calendar of your watch activity. Current streak, best ever, and total active days at a glance.
90-day heatmap grid, colored by daily watch volume. Click the range button to switch between 30/90/180/365 days.
SVG spider chart showing your top-5 genre distribution, and a 24-hour polar chart of when you actually watch.
Every admin section is collapsible so the page stays clean: webhook notifications, toast preview, UI feature toggles, visual badge editor, challenge templates, audit log, progress injection, custom badges, seasonal challenges, per-badge enable/disable, and the v2.0 testing tools (grant score / power-up / cosmetic, backfill milestones).
Scan watch history, reset badges, scan all users, or load a specific user ID — all from one row under the Advanced options toggle.
Auto-injected into the Jellyfin nav menu — no theme changes required.
Full per-version notes and signed binaries live on the GitHub Releases page:
➡ github.com/ZL154/AchievementBadges_for_Jellyfin/releases
Highlights:
x.y.z.0 for 10.11, x.y.z.1 for 12) with the entry in 12's avatar menu and the Authorization header 12 requires (#109/#117/#122); ContainerCompletionPercent + ItemPlayCount with a library picker (#107/#108); JellyEmu game achievements (#115/#120); every shop cosmetic on the shareable card and the drawer card (#42/#119); toast position under Revamp (#116/#118); Pastel page leak, invisible borders/frames, stale stylesheet token, #97 diagnosticsFull notes for earlier versions, newest first.
Achievement Badges runs on Jellyfin 12, gains the first badges that can point at one specific thing in your library, learns to count games, and finally shows the bling you bought in the shop to everyone else. Drop-in upgrade from v2.3.x — no schema breakage or manual migration. Full notes in docs/release-notes/v2.4.0.md.
x.y.z.0 is the Jellyfin 10.11 build (.NET 9) and x.y.z.1 the Jellyfin 12 build (.NET 10). The plugin catalog picks the right one for your server; upgrading the server from 10.11 to 12 offers the .1 build as a plugin update afterwards.Authorization header 12 requires. Jellyfin 12 switches legacy authorization off, so the old X-Emby-Token alone answers 401 — which made every earlier version's page dead on a 12 server. Both headers are sent now; 10.11 accepts either.#/ routes, and the tab is named Achievements while the page is open.ContainerCompletionPercent (played items over total in one series, season, collection, playlist or album — 100 means you finished it) and ItemPlayCount (Jellyfin's own play count for a single movie, episode or track — 1 is "watched it", 3005 is the Childish Gambino badge).Request failed: 400 (GET users/…/summary)), and a failed reload is no longer reported as a failed reset or scan.Users/Me requests fired on the login page; the admin hero announced "v1.9.2 / ABI 10.11.0.0" forever; the shop's "Auto-unlock at N score" pill was clipped.Big thanks to @camarigor for the Jellyfin 12 build and web-client work (#117, #122), targeted badges (#108), game achievements (#120), the shop cosmetics on the card (#119), the toast fix (#118) and the dependency round (#128); to @Lyxon1337 for the Jellyfin 12 report; to @unknownTGG for the targeted-badges request; and to @TsunamicFlame for #42, #115, #116 and the field testing behind them.
A fast follow to 2.3.0, fixing the music feature it shipped and cleaning up the leaderboards. Drop-in upgrade from v2.3.0 / v2.2.x — no schema breakage.
Achievement Badges gains a social layer — see how your friends are doing, and share your own card — on top of a run of reliability fixes, several of which matter to anyone on the published v2.2.0 build. Drop-in upgrade from v2.2.x / v2.1.x / v2.0.x — no schema breakage or manual migration.
Hover a friend's name or avatar in the drawer for a summary card — rank tier, completion, score, best streak and equipped showcase. Click to pin a larger card that survives the pointer leaving, closes on Escape or an outside click, and is reachable by keyboard. It's backed by a public-summary endpoint that's privacy-gated to expose nothing the leaderboard doesn't: anyone opted out of being listed is equally invisible here.
Three server-rendered profile-card skins — Console (default), Metro, and Aurora Spine — each drawn with the card owner's own rank colour as the single accent. Every user picks their own skin in preferences; a card opened without an explicit style falls back to the owner's choice.
Credit genuine first watches the library scan can't prove on its own — media you deleted, and true rewatch counts — from Tracearr's public v2 API. A standalone sync button is made idempotent by a ledger, so pressing it twice credits nothing the second time. Configured admin-side with a URL and token; nothing is required on the Tracearr side. See Tracearr integration.
GetOrCreateProfile now normalises the user id first, so the friends and messaging paths can't create an empty profile beside the live one that later folds over it. Opening the friends panel was enough to trigger it.Every new interface string is translated across all 8 UI languages, and the admin user-picker strings were completed in the seven non-English locales for full key parity.
Big thanks to @camarigor for the friend profiles, Tracearr integration, the library and artist completion wiring, the watch-carry work, and the profile data-loss + gzip injection fixes.
This release makes Achievement Badges fit each user's Jellyfin layout and notification preferences while keeping the stock Achievements page available at all times. Drop-in upgrade from v2.1.x / v2.0.x — no schema breakage or manual migration.
A bug-fix patch resolving the outstanding reports from #24 and #36, plus a full builder-localization pass. Drop-in upgrade from v2.1.x / v2.0.x — no schema breakage; a one-time, ID-preserving custom-badge migration runs automatically.
index.html, the plugin used to think it was still patched and never re-injected. The guard now keys on the actual script tag, so a stripped page is always repaired.category.* keys, and every built-in category is now translated.Achievements expand beyond film & TV, and admins get real badge-authoring power. Drop-in upgrade from v2.0.x — no schema breakage, no data migration.
18 built-in music badges driven by real listening — total plays, listening hours, and unique albums / artists / genres / decades. Audio items now flow through the same playback-credit pipeline (80% real-listen gate) as films and episodes.
8 book badges — books completed, audiobook listening hours, and book series completed. An audiobook-counting policy lets admins choose whether audiobook plays count toward Books only (default), Music only, or Both.
How ebook completion is detected (important): Jellyfin has no "finished reading an ebook" signal — unlike audiobooks and music, which stream through a session and track listening time automatically, an ebook is text with no runtime. So an ebook counts once it's marked Played (the ✓ / "Mark as watched" toggle on the item). Simply reaching the last page in Jellyfin's reader does not auto-mark it, so book badges won't move until the book is marked played. This is a Jellyfin limitation, not something the plugin can detect on its own.
Define your own badges with compound AND/OR criteria across any metric — film, TV, music, books and more. Simple badges via the admin form; compound criteria, icons, and import/export via the API. Full guide + copy-paste templates: Custom badges.
On bare-metal Linux where /usr/share/jellyfin/web isn't writable, the on-disk UI injection used to fail silently. The plugin now detects the JavaScript Injector plugin and surfaces the exact script URLs to paste in. Details: Troubleshooting.
Anime is now detected from Genres and Tags, read from both the item and its parent Series, with admin-configurable libraries / genres / tags — fixing anime badges that never fired when the classification lived on the Series or in Tags.
Time-windowed badges (daily / weekly / monthly) are now skipped during the initial history scan, so they no longer false-unlock from lifetime totals. A new admin audit + cleanup tool finds and clears badges wrongly awarded by pre-v2.1.0 backfills (re-earnable organically afterward).
A globe dropdown on the admin page switches the UI language live and persists across reloads, and the entire admin page — including the Integrity and testing-tools sections — is now localized across all 8 languages.
Achievement Badges is built and maintained in my spare time. If it's useful to you and you'd like to support ongoing development, any of these means a lot:
Not expected, just appreciated. Contributions — issues, PRs, translation fixes — are equally valuable.
ContainerCompletionPercent and ItemPlayCount.UserOwnershipFilter.This project is released under the MIT License — one of the most permissive open-source licenses in common use.
Summary:
| You can | You must | You cannot |
|---|---|---|
| Use it on any Jellyfin server, personal or commercial | Keep the copyright + license notice in any redistribution | Hold the authors liable if something breaks |
| Fork and modify however you want | Claim the authors endorse your fork | |
| Redistribute modified or unmodified copies | ||
| Bundle it with proprietary software | ||
| Include it in a paid product |
If you just want to run the plugin, none of this affects you — install it and enjoy.
Pull requests are welcome. By submitting a contribution you agree that your changes will be licensed under the same MIT terms. Keep contributions focused (one feature or fix per PR) and include a short description of what changed and why in the PR body.
Jellyfin.Controller and Jellyfin.Model NuGet packages, which remain under their own GPL-2.0 license.See LICENSE for the full license text and third-party notices.
⭐ If you use this plugin, consider starring the repository.
HTML
49.4%
C#
34.9%
JavaScript
9.0%
CSS
6.7%
A Jellyfin plugin that adds achievement-style badges to user profiles based on viewing activity. Users unlock badges for milestones such as first watch, binge sessions and late-night viewing. Designed to gamify the Jellyfin experience and encourage engagement across libraries.
HTML
70
386 commits
updated Oct 1, 2026
█████╗ ██████╗██╗ ██╗██╗███████╗██╗ ██╗███████╗███╗ ███╗███████╗███╗ ██╗████████╗
██╔══██╗██╔════╝██║ ██║██║██╔════╝██║ ██║██╔════╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝
███████║██║ ███████║██║█████╗ ██║ ██║█████╗ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║
██╔══██║██║ ██╔══██║██║██╔══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║
██║ ██║╚██████╗██║ ██║██║███████╗ ╚████╔╝ ███████╗██║ ╚═╝ ██║███████╗██║ ╚████║ ██║
╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝
A full progression, gamification and achievement system for Jellyfin that rewards users based on real viewing activity. Think Xbox Gamerscore meets Letterboxd meets Steam profile customization, built natively into your media server.
Status: Active development — v2.4.1 is live: everything reported against 2.4.0, including two faults that made a correct install look broken on Jellyfin 12 (an uninstall that failed outright, and a page that only worked in a private window), plus a targeted badge cap you can raise to 1000, a top-center toast placement with a server default, an option to keep login-hidden accounts out of other users' views, and a fix for a collision that had been breaking your server's whole API document since 2.1.0. See What's new in v2.4.1. v2.4.0 brought Jellyfin 12 support (a dedicated 12.0 build in every release, and the web side reworked for the new layout and authorization), targeted badges that point at one series, season, collection, playlist, album or item, JellyEmu game achievements, and your shop bling on the shareable card. Before that, v2.3.1 fixed the music feature and cleaned up the leaderboards, and 2.3.0 brought friend profile cards, three shareable card skins (Console / Metro / Aurora), Tracearr history crediting, and the profile data-loss + gzip injection fixes every published build needs. Built on v2.2 "Your Screen, Your Rules", the v2.1 "Open Library" expansion, and v2.0 "Choose Your Loadout".
Over 200 built-in achievements across 35+ categories, a 10-tier rank ladder from Rookie to Immortal, a full score economy with combos, prestige, daily/weekly quests, a Score Shop with 70+ cosmetics, power-up consumables, a Friends drawer with messaging, plus admin power features like custom badges, seasonal challenges, webhook notifications, and a full audit log.
Designed to integrate cleanly with modern Jellyfin setups and themes like NetFin, ElegantFin, or StarTrack.
Everything reported against 2.4.0, including two faults that made a correct install look broken on Jellyfin 12, plus a cap the people who author targeted badges by the hundred can now raise. Drop-in upgrade from v2.4.0 — no schema change, no migration. Full notes in docs/release-notes/v2.4.1.md.
x.y.z.0. Jellyfin then read two different numbers for one install — the dashboard shows the assembly's version, uninstall and the image endpoint resolve through the manifest's — so on Jellyfin 12 the plugin installed as 2.4.0.1, displayed as 2.4.0.0, and could not be removed. Each package now stamps the version it ships as. Stuck on 2.4.0.1? Updating through the catalogue is enough; the restart that loads the new build deletes the old folder.304 Not Modified from the file on disk, and there is no page body for the plugin to inject into — so the browser keeps using a copy with no plugin in it, forever. Worst on servers with a read-only web directory, where the on-disk patch can never take either. The middleware now forces a full page while the on-disk patch has not taken. Nobody needs to clear their browsing data — a normal reload after updating is enough.<body>; they are now anchored to the plugin's own two surfaces, and a test refuses any new rule of that shape.Found while testing this release, not reported by anyone, and present since 2.1.0 on both Jellyfin 10.11 and 12: GET /api-docs/openapi.json answered 500 for the whole server.
Jellyfin builds one OpenAPI document from itself and every installed plugin, and each schema in it is keyed by the bare type name. This plugin's media-type enum was called MediaType, which is also the name of one of Jellyfin's own enums — and a duplicate name is not resolved, it throws, taking the entire document down. Swagger UI, the dashboard's API browser and every generator that reads the spec were all broken, by a plugin that started cleanly and logged nothing about it.
The enum is now BadgeMediaType. Its numbers and the property carrying it are unchanged, so there is nothing to migrate. With the collision gone the document builds, and this plugin's 126 routes appear in it for the first time. A test now walks the plugin's public types against Jellyfin's and fails on any shared name, because nothing else could catch this: the break was in the host's document, not in this plugin's.
If your API docs page is still broken after updating, another plugin is colliding the same way — the server log names both types.
Big thanks to @camarigor for the cap and the per-play rework (#130), the Custom Tabs repair (#132), the Revamp scoping (#134), the toast work (#137), the hidden-accounts option (#139), and for catching what the first pass at #140/#141 got wrong (#144); to @Roboatlas21 for diagnosing both Jellyfin 12 faults from his logs (#140, #141); to @Tschiyo for the cap request (#129); to @Borededdy for the blank Custom Tab (#131); to @clarjon1 for the Revamp leak (#133); to @Verdancy-Rin for the top-center placement (#136); and to @Digital-Yeti for the hidden-accounts request (#138).
??? until unlockedStandout sub-collections:
"anime", case-insensitive). Anime Curious / Anime Fan / Otaku / Anime Veteran / All-Otaku at 5 / 15 / 50 / 200 / 500 anime items.BaseItem.Studios:| Badge | Studio | Threshold |
|---|---|---|
| Spirited Away | Studio Ghibli | 5 |
| A24 Acolyte | A24 | 15 |
| It's Not TV | HBO | 25 |
| Netflix and Watch | Netflix | 50 |
| Auntie Beeb | BBC | 25 |
| House of Mouse | Disney | 50 |
Backfill credits these too — the watch-history backfill task passes
Studios,SeriesId,SeasonNumber,EpisodeNumberthrough to the achievement service, so historical episodes credit anime / studio / pilot badges. Time-of-day buckets only count NEW sessions.
/dashboard + /plugins pages and during media playback; reappears as soon as you leave either stateISessionManager, with a 15-minute grace window so casual browsing still counts as online (not just active playback)IUserDataManager with reflection-based LastPlayedDate lookupFriendsService.BuildFriendRow — can't be bypassed by client tamperingbody[data-ab-style="revamp"] so the same tokens apply globallyXbox-Guide-style chat built into the Friends drawer. No external service, no WebSockets, everything stored on your own server.
.exe renamed to .png. Click-to-zoom lightboxreadBy map so group receipts work tooUserAchievementProfile.Preferences.BlockedUsers[Authorize]-gatedFriendsSimpleMode treats the whole server as one friend list for messaging too (with the #138 option on, only between users who can see each other)messages.json + attachments.json + attachments/<id>.<ext> on disk under plugins/configurations/achievementbadges/. Atomic writes via temp file + File.Move so a crash mid-send can't corrupt the store. Messages survive server restarts/badges/rarity-stats endpoint with a 5-minute server-side cache so it doesn't re-scan every profile on every page loadLoad() walks primary badges.json → .bak → .recovery before giving up. A flaky primary file with a clean backup recovers silently.bak is rotated on every successful save.recovery captures in-session state when the primary is quarantined, so restart never feels like a fresh resetLastLoadSummary exposed via the /test endpoint so admins can see recovery activitybadges.json.corrupt-<timestamp> (not deleted) for manual recovery#/achievements with the new Loadout tab (v2.0) for managing power-ups, shop, and cosmetics/Plugins/AchievementBadges/users/{id}/profile-card, in Console (default), Metro, or Aurora Spine, each drawn with the owner's rank colour as the accent. Users pick their skin in preferences; ?style= overrides per link, and a request with no style falls back to the owner's choiceab-style-pref localStorage):
01 / MY BADGES, 02 / QUESTS…), control-panel filter strip, ambient drift orb, film grain overlay, day-streak pulse, rank-name shimmer, full page entrance cascadeA gear icon on the achievements page opens a full settings panel with auto-save:
BadgeLocalizer) so both UI chrome and badge titles on the leaderboard / showcase / admin grid / equipped showcase localise together/Plugins/AchievementBadges/custom-badges API. See Custom badgesWebhookSigningSecret, every outbound POST carries X-AchievementBadges-Signature: sha256=<hex> + X-AchievementBadges-Timestamp: <unix>. Receivers verify with HMAC(secret, timestamp + "." + raw_body) and reject stale timestamps to prevent replay. Same envelope as Stripe and GitHub. Empty secret = legacy unsigned behaviour (backward compatible).AdminAuditLogFilter writes an entry on every RequiresElevation action — answer "who unlocked X for whom last Tuesday" without grepping runtime logs.SuspiciousRatePerHour audit flag (default 30/h, configurable) for soft anti-abuseILibraryManager.GetPeople() for directors/actorsAdded in v2.1.0 "Open Library". Admin-only. All endpoints require admin elevation.
Define your own badges with compound AND/OR criteria the built-in catalog can't express. The plugin config page (Dashboard → Plugins → Achievement Badges → Custom Badges) has a form for simple badges (single metric + threshold). For compound criteria, icon control, and import/export, use the API below.
A badge's criteria is a tree. A node is either:
{ "metric": "<Metric>", "threshold": <int>, "metricParameter": "<optional>" } — true when the metric value ≥ threshold.{ "operator": "And" | "Or", "children": [ <node>, ... ] } — true when And(all)/Or(any) of children are true.Limits: max depth 5, max 64 nodes per badge, max 500 badges per instance.
Added in v2.4.0, from #107.
Two metrics point at one specific thing in your library rather than at an aggregate:
ContainerCompletionPercent — played items over total items of one series, season, collection, playlist or album. Threshold 100 means "you finished it".ItemPlayCount — how many times one specific item has been played. Threshold 1 is "you watched this movie"; larger thresholds are for the track you keep going back to.Both take their target in metricParameter, formatted "{guid}|{name}". The config page has a library picker that writes it for you. Both halves are stored on purpose: the GUID survives a rename, and the name survives an item being deleted and re-added, so the badge re-resolves itself and rewrites the GUID.
For an arbitrary group of episodes, such as one story arc of a long-running show, make a Jellyfin collection and point a ContainerCompletionPercent badge at it. That keeps the grouping in a native Jellyfin feature instead of a badge-editor episode list.
Targeted badges are evaluated against your existing history, so a badge you author today unlocks immediately for anyone who already finished the target, with no scan needed. How many distinct targets all badges may reference is set on the config page, under Custom badges, as Targeted badge cap (1 to 1000, default 50). The page shows how many targets the enabled badges reference against the cap and names the ones past it, which are not computed. Every play checks each target with a set lookup: series, seasons and folders against the played item's ancestors, collections and playlists against a cached member list that is refreshed when the container changes, so hundreds of targets of any kind are fine on any server.
// POST /Plugins/AchievementBadges/custom-badges
{
"name": "Straw Hat",
"description": "Finish One Piece: Alabasta.",
"rarity": "Epic",
"media": "TV",
"criteria": {
"metric": "ContainerCompletionPercent",
"metricParameter": "8f4e2a1b9c3d4e5f6a7b8c9d0e1f2a3b|One Piece: Alabasta",
"threshold": 100
}
}
Added in v2.4.0, from #115.
Games played through JellyEmu earn achievements with no setup on the JellyEmu side. JellyEmu reports every game session to Jellyfin as a playback session and tags each game with JellyEmu, Game and its platform; this plugin reads those and measures each session as the smaller of what the emulator reported and what the plugin saw on its own clock, with a floor (MinGameSessionSeconds, default 60) so a misclick is not a play and a cap (MaxGameSessionMinutes, default 360) so a tab left open is not a night of play.
Built in, under the Games category: session ladders (1, 25, 100), distinct games (10, 50), hours (10, 100) and platforms (3, 8). For custom badges, the builder offers:
GamePlatformGames with the platform tag as parameter (SNES, SegaGenesis, PlayStation; the spelling is JellyEmu's, matched without regard to case): "play 10 Sega Genesis games".GameStudioGames with the developer or publisher as parameter: "play 5 games by Capcom".GameHours with a specific game as target, chosen in the picker like any other targeted badge: "30 hours in this game".Two limits worth knowing: play time counts from the moment this version is installed, because games carry no played flag to rebuild history from; and nothing here reads RetroAchievements, whose unlocks cannot happen in JellyEmu's browser emulator (see the discussion on #115).
// POST /Plugins/AchievementBadges/custom-badges
{
"name": "Cinephile",
"description": "Watch 100 films.",
"rarity": "Rare", // Common | Uncommon | Rare | Epic | Legendary | Mythic
"media": "Film", // Film | TV | Music | Book | Anime | Multi
"iconUrl": "", // optional external https URL; blank = default trophy
"enabled": true,
"criteria": { "metric": "MoviesWatched", "threshold": 100 }
}
{
"name": "Renaissance Viewer",
"description": "Watch 100 films AND listen to 50 music tracks.",
"rarity": "Epic",
"media": "Multi",
"enabled": true,
"criteria": {
"operator": "And",
"children": [
{ "metric": "MoviesWatched", "threshold": 100 },
{ "metric": "MusicPlaysTotal", "threshold": 50 }
]
}
}
Nested example — "(finish 5 horror series OR 10h of audiobooks) AND watch 25 anime items":
{
"name": "Eclectic",
"description": "Genre-spanning dedication.",
"rarity": "Legendary",
"media": "Multi",
"enabled": true,
"criteria": {
"operator": "And",
"children": [
{
"operator": "Or",
"children": [
{ "metric": "SeriesCompleted", "threshold": 5, "metricParameter": "Horror" },
{ "metric": "AudiobookListeningHours", "threshold": 10 }
]
},
{ "metric": "AnimeItemsWatched", "threshold": 25 }
]
}
}
| Method | Route | Purpose |
|---|---|---|
GET | /Plugins/AchievementBadges/custom-badges | List all |
GET | /Plugins/AchievementBadges/custom-badges/{id} | Fetch one |
POST | /Plugins/AchievementBadges/custom-badges | Create (fresh id assigned) |
PUT | /Plugins/AchievementBadges/custom-badges/{id} | Update |
DELETE | /Plugins/AchievementBadges/custom-badges/{id} | Delete |
GET | /Plugins/AchievementBadges/custom-badges/export | Export all as JSON |
POST | /Plugins/AchievementBadges/custom-badges/import | Bulk import (fresh ids) |
GET | /Plugins/AchievementBadges/custom-badges/targets | Targeted badge cap: configured value, applied cap, bounds, targets observed, and the names past it (#129) |
POST | /Plugins/AchievementBadges/custom-badges/targets | Set the cap (1-1000, clamped); answers with the fresh summary (#129) |
Every value AchievementMetric exposes, 71 of them. Entries marked * accept a metricParameter.
Watching — TotalItemsWatched, MoviesWatched, SeriesCompleted, LongSeriesCompleted, VeryLongSeriesCompleted, RewatchCount, ShortItemsWatched, LongestItemMinutes, TotalMinutesWatched, SeriesSampledOnly, SeriesBingedAfterPilot
Streaks and days — DaysWatched, CurrentWatchStreak, BestWatchStreak, DaysLoggedIn, CurrentLoginStreak, BestLoginStreak, MaxEpisodesInSingleDay, MaxMoviesInSingleDay, MaxMinutesInSingleDay
Time of day — LateNightSessions, EarlyMorningSessions, AfternoonSessions, PrimeTimeSessions, WeekendSessions
Variety — UniqueGenresWatched, UniqueCountriesWatched, UniqueLanguagesWatched, UniqueDecadesWatched, UniqueLibrariesVisited, TopDirectorCount, TopActorCount, MaxLibraryItemCount
Completion — LibraryCompletionPercent *, LibrariesAt100Percent, BadgesUnlockedPercent, ArtistCompletionPercent *
Music — MusicPlaysTotal, MusicListeningHours, UniqueMusicAlbums, UniqueMusicArtists, UniqueMusicGenres, UniqueMusicDecades, MusicGenrePlays *, MusicGenreListeningHours *
Books — BooksCompleted, AudiobookListeningHours, UniqueBookSeriesCompleted
Score and prestige — PrestigeLevel, LifetimeScore, BestComboCount
Parameterized by name — GenreItemsWatched *, PersonItemsWatched *, StudioItemsWatched *, DecadeItemsWatched *, DayOfWeekItemsWatched *
Holidays and dates — WatchedOnChristmas, WatchedOnNewYear, WatchedOnHalloween, WatchedOnEid, WatchedOnValentines, WatchedOnEaster, WatchedOnLunarNewYear, WatchedOnDiwali, WatchedOnThanksgiving, WatchedOnIndependenceDayUS, WatchedOnBonfireNight, WatchedOnBoxingDay, WatchedOnMothersDay, WatchedOnFathersDay
Anime — AnimeItemsWatched
Parameterized metrics match their parameter case-insensitively. GenreItemsWatched with metricParameter of "horror" counts only horror items; LibraryCompletionPercent with "Movies" reads that one library, and without a parameter it reads whichever library the user is furthest through. The same applies to ArtistCompletionPercent, which without a parameter reads the user's best artist.
Optional. Set Tracearr URL and Tracearr API token in the plugin settings, generate the token in Tracearr under Settings, and a watch history scan will also read your Tracearr history. Leave either field empty and nothing changes.
It exists because a library scan has two blind spots it cannot fix on its own:
IsPlayed on items that still exist, so a film you watched and later removed is invisible to it. Tracearr recorded the play when it happened and still has it.IsPlayed is a boolean, so no number of viewings can produce a rewatch count. That leaves the Rewatch badges unreachable for anyone who installed the plugin after they had already been watching.Only plays Tracearr considers finished are credited, each on the date it happened rather than today, so an old viewing cannot manufacture a streak. A play the library already accounted for is not counted again: the first viewing of a known item is skipped, and only repeats become rewatches.
Nothing is required on the Tracearr side. It uses the existing public v2 API.
user-60-per-min) on every controller route, with stricter overrides preserved on cooldown routes. Static-asset routes (CSS / JS / translations / video bgs) opt out via [DisableRateLimiting] so multi-user households behind a shared NAT don't collectively exhaust the limit.X-Content-Type-Options + X-Frame-Options + Referrer-Policy + Permissions-Policy on the anonymous profile-card endpoint<animate> / <set> SMIL elements that could mutate attributes mid-render[1, 1000] at controllerMessagingService + AchievementBadgeService are IDisposable so the debounced-save Timer is released on plugin reload<use href> rejectiondotnet list package --vulnerable + gitleaks on every push, every PR, and weekly cronSECURITY.md with full threat model, trust boundaries, defences-in-place inventory, continuous verification matrix, disclosure SLA, and safe-harbour for researcherscosign verify-blob or gh attestation verifyRestorePackagesWithLockFile, CI runs in locked mode so drift fails fastSave() in AchievementBadgeService and MessagingService — coalesces back-to-back disk writes from playback and messaging hot paths into one flush per 1.5sFriendsService.LastWatched cache — 90s TTL, invalidated on play, eliminates per-friend 50-item DB query on every friends-list callWriteIndented = false on production stores (~50% smaller badges.json)client-script + asset routes — one read per processCache-Control: public, max-age=86400, immutable on assets with version-only cache busting; video backgrounds use HTTP Range so the browser only streams the bytes it needs to start playingCache-Control: no-cache, must-revalidate on translations (v2.0) so users always get fresh strings after a plugin update without hard-refreshinghttps://raw.githubusercontent.com/ZL154/AchievementBadges_for_Jellyfin/main/manifest.json
#/achievements — and try the Loadout tabx.y.z.0 is the 10.11 build, x.y.z.1 is the 12 build. Installing by hand? Take the zip whose 4th version segment matches your server. Upgrading the server from 10.11 to 12 will offer the .1 build as a plugin update afterwards.item.People will stay empty if your library doesn't have people scrapeddata-ab-repaired-panel="true". Other Custom Tabs tabs are still empty on such a server, since only this one is ours to repair.| Feature | Depends on |
|---|---|
| Sidebar + header injection | Nothing (works standalone) |
| Custom Tabs page host | Custom Tabs plugin + enable the integration, save, and restart Jellyfin |
| Plugin Pages page host | Plugin Pages plugin + Jellyfin restart after enabling integration |
| Watch history backfill | Played flag on items (Jellyfin default) |
| Genre badges | Items with Genres metadata |
| Director/Actor badges | Items with People metadata (TMDb/OMDb scrape) |
| Era / decade badges | Items with ProductionYear metadata |
| Country badges | Items with ProductionLocations metadata |
| Language badges | Items with OriginalLanguage metadata |
| Runtime badges | Items with RunTimeTicks populated |
| Library completion | At least one library folder with items |
| Webhook notifications | A webhook URL (Discord, Slack, or generic) |
| Animated video backgrounds | Modern browser with H.264 + <video> autoplay-muted support (all evergreen browsers) |
The plugin injects its scripts into Jellyfin's index.html at startup. If the web directory isn't writable, the injection fails silently and no UI loads (no sidebar entry, no toasts, no achievements page).
Diagnose: visit https://your-server/Plugins/AchievementBadges/test — the JSON response shows:
DiagIndexFound — whether index.html was locatedDiagIndexPatched — whether the script tags were successfully writtenDiagLastError — the exact error if patching failed (usually Unauthorized: Access denied)Common cause: on Docker or Linux installs, Jellyfin doesn't have write access to /usr/share/jellyfin/web/. Fix by granting write permission:
# Docker: run inside the container
chmod -R a+w /usr/share/jellyfin/web/
# Systemd: fix ownership
sudo chown -R jellyfin:jellyfin /usr/share/jellyfin/web/
Then restart Jellyfin. The plugin will patch index.html on the next startup.
Can't (or won't) make the web dir writable? Use the JavaScript Injector plugin (v2.1.0, #26). On bare-metal Linux where /usr/share/jellyfin/web is owned by root, install the JavaScript Injector plugin. On the next startup, when the on-disk patch fails, Achievement Badges detects that plugin and writes the exact script URLs you need into the /Plugins/AchievementBadges/test diagnostics (DiagJsInjectorGuidance) and the server log. Paste these three into JS Injector's settings — one per entry:
/Plugins/AchievementBadges/client-script/sidebar
/Plugins/AchievementBadges/client-script/standalone
/Plugins/AchievementBadges/client-script/enhance
Restart Jellyfin and the UI loads through JS Injector — no writable web directory required. (The plugin doesn't call JS Injector's API directly, so this keeps working across JS Injector versions; the on-disk patch remains the default when the dir is writable.)
Still broken? The plugin has a middleware fallback that rewrites index.html at runtime (no disk write needed). If that's also failing, check whether a reverse proxy (nginx/Caddy) is caching a stale index.html from before the plugin was installed. Clear the proxy cache or restart it.
/nix/store)NixOS serves Jellyfin's web files from the immutable Nix store, so neither the disk patcher nor the middleware can modify index.html. Use a NixOS overlay to inject the script tags at build time:
nixpkgs.overlays = [
(final: prev: {
jellyfin-web = prev.jellyfin-web.overrideAttrs (finalAttrs: previousAttrs: {
installPhase = ''
runHook preInstall
sed -i 's#</body>#<!-- achievementbadges-bootstrap --><script src="/Plugins/AchievementBadges/client-script/sidebar"></script><script src="/Plugins/AchievementBadges/client-script/standalone" defer></script><script src="/Plugins/AchievementBadges/client-script/enhance" defer></script></body>#' dist/index.html
mkdir -p $out/share
cp -a dist $out/share/jellyfin-web
runHook postInstall
'';
});
})
];
The plugin DLL serves the JS files from embedded resources — the three <script> tags just tell the browser to load them. Rebuild your NixOS config after adding the overlay and restart Jellyfin.
/api-docs/openapi.json returns 500)Jellyfin builds one OpenAPI document from the server and every installed plugin, and each type in it is keyed by its bare class name. If two loaded types want the same name the document does not pick one — it fails, and the whole document 500s. Badges keep working, nothing is logged by the plugin, and the only visible symptom is the API docs page.
This plugin caused it from 2.1.0 to 2.4.0 (MediaType, colliding with Jellyfin's own) and no longer does. If the page is still broken on v2.4.1+, another plugin is colliding — the server log names both types:
Can't use schemaId "$PluginConfiguration" for type "$Jellyfin.Plugin.A.PluginConfiguration".
The same schemaId is already used for type "$Jellyfin.Plugin.B.Configuration.PluginConfiguration"
PluginConfiguration is the usual culprit, since most plugins name their config class that. Report it to whichever plugin appears there; there is nothing to change on your server.
The 4 HD video cosmetics need browser MP4/H.264 + autoplay-muted support. Every evergreen browser handles this fine, but if you see a static page where there should be motion:
chrome://flags/#autoplay-policy)muted + playsinline so it should autoplay everywhere; if your browser still blocks it, click anywhere on the page onceGET /Plugins/AchievementBadges/users/{userId} — full badge list
GET /Plugins/AchievementBadges/users/{userId}/summary — unlocked/total/score
GET /Plugins/AchievementBadges/users/{userId}/rank — rank tier + next tier
GET /Plugins/AchievementBadges/users/{userId}/equipped — equipped badges
POST /Plugins/AchievementBadges/users/{userId}/equipped/{badgeId}
DELETE /Plugins/AchievementBadges/users/{userId}/equipped/{badgeId}
GET /Plugins/AchievementBadges/users/{userId}/recap?period=week|month|year
GET /Plugins/AchievementBadges/users/{userId}/watch-calendar?days=90
GET /Plugins/AchievementBadges/users/{userId}/quests — daily + weekly + reroll state
GET /Plugins/AchievementBadges/users/{userId}/bank — score bank + prestige
POST /Plugins/AchievementBadges/users/{userId}/prestige
POST /Plugins/AchievementBadges/users/{userId}/buy-badge/{badgeId}
POST /Plugins/AchievementBadges/users/{userId}/gift/{toUserId}?amount=N
GET /Plugins/AchievementBadges/users/{userId}/chase/{badgeId} — items to watch to finish a badge
GET /Plugins/AchievementBadges/users/{userId}/recommendations — top 3 closest-to-unlock
GET /Plugins/AchievementBadges/users/{userId}/profile-card — HTML profile card
GET /Plugins/AchievementBadges/users/{userId}/unlocks-since?since=ISO&deviceId=ID
GET /Plugins/AchievementBadges/users/{userId}/library-completion
POST /Plugins/AchievementBadges/users/{userId}/login-ping
GET /Plugins/AchievementBadges/users/{userId}/directory (#138) users this user may see, for friend search and compare
GET /Plugins/AchievementBadges/leaderboard?limit=10
GET /Plugins/AchievementBadges/leaderboard/{category}?limit=10 — score|movies|episodes|hours|streak|series
GET /Plugins/AchievementBadges/embedded-page — Plugin Pages host fragment
GET /Plugins/AchievementBadges/server/stats
GET /Plugins/AchievementBadges/users/{userId}/powerups — inventory + active state + ScoreBank
POST /Plugins/AchievementBadges/users/{userId}/powerups/use/{type} — XpBoost | DoubleCredit (StreakFreeze auto-only)
GET /Plugins/AchievementBadges/shop/catalog — full shop catalog
POST /Plugins/AchievementBadges/users/{userId}/shop/purchase — body: {"ItemId":"..."}
GET /Plugins/AchievementBadges/users/{userId}/cosmetics — owned + equipped state + LifetimeScore
POST /Plugins/AchievementBadges/users/{userId}/cosmetics/equip — body: {"CosmeticId":"..."}
POST /Plugins/AchievementBadges/users/{userId}/cosmetics/unequip?kind=ProfileTheme|BadgeFrame|RankTitle|Avatar|Background|ProfileBorder
POST /Plugins/AchievementBadges/users/{userId}/quests/daily/reroll
POST /Plugins/AchievementBadges/users/{userId}/quests/weekly/reroll
GET /Plugins/AchievementBadges/asset/{name} — animated background mp4s (range-enabled)
RequiresElevation)POST /Plugins/AchievementBadges/users/{userId}/backfill
POST /Plugins/AchievementBadges/backfill-all
POST /Plugins/AchievementBadges/users/{userId}/reset
POST /Plugins/AchievementBadges/users/{userId}/reset-badge/{badgeId}
POST /Plugins/AchievementBadges/users/{userId}/library-completion/recompute
POST /Plugins/AchievementBadges/users/{userId}/import
GET /Plugins/AchievementBadges/users/{userId}/export
GET/POST /Plugins/AchievementBadges/admin/badge-catalog — enable/disable badges
GET/POST /Plugins/AchievementBadges/admin/custom-badges — custom badge definitions
GET/POST /Plugins/AchievementBadges/admin/challenges — seasonal challenges
GET /Plugins/AchievementBadges/admin/challenge-templates — one-click templates
GET/POST /Plugins/AchievementBadges/admin/webhook — webhook config (incl. HMAC secret)
GET/POST /Plugins/AchievementBadges/admin/ui-features — UI feature toggles
GET /Plugins/AchievementBadges/admin/audit-log?limit=200
POST /Plugins/AchievementBadges/admin/users/{userId}/inject-counters
GET/POST /Plugins/AchievementBadges/admin/feature-config — feature kill switches + admin controls
DELETE /Plugins/AchievementBadges/admin/users/{userId}/reset — wipe user's achievement progress
POST /Plugins/AchievementBadges/admin/users/{userId}/test/inject-playbacks — verify integrity caps end-to-end (v1.9.8)
DELETE /Plugins/AchievementBadges/admin/users/{userId}/badges/{badgeId} — manual badge revoke (v1.9.8)
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-score — v2.0 testing
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-powerup/{type} — v2.0 testing
POST /Plugins/AchievementBadges/admin/users/{userId}/grant-cosmetic — v2.0 testing
POST /Plugins/AchievementBadges/admin/backfill-milestones — v2.0: retroactively unlock title milestones across all profiles
Pops up during playback when a badge unlocks. Xbox circle pops in with pulse rings, expands into a banner, trophy rotates (or diamond spritesheet for rare unlocks), text slides up, shimmer sweeps across, then everything collapses. Per-rarity color, glow, and sound.
Live demo: download
achievement-combined.html(regular) orachievement-combined-rare.html(rare with diamond) and open in a browser. Click anywhere to start the sound. Loops every 10.5s.
The full profile view, shown in the Jellyfin sidebar. Rank progress bar, day streak, score, completion percentage, and the tab bar (My Badges, Quests, Recap, Leaderboard, Compare, Activity, Wrapped, Stats, and the new Loadout tab from v2.0).
200+ badges across 35+ categories, each with live progress bars and an Equip button. Unlocked badges show in color with a green status tag; locked badges dim. Rarity-colored borders let you scan the grid visually.
Genre specialist badges and streak extremes across all six rarity colors — Common, Uncommon, Rare, Epic, Legendary, Mythic.
Rotating quests from a template pool. Everyone on the server gets the same daily + weekly challenges so people can race each other. Completing them pays into the score bank. In v2.0, each section header has a reroll button.
Weekly, monthly and yearly breakdowns of what you've actually watched — total items, active days, top genres, top directors, and top actors.
Spotify-style end-of-year recap with a big gradient hero, "your numbers" (movies, episodes, active days, best streak, total hours), "your highlights" (biggest day, biggest month, most-watched weekday) and "your favorites" (top genres/directors/actors).
Podium view for the top 3, ranked list below. Switch categories with the tab row: Score, Movies, Episodes, Hours, Best Streak, Series. (Usernames blurred as User 1–10.)
Head-to-head profile comparison between any two users on your server. Gradient bars show the relative values on 12 core metrics, and the bottom pills break down how many badges each user has that the other doesn't. (Usernames blurred as User 1 / User 2.)
GitHub-style year calendar of your watch activity. Current streak, best ever, and total active days at a glance.
90-day heatmap grid, colored by daily watch volume. Click the range button to switch between 30/90/180/365 days.
SVG spider chart showing your top-5 genre distribution, and a 24-hour polar chart of when you actually watch.
Every admin section is collapsible so the page stays clean: webhook notifications, toast preview, UI feature toggles, visual badge editor, challenge templates, audit log, progress injection, custom badges, seasonal challenges, per-badge enable/disable, and the v2.0 testing tools (grant score / power-up / cosmetic, backfill milestones).
Scan watch history, reset badges, scan all users, or load a specific user ID — all from one row under the Advanced options toggle.
Auto-injected into the Jellyfin nav menu — no theme changes required.
Full per-version notes and signed binaries live on the GitHub Releases page:
➡ github.com/ZL154/AchievementBadges_for_Jellyfin/releases
Highlights:
x.y.z.0 for 10.11, x.y.z.1 for 12) with the entry in 12's avatar menu and the Authorization header 12 requires (#109/#117/#122); ContainerCompletionPercent + ItemPlayCount with a library picker (#107/#108); JellyEmu game achievements (#115/#120); every shop cosmetic on the shareable card and the drawer card (#42/#119); toast position under Revamp (#116/#118); Pastel page leak, invisible borders/frames, stale stylesheet token, #97 diagnosticsFull notes for earlier versions, newest first.
Achievement Badges runs on Jellyfin 12, gains the first badges that can point at one specific thing in your library, learns to count games, and finally shows the bling you bought in the shop to everyone else. Drop-in upgrade from v2.3.x — no schema breakage or manual migration. Full notes in docs/release-notes/v2.4.0.md.
x.y.z.0 is the Jellyfin 10.11 build (.NET 9) and x.y.z.1 the Jellyfin 12 build (.NET 10). The plugin catalog picks the right one for your server; upgrading the server from 10.11 to 12 offers the .1 build as a plugin update afterwards.Authorization header 12 requires. Jellyfin 12 switches legacy authorization off, so the old X-Emby-Token alone answers 401 — which made every earlier version's page dead on a 12 server. Both headers are sent now; 10.11 accepts either.#/ routes, and the tab is named Achievements while the page is open.ContainerCompletionPercent (played items over total in one series, season, collection, playlist or album — 100 means you finished it) and ItemPlayCount (Jellyfin's own play count for a single movie, episode or track — 1 is "watched it", 3005 is the Childish Gambino badge).Request failed: 400 (GET users/…/summary)), and a failed reload is no longer reported as a failed reset or scan.Users/Me requests fired on the login page; the admin hero announced "v1.9.2 / ABI 10.11.0.0" forever; the shop's "Auto-unlock at N score" pill was clipped.Big thanks to @camarigor for the Jellyfin 12 build and web-client work (#117, #122), targeted badges (#108), game achievements (#120), the shop cosmetics on the card (#119), the toast fix (#118) and the dependency round (#128); to @Lyxon1337 for the Jellyfin 12 report; to @unknownTGG for the targeted-badges request; and to @TsunamicFlame for #42, #115, #116 and the field testing behind them.
A fast follow to 2.3.0, fixing the music feature it shipped and cleaning up the leaderboards. Drop-in upgrade from v2.3.0 / v2.2.x — no schema breakage.
Achievement Badges gains a social layer — see how your friends are doing, and share your own card — on top of a run of reliability fixes, several of which matter to anyone on the published v2.2.0 build. Drop-in upgrade from v2.2.x / v2.1.x / v2.0.x — no schema breakage or manual migration.
Hover a friend's name or avatar in the drawer for a summary card — rank tier, completion, score, best streak and equipped showcase. Click to pin a larger card that survives the pointer leaving, closes on Escape or an outside click, and is reachable by keyboard. It's backed by a public-summary endpoint that's privacy-gated to expose nothing the leaderboard doesn't: anyone opted out of being listed is equally invisible here.
Three server-rendered profile-card skins — Console (default), Metro, and Aurora Spine — each drawn with the card owner's own rank colour as the single accent. Every user picks their own skin in preferences; a card opened without an explicit style falls back to the owner's choice.
Credit genuine first watches the library scan can't prove on its own — media you deleted, and true rewatch counts — from Tracearr's public v2 API. A standalone sync button is made idempotent by a ledger, so pressing it twice credits nothing the second time. Configured admin-side with a URL and token; nothing is required on the Tracearr side. See Tracearr integration.
GetOrCreateProfile now normalises the user id first, so the friends and messaging paths can't create an empty profile beside the live one that later folds over it. Opening the friends panel was enough to trigger it.Every new interface string is translated across all 8 UI languages, and the admin user-picker strings were completed in the seven non-English locales for full key parity.
Big thanks to @camarigor for the friend profiles, Tracearr integration, the library and artist completion wiring, the watch-carry work, and the profile data-loss + gzip injection fixes.
This release makes Achievement Badges fit each user's Jellyfin layout and notification preferences while keeping the stock Achievements page available at all times. Drop-in upgrade from v2.1.x / v2.0.x — no schema breakage or manual migration.
A bug-fix patch resolving the outstanding reports from #24 and #36, plus a full builder-localization pass. Drop-in upgrade from v2.1.x / v2.0.x — no schema breakage; a one-time, ID-preserving custom-badge migration runs automatically.
index.html, the plugin used to think it was still patched and never re-injected. The guard now keys on the actual script tag, so a stripped page is always repaired.category.* keys, and every built-in category is now translated.Achievements expand beyond film & TV, and admins get real badge-authoring power. Drop-in upgrade from v2.0.x — no schema breakage, no data migration.
18 built-in music badges driven by real listening — total plays, listening hours, and unique albums / artists / genres / decades. Audio items now flow through the same playback-credit pipeline (80% real-listen gate) as films and episodes.
8 book badges — books completed, audiobook listening hours, and book series completed. An audiobook-counting policy lets admins choose whether audiobook plays count toward Books only (default), Music only, or Both.
How ebook completion is detected (important): Jellyfin has no "finished reading an ebook" signal — unlike audiobooks and music, which stream through a session and track listening time automatically, an ebook is text with no runtime. So an ebook counts once it's marked Played (the ✓ / "Mark as watched" toggle on the item). Simply reaching the last page in Jellyfin's reader does not auto-mark it, so book badges won't move until the book is marked played. This is a Jellyfin limitation, not something the plugin can detect on its own.
Define your own badges with compound AND/OR criteria across any metric — film, TV, music, books and more. Simple badges via the admin form; compound criteria, icons, and import/export via the API. Full guide + copy-paste templates: Custom badges.
On bare-metal Linux where /usr/share/jellyfin/web isn't writable, the on-disk UI injection used to fail silently. The plugin now detects the JavaScript Injector plugin and surfaces the exact script URLs to paste in. Details: Troubleshooting.
Anime is now detected from Genres and Tags, read from both the item and its parent Series, with admin-configurable libraries / genres / tags — fixing anime badges that never fired when the classification lived on the Series or in Tags.
Time-windowed badges (daily / weekly / monthly) are now skipped during the initial history scan, so they no longer false-unlock from lifetime totals. A new admin audit + cleanup tool finds and clears badges wrongly awarded by pre-v2.1.0 backfills (re-earnable organically afterward).
A globe dropdown on the admin page switches the UI language live and persists across reloads, and the entire admin page — including the Integrity and testing-tools sections — is now localized across all 8 languages.
Achievement Badges is built and maintained in my spare time. If it's useful to you and you'd like to support ongoing development, any of these means a lot:
Not expected, just appreciated. Contributions — issues, PRs, translation fixes — are equally valuable.
ContainerCompletionPercent and ItemPlayCount.UserOwnershipFilter.This project is released under the MIT License — one of the most permissive open-source licenses in common use.
Summary:
| You can | You must | You cannot |
|---|---|---|
| Use it on any Jellyfin server, personal or commercial | Keep the copyright + license notice in any redistribution | Hold the authors liable if something breaks |
| Fork and modify however you want | Claim the authors endorse your fork | |
| Redistribute modified or unmodified copies | ||
| Bundle it with proprietary software | ||
| Include it in a paid product |
If you just want to run the plugin, none of this affects you — install it and enjoy.
Pull requests are welcome. By submitting a contribution you agree that your changes will be licensed under the same MIT terms. Keep contributions focused (one feature or fix per PR) and include a short description of what changed and why in the PR body.
Jellyfin.Controller and Jellyfin.Model NuGet packages, which remain under their own GPL-2.0 license.See LICENSE for the full license text and third-party notices.
⭐ If you use this plugin, consider starring the repository.
HTML
49.4%
C#
34.9%
JavaScript
9.0%
CSS
6.7%