# Jellystat in Magent Beta Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database. ## Setup 1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish. 2. Create an API key in Jellystat's settings. 3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL. 4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username. Alternatively, set these backend environment variables: ```dotenv JELLYSTAT_URL=http://jellystat:3000 JELLYSTAT_API_KEY=your-jellystat-api-key ``` `JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values. ## What users see - Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays. - Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods. - Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache. - Clear setup, account-link, no-history and temporary-unavailability states. The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Monthly reports are available from My Stats; admin reporting and newsletters can build on this integration later. ## Data semantics and boundaries History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`. - Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts. - Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals. - Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability. - Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name. - Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total. - A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`. - Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected. - The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again. ## Validation Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_insights_media.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification, artwork authorization, transcoding attribution and empty/error states. After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance. ## Monthly reports Open **My Stats → Monthly reports** (`/insights/reports`). The default is the most recent complete calendar month. The month picker covers the current month and the previous 23 months. Reports include viewing and request totals, changes against the preceding month, daily viewing, active days, longest streak, favourite titles, players, transcoding and the latest 20 plays in that month. The chart and streaming cards are shared with the Stats overview. - Completed months compare full UTC calendar months, even when their lengths differ. The current month compares the same elapsed time in the previous month, capped at that month's end when it is shorter. The page identifies reports that are still in progress. - Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage. - Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules. - `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials. - Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata. - One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result. `backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only. ## Personal monthly email recaps New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription. **Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email. New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token. Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in. Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled. The schedule uses a selected day from 1–28 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds. SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged. - A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown. - Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report. - The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html). - Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries. The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides. `backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`. ## Artwork and transcoding Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached. How you streamed includes audio transcoding minutes and hardware-assisted video transcoding minutes. These are playback durations attributed to the recorded transcode flags, not GPU busy time or encoder runtime. Audio and video durations can overlap. Video must actually be transcoded for hardware-assisted minutes to count; a hardware label on an audio-only conversion does not count as GPU video work. Direct-play records ignore residual transcoding metadata, and missing details remain unknown. Jellyfin exposes separate [video/audio passthrough flags and hardware type](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Session/TranscodingInfo.cs), with [hardware type names](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Entities/HardwareAccelerationType.cs). The existing Jellystat history does not contain GPU utilization or GPU busy-time samples, so Magent does not calculate those figures.