v1.9.8
Integrity release. No new features for end-users; the plugin's behaviour for legitimate watchers is unchanged. What this release closes is a class of playback-credit exploits that let a user game badges without actually watching.
The bug
PlaybackCompletionTracker trusted Jellyfin's PlayedToCompletion flag and used a position/runtime ratio to decide whether to credit a play. That meant three things were enough to mark an item as "watched" for badge purposes:
- Clicking "Mark as played" on a movie's
…menu (PlayedToCompletion firestrue, we credited at 100%). - Seeking to the end of the timeline and stopping (position/runtime hit 90%+, we credited).
- Spam play/stop clicks during which Jellyfin's player asynchronously sets PlayedToCompletion.
None of these required actually watching the item.
The fix
The tracker now maintains per-session accumulated play ticks. On every PlaybackProgress event it adds the position delta to a per-session counter — but only if the delta is between 0 and ~15 seconds. Seeks (large positive jumps) and rewinds (negative deltas) don't accumulate. On PlaybackStopped, the completion percent is computed from those accumulated ticks instead of the raw position/runtime ratio, then put through the existing 80% gate. PlayedToCompletion is no longer trusted.
Effect:
| Scenario | v1.9.7 | v1.9.8 |
|---|---|---|
| Mark-as-played, no playback | credited | rejected |
| Start playback, seek to 95%, stop | credited | rejected |
| Spam play+stop on a fresh item | credited | rejected |
| Real watch >=80% of runtime | credited | credited |
| 2x playback speed, real watch | credited | credited |
| Pause / resume mid-session | credited | credited |
A reference log line surfaces when the discrepancy fires - admins debugging legitimate "why didn't this credit?" reports can grep for the message and see exactly what the tick math reported.
Defense in depth
The credit-gate fix is the primary one. v1.9.8 also adds three layers of defense around it:
- 60s minimum runtime - items shorter than 60 seconds (Projectionist prerolls, Intros plugins, bumpers) get rejected up front. They live in the Movie/Episode library type so without this filter every preroll play silently credited +1 movie.
- Daily credit cap - soft cap on items credited per user per UTC day. Default 200 (real users never hit it; spammers do). Configurable in admin UI. Audit-logged as
rate_cap_blockedthe first time per day each user hits the cap. - Suspicious-rate audit flag - if a user crosses 30 credits in a rolling 60-minute window (configurable), an audit-log entry with type
suspicious_ratefires. Doesn't block - just makes the pattern visible. Throttled to one entry per user per hour.
Both controls are on by default. Both are configurable in the admin UI.
Admin tools
A new "Integrity & anti-abuse" section appears in both admin surfaces (Classic plugin config page in the Jellyfin dashboard, and the in-app Revamp admin panel inside the achievements page).
- Settings - cap + flag toggles and their thresholds.
- Test injection - pick a user, choose a count (default 50), choose movies vs episodes, hit Run. Drives N synthetic playbacks through the real path so cap+flag fire end-to-end. Response reports
Requested,Credited,Blocked, and the last block reason. Use on a throwaway test user to verify caps work before relying on them. - Manual badge revoke - pick a user, enter a badge id, confirm. Un-awards that specific badge, un-equips it if it was in the showcase. Audit-logged as
admin_revoke_badge.
Other behaviour changes
PlaybackCompletionService.RecordCompletionnow writes per-day credit counters and a rolling-hour timestamp queue intoplaybackstate.json. Old state files load cleanly with defaulted fields; new state is pruned to 30 days of date entries.PluginConfigurationgainsEnableDailyCreditCap,DailyCreditCap,EnableSuspiciousActivityFlag,SuspiciousRatePerHour.- New REST endpoints:
GET/POST /admin/integrity-config,POST /admin/users/{userId}/test/inject-playbacks?count=N&isMovie=true|false,DELETE /admin/users/{userId}/badges/{badgeId}.
Upgrade
Drop-in upgrade from v1.9.7. No config migration needed. Existing badge unlocks are NOT retroactively re-evaluated against the new gate - only credits going forward are affected. If you want to manually revoke a badge a user gamed previously, use the new revoke tool.
Package checksums
- MD5: see
Jellyfin.Plugin.AchievementBadges_1.9.8.0.md5 - SHA256: see
Jellyfin.Plugin.AchievementBadges_1.9.8.0.sha256
Sigstore-signed + SLSA build-provenance attested. Verify with cosign verify-blob or gh attestation verify.