Compare commits

..
125 Commits
Author SHA1 Message Date
Assclaw 0f4071c970 fix(auth): accept configured public origin for sign-in
Magent CI/CD / verify (push) Successful in 4m15s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-19 12:26:09 +12:00
Assclaw a6a4a9aa24 fix: apply CSP nonce to Next scripts
Magent CI/CD / verify (push) Successful in 4m41s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:51:23 +12:00
Assclaw 91a950a3b0 fix: enforce isolated beta deployment path
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:41:34 +12:00
Assclaw 2525a9eb25 fix: preserve readable deployment source modes
Magent CI/CD / verify (push) Successful in 3m36s
Magent CI/CD / deploy-beta (push) Successful in 1m35s
2026-09-17 22:34:17 +12:00
Assclaw 8a6fe71446 fix: permit beta data ownership setup
Magent CI/CD / verify (push) Successful in 3m39s
Magent CI/CD / deploy-beta (push) Failing after 1m44s
2026-09-17 22:27:07 +12:00
Assclaw 6ab79efc35 fix: use Docker volume for CI smoke data
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Failing after 1m53s
2026-09-17 22:11:28 +12:00
Assclaw f852e7c941 chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 20:03:47 +12:00
Assclaw 5639dbcb83 security: harden data auth and deployment 2026-09-17 18:31:35 +12:00
Assclaw a6d1c73837 chore: patch vulnerable dependencies
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 15:22:26 +12:00
Assclaw aed1bf9256 feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-16 17:15:40 +12:00
Assclaw 05a540ecbb fix: keep site banner off login page
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 56s
2026-09-16 14:37:00 +12:00
Assclaw d75f36c691 feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-16 14:22:38 +12:00
Assclaw 3465343a69 feat: route ready-title issues through guided workflow
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-15 15:46:09 +12:00
Assclaw 4ba1a5763e feat: simplify ready requests and add global search
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
2026-09-15 15:15:35 +12:00
Assclaw dd51332f3c Wait for Arr download queue hand-off before reporting search outcome
Magent CI/CD / verify (push) Successful in 2m10s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-15 14:15:24 +12:00
Assclaw 6a84e68a03 Show request submission progress in a focused follow-up dialog
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 55s
2026-09-15 14:02:08 +12:00
Assclaw c194db167a Illustrate monthly reports and add viewing pattern breakdowns
Magent CI/CD / verify (push) Successful in 1m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-13 22:21:02 +12:00
Assclaw e232335ca9 Organize diagnostics into horizontal rows and group notification controls
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 50s
2026-09-13 22:08:41 +12:00
Assclaw 8df02fdfd7 Feather the top edge of request artwork
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-13 21:57:26 +12:00
Assclaw ce756c1a65 Restore monitoring on recheck and prevent release-search proxy timeouts
Magent CI/CD / verify (push) Successful in 2m1s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:47:36 +12:00
Assclaw b3b83bda4f Feather request artwork into the page at the sides and bottom
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:27:05 +12:00
Assclaw 0eecab4e0e Add cinematic title artwork backgrounds to request pages
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 51s
2026-09-13 19:22:06 +12:00
Assclaw 765b0d2033 Match Jellyfin punctuation variants using provider metadata and strict fallback
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 17:36:31 +12:00
Assclaw 1debf6053c Keep last verified stage visible while background refresh catches up
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:25:00 +12:00
Assclaw d7e5c75cb1 Serve recent stages from SQLite with configurable background refresh
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 16:18:30 +12:00
Assclaw dcd8082c8d Filter recent requests by resolved display stage before pagination
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:28:52 +12:00
Assclaw e3332bec1f Explain search outcomes and provide a direct version selection action
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 12:16:28 +12:00
Assclaw f57058eb26 Show a clear spinner while request searches are working
Magent CI/CD / verify (push) Successful in 2m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:11:14 +12:00
Assclaw 8734f461bb Replace request activity feed with simple current progress
Magent CI/CD / verify (push) Successful in 1m57s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 23:06:14 +12:00
Assclaw 835d9c8de3 Tidy release picker profile toggle and repair display text
Magent CI/CD / verify (push) Successful in 2m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:56:21 +12:00
Assclaw 956fb3ecb1 Inherit email link addresses from hosting and proxy settings
Magent CI/CD / verify (push) Successful in 2m2s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 22:46:46 +12:00
Assclaw 4f7853b17b Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped
2026-09-11 20:26:57 +12:00
Assclaw de25255ea8 Reconcile verified account IDs and make language repairs observable
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 19:56:32 +12:00
Assclaw 38169b881e Support original-language movie requests and fix repair dialog layout
Magent CI/CD / verify (push) Successful in 1m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 18:05:18 +12:00
Assclaw 52c85daae3 Add reviewed duplicate account consolidation and prevent duplicate imports
Magent CI/CD / verify (push) Successful in 11m16s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m33s
2026-09-11 16:38:53 +12:00
Assclaw df6fe58278 Normalize portal kinds before checking feature access
Magent CI/CD / verify (push) Successful in 11m13s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s
2026-09-11 12:32:51 +12:00
Assclaw ec0a866ef3 Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-11 12:31:25 +12:00
Assclaw e2be8b3872 Remove changelog link from user menu
Magent CI/CD / verify (push) Successful in 11m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 5m59s
2026-09-10 17:12:30 +12:00
Assclaw b286ca3c42 Let users email personal reports on demand
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-10 16:15:39 +12:00
Assclaw 6e473fd0a7 Unify user management and add reviewed identity repairs
Magent CI/CD / verify (push) Canceled after 9m3s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 16:06:30 +12:00
Assclaw 9856c7fb90 Collapse request discovery after selecting a title
Magent CI/CD / verify (push) Successful in 11m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m11s
2026-09-10 15:47:50 +12:00
Assclaw df651eb312 Use admin quality defaults throughout the request pipeline
Magent CI/CD / verify (push) Successful in 10m49s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m6s
2026-09-10 15:20:23 +12:00
Assclaw 77f2c1b42a Add reviewed resolution for missing user identity links
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s
2026-09-10 15:02:04 +12:00
Assclaw b310e86f80 Align user directory headings with account rows
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m47s
2026-09-10 13:46:11 +12:00
Assclaw 747a330b19 Center the user directory and group management tools in a dialog
Magent CI/CD / verify (push) Canceled after 9m23s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 13:36:37 +12:00
Assclaw b0f8c89db7 Add Grizzlyflix newsletters with curated editions and weekly delivery
Magent CI/CD / verify (push) Successful in 10m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m14s
2026-09-09 23:42:52 +12:00
Assclaw e014baadc3 Preserve monthly recap destinations through sign-in
Magent CI/CD / verify (push) Successful in 10m36s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m26s
2026-09-09 22:42:58 +12:00
Assclaw 1979e02cde Add opt-in monthly email recaps with scheduling and delivery history
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-09 22:39:22 +12:00
Assclaw 333a799e21 Add personal monthly viewing reports and CSV exports
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s
2026-09-09 16:25:35 +12:00
Assclaw 437836243c Remove page numbering from navigation and page links
Magent CI/CD / verify (push) Successful in 10m28s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m0s
2026-09-08 16:36:38 +12:00
Assclaw e7e4c9eff3 Fix viewing-history artwork and add transcode playback metrics
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s
2026-09-08 16:15:16 +12:00
Assclaw 12611a9819 Add admin review and confirmation of cross-service user IDs
Magent CI/CD / verify (push) Successful in 10m29s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 54s
2026-09-08 15:41:45 +12:00
Assclaw 2976145dd8 Add private Jellystat viewing stats to Magent beta
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m16s
2026-09-08 11:25:05 +12:00
Assclaw a3b5759708 Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:59:51 +12:00
Assclaw 13edcb8136 Prevent non-admin issue responses exposing account identities
Magent CI/CD / verify (push) Successful in 10m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:45:19 +12:00
Assclaw 131b5fc5c7 Add private Prometheus API metrics and Grafana performance dashboard
Magent CI/CD / verify (push) Successful in 10m24s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:23:35 +12:00
Assclaw bd1f2cb1cb Make request status badges prominent and easier to scan
Magent CI/CD / verify (push) Successful in 10m32s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 17:31:16 +12:00
Assclaw edca300d27 Add bulk enable invite access to user directory
Magent CI/CD / verify (push) Successful in 10m27s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:19:12 +12:00
Assclaw 4034a8f72a Add post-login welcome and friendly how-it-works guide
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:08:37 +12:00
Assclaw 0637860b95 Show imported Seerr accounts while preserving linked-user deduplication
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 15:20:10 +12:00
Assclaw 697fc235ee Serve resilient launch cover independently of application upstream
Magent CI/CD / verify (push) Failing after 50m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:46:09 +12:00
Assclaw 62ee07f92b Record verified coming-soon cutover and rollback procedure
Magent CI/CD / verify (push) Canceled after 14m20s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:31:44 +12:00
Assclaw 458ef53f47 Exclude production bootstrap credentials from source and build context
Magent CI/CD / verify (push) Canceled after 1m56s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:29:43 +12:00
Assclaw c2685f43a7 Prepare clean production setup and coming-soon cover
Magent CI/CD / verify (push) Successful in 11m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:14:22 +12:00
Assclaw 98d8b197a9 Link invite trace accounts to admin user profiles
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m54s
2026-09-06 22:47:16 +12:00
Assclaw 625f9ad7f0 Discover Sonarr episode downloads and keep live tracker updating
Magent CI/CD / verify (push) Canceled after 1m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:46:01 +12:00
Assclaw bd668715a3 Keep diagnostic health cards compact beside database details
Magent CI/CD / verify (push) Canceled after 6m1s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:39:59 +12:00
Assclaw 212ac560ec Tidy invite delivery choices and align email fields
Magent CI/CD / verify (push) Canceled after 1m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:38:17 +12:00
Assclaw a32928b1c5 Make verified repair acceptance prominent and simplify confirmation email
Magent CI/CD / verify (push) Canceled after 5m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:32:21 +12:00
Assclaw 74c49fad5b Compact request activity into a live summary and modal history 2026-09-06 22:20:33 +12:00
Assclaw 009bb35032 Track repair collection cycles and reconcile request availability
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s
2026-09-06 22:06:30 +12:00
Assclaw 8d720de500 Streamline issue wizard and make selections explicit
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m2s
2026-09-06 21:44:44 +12:00
Assclaw 1851fa9753 Show actual collector search activity in request pipeline
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m4s
2026-09-06 21:14:42 +12:00
Assclaw dec1dd902c Unify page layouts, compact headers and shared UI styling 2026-09-06 19:52:39 +12:00
Assclaw 4d67567d4c Simplify navigation and modernize profile and sign-in
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m58s
2026-09-06 18:24:18 +12:00
Assclaw b5e4c57e93 Simplify settings workspace and fix responsive admin controls
Magent CI/CD / verify (push) Successful in 11m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-06 17:42:28 +12:00
Assclaw f65e1b114c Apply Stitch media operations redesign
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-09-01 22:58:48 +12:00
Assclaw 32bfa20ab7 Add wrong download repair option
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 22:42:07 +12:00
Assclaw 0b59289a2e Automate repair completion confirmation
Magent CI/CD / verify (push) Canceled after 4m31s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:37:23 +12:00
Assclaw ded794a819 Add self-service profile email management
Magent CI/CD / verify (push) Canceled after 10m22s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:26:53 +12:00
Assclaw c6d449dc17 Move invite flow to client navigation 2026-09-01 22:20:48 +12:00
Assclaw 06d944c9d9 Add admin user email management
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 20s
2026-09-01 22:09:20 +12:00
Assclaw 0ac53b7f59 Add quality-aware release picker modal
Magent CI/CD / verify (push) Canceled after 7m6s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:02:22 +12:00
Assclaw 2dbe11e6bc Rework invite creation as guided flow
Magent CI/CD / verify (push) Canceled after 8m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:53:27 +12:00
Assclaw 3aac40ba0f Keep issue dialog above site navigation
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 21:33:02 +12:00
Assclaw b6c48a0be7 Clarify completed repair handoff
Magent CI/CD / verify (push) Canceled after 9m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:22:55 +12:00
Assclaw 3fc52f70c7 Show live repair progress on requests
Magent CI/CD / verify (push) Canceled after 4m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:17:50 +12:00
Assclaw 6391fbfd81 Monitor media before issue repair searches
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 20:50:16 +12:00
Assclaw 0ed22dd315 Separate invites from profile navigation
Magent CI/CD / verify (push) Successful in 10m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 19:51:02 +12:00
Assclaw 7ed0f4b103 Add global admin user-view preview
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 17:04:25 +12:00
Assclaw b3c41f6dea Add safe user preview for invites
Magent CI/CD / verify (push) Successful in 10m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 16:51:32 +12:00
Assclaw 976d24217b Fix top-level invites navigation
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 15:28:28 +12:00
Assclaw c49a149cfd Refresh invite operations workspace
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 15:12:37 +12:00
Assclaw 5de14b1cb7 Add admin issue deletion workflow
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 14:58:58 +12:00
Assclaw 16876e1cf0 Advance issue status from repair actions
Magent CI/CD / verify (push) Successful in 10m30s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Canceled after 5s
2026-09-01 14:48:08 +12:00
Assclaw c7a56f2525 Add issue workflow progress tracker
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 14:44:17 +12:00
Assclaw 87a4aae246 Move issue history into sidebar modal
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 14:28:27 +12:00
Assclaw e58614305e Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 13:44:13 +12:00
Assclaw 2adbed7259 Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s
2026-09-01 11:40:43 +12:00
Assclaw 393b8c2a88 Add prominent watch button to ready requests
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 11:16:43 +12:00
Assclaw b0eff9ffcf Clarify and live-refresh media indexing state
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 26s
2026-09-01 09:41:15 +12:00
Assclaw ae6cee5d0b Explain remote service responses in plain English
Magent CI/CD / verify (push) Successful in 10m20s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-31 22:28:56 +12:00
Assclaw 906a777b95 Fix issue search result card layout
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-31 22:14:33 +12:00
Assclaw ec8145a58a Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 22:05:11 +12:00
Assclaw f8770cb44a Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 21:46:43 +12:00
Assclaw 0e04d219a0 Reorganize admin configuration sections
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-31 16:32:24 +12:00
Assclaw 3402e53c31 Protect advanced request diagnostics for non-admins
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 16:09:17 +12:00
Assclaw ecf9b230c1 Separate new requests from request tracking
Magent CI/CD / verify (push) Canceled after 7m35s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 16:02:00 +12:00
Assclaw 8f810e0f36 Isolate sparse request recheck regression test
Magent CI/CD / verify (push) Successful in 10m11s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-31 15:23:49 +12:00
Assclaw 82d87d968e Fix sparse request metadata hydration 2026-08-31 15:21:47 +12:00
Assclaw 372f4a1bfc Fix Seerr transport payload forwarding
Magent CI/CD / verify (push) Canceled after 9m15s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:14:32 +12:00
Assclaw 547ed754e6 Handle Seerr CSRF on request creation
Magent CI/CD / verify (push) Canceled after 2m30s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:11:57 +12:00
Assclaw a55369190b Move request service icons beside card content
Magent CI/CD / verify (push) Canceled after 6m44s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:05:01 +12:00
Assclaw ee81749b43 Add Sonarr and Radarr request icons
Magent CI/CD / verify (push) Canceled after 5m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:59:23 +12:00
Assclaw 9dfea25d56 Redesign the request portal flow
Magent CI/CD / verify (push) Canceled after 5m59s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:53:15 +12:00
Assclaw 963506d098 Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-30 21:23:20 +12:00
Assclaw 02245d365e Add live request pipeline recheck
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m56s
2026-08-30 19:50:09 +12:00
Assclaw 2cbd9fe73f Hide landing page live update indicator
Magent CI/CD / verify (push) Successful in 11m5s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-08-30 19:24:56 +12:00
Assclaw 9db32481bd Resolve Arr metadata before adding media
Magent CI/CD / verify (push) Successful in 11m12s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-30 16:32:14 +12:00
Assclaw 3815dfea60 Move fleet health into admin settings and tidy landing page
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-08-29 22:53:48 +12:00
Assclaw c073581639 Mark available downloads complete
Magent CI/CD / verify (push) Successful in 10m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-29 22:41:50 +12:00
Assclaw 06a000bb06 Balance manual results across missing seasons
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-29 22:17:39 +12:00
Assclaw 391cd41d71 Route manual downloads through Arr collectors
Magent CI/CD / verify (push) Canceled after 3m43s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-29 22:13:46 +12:00
Assclaw 96fc43365f Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-29 20:52:49 +12:00
Assclaw 655e2f8158 Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-29 20:27:17 +12:00
293 changed files with 70142 additions and 4427 deletions
+1 -1
View File
@@ -1 +1 @@
251260445
0803262237
+9
View File
@@ -1,5 +1,14 @@
.git
.env
.env.*
.venv/
**/.pytest_cache/
stitch_magent_media_operations_redesign/
*.tar
*.tar.gz
*.zip
bootstrap-admin.json
release.tar
*.log
data/*
!data/branding/
+20
View File
@@ -0,0 +1,20 @@
# Provision this as .env on the beta host. Do not copy production secrets or data.
APP_NAME=Magent Beta
CORS_ALLOW_ORIGIN=https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL=https://beta.grizzlyflix.co.nz
MAGENT_API_URL=https://beta.grizzlyflix.co.nz/api
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=json
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
AUTH_COOKIE_NAME=magent_beta_auth
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+18
View File
@@ -0,0 +1,18 @@
# Copy to .env for local development. Never reuse these example values in a deployed environment.
APP_NAME=Magent
CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=http://localhost:3000
MAGENT_API_URL=http://localhost:8000
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=text
# Generate independent values as documented in README.md.
JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+17
View File
@@ -0,0 +1,17 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.tgz binary
*.woff binary
*.woff2 binary
+106
View File
@@ -0,0 +1,106 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- main
- prod
pull_request:
branches:
- beta
- main
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Verify generated build metadata
run: python scripts/verify_build_metadata.py
- name: Audit frontend production dependencies
working-directory: frontend
run: npm audit --omit=dev --package-lock-only --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Check frontend formatting
working-directory: frontend
run: npm run format:check
- name: Type-check frontend
working-directory: frontend
run: npm run typecheck
- name: Test frontend
working-directory: frontend
run: npm test
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Validate Compose configuration
run: |
cp .env.example .env
docker compose -f docker-compose.yml config --quiet
- name: Build and smoke-test container
run: bash scripts/ci_container_smoke.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh
+13
View File
@@ -1,5 +1,7 @@
.env
bootstrap-admin.json
.venv/
.security-test-venv*/
data/
!data/branding/
!data/branding/**
@@ -7,6 +9,17 @@ backend/__pycache__/
**/__pycache__/
*.pyc
backend/.pytest_cache/
.coverage
coverage.xml
htmlcov/
frontend/node_modules/
frontend/.next/
*.tsbuildinfo
*.log
**/.pytest_cache/
.env.*
!.env.example
!.env.*.example
*.tar
*.tar.gz
*.zip
+68
View File
@@ -0,0 +1,68 @@
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
WORKDIR /frontend
ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --include=dev
COPY frontend/app ./app
COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js
COPY frontend/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ARG MAGENT_UID=1000
ARG MAGENT_GID=1000
RUN groupadd --gid ${MAGENT_GID} magent \
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=magent:magent backend/app ./app
COPY --chown=magent:magent data/branding /app/data/branding
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
RUN chown -R magent:magent /app
USER magent:magent
EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|| exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+71
View File
@@ -0,0 +1,71 @@
# Production
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
The root `Dockerfile` is the supported build entry point. Source releases come
from `main`; use `prod-<short-commit>` tags to identify an exact release.
## Live deployment
- Host: GRZ-DKR01 (`10.30.1.81`).
- Container and Compose service: `magent`; Compose project: `arrstack`.
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data``/app/data`.
- Public URL: `https://magent.grizzlyflix.co.nz`.
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
## Release checklist
1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`.
The repository workflow verifies `main` but intentionally does not deploy it;
production changes require the remaining explicit release steps below.
2. Build from a clean source export using the root Dockerfile. Never include
`.env`, databases or bootstrap credentials in the build context.
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
Confirm their digests match.
4. Pull the new image before stopping production. Keep the old image under a
rollback tag and back up the current Compose configuration.
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
and its WAL files are consistent. Protect backups: they contain private data.
6. Recreate only this service with `docker compose -p arrstack -f
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
Confirm that Compose selects the intended image before running this command.
7. Check container health, the API `/health` endpoint, public login, the changed
feature, database integrity and account counts. Do not trigger bulk permission
changes, email sends or user imports as a deployment smoke test.
Include browser-origin POST checks for both `/api/auth/login` and
`/api/auth/jellyfin/login`: an empty form with `Origin` set to the public URL
must reach input validation (422), while an unrelated origin must return 403.
GET-only login/health checks do not detect origin-policy lockouts. Set
`CORS_ALLOW_ORIGIN` to the exact public origin; the state-change guard also
accepts the explicitly configured Hosting & proxy public URL, never a URL
inferred from request Host or forwarded headers.
For rollback, select the saved image and recreate only Magent. Restore data only
if needed; doing so can discard activity since the backup. Never restore a whole
shared Compose or Caddy file without checking for unrelated changes first.
## Build metadata
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
display build number as the frontend package files. `.env` should have exactly
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
tags identify the deployed source commit independently of this display value.
`scripts/process1.ps1` is a local development workflow: it updates metadata,
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
It is **not** the production deployment command. Its build-number helper can be
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
## Fresh instances and historical notes
`scripts/prepare_production_settings.py` exports only allowlisted connection and
SMTP settings for a fresh instance. Do not use it to replace a live database.
`docker-compose.production.yml` is the separate fresh-instance template, not the
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
+69 -12
View File
@@ -1,10 +1,10 @@
# Magent
Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## How it works
1) Requests are pulled from Jellyseerr and stored locally.
1) Requests are pulled from Seerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
@@ -14,7 +14,7 @@ Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services.
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
@@ -23,6 +23,8 @@ Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services.
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
- Docker-first deployment for easy hosting.
## Quick start (Docker - primary)
@@ -64,10 +66,11 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="adminadmin"
JWT_SECRET="replace-with-at-least-32-random-characters"
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
JWT_EXP_MINUTES="120"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
## Screenshots
@@ -112,10 +115,11 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="change-me"
$env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="admin"
$env:ADMIN_PASSWORD="adminadmin"
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
$env:JWT_EXP_MINUTES="120"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
@@ -132,6 +136,19 @@ Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite).
### Local quality checks
```bash
bash scripts/ci_backend_quality_gate.sh
cd frontend
npm ci
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
```
## Public Hosting Notes
The frontend proxies `/api/*` to the backend container. Set:
@@ -141,6 +158,46 @@ The frontend proxies `/api/*` to the backend container. Set:
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
## Gitea CI/CD
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
- Push to `main` or `prod`: runs the same verification without automatically changing production.
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
The beta deploy step ships tracked repository files over SSH, preserves beta's own `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
## Security and data handling
Generate independent signing and settings-encryption secrets before first startup:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
@@ -160,7 +217,7 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
### No recent requests
- Confirm Jellyseerr credentials in Settings.
- Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
-19
View File
@@ -1,19 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
ARG BUILD_NUMBER=dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SITE_BUILD_NUMBER=${BUILD_NUMBER}
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2 -2
View File
@@ -9,12 +9,12 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
if snapshot.state == NormalizedState.requested:
root_cause = "approval"
summary = "The request is waiting for approval in Jellyseerr."
summary = "The request is waiting for approval in Seerr."
recommendations.append(
TriageRecommendation(
action_id="wait_for_approval",
title="Ask an admin to approve the request",
reason="Jellyseerr has not marked this request as approved.",
reason="Seerr has not marked this request as approved.",
risk="low",
)
)
+58
View File
@@ -0,0 +1,58 @@
"""Shared HTTP request and error contracts."""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class StrictRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class ErrorResponse(BaseModel):
detail: str
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
400: {"model": ErrorResponse, "description": "Invalid request"},
401: {"model": ErrorResponse, "description": "Authentication required"},
403: {"model": ErrorResponse, "description": "Permission denied"},
404: {"model": ErrorResponse, "description": "Resource not found"},
409: {"model": ErrorResponse, "description": "Request conflict"},
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
500: {"model": ErrorResponse, "description": "Unexpected server error"},
502: {"model": ErrorResponse, "description": "Upstream service error"},
503: {"model": ErrorResponse, "description": "Service unavailable"},
}
class SignupRequest(StrictRequest):
invite_code: str = Field(min_length=1, max_length=256)
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=1024)
email: Optional[str] = Field(default=None, max_length=320)
class ForgotPasswordRequest(StrictRequest):
identifier: Optional[str] = Field(default=None, max_length=320)
username: Optional[str] = Field(default=None, max_length=100)
email: Optional[str] = Field(default=None, max_length=320)
class PasswordResetRequest(StrictRequest):
token: str = Field(min_length=1, max_length=512)
new_password: str = Field(min_length=1, max_length=1024)
class ProfileEmailUpdateRequest(StrictRequest):
email: Optional[str] = Field(default=None, max_length=320)
class ChangePasswordRequest(StrictRequest):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
return payload if isinstance(payload, dict) else payload.model_dump()
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+193 -16
View File
@@ -1,32 +1,152 @@
from typing import Dict, Any
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity
from .security import safe_decode_token, TokenError
from .config import settings
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client and request.client.host:
return request.client.host
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if direct_host:
return direct_host
return "unknown"
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
return {
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
"path": "/",
}
def _state_cookie_settings() -> dict[str, Any]:
cookie = _cookie_settings()
cookie["httponly"] = False
return cookie
def set_auth_cookies(response: Response, token: str) -> None:
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
response.set_cookie(
settings.auth_cookie_name,
token,
max_age=max_age,
**_cookie_settings(),
)
response.set_cookie(
settings.auth_state_cookie_name,
"1",
max_age=max_age,
**_state_cookie_settings(),
)
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
response.delete_cookie(
settings.auth_state_cookie_name,
path="/",
domain=settings.auth_cookie_domain or None,
)
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
if oauth_token:
return oauth_token
cookie_token = request.cookies.get(settings.auth_cookie_name)
if isinstance(cookie_token, str) and cookie_token.strip():
return cookie_token.strip()
return None
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if provider != "local":
return provider
password_hash = user.get("password_hash")
if isinstance(password_hash, str) and password_hash:
if verify_password("jellyfin-user", password_hash):
return "jellyfin"
if verify_password("jellyseerr-user", password_hash):
return "jellyseerr"
return provider
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(user, dict):
return {}
resolved_provider = resolve_user_auth_provider(user)
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if resolved_provider != stored_provider:
username = str(user.get("username") or "").strip()
if username:
set_user_auth_provider(username, resolved_provider)
refreshed_user = get_user_by_username(username)
if refreshed_user:
user = refreshed_user
normalized = dict(user)
normalized["auth_provider"] = resolved_provider
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
normalized["password_provider"] = (
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
)
return normalized
def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
allowed_token_types: Optional[set[str]] = None,
) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
token_type = str(payload.get("typ") or "access").strip().lower()
if allowed_token_types and token_type not in allowed_token_types:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub")
if not username:
@@ -37,6 +157,15 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
token_version = payload.get("ver")
if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked")
user = normalize_user_auth_provider(user)
from .feature_access import permissions
features = permissions(user)
if request is not None:
ip = _extract_client_ip(request)
@@ -44,13 +173,61 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
upsert_user_activity(user["username"], ip, user_agent)
return {
"features": features,
"username": user["username"],
"email": user.get("email"),
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"),
"auth_version": int(user.get("auth_version") or 1),
}
def get_current_user(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
resolved_token = _extract_access_token(request, token)
if not resolved_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(resolved_token, request)
def get_current_user_event_stream(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
resolved_token = _extract_access_token(request, token)
stream_query_token = request.query_params.get("stream_token")
if resolved_token:
# Allow standard bearer tokens for non-browser EventSource clients.
return _load_current_user_from_token(resolved_token, None)
if not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(
str(stream_query_token),
None,
allowed_token_types={"sse"},
)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
File diff suppressed because one or more lines are too long
+399 -12
View File
@@ -1,11 +1,262 @@
from typing import Any, Dict, Optional
import logging
import time
import httpx
from ..logging_config import sanitize_headers, sanitize_value
from ..services.operation_progress import finish_remote_call, start_remote_call
from ..metrics import record_remote
_SERVICE_NAMES = {
"JellyseerrClient": "Seerr",
"SonarrClient": "Sonarr",
"RadarrClient": "Radarr",
"BazarrClient": "Bazarr",
"ProwlarrClient": "Prowlarr",
"JellyfinClient": "Jellyfin",
"QBittorrentClient": "qBittorrent",
}
def _result_items(result: Any, *keys: str) -> list[Any]:
if isinstance(result, list):
return result
if not isinstance(result, dict):
return []
for key in keys:
value = result.get(key)
if isinstance(value, list):
return value
return []
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
candidates = result if isinstance(result, list) else [result]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
title = str(candidate.get("title") or candidate.get("name") or "").strip()
if title:
return title
if isinstance(payload, dict):
title = str(payload.get("title") or payload.get("name") or "").strip()
if title:
return title
return None
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
noun = singular if count == 1 else (plural or f"{singular}s")
return f"{count} {noun}"
def _queue_result_message(service: str, result: Any) -> str:
records = _result_items(result, "records", "items")
total = result.get("totalRecords") if isinstance(result, dict) else None
count = int(total) if isinstance(total, int) else len(records)
if count == 0:
return f"{service} has no matching downloads in its queue."
first = next((item for item in records if isinstance(item, dict)), None)
progress_text = ""
if first:
size = first.get("size")
size_left = first.get("sizeleft")
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
progress_text = f" The first is {progress}% complete."
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
raw_name = str((payload or {}).get("name") or "").strip()
names = {
"MoviesSearch": "movie search",
"SeriesSearch": "series search",
"EpisodeSearch": "episode search",
"DownloadRelease": "release download",
"RefreshMovie": "movie refresh",
"RescanMovie": "movie rescan",
"RefreshSeries": "series refresh",
"RescanSeries": "series rescan",
}
return names.get(raw_name, "command")
def _operation_result_message(
service: str,
method: str,
path: str,
result: Any,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> str:
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
normalized_method = method.upper()
title = _result_title(result, payload)
title_text = f' "{title}"' if title else ""
if service == "Seerr":
if normalized_path.endswith("/request") and normalized_method == "POST":
request_id = result.get("id") if isinstance(result, dict) else None
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
return f"Seerr created the request{suffix} and passed it into the collection workflow."
if "/request/" in normalized_path and normalized_method == "GET":
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
status = result.get("status") if isinstance(result, dict) else None
status_text = status_names.get(status)
return (
f"Seerr found the request; it is currently {status_text}."
if status_text
else "Seerr found the request and returned its current status."
)
if service in {"Radarr", "Sonarr"}:
media_name = "movie" if service == "Radarr" else "series"
media_path = "/movie" if service == "Radarr" else "/series"
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
return (
f"{service} found {_count_message(count, 'download option')}."
if count
else f"{service} could not find a suitable download option."
)
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'download quality setting')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'library folder')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
if service == "Sonarr" and "/episodefile" in normalized_path:
if normalized_method == "DELETE":
return "Sonarr removed the existing episode file so it can be replaced."
count = len(_result_items(result))
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
return "Sonarr marked the selected episodes as wanted."
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
episodes = _result_items(result)
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
return "Radarr removed the existing movie file so it can be replaced."
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
if is_media_endpoint:
if normalized_method == "GET":
found = bool(result) if not isinstance(result, list) else len(result) > 0
return (
f"{service} found{title_text} in its library list."
if found
else f"This {media_name} is not currently in {service}."
)
if normalized_method == "POST":
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
search_text = " and started looking for a download" if search_requested else ""
subject = title_text or f" the {media_name}"
return f"{service} added{subject}{search_text}."
if normalized_method == "PUT":
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
if "/system/status" in normalized_path:
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
return f"Connected to {service}{f' version {version}' if version else ''}."
if service == "Prowlarr":
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "The download search sources are working normally."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible download')}."
if results
else "Prowlarr did not find any possible downloads."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
target = "movie" if "/movies/" in normalized_path else "selected episode"
language = str((params or {}).get("language") or "the requested language").upper()
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} finished this check without reporting a problem."
if normalized_method == "POST":
return f"{service} received the request. Its result will be checked separately."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
return f"{service} confirmed the item was removed."
return f"{service} completed the request successfully."
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
explanations = {
400: "rejected the request because some details were invalid",
401: "rejected Magent's login details",
403: "refused permission for this action",
404: "could not find the requested item",
409: "reported a conflict, usually because the item already exists",
422: "could not use the details Magent supplied",
429: "is busy and asked Magent to try again later",
500: "encountered an internal error while processing the request",
502: "could not reach one of its own dependent services",
503: "is temporarily unavailable",
504: "did not finish before the request timed out",
}
explanation = explanations.get(status_code)
if explanation:
return f"{service} {explanation}."
if status_code:
return f"{service} could not complete the request (response code {status_code})."
return f"Magent could not get a usable response from {service}."
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
normalized_path = path.lower()
normalized_method = method.upper()
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
return "Reading the request from Seerr…", "Seerr returned the current request record"
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
return "Checking Radarr for the movie…", "Radarr returned the movie record"
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
return "Checking Sonarr for the series…", "Sonarr returned the series record"
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
if service == "Sonarr" and "/episode" in normalized_path:
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
return f"Checking releases through {service}", f"{service} returned release information"
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
if normalized_method == "GET":
return f"Checking {service}'s search activity…", f"{service} returned its current activity"
return f"Sending a command to {service}", f"{service} accepted the command"
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking whether the download search sources are working…", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
class ApiClient:
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
self.base_url = base_url.rstrip("/") if base_url else None
self.api_key = api_key
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
def configured(self) -> bool:
return bool(self.base_url)
@@ -13,20 +264,156 @@ class ApiClient:
def headers(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key} if self.api_key else {}
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
if response is None:
return None
try:
payload = sanitize_value(response.json())
except ValueError:
payload = sanitize_value(response.text)
if isinstance(payload, str) and len(payload) > 500:
return f"{payload[:500]}..."
return payload
async def _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
return await client.request(
method,
url,
headers=headers,
params=params,
json=payload,
)
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
if not self.base_url:
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=self.headers(), params=params)
response.raise_for_status()
return response.json()
started_at = time.perf_counter()
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
active_message, _ = _operation_messages(service_name, method, path)
operation_event_id = start_remote_call(service_name, active_message)
metric_status = 'error'
self.logger.debug(
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
method,
url,
sanitize_value(params),
sanitize_value(payload),
sanitize_headers(self.headers()),
)
try:
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await self._send_request(
client,
method,
url,
headers=self.headers(),
params=params,
payload=payload,
)
metric_status = str(response.status_code)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.debug(
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
method,
url,
response.status_code,
duration_ms,
)
result = response.json() if response.content else None
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_operation_result_message(
service_name,
method,
path,
result,
params=params,
payload=payload,
),
)
return result
except httpx.HTTPStatusError as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response = exc.response
status = response.status_code if response is not None else "unknown"
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
log_fn(
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
method,
url,
status,
duration_ms,
self._response_summary(response),
)
finish_remote_call(
operation_event_id,
success=False,
status_code=status if isinstance(status, int) else None,
message=_operation_error_message(
service_name,
status if isinstance(status, int) else None,
),
)
raise
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.exception(
"outbound request failed method=%s url=%s duration_ms=%s",
method,
url,
duration_ms,
)
finish_remote_call(
operation_event_id,
success=False,
message=_operation_error_message(service_name, None),
)
raise
finally:
record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
async def get(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
return await self._request(
"GET", path, params=params, timeout_seconds=timeout_seconds
)
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=self.headers(), json=payload)
response.raise_for_status()
return response.json()
return await self._request("POST", path, payload=payload)
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload)
async def delete(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
+48
View File
@@ -0,0 +1,48 @@
from typing import Any, Optional
from .base import ApiClient
class BazarrClient(ApiClient):
async def get_system_status(self) -> Optional[Any]:
return await self._request("GET", "/api/system/status")
async def search_movie_subtitles(
self,
radarr_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/movies/subtitles",
params={
"radarrid": radarr_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
async def search_episode_subtitles(
self,
series_id: int,
episode_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/episodes/subtitles",
params={
"seriesid": series_id,
"episodeid": episode_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
+240 -12
View File
@@ -1,6 +1,24 @@
import re
from typing import Any, Dict, Optional
import httpx
from .base import ApiClient
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _availability_message(result: Any) -> str:
if not isinstance(result, dict):
return "Jellyfin did not return any matching library items."
total = result.get("TotalRecordCount")
items = result.get("Items")
available = (
(isinstance(total, int) and total > 0)
or (isinstance(items, list) and len(items) > 0)
)
return (
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
if available
else "Grizzlyflix did not find this title in its library search."
)
class JellyfinClient(ApiClient):
@@ -10,61 +28,271 @@ class JellyfinClient(ApiClient):
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
def _emby_headers(self) -> Dict[str, str]:
return {"X-Emby-Token": self.api_key} if self.api_key else {}
@staticmethod
def _extract_user_id(payload: Any) -> Optional[str]:
if not isinstance(payload, dict):
return None
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
if not isinstance(candidate, dict):
return None
for key in ("Id", "id", "UserId", "userId"):
value = candidate.get(key)
if value is None:
continue
if isinstance(value, (str, int)):
text = str(value).strip()
if text:
return text
return None
async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users"
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
users = await self.get_users()
if not isinstance(users, list):
return None
target = username.strip().lower()
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip().lower()
if name and name == target:
return user
return None
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users/AuthenticateByName"
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
headers = self._emby_headers()
payload = {"Username": username, "Pw": password}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/New"
headers = self._emby_headers()
payload = {"Name": username}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def set_user_password(self, user_id: str, password: str) -> None:
if not self.base_url or not self.api_key:
return None
headers = self._emby_headers()
payloads = [
{"CurrentPw": "", "NewPw": password},
{"CurrentPwd": "", "NewPw": password},
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
{"NewPw": password, "ResetPassword": False},
]
paths = [
f"/Users/{user_id}/Password",
f"/Users/{user_id}/EasyPassword",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=10.0) as client:
for path in paths:
url = f"{self.base_url}{path}"
for payload in payloads:
try:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return
except httpx.HTTPStatusError as exc:
last_error = exc
continue
except Exception as exc:
last_error = exc
continue
if last_error:
raise last_error
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
user = await self.get_user(user_id)
if not isinstance(user, dict):
raise RuntimeError("Jellyfin user details not available")
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
payload = {**policy, "IsDisabled": bool(disabled)}
url = f"{self.base_url}/Users/{user_id}/Policy"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
async def delete_user(self, user_id: str) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=headers)
response.raise_for_status()
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
created = await self.create_user(username)
user_id = self._extract_user_id(created)
if not user_id:
users = await self.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip()
if name.lower() == username.strip().lower():
created = user
user_id = self._extract_user_id(user)
break
if not user_id:
raise RuntimeError("Jellyfin user created but user ID was not returned")
await self.set_user_password(user_id, password)
return created
async def search_items(
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items"
params = {
"SearchTerm": term,
"IncludeItemTypes": ",".join(item_types or []),
"Recursive": "true",
"Fields": "Path,MediaSources,ProviderIds,OriginalTitle,SortName",
"Limit": limit,
}
headers = {"X-Emby-Token": self.api_key}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
headers = self._emby_headers()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
normalized = ' '.join(re.sub(r"[^\w\s]", ' ', term, flags=re.UNICODE).split())
terms = list(dict.fromkeys([term, normalized]))
if normalized != term and normalized.split():
terms.append(max(normalized.split(), key=len))
items = {}
for search_term in dict.fromkeys(terms):
if not search_term:
continue
response = await client.get(url, headers=headers, params={**params, "SearchTerm": search_term})
response.raise_for_status()
payload = response.json()
for item in payload.get('Items', []):
if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_availability_message(result),
)
return result
except Exception as exc:
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
async def get_series_episodes(self, series_id: str) -> list[Dict[str, Any]]:
if not self.base_url or not self.api_key or not str(series_id).strip():
return []
url = f"{self.base_url}/Items"
params = {
"ParentId": str(series_id).strip(),
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Fields": "Path,ProviderIds,MediaSources",
"Limit": 10000,
}
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get(url, headers=self._emby_headers(), params=params)
response.raise_for_status()
return response.json()
payload = response.json()
if not isinstance(payload, dict):
return []
items = payload.get("Items") or payload.get("items") or []
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
async def get_system_info(self) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/System/Info"
headers = {"X-Emby-Token": self.api_key}
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Sessions"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh"
headers = {"X-Emby-Token": self.api_key}
headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message="Jellyfin accepted the library refresh and is scanning for new media.",
)
except Exception as exc:
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
+94 -6
View File
@@ -1,8 +1,44 @@
from typing import Any, Dict, Optional
from urllib.parse import quote, unquote, urlsplit
import httpx
from .base import ApiClient
class JellyseerrClient(ApiClient):
async def _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
request_headers = dict(headers)
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
# Seerr's optional CSRF protection also applies to API-key writes.
# Seed its secret/token cookie pair, then echo the readable token in
# the header Seerr's own web client uses.
csrf_response = await client.get(
f"{self.base_url}/api/v1/auth/me",
headers=self.headers(),
)
csrf_response.raise_for_status()
csrf_token = client.cookies.get("XSRF-TOKEN")
if csrf_token:
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
parsed_base = urlsplit(self.base_url)
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
return await super()._send_request(
client,
method,
url,
headers=request_headers,
params=params,
payload=payload,
)
async def get_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/status")
@@ -18,9 +54,6 @@ class JellyseerrClient(ApiClient):
},
)
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/media/{media_id}")
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/movie/{tmdb_id}")
@@ -28,10 +61,65 @@ class JellyseerrClient(ApiClient):
return await self.get(f"/api/v1/tv/{tmdb_id}")
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
# Seerr rejects the `+` encoding that standard query builders use for
# spaces. Build this query explicitly so multi-word titles are sent as
# percent-encoded values.
encoded_query = quote(query, safe="")
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
async def get_service_settings(self, media_type: str) -> Optional[Any]:
service = "sonarr" if media_type == "tv" else "radarr"
return await self.get(f"/api/v1/settings/{service}")
async def create_request(
self,
*,
media_type: str,
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
server_id: Optional[int] = None,
profile_id: Optional[int] = None,
root_folder: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"mediaType": media_type,
"mediaId": media_id,
}
if isinstance(seasons, list) and seasons:
payload["seasons"] = seasons
if isinstance(is_4k, bool):
payload["is4k"] = is_4k
if isinstance(server_id, int):
payload["serverId"] = server_id
if isinstance(profile_id, int):
payload["profileId"] = profile_id
if isinstance(root_folder, str) and root_folder.strip():
payload["rootFolder"] = root_folder.strip()
return await self.post("/api/v1/request", payload=payload)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/search",
"/api/v1/user",
params={
"query": query,
"page": page,
"take": take,
"skip": skip,
},
)
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/user/{user_id}")
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.delete(f"/api/v1/user/{user_id}")
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
payload = {"email": email, "password": password}
try:
return await self.post("/api/v1/auth/local", payload=payload)
except httpx.HTTPStatusError as exc:
# Backward compatibility for older Seerr/Overseerr deployments
# that still expose /auth/login instead of /auth/local.
if exc.response is not None and exc.response.status_code in {404, 405}:
return await self.post("/api/v1/auth/login", payload=payload)
raise
+118
View File
@@ -0,0 +1,118 @@
"""Jellystat API adapter. Credentials and raw history never leave the backend."""
import asyncio
import json
import re
from datetime import datetime
import httpx
from .base import ApiClient
class JellystatError(Exception):
pass
class HistoryLimitError(JellystatError):
pass
def same_user_id(left, right) -> bool:
return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
class JellystatClient(ApiClient):
PAGE_SIZE = 200
MAX_PAGES = 50
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
try:
response = await client.request(method, f"{self.base_url}{path}",
headers={"x-api-token": self.api_key}, **kwargs)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise JellystatError("Jellystat did not return a valid response") from exc
async def test_connection(self) -> dict:
# This protected endpoint confirms API authentication without returning user data.
async with httpx.AsyncClient(timeout=10.0) as client:
result = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(result, list):
raise JellystatError("Jellystat returned an unexpected library response")
return {"connected": True}
async def check_user_ids(self, user_ids: list[str]) -> dict:
"""Read metadata for known identities; never scan everyone's playback history."""
if not self.configured():
return {user_id: {"state": "not_configured"} for user_id in user_ids}
results = {user_id: {"state": "unavailable"} for user_id in user_ids}
semaphore = asyncio.Semaphore(6)
async with httpx.AsyncClient(timeout=8.0) as client:
async def check(user_id):
if not re.fullmatch(r"[a-f0-9]{32}", user_id):
return
async with semaphore:
try:
response = await client.post(f"{self.base_url}/api/getUserDetails",
headers={"x-api-token": self.api_key}, json={"userid": user_id})
if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
results[user_id] = {"state": "missing"}
return
response.raise_for_status()
row = response.json()
if row is None:
results[user_id] = {"state": "missing"}
elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
except (httpx.HTTPError, ValueError):
pass
try:
async with asyncio.timeout(25):
await asyncio.gather(*(check(user_id) for user_id in user_ids))
except TimeoutError:
pass
return results
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
raise JellystatError("Invalid linked Jellyfin identity")
# Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
try:
async with asyncio.timeout(30):
async with httpx.AsyncClient(timeout=10.0) as client:
libraries = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
raise JellystatError("Jellystat returned an unexpected library response")
history = []
for page in range(1, self.MAX_PAGES + 1):
payload = await self._read(client, "POST", "/api/getUserHistory",
json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
"sort": "ActivityDateInserted", "desc": "true", "filters": filters})
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
raise JellystatError("Jellystat returned an unexpected history response")
rows = payload["results"]
try:
pages = int(payload["pages"])
except (KeyError, TypeError, ValueError) as exc:
raise JellystatError("Jellystat did not return history pagination") from exc
if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
raise JellystatError("Jellystat returned invalid history pagination")
if pages > self.MAX_PAGES:
raise HistoryLimitError("Select a shorter period to view this history")
for row in rows:
if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
raise JellystatError("Jellystat returned history for an unexpected account")
history.extend(rows)
if page >= pages:
return history, libraries
if not rows:
raise JellystatError("Jellystat returned incomplete history")
except TimeoutError as exc:
raise JellystatError("Jellystat took too long to return history") from exc
raise HistoryLimitError("Select a shorter period to view this history")
+147 -17
View File
@@ -1,7 +1,64 @@
from typing import Any, Dict, Optional
import httpx
import logging
from .base import ApiClient
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
return "finished"
if "pause" in normalized or normalized == "stoppeddl":
return "paused"
if "stall" in normalized:
return "waiting for data"
if normalized.startswith("queued"):
return "waiting in the queue"
if normalized == "metadl":
return "getting the download details"
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
return "checking the downloaded files"
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
return "downloading"
if "upload" in normalized:
return "downloaded and sharing with others"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "unable to continue"
return "present"
def _torrent_result_message(result: Any) -> str:
torrents = result if isinstance(result, list) else []
if not torrents:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
progress = first.get("progress")
progress_text = (
f"{max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
state_text = _torrent_state_text(first.get("state"))
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
active = sum(
1
for item in torrents
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
)
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
def _torrent_action_message(path: str) -> str:
normalized_path = path.lower()
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
return "qBittorrent accepted the request to resume the download."
if normalized_path.endswith("/add"):
return "qBittorrent accepted the release and added it to the download queue."
return "qBittorrent accepted the requested download action."
class QBittorrentClient(ApiClient):
@@ -23,34 +80,100 @@ class QBittorrentClient(ApiClient):
headers={"Referer": self.base_url},
)
response.raise_for_status()
if response.text.strip().lower() != "ok.":
text = response.text.strip().lower()
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
raise RuntimeError("qBittorrent login failed")
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.json()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
result = response.json()
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_result_message(result),
)
return result
except Exception as exc:
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.text.strip()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
result = response.text.strip()
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
)
return result
except Exception as exc:
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_action_message(path),
)
except Exception as exc:
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def is_webui_reachable(self) -> bool:
if not self.base_url:
return False
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(self.base_url)
response.raise_for_status()
return True
except httpx.HTTPError:
return False
async def get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info")
@@ -61,6 +184,9 @@ class QBittorrentClient(ApiClient):
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"category": category})
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"tag": tag})
async def get_app_version(self) -> Optional[Any]:
return await self._get_text("/api/v2/app/version")
@@ -73,7 +199,9 @@ class QBittorrentClient(ApiClient):
return
raise
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
async def add_torrent_url(
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
@@ -85,4 +213,6 @@ class QBittorrentClient(ApiClient):
data: Dict[str, Any] = {"urls": url}
if category:
data["category"] = category
if tags:
data["tags"] = tags
await self._post_form("/api/v2/torrents/add", data=data)
+37 -1
View File
@@ -9,6 +9,13 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
return result if isinstance(result, dict) else None
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie")
@@ -19,7 +26,12 @@ class RadarrClient(ApiClient):
return await self.get("/api/v3/qualityprofile")
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
return await self.get("/api/v3/queue", params={"movieIds": movie_id, "pageSize": 1000})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
)
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
@@ -27,6 +39,21 @@ class RadarrClient(ApiClient):
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
async def monitor_movie(
self, movie_id: int, monitored: bool = True
) -> Optional[Dict[str, Any]]:
movie = await self.get_movie(movie_id)
if not isinstance(movie, dict):
raise ValueError("Radarr did not return the movie before updating its monitored state")
movie["monitored"] = monitored
return await self.update_movie(movie)
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/moviefile/{movie_file_id}",
params={"deleteFromClient": "true"},
)
async def add_movie(
self,
tmdb_id: int,
@@ -34,9 +61,15 @@ class RadarrClient(ApiClient):
root_folder: str,
monitored: bool = True,
search_for_movie: bool = True,
title: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Radarr could not resolve a title for this TMDB ID")
payload = {
"tmdbId": tmdb_id,
"title": resolved_title,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
@@ -44,6 +77,9 @@ class RadarrClient(ApiClient):
}
return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+68 -3
View File
@@ -9,6 +9,23 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
if not isinstance(result, list):
return None
for item in result:
if not isinstance(item, dict):
continue
try:
if int(item.get("tvdbId")) == tvdb_id:
return item
except (TypeError, ValueError):
continue
return next((item for item in result if isinstance(item, dict)), None)
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder")
@@ -16,7 +33,22 @@ class SonarrClient(ApiClient):
return await self.get("/api/v3/qualityprofile")
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
records = []
page = 1
while True:
result = await self.get("/api/v3/queue", params={
"seriesIds": series_id, "includeEpisode": "true",
"page": page, "pageSize": 100,
})
if not isinstance(result, dict) or not isinstance(result.get("records"), list):
raise ValueError("Sonarr returned an invalid queue")
batch = result["records"]
records.extend(batch)
if not batch or len(records) >= int(result.get("totalRecords", len(records))):
return {**result, "records": records, "totalRecords": len(records)}
page += 1
if page > 100:
raise ValueError("Sonarr queue exceeded the safe paging limit")
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
@@ -24,12 +56,39 @@ class SonarrClient(ApiClient):
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get(
"/api/v3/release",
params={"seriesId": series_id, "seasonNumber": season_number},
timeout_seconds=90.0,
)
async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
return await self.get('/api/v3/release', params={'episodeId': episode_id}, timeout_seconds=90.0)
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
async def monitor_episodes(
self, episode_ids: list[int], monitored: bool = True
) -> Optional[Dict[str, Any]]:
return await self.put(
"/api/v3/episode/monitor",
payload={"episodeIds": episode_ids, "monitored": monitored},
)
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/episodefile/{episode_file_id}",
params={"deleteFromClient": "true"},
)
async def add_series(
self,
tvdb_id: int,
@@ -39,18 +98,24 @@ class SonarrClient(ApiClient):
title: Optional[str] = None,
search_missing: bool = True,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
payload = {
"tvdbId": tvdb_id,
"title": resolved_title,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
"seasonFolder": True,
"addOptions": {"searchForMissingEpisodes": search_missing},
}
if title:
payload["title"] = title
return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/series", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
+249 -9
View File
@@ -1,22 +1,90 @@
import re
from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def normalize_banner_color(value: object) -> Optional[str]:
color = str(value or "").strip().lower()
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
)
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=120, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER"))
jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE"))
settings_encryption_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY")
)
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
password_reset_rate_limit_window_seconds: int = Field(
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
)
password_reset_rate_limit_max_attempts_ip: int = Field(
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
password_reset_rate_limit_max_attempts_identifier: int = Field(
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
auth_cookie_secure: bool = Field(
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
)
auth_state_cookie_name: str = Field(
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field(
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
)
log_file_backup_count: int = Field(
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
)
log_http_client_level: str = Field(
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
)
log_background_sync_level: str = Field(
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
)
requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
)
requests_stage_refresh_minutes: int = Field(default=15, ge=1, le=1440, validation_alias=AliasChoices("REQUESTS_STAGE_REFRESH_MINUTES"))
requests_poll_interval_seconds: int = Field(
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
)
@@ -35,12 +103,19 @@ class Settings(BaseSettings):
requests_data_source: str = Field(
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
)
issue_confirmation_contact_attempts: int = Field(
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
)
issue_confirmation_interval_value: int = Field(
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
)
issue_confirmation_interval_unit: str = Field(
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
)
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
@@ -50,8 +125,158 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
site_banner_background_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
)
site_banner_border_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
)
site_login_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
)
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
site_login_show_local_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
)
site_login_show_forgot_password: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
)
site_login_show_signup_link: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
)
site_nav_show_requests: bool = Field(
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
)
magent_application_port: int = Field(
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
)
magent_api_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
)
magent_api_port: int = Field(
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
)
magent_bind_host: str = Field(
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
)
magent_proxy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
)
magent_proxy_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
)
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_trusted_proxies: str = Field(
default="127.0.0.1,::1",
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
magent_ssl_bind_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
)
magent_ssl_certificate_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
)
magent_ssl_private_key_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
)
magent_ssl_certificate_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
)
magent_ssl_private_key_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
)
magent_notify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
)
magent_notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
)
magent_notify_email_smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
)
magent_notify_email_smtp_port: int = Field(
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
)
magent_notify_email_smtp_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
)
magent_notify_email_smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
)
magent_notify_email_from_address: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
)
magent_notify_email_from_name: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
)
magent_notify_email_use_tls: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
)
magent_notify_email_use_ssl: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
)
magent_notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
)
magent_notify_discord_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
)
magent_notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
)
magent_notify_telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
)
magent_notify_telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
)
magent_notify_push_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
)
magent_notify_push_provider: Optional[str] = Field(
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
)
magent_notify_push_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
)
magent_notify_push_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
)
magent_notify_push_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
)
magent_notify_push_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
)
magent_notify_push_device: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
)
magent_notify_webhook_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
)
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
magent_allow_private_notification_targets: bool = Field(
default=False,
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
)
jellyseerr_base_url: Optional[str] = Field(
@@ -60,6 +285,11 @@ class Settings(BaseSettings):
jellyseerr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
)
jellystat_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
)
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
jellyfin_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
)
@@ -107,6 +337,16 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
bazarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
)
bazarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
)
bazarr_default_language: str = Field(
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
)
@@ -125,7 +365,7 @@ class Settings(BaseSettings):
)
discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
+3456 -182
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
"""Live account permissions. Invite access uses the existing users column."""
from .db import _connect
FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
def permissions(user: dict) -> dict[str, bool]:
if user.get("role") == "admin":
return dict.fromkeys(FEATURES, True)
values = dict.fromkeys(FEATURES, True)
values["ignore_profile_limits"] = False
values["invites"] = bool(user.get("invite_management_enabled", False))
with _connect() as conn:
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
(user.get("username", ""),)).fetchall()
values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
return values
def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
raise ValueError("Choose valid features with true or false values")
with _connect() as conn:
conn.execute("BEGIN IMMEDIATE")
users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
(" AND username = ? COLLATE NOCASE" if username is not None else ""),
(username,) if username is not None else ()).fetchall()
for (user_id,) in users:
for feature, enabled in changes.items():
if feature == "invites":
conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
else:
conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
(user_id, feature, int(enabled)))
return len(users)
+75
View File
@@ -0,0 +1,75 @@
from fastapi import Depends, HTTPException, Request
from .auth import get_current_user, get_current_user_event_stream
from .db import get_portal_item
def check(user: dict, *features: str) -> None:
access = user.get("features") or {}
if user.get("role") == "admin":
return
if not any(access.get(feature, False) for feature in features):
raise HTTPException(status_code=403, detail="This feature is disabled for your account")
def require_stats(user: dict = Depends(get_current_user)) -> dict:
check(user, "stats")
return user
def require_invites(user: dict = Depends(get_current_user)) -> dict:
check(user, "invites")
return user
def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
path = request.url.path.rstrip("/")
if path.endswith("/search") and "/actions/" not in path:
# The issue picker uses the same media search; creation is checked separately.
check(user, "new_requests", "issues")
elif path.endswith(("/create", "/request-options")):
check(user, "new_requests")
elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
check(user, "issues")
else:
check(user, "requests")
async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
if user.get("role") == "admin":
return
path = request.url.path.rstrip("/")
access = user.get("features", {})
if access.get("requests") and access.get("issues") and access.get("new_requests"):
return
if "/issues" in path:
check(user, "issues")
elif path.endswith("/requests") or path.endswith("/pipeline"):
check(user, "requests")
elif "item_id" in request.path_params:
try:
item = get_portal_item(int(request.path_params["item_id"]))
except (ValueError, TypeError):
item = None
if not item:
raise HTTPException(status_code=404, detail="Item not found")
check(user, "requests" if item.get("kind") == "request" else "issues")
elif path.endswith("/items") and request.method == "POST":
payload = await request.json()
kind = str(payload.get("kind") or "").strip().lower() if isinstance(payload, dict) else ""
check(user, "new_requests" if not kind or kind == "request" else "issues")
elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
kind = request.query_params["kind"].strip().lower()
if not kind:
check(user, "requests")
check(user, "issues")
else:
check(user, "requests" if kind == "request" else "issues")
else:
# Unfiltered lists/overview can include both kinds.
check(user, "requests")
check(user, "issues")
def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
check(user, "requests")
return user
+190 -8
View File
@@ -1,10 +1,174 @@
import contextvars
import json
import logging
import os
import re
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Optional
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
"magent_request_id", default="-"
)
_SENSITIVE_KEYWORDS = (
"api_key",
"authorization",
"cert",
"cookie",
"jwt",
"key",
"pass",
"password",
"pem",
"private",
"secret",
"session",
"signature",
"token",
)
_MAX_BODY_BYTES = 4096
_SENSITIVE_PATH_PATTERNS = (
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
)
def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None:
class RequestContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = REQUEST_ID_CONTEXT.get("-")
return True
class JsonLogFormatter(logging.Formatter):
"""Stable JSON output for production log collectors."""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "-"),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def bind_request_id(request_id: str) -> contextvars.Token[str]:
return REQUEST_ID_CONTEXT.set(request_id or "-")
def reset_request_id(token: contextvars.Token[str]) -> None:
REQUEST_ID_CONTEXT.reset(token)
def current_request_id() -> str:
return REQUEST_ID_CONTEXT.get("-")
def sanitize_path(path: str) -> str:
sanitized = str(path or "")
for pattern in _SENSITIVE_PATH_PATTERNS:
sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
return sanitized
def _is_sensitive_key(key: str) -> bool:
lowered = key.strip().lower()
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
def _redact_scalar(value: Any) -> Any:
if value is None or isinstance(value, (int, float, bool)):
return value
return "[REDACTED]"
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
if key_hint and _is_sensitive_key(key_hint):
return _redact_scalar(value)
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, bytes):
return f"<bytes:{len(value)}>"
if isinstance(value, str):
return value if len(value) <= 512 else f"{value[:509]}..."
if depth >= 3:
return f"<{type(value).__name__}>"
if isinstance(value, Mapping):
return {
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
for key, item in value.items()
}
if isinstance(value, (list, tuple, set)):
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
if hasattr(value, "model_dump"):
try:
return sanitize_value(value.model_dump(), depth=depth + 1)
except Exception:
return f"<{type(value).__name__}>"
return str(value)
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key).lower(): sanitize_value(value, key_hint=str(key))
for key, value in headers.items()
}
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
if not body:
return None
normalized = (content_type or "").split(";")[0].strip().lower()
if normalized == "application/json":
preview = body[:_MAX_BODY_BYTES]
try:
payload = json.loads(preview.decode("utf-8"))
summary = sanitize_value(payload)
if len(body) > _MAX_BODY_BYTES:
return {"truncated": True, "bytes": len(body), "payload": summary}
return summary
except Exception:
pass
if normalized == "application/x-www-form-urlencoded":
try:
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
compact = {
key: value[0] if len(value) == 1 else value
for key, value in parsed.items()
}
return sanitize_value(compact)
except Exception:
pass
if normalized.startswith("multipart/"):
return {"content_type": normalized, "bytes": len(body)}
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
return {
"content_type": normalized or "unknown",
"bytes": len(body),
"preview": preview if len(body) <= 256 else f"{preview}...",
}
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
if not level_name:
return fallback
return getattr(logging, str(level_name).upper(), fallback)
def configure_logging(
log_level: Optional[str],
log_file: Optional[str],
*,
log_file_max_bytes: int = 20_000_000,
log_file_backup_count: int = 10,
log_http_client_level: Optional[str] = "INFO",
log_background_sync_level: Optional[str] = "INFO",
log_format: Optional[str] = "text",
) -> None:
level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@@ -18,15 +182,27 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
log_path = os.path.join(os.getcwd(), log_path)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
file_handler = RotatingFileHandler(
log_path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
log_path,
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
backupCount=max(1, int(log_file_backup_count or 10)),
encoding="utf-8",
)
try:
os.chmod(log_path, 0o600)
except OSError:
pass
handlers.append(file_handler)
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
context_filter = RequestContextFilter()
if str(log_format or "text").strip().lower() == "json":
formatter: logging.Formatter = JsonLogFormatter()
else:
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter)
root = logging.getLogger()
@@ -38,4 +214,10 @@ def configure_logging(log_level: Optional[str], log_file: Optional[str]) -> None
logging.getLogger("uvicorn").setLevel(level)
logging.getLogger("uvicorn.error").setLevel(level)
logging.getLogger("uvicorn.access").setLevel(level)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
logging.getLogger("app.clients.base").setLevel(http_client_level)
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+270 -13
View File
@@ -1,29 +1,69 @@
import asyncio
import logging
import os
import time
import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings
from .db import init_db, set_setting
from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
run_local_request_stage_loop,
run_requests_delta_loop,
run_daily_requests_full_sync,
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
from .routers.admin import router as admin_router
from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .routers.recaps import router as recaps_router
from .routers.newsletters import router as newsletters_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging
from .services.issue_resolution import run_issue_confirmation_loop
from .services.email_recaps import run_email_recap_loop
from .services.newsletters import run_newsletter_loop
from .services.operation_progress import (
begin_operation,
finish_operation,
normalize_operation_id,
reset_operation,
)
from .logging_config import (
bind_request_id,
configure_logging,
reset_request_id,
sanitize_headers,
sanitize_path,
)
from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .secret_storage import validate_secret_storage_configuration
from .services.request_origins import is_allowed_request_origin
app = FastAPI(title=settings.app_name)
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
app = FastAPI(
title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
)
app.add_middleware(
CORSMiddleware,
@@ -34,29 +74,246 @@ app.add_middleware(
)
@app.middleware("http")
async def log_requests_and_add_security_headers(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = bind_request_id(request_id)
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
operation_token = None
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
operation_token = begin_operation(
operation_id,
label=request.headers.get("X-Magent-Operation-Label"),
path=sanitize_path(request.url.path),
)
request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "")
if origin and not is_allowed_request_origin(origin):
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
reset_operation(operation_token)
reset_request_id(token)
return JSONResponse(
status_code=403,
content={"detail": "Cross-origin state change rejected"},
headers={"X-Request-ID": request_id},
)
started_at = time.perf_counter()
body_summary = {
"content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
"declared_bytes": request.headers.get("content-length"),
}
logger.info(
"request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
request.method,
sanitize_path(request.url.path),
sorted(set(request.query_params.keys())),
request.client.host if request.client else "-",
sanitize_headers(
{
key: value
for key, value in request.headers.items()
if key.lower()
in {
"content-type",
"content-length",
"user-agent",
"x-forwarded-for",
"x-forwarded-proto",
"x-request-id",
}
}
),
body_summary,
)
try:
response = await call_next(request)
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
record_api(request, 500, time.perf_counter() - started_at)
logger.exception(
"request failed method=%s path=%s duration_ms=%s",
request.method,
sanitize_path(request.url.path),
duration_ms,
)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=500)
reset_operation(operation_token)
reset_request_id(token)
raise
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
record_api(request, response.status_code, time.perf_counter() - started_at)
response.headers.setdefault("X-Request-ID", request_id)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
)
logger.info(
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
request.method,
sanitize_path(request.url.path),
response.status_code,
duration_ms,
sanitize_headers(
{
key: value
for key, value in response.headers.items()
if key.lower() in {"content-type", "content-length", "x-request-id"}
}
),
)
if operation_id and operation_token is not None:
finish_operation(
operation_id,
success=response.status_code < 400,
status_code=response.status_code,
)
reset_operation(operation_token)
reset_request_id(token)
return response
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
async def _run_background_task(
name: str, coroutine_factory: Callable[[], Awaitable[None]]
) -> None:
token = bind_request_id(f"task-{name}")
logger.info("background task started task=%s", name)
try:
await coroutine_factory()
logger.warning("background task exited task=%s", name)
except asyncio.CancelledError:
logger.info("background task cancelled task=%s", name)
raise
except Exception:
logger.exception("background task crashed task=%s", name)
raise
finally:
reset_request_id(token)
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
task = asyncio.create_task(
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
)
_background_tasks.append(task)
def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if len(jwt_secret) < 32 or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
)
def _enforce_secret_configuration() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
)
validate_secret_storage_configuration()
def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
)
@app.on_event("startup")
async def startup() -> None:
start_metrics()
configure_logging(
settings.log_level,
settings.log_file,
log_file_max_bytes=settings.log_file_max_bytes,
log_file_backup_count=settings.log_file_backup_count,
log_http_client_level=settings.log_http_client_level,
log_background_sync_level=settings.log_background_sync_level,
log_format=settings.log_format,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
_enforce_secret_configuration()
init_db()
if settings.site_build_number and settings.site_build_number.strip():
set_setting("site_build_number", settings.site_build_number.strip())
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup())
configure_logging(
runtime.log_level,
runtime.log_file,
log_file_max_bytes=runtime.log_file_max_bytes,
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
logger.info(
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
runtime.log_level,
runtime.log_file,
runtime.log_file_max_bytes,
runtime.log_file_backup_count,
runtime.log_http_client_level,
runtime.log_background_sync_level,
runtime.requests_data_source,
)
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation paused for initial setup")
return
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("request-local-stages", run_local_request_stage_loop)
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
_launch_background_task("email-recaps", run_email_recap_loop)
_launch_background_task("newsletters", run_newsletter_loop)
logger.info("startup complete")
app.include_router(requests_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)
app.include_router(portal_router)
app.include_router(operations_router)
app.include_router(insights_router)
app.include_router(identities_router)
app.include_router(recaps_router)
app.include_router(newsletters_router)
+27
View File
@@ -0,0 +1,27 @@
"""Low-cardinality operational metrics; no URLs, query values or user data."""
import os
from prometheus_client import Counter, Histogram, start_http_server
BUCKETS = (.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60)
API_CALLS = Counter('magent_api_requests_total', 'API responses by route template', ['method', 'route', 'status'])
API_TIME = Histogram('magent_api_response_seconds', 'Time until response headers (not stream lifetime)', ['method', 'route'], buckets=BUCKETS)
REMOTE_CALLS = Counter('magent_remote_requests_total', 'Logical service client calls', ['service', 'method', 'status'])
REMOTE_TIME = Histogram('magent_remote_response_seconds', 'Logical service client call duration', ['service', 'method'], buckets=BUCKETS)
_server = None
def start_metrics():
global _server
if _server is None and os.getenv('MAGENT_METRICS_ENABLED', '').lower() == 'true':
_server = start_http_server(int(os.getenv('MAGENT_METRICS_PORT', '9108')), addr=os.getenv('MAGENT_METRICS_BIND', '127.0.0.1'))
def record_api(request, status, seconds):
route = getattr(request.scope.get('route'), 'path', 'unmatched')
method = request.method if request.method in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
API_CALLS.labels(method, route, str(status)).inc()
API_TIME.labels(method, route).observe(max(0, seconds))
def record_remote(service, method, status, seconds):
service = service if service in {'Seerr', 'Jellyfin', 'Sonarr', 'Radarr', 'Bazarr', 'Prowlarr', 'qBittorrent'} else 'Other'
method = method.upper() if method.upper() in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
REMOTE_CALLS.labels(service, method, str(status)).inc()
REMOTE_TIME.labels(service, method).observe(max(0, seconds))
+2
View File
@@ -35,6 +35,7 @@ class ActionOption(BaseModel):
id: str
label: str
risk: str
description: Optional[str] = None
requires_confirmation: bool = True
@@ -48,6 +49,7 @@ class Snapshot(BaseModel):
timeline: List[TimelineHop] = Field(default_factory=list)
actions: List[ActionOption] = Field(default_factory=list)
artwork: Dict[str, Any] = Field(default_factory=dict)
presentation: Dict[str, Any] = Field(default_factory=dict)
raw: Dict[str, Any] = Field(default_factory=dict)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import ipaddress
import socket
from functools import lru_cache
from typing import Iterable
from urllib.parse import urlparse
from .config import settings
_METADATA_HOSTS = {
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
}
def _normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _split_csv(value: object) -> list[str]:
raw = _normalize_text(value)
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
return bool(
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_multicast
or ip_obj.is_unspecified
or ip_obj.is_reserved
or ip_obj.is_private
)
@lru_cache(maxsize=256)
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
resolved: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
if family == socket.AF_INET:
resolved.append(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.append(ipaddress.ip_address(sockaddr[0]))
return tuple(resolved)
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
candidate = _normalize_text(host)
if not candidate:
return False
try:
host_ip = ipaddress.ip_address(candidate)
except ValueError:
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
for entry in trusted_proxies:
raw = _normalize_text(entry)
if not raw:
continue
try:
if "/" in raw:
if host_ip in ipaddress.ip_network(raw, strict=False):
return True
elif host_ip == ipaddress.ip_address(raw):
return True
except ValueError:
continue
return False
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
return False
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
if not trusted:
return False
return _is_trusted_proxy_host(client_host or "", trusted)
def validate_notification_target_url(
url: str,
*,
allow_private: bool | None = None,
) -> str:
raw = _normalize_text(url)
if not raw:
raise ValueError("URL cannot be empty.")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if parsed.username or parsed.password:
raise ValueError("URL must not embed credentials.")
hostname = _normalize_text(parsed.hostname).lower()
if not hostname:
raise ValueError("URL must include a valid host.")
allow_private_targets = (
settings.magent_allow_private_notification_targets
if allow_private is None
else bool(allow_private)
)
if hostname in _METADATA_HOSTS:
raise ValueError("Metadata service targets are not allowed.")
if hostname == "localhost" and not allow_private_targets:
raise ValueError("Local notification targets are not allowed.")
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
if host_ip is not None:
if _ip_is_sensitive(host_ip) and not allow_private_targets:
raise ValueError("Private or local notification targets are not allowed.")
return raw
try:
resolved_ips = _resolve_host_ips(hostname)
except socket.gaierror as exc:
raise ValueError("Host could not be resolved.") from exc
if not resolved_ips:
raise ValueError("Host could not be resolved.")
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
raise ValueError("Private or local notification targets are not allowed.")
return raw
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+58 -16
View File
@@ -1,8 +1,9 @@
import os
import warnings
from io import BytesIO
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont
@@ -11,6 +12,14 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
_MAX_IMAGE_PIXELS = 25_000_000
_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
def _ensure_branding_dir() -> None:
@@ -41,6 +50,18 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
@@ -65,35 +86,56 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH):
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH):
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Please upload an image file.")
content = await file.read()
content_type = str(file.content_type or "").lower()
extension = os.path.splitext(str(file.filename or ""))[1].lower()
if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
try:
image = Image.open(BytesIO(content))
except OSError as exc:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
candidate = Image.open(BytesIO(content))
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
raise ValueError("Unsupported image format")
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
raise Image.DecompressionBombError("Image pixel limit exceeded")
candidate.verify()
image = Image.open(BytesIO(content))
image.load()
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
_ensure_branding_dir()
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..feature_guards import require_request_stream, check
from ..feature_access import permissions
from ..db import get_user_by_username
from . import requests as requests_router
router = APIRouter(prefix="/events", tags=["events"])
def _sse_json(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
try:
return value.model_dump(mode="json")
except TypeError:
return value.model_dump()
if hasattr(value, "dict"):
try:
return value.dict()
except TypeError:
return value
return value
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"state": entry.get("state"),
"state_reason": entry.get("state_reason"),
"created_at": entry.get("created_at"),
}
)
return items
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"action_id": entry.get("action_id"),
"label": entry.get("label"),
"status": entry.get("status"),
"message": entry.get("message"),
"created_at": entry.get("created_at"),
}
)
return items
@router.get("/stream")
async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
next_recent_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
stage=recent_stage,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"stage": recent_stage,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/requests/{request_id}/stream")
async def request_events_stream(
request_id: str,
request: Request,
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
raise HTTPException(status_code=400, detail="Missing request id")
async def event_generator():
yield "retry: 2000\n\n"
last_signature: Optional[str] = None
next_refresh_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
if now >= next_refresh_at:
next_refresh_at = now + 2.0
try:
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
history_payload = await requests_router.request_history(
request_id=request_id, limit=5, user=user
)
actions_payload = await requests_router.request_actions(
request_id=request_id, limit=5, user=user
)
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"snapshot": _jsonable(snapshot),
"history": _request_history_brief(
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
),
"actions": _request_actions_brief(
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
),
}
except HTTPException as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc.detail),
"status_code": int(exc.status_code),
}
except Exception as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_signature:
last_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+9 -1
View File
@@ -3,6 +3,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@@ -11,9 +12,16 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url
webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")
try:
webhook_url = validate_notification_target_url(webhook_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}:
+99
View File
@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import require_admin
from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity
from ..services.duplicate_accounts import repair_duplicates
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
class Confirmation(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
user_ids: list[int] = Field(min_length=1, max_length=3000)
@field_validator("user_ids")
@classmethod
def unique_positive_ids(cls, value):
if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
raise ValueError("Choose unique positive user IDs")
return value
@router.get("")
async def review(response: Response):
response.headers["Cache-Control"] = "no-store"
report, _, _ = await review_identities()
return report
@router.post("/confirm")
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await confirm_identities(payload.revision, payload.user_ids, admin)
class Resolution(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: int = Field(gt=0, strict=True)
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ResolutionConfirmation(Resolution):
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
@router.post("/resolve/check")
async def check_resolution(payload: Resolution, response: Response):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
@router.post("/resolve/confirm")
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
class RepairResolution(Resolution):
create_seerr: bool = Field(default=False, strict=True)
class RepairConfirmation(RepairResolution):
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
@router.post('/repair/check')
async def check_repair(payload: RepairResolution, response: Response):
response.headers['Cache-Control'] = 'no-store'
return await repair_identity(payload.user_id, payload.jellyfin_user_id, create_seerr=payload.create_seerr)
@router.post('/repair/confirm')
async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers['Cache-Control'] = 'no-store'
return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr)
class DuplicateCheck(BaseModel):
model_config = ConfigDict(extra='forbid')
user_id: int = Field(gt=0, strict=True)
keep_id: int | None = Field(default=None, gt=0, strict=True)
class DuplicateConfirmation(DuplicateCheck):
keep_id: int = Field(gt=0, strict=True)
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
@router.post('/duplicates/check')
async def check_duplicates(payload: DuplicateCheck, response: Response):
response.headers['Cache-Control'] = 'no-store'
return await repair_duplicates(payload.user_id, payload.keep_id)
@router.post('/duplicates/confirm')
async def confirm_duplicates(payload: DuplicateConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers['Cache-Control'] = 'no-store'
return await repair_duplicates(payload.user_id, payload.keep_id, payload.revision, admin)
+31 -13
View File
@@ -1,7 +1,9 @@
import os
import re
import mimetypes
from fastapi import APIRouter, HTTPException, Response
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
import httpx
@@ -11,6 +13,7 @@ router = APIRouter(prefix="/images", tags=["images"])
_TMDB_BASE = "https://image.tmdb.org/t/p"
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
logger = logging.getLogger(__name__)
def _safe_filename(path: str) -> str:
@@ -19,13 +22,24 @@ def _safe_filename(path: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image"
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
if not path or "://" in path or ".." in path:
return False
return None
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
return None
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
return os.path.join(cache_dir, _safe_filename(path))
def is_tmdb_cached(path: str, size: str) -> bool:
file_path = tmdb_cache_path(path, size)
return bool(file_path and os.path.exists(file_path))
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if not path or "://" in path or ".." in path:
return False
runtime = get_runtime_settings()
@@ -33,9 +47,10 @@ async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if cache_mode != "cache":
return False
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
file_path = tmdb_cache_path(path, size)
if not file_path:
return False
os.makedirs(os.path.dirname(file_path), exist_ok=True)
if os.path.exists(file_path):
return True
@@ -64,9 +79,10 @@ async def tmdb_image(path: str, size: str = "w342"):
if cache_mode != "cache":
return RedirectResponse(url=url)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
file_path = tmdb_cache_path(path, size)
if not file_path:
raise HTTPException(status_code=400, detail="Invalid image path")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
@@ -77,6 +93,8 @@ async def tmdb_image(path: str, size: str = "w342"):
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers)
raise HTTPException(status_code=502, detail="Image cache failed")
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
except (httpx.HTTPError, OSError) as exc:
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
return RedirectResponse(url=url)
+78
View File
@@ -0,0 +1,78 @@
from ..feature_guards import require_stats
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..services.insights import get_insights
from ..services.insights_artwork import get_artwork
from ..services.monthly_reports import get_monthly_report, report_csv
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
class MonthlyReportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
async def monthly_data(user: dict, month: str | None) -> dict:
try:
return await get_monthly_report(user, month)
except ValueError as exc:
raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
except HistoryLimitError as exc:
raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
except JellystatError as exc:
raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
@router.get("/reports/monthly")
async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
user: dict = Depends(get_current_user)) -> dict:
response.headers["Cache-Control"] = "no-store"
return await monthly_data(user, query.month)
@router.get("/reports/monthly.csv")
async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
report = await monthly_data(user, query.month)
if report["state"] != "ready":
raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
"Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
@router.get("/artwork/{item_id}")
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
return Response(content=content, media_type=media_type,
headers={"Cache-Control": "private, max-age=600", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"})
class InsightsQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
days: int = 30
@field_validator("days")
@classmethod
def supported_period(cls, value: int) -> int:
if value not in {7, 30, 90, 365}:
raise ValueError("Choose 7, 30, 90 or 365 days")
return value
@router.get("")
async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
user: dict = Depends(get_current_user)) -> dict:
response.headers["Cache-Control"] = "no-store"
try:
return await get_insights(user, query.days)
except HistoryLimitError as exc:
raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
except JellystatError as exc:
raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
+193
View File
@@ -0,0 +1,193 @@
import time
from datetime import datetime, timezone
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin
from ..runtime import get_runtime_settings
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache
router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)])
class Settings(StrictPayload):
enabled: bool
weekday: int = Field(ge=0, le=6)
hour: int = Field(ge=0, le=23)
limit_titles: int = Field(ge=1, le=24)
public_url: str = Field(default="", max_length=500)
intro: str = Field(default='', max_length=2000)
revision: int = Field(ge=1)
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
class NewDraft(StrictPayload):
days: Literal[7, 14, 30] = 7
class Selection(StrictPayload):
id: str = Field(pattern=r'^[a-f0-9]{32}$')
selected: bool
featured: bool
class Version(StrictPayload):
revision: int = Field(ge=1)
class EditionUpdate(Version):
subject: str = Field(min_length=1, max_length=150)
intro: str = Field(default='', max_length=2000)
titles: list[Selection] = Field(max_length=60)
@field_validator('subject')
@classmethod
def subject_line(cls, value):
value = value.strip()
if not value or any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError('Use a single, non-empty subject line.')
return value
class Test(Version):
request_id: UUID
class Publish(Version):
send_at: datetime | None = None
def fail(exc):
if isinstance(exc, service.NewsletterError):
raise HTTPException(exc.status, exc.detail) from exc
if isinstance(exc, store.Conflict):
raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc
raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc
@router.get('/profile/newsletters')
def preference(user: dict = Depends(get_current_user)):
try:
return service.preferences(user)
except service.NewsletterError as exc:
fail(exc)
@router.put('/profile/newsletters')
async def set_preference(payload: Preference, user: dict = Depends(get_current_user)):
try:
if payload.enabled:
return await service.subscribe(user)
store.disable(service.account_for(user)['id'])
return service.preferences(user)
except service.NewsletterError as exc:
fail(exc)
@router.post('/newsletter-subscription/check')
def check_token(payload: TokenAction):
try:
return service.token_action(payload.token, payload.action)
except service.NewsletterError as exc:
fail(exc)
@router.post('/newsletter-subscription/confirm')
def confirm_token(payload: TokenAction):
try:
return service.token_action(payload.token, payload.action, apply=True)
except service.NewsletterError as exc:
fail(exc)
@router.get('/admin/newsletters')
def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)):
ready, detail = service.delivery_ready()
return {'settings': store.public_settings(), 'ready': ready, 'detail': detail,
'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)}
@router.put('/admin/newsletters')
def settings(payload: Settings, user: dict = Depends(require_admin)):
try:
public_url = magent_public_url(payload.public_url or store.settings()['public_url'])
ready, detail = service.delivery_ready(public_url)
if payload.enabled and not ready:
raise service.NewsletterError(detail)
return store.save_settings({**payload.model_dump(), "public_url": public_url}, datetime.now(timezone.utc))
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/drafts', status_code=201)
async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)):
try:
return await service.create_draft(user, payload.days)
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
fail(exc)
@router.get('/admin/newsletters/editions/{identity}')
def edition(identity: UUID, user: dict = Depends(require_admin)):
try:
return service.require_edition(identity.hex)
except service.NewsletterError as exc:
fail(exc)
@router.put('/admin/newsletters/editions/{identity}')
def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)):
try:
return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro,
[entry.model_dump() for entry in payload.titles], time.time())
except store.Conflict as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/preview')
async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)):
try:
return await service.preview(identity.hex, payload.revision)
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/test', status_code=202)
def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)):
try:
return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id))
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/publish', status_code=202)
def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)):
try:
return service.publish(identity.hex, payload.revision, payload.send_at)
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/cancel')
def cancel(identity: UUID, user: dict = Depends(require_admin)):
try:
service.require_edition(identity.hex)
return store.cancel(identity.hex, time.time())
except service.NewsletterError as exc:
fail(exc)
@router.get('/admin/newsletters/artwork/{identity}')
async def artwork(identity: UUID, user: dict = Depends(require_admin)):
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, 'Artwork unavailable')
content = await catalog.poster(runtime, identity.hex)
if not content:
raise HTTPException(404, 'Artwork unavailable')
return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..services.operation_progress import get_operation
router = APIRouter(
prefix="/operations",
tags=["operations"],
dependencies=[Depends(get_current_user)],
)
@router.get("/{operation_id}")
async def operation_status(operation_id: str) -> dict:
operation = get_operation(operation_id)
if not operation:
raise HTTPException(status_code=404, detail="Operation not found")
return operation
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import urlsplit
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
def no_cache(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)])
class StrictPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
class Preference(StrictPayload):
enabled: bool
automatic_monthly: bool | None = Field(default=None, strict=True)
class RecapSettings(StrictPayload):
enabled: bool
day: int = Field(ge=1, le=28)
hour: int = Field(ge=0, le=23)
public_url: str = Field(default="", max_length=500)
@field_validator("public_url")
@classmethod
def origin_only(cls, value: str) -> str:
value = value.strip().rstrip('/')
if not value:
return value
try:
url = urlsplit(value)
port = url.port
except ValueError as exc:
raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc
if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value)
or any(char in value for char in '<>"\\') or (port is not None and port < 1)):
raise ValueError("Enter a http(s) Magent address without a path, credentials or query.")
return value
class TestEmail(StrictPayload):
month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$")
request_id: UUID
class TokenAction(StrictPayload):
token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
action: Literal["confirm", "unsubscribe"]
def error(exc: recaps.RecapError):
raise HTTPException(exc.status, exc.detail) from exc
@router.get("/profile/email-recaps")
def preferences(user: dict = Depends(require_stats)) -> dict:
try:
return recaps.preferences(user)
except recaps.RecapError as exc:
error(exc)
@router.put("/profile/email-recaps")
async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
try:
if payload.enabled:
return await recaps.subscribe(user, payload.automatic_monthly)
store.disable(recaps.current_account(user)["id"])
return recaps.preferences(user)
except recaps.RecapError as exc:
error(exc)
@router.post("/email-recaps/check")
def check_token(payload: TokenAction) -> dict:
try:
return recaps.token_action(payload.token, payload.action)
except recaps.RecapError as exc:
error(exc)
@router.post("/email-recaps/confirm")
def apply_token(payload: TokenAction) -> dict:
try:
return recaps.token_action(payload.token, payload.action, apply=True)
except recaps.RecapError as exc:
error(exc)
@router.get("/admin/email-recaps")
def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict:
ready, detail = recaps.delivery_ready()
months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:]
return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months,
"worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)}
@router.put("/admin/email-recaps")
def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict:
if payload.enabled:
# Validate against the proposed URL without writing any partial settings.
ready, detail = recaps.smtp_email_config_ready()
runtime = recaps.get_runtime_settings()
if not magent_public_url(payload.public_url or store.settings()["public_url"]) or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
return store.save_settings({**payload.model_dump(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc))
@router.get("/admin/email-recaps/preview")
async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict:
try:
return await recaps.preview(user, month)
except recaps.RecapError as exc:
error(exc)
@router.post("/admin/email-recaps/test", status_code=202)
def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
try:
return recaps.queue_test(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
@router.post('/profile/email-recaps/send', status_code=202)
def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
try:
return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -1,8 +1,11 @@
from typing import Any, Dict
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..config import normalize_banner_color
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
@@ -13,19 +16,39 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
login_message = (runtime.site_login_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or "").strip(),
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
},
"login": {
"message": login_message,
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
"showSignupLink": bool(runtime.site_login_show_signup_link),
},
"navigation": {
"showRequests": bool(runtime.site_nav_show_requests),
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
info["changelog"] = (CHANGELOG or "").strip()
playback_url = (runtime.jellyfin_public_url or "").strip()
try:
parsed = urlsplit(playback_url)
valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
except ValueError:
valid = False
info["mediaServerUrl"] = playback_url if valid else None
return info
+104 -11
View File
@@ -1,17 +1,19 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..auth import require_admin
from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
@@ -26,12 +28,42 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
return {"name": name, "status": "down", "message": str(exc)}
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
if not qbittorrent.base_url:
return {"name": "qBittorrent", "status": "not_configured"}
if not qbittorrent.username or not qbittorrent.password:
reachable = await qbittorrent.is_webui_reachable()
return {
"name": "qBittorrent",
"status": "degraded" if reachable else "not_configured",
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
}
try:
result = await qbittorrent.get_app_version()
return {"name": "qBittorrent", "status": "up", "detail": result}
except RuntimeError as exc:
if "login failed" in str(exc).lower():
reachable = await qbittorrent.is_webui_reachable()
if reachable:
return {
"name": "qBittorrent",
"status": "degraded",
"message": "qBittorrent is reachable but the saved credentials were rejected",
}
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except httpx.HTTPError as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except Exception as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
@router.get("/services")
async def services_status() -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -41,7 +73,7 @@ async def services_status() -> Dict[str, Any]:
services = []
services.append(
await _check(
"Jellyseerr",
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
)
@@ -60,6 +92,13 @@ async def services_status() -> Dict[str, Any]:
radarr.get_system_status,
)
)
services.append(
await _check(
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
)
)
prowlarr_status = await _check(
"Prowlarr",
prowlarr.configured(),
@@ -71,13 +110,7 @@ async def services_status() -> Dict[str, Any]:
prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status)
services.append(
await _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
)
)
services.append(await _check_qbittorrent(qbittorrent))
services.append(
await _check(
"Jellyfin",
@@ -86,6 +119,11 @@ async def services_status() -> Dict[str, Any]:
)
)
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
# Optional analytics must not degrade the media pipeline when not configured.
if jellystat.configured():
services.append(await _check("Jellystat", True, jellystat.test_connection))
overall = "up"
if any(s.get("status") == "down" for s in services):
overall = "down"
@@ -93,3 +131,58 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded"
return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
if service_key == "jellystat":
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
checks = {
"seerr": (
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"jellyseerr": (
"Seerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"bazarr": (
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key == "qbittorrent":
return await _check_qbittorrent(qbittorrent)
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
name, configured, func = checks[service_key]
result = await _check(name, configured, func)
if name == "Prowlarr" and result.get("status") == "up":
health = result.get("detail")
if isinstance(health, list) and health:
result["status"] = "degraded"
result["message"] = "Health warnings"
return result
+33
View File
@@ -2,18 +2,49 @@ from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"auth_rate_limit_window_seconds",
"auth_rate_limit_max_attempts_ip",
"auth_rate_limit_max_attempts_user",
"password_reset_rate_limit_window_seconds",
"password_reset_rate_limit_max_attempts_ip",
"password_reset_rate_limit_max_attempts_identifier",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"jwt_exp_minutes",
"log_file_max_bytes",
"log_file_backup_count",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_stage_refresh_minutes",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"magent_notify_email_smtp_port",
}
_BOOL_FIELDS = {
"magent_proxy_enabled",
"magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_telegram_enabled",
"magent_notify_push_enabled",
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr",
"site_banner_enabled",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
"site_nav_show_requests",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -22,6 +53,8 @@ def get_runtime_settings():
for key, value in overrides.items():
if value is None:
continue
if key in _SKIP_OVERRIDE_FIELDS:
continue
if key in _INT_FIELDS:
try:
update[key] = int(value)
+116
View File
@@ -0,0 +1,116 @@
"""Transactional, versioned SQLite schema migrations for Magent."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
MigrationStep = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True)
class Migration:
version: int
name: str
apply: MigrationStep
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
column = definition.split(maxsplit=1)[0].strip('"')
if column not in _column_names(conn, table):
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
for definition in (
"email TEXT",
"last_login_at TEXT",
"is_blocked INTEGER NOT NULL DEFAULT 0",
"auth_provider TEXT NOT NULL DEFAULT 'local'",
"jellyfin_password_hash TEXT",
"last_jellyfin_auth_at TEXT",
"jellyseerr_user_id INTEGER",
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
"profile_id INTEGER",
"expires_at TEXT",
"invited_by_code TEXT",
"invited_at TEXT",
"auth_version INTEGER NOT NULL DEFAULT 1",
):
_add_column(conn, "users", definition)
for definition in ("recipient_email TEXT", "code_hint TEXT"):
_add_column(conn, "signup_invites", definition)
for definition in (
"related_item_id INTEGER",
"workflow_request_status TEXT",
"workflow_media_status TEXT",
"issue_type TEXT",
"issue_resolved_at TEXT",
"metadata_json TEXT",
):
_add_column(conn, "portal_items", definition)
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
statements = (
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
"(related_item_id, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache "
"(requested_by_id, created_at DESC, request_id DESC)",
)
for statement in statements:
conn.execute(statement)
MIGRATIONS = (
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
)
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)
"""
)
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
completed: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
savepoint = f"magent_migration_{migration.version}"
conn.execute(f"SAVEPOINT {savepoint}")
try:
migration.apply(conn)
conn.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
)
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
raise
completed.append(migration.version)
return completed
+74
View File
@@ -0,0 +1,74 @@
import base64
import hashlib
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
ENCRYPTED_PREFIX = "enc:v1:"
SENSITIVE_SETTING_KEYS = frozenset(
{
"jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"magent_notify_email_smtp_password", "magent_notify_discord_webhook_url",
"magent_notify_telegram_bot_token", "magent_notify_push_token",
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
"prowlarr_api_key", "qbittorrent_password",
}
)
def _fernet_key() -> bytes:
configured = str(settings.settings_encryption_key or "").strip()
if configured:
try:
decoded = base64.urlsafe_b64decode(configured.encode("ascii"))
except Exception as exc:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc
if len(decoded) != 32:
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes")
return configured.encode("ascii")
jwt_secret = str(settings.jwt_secret or "").strip()
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key"
)
derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest()
return base64.urlsafe_b64encode(derived)
def is_sensitive_setting(key: str) -> bool:
return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS
def validate_secret_storage_configuration() -> None:
"""Validate the configured or JWT-derived Fernet key without touching stored data."""
Fernet(_fernet_key())
def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if text.startswith(ENCRYPTED_PREFIX):
return text
token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii")
return ENCRYPTED_PREFIX + token
def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
if value is None or not is_sensitive_setting(key):
return value
text = str(value)
if not text.startswith(ENCRYPTED_PREFIX):
return text
try:
return Fernet(_fernet_key()).decrypt(
text[len(ENCRYPTED_PREFIX) :].encode("ascii")
).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError(
f"Stored secret '{key}' cannot be decrypted with the configured key"
) from exc
+84 -8
View File
@@ -1,13 +1,23 @@
from datetime import datetime, timedelta, timezone
import uuid
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_pwd_context = CryptContext(
schemes=["argon2", "pbkdf2_sha256"],
deprecated=["pbkdf2_sha256"],
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
_ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 12
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str:
@@ -15,18 +25,84 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
try:
return _pwd_context.verify(plain_password, hashed_password)
except (TypeError, ValueError):
return False
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]:
try:
return _pwd_context.verify_and_update(plain_password, hashed_password)
except (TypeError, ValueError):
return False, None
def validate_password_policy(password: str) -> str:
candidate = password.strip()
if len(candidate) < MIN_PASSWORD_LENGTH:
raise ValueError(PASSWORD_POLICY_MESSAGE)
return candidate
def _create_token(
subject: str,
role: str,
*,
expires_at: datetime,
token_type: str = "access",
auth_version: int = 1,
) -> str:
issued_at = datetime.now(timezone.utc)
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
"iat": issued_at,
"jti": uuid.uuid4().hex,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"ver": max(1, int(auth_version or 1)),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(
subject: str,
role: str,
expires_minutes: Optional[int] = None,
*,
auth_version: int = 1,
) -> str:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
return _create_token(subject, role, expires_at=expires, token_type="access", auth_version=auth_version)
def create_stream_token(
subject: str,
role: str,
expires_seconds: int = 120,
*,
auth_version: int = 1,
) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse", auth_version=auth_version)
def decode_token(token: str) -> Dict[str, Any]:
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
return jwt.decode(
token,
settings.jwt_secret,
algorithms=[_ALGORITHM],
audience=settings.jwt_audience,
issuer=settings.jwt_issuer,
options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]},
)
class TokenError(Exception):
@@ -36,5 +112,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]:
try:
return decode_token(token)
except JWTError as exc:
except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc
+21
View File
@@ -0,0 +1,21 @@
"""Shared Sonarr/Radarr configuration helpers."""
from typing import Any
class RootFolderNotFoundError(ValueError):
pass
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
configured = str(root_folder or "").strip()
if not configured.isdigit():
return configured
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if isinstance(folder, dict) and folder.get("id") == int(configured):
path = str(folder.get("path") or "").strip()
if path:
return path
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
+61
View File
@@ -0,0 +1,61 @@
"""Read title-specific search activity without starting a search or changing monitoring."""
from typing import Any
from ..clients.base import ApiClient
from ..models import RequestType
def _ids(values: Any) -> set[int]:
if not isinstance(values, list):
return set()
return {value for value in values if type(value) is int and value > 0}
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
"""Only a matching queued/started search is evidence of current activity.
Completed commands, RSS syncs and library-wide jobs do not establish that this
title is being searched. Episode searches are matched using Sonarr episode IDs.
"""
if not isinstance(commands, list):
return "unavailable"
episode_ids = _ids([
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
])
queued = False
for command in commands:
if not isinstance(command, dict):
continue
body = command.get("body")
if not isinstance(body, dict):
continue
name = str(command.get("name") or body.get("name") or "").lower()
if request_type == RequestType.movie:
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
else:
matches = (
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
) or (
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
)
if not matches or command.get("ended"):
continue
status = str(command.get("status", "")).lower()
if status in {"started", "1"}:
return "searching"
if status in {"queued", "0"}:
queued = True
return "queued" if queued else "idle"
async def read_search_status(
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
) -> str:
try:
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
except Exception:
# Search telemetry must not turn a healthy library record into an error.
return "unavailable"
return search_status(commands, request_type, item_id, episodes)
+735
View File
@@ -0,0 +1,735 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from time import perf_counter
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from urllib.parse import urlparse
import httpx
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import get_database_diagnostics
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
@dataclass(frozen=True)
class DiagnosticCheck:
key: str
label: str
category: str
description: str
live_safe: bool
configured: bool
config_detail: str
target: Optional[str]
runner: DiagnosticRunner
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _url_target(url: Optional[str]) -> Optional[str]:
raw = _clean_text(url)
if not raw:
return None
try:
parsed = urlparse(raw)
except Exception:
return raw
host = parsed.hostname or parsed.netloc or raw
if parsed.port:
host = f"{host}:{parsed.port}"
return host
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
resolved_host = _clean_text(host)
if not resolved_host:
return None
if port is None:
return resolved_host
return f"{resolved_host}:{port}"
def _http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
body = ""
try:
body = response.text.strip()
except Exception:
body = ""
if body:
return f"HTTP {response.status_code}: {body}"
return f"HTTP {response.status_code}"
return str(exc)
def _config_status(detail: str) -> str:
lowered = detail.lower()
if "disabled" in lowered:
return "disabled"
return "not_configured"
def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Discord webhook URL is required."
def _telegram_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
return False, "Telegram notifications are disabled."
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
return True, "ok"
return False, "Telegram bot token and chat ID are required."
def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Generic webhook URL is required."
def _push_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_topic):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "ntfy requires a base URL and topic."
if provider == "gotify":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_token):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Gotify requires a base URL and app token."
if provider == "pushover":
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
return True, "ok"
return False, "Pushover requires an application token and user key."
if provider == "webhook":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url:
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Webhook relay requires a target URL."
if provider == "telegram":
return _telegram_config_ready(runtime)
if provider == "discord":
return _discord_config_ready(runtime)
return False, f"Unsupported push provider: {provider or 'unknown'}"
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
summary = {
"total": len(results),
"up": 0,
"down": 0,
"degraded": 0,
"not_configured": 0,
"disabled": 0,
}
for result in results:
status = str(result.get("status") or "").strip().lower()
if status in summary:
summary[status] += 1
return summary
async def _run_http_json_get(
url: str,
*,
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
payload = response.json()
return {"response": payload}
async def _run_http_text_get(url: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
body = response.text
return {"response": body, "message": f"HTTP {response.status_code}"}
async def _run_http_post(
url: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
data_payload: Any = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
response.raise_for_status()
if not response.content:
return {"message": f"HTTP {response.status_code}"}
content_type = response.headers.get("content-type", "")
if "application/json" in content_type.lower():
try:
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
except Exception:
pass
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
async def _run_database_check() -> Dict[str, Any]:
detail = await asyncio.to_thread(get_database_diagnostics)
integrity = _clean_text(detail.get("integrity_check"), "unknown")
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
status = "up" if integrity == "ok" else "degraded"
return {
"status": status,
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
"detail": detail,
}
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
payload = result.get("response")
build_number = payload.get("build") if isinstance(payload, dict) else None
message = "Health endpoint responded"
if build_number:
message = f"Health endpoint responded (build {build_number})"
return {"message": message, "detail": payload}
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
result = await _run_http_text_get(base_url.rstrip("/"))
body = result.get("response")
if isinstance(body, str) and "<html" in body.lower():
return {"message": "Application page responded", "detail": "html"}
return {"status": "degraded", "message": "Application responded with unexpected content"}
async def _run_seerr_check(runtime) -> Dict[str, Any]:
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
payload = await client.get_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Seerr responded"
if version:
message = f"Seerr version {version}"
return {"message": message, "detail": payload}
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
payload = await client.get_system_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Sonarr responded"
if version:
message = f"Sonarr version {version}"
return {"message": message, "detail": payload}
async def _run_radarr_check(runtime) -> Dict[str, Any]:
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
payload = await client.get_system_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Radarr responded"
if version:
message = f"Radarr version {version}"
return {"message": message, "detail": payload}
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
payload = await client.get_health()
if isinstance(payload, list) and payload:
return {
"status": "degraded",
"message": f"Prowlarr health warnings: {len(payload)}",
"detail": payload,
}
return {"message": "Prowlarr reported healthy", "detail": payload}
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
client = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
version = await client.get_app_version()
message = "qBittorrent responded"
if isinstance(version, str) and version:
message = f"qBittorrent version {version}"
return {"message": message, "detail": version}
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
payload = await client.get_system_info()
version = payload.get("Version") if isinstance(payload, dict) else None
message = "Jellyfin responded"
if version:
message = f"Jellyfin version {version}"
return {"message": message, "detail": payload}
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email)
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
warning = _clean_text(result.get("warning"))
if warning:
return {
"status": "degraded",
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
"detail": result,
}
return {"message": f"Test email sent to {recipient}", "detail": result}
async def _run_discord_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
payload = {
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
async def _run_telegram_check(runtime) -> Dict[str, Any]:
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(url, json_payload=payload)
return {"message": "Telegram ping accepted", "detail": result.get("response")}
async def _run_webhook_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
payload = {
"type": "diagnostics.ping",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Webhook accepted ping", "detail": result.get("response")}
async def _run_push_check(runtime) -> Dict[str, Any]:
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
message = f"{env_settings.app_name} diagnostics ping"
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
if provider == "ntfy":
base_url = _clean_text(runtime.magent_notify_push_base_url)
topic = _clean_text(runtime.magent_notify_push_topic)
result = await _run_http_post(
f"{base_url.rstrip('/')}/{topic}",
data_payload=f"{message}\n{build_suffix}",
headers={"Content-Type": "text/plain; charset=utf-8"},
)
return {"message": "ntfy push accepted", "detail": result.get("response")}
if provider == "gotify":
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
result = await _run_http_post(
f"{base_url.rstrip('/')}/message",
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
params={"token": token},
)
return {"message": "Gotify push accepted", "detail": result.get("response")}
if provider == "pushover":
token = _clean_text(runtime.magent_notify_push_token)
user_key = _clean_text(runtime.magent_notify_push_user_key)
device = _clean_text(runtime.magent_notify_push_device)
payload = {
"token": token,
"user": user_key,
"message": f"{message}\n{build_suffix}",
"title": env_settings.app_name,
}
if device:
payload["device"] = device
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
return {"message": "Pushover push accepted", "detail": result.get("response")}
if provider == "webhook":
base_url = _clean_text(runtime.magent_notify_push_base_url)
payload = {
"type": "diagnostics.push",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(base_url, json_payload=payload)
return {"message": "Push webhook accepted", "detail": result.get("response")}
if provider == "telegram":
return await _run_telegram_check(runtime)
if provider == "discord":
return await _run_discord_check(runtime)
raise RuntimeError(f"Unsupported push provider: {provider}")
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
runtime = get_runtime_settings()
seerr_target = _url_target(runtime.jellyseerr_base_url)
jellyfin_target = _url_target(runtime.jellyfin_base_url)
sonarr_target = _url_target(runtime.sonarr_base_url)
radarr_target = _url_target(runtime.radarr_base_url)
prowlarr_target = _url_target(runtime.prowlarr_base_url)
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
webhook_target = _url_target(runtime.magent_notify_webhook_url)
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
push_target = None
if push_provider == "pushover":
push_target = "api.pushover.net"
elif push_provider == "telegram":
push_target = telegram_target or "api.telegram.org"
elif push_provider == "discord":
push_target = discord_target or "discord.com"
else:
push_target = _url_target(runtime.magent_notify_push_base_url)
email_ready, email_detail = smtp_email_config_ready()
email_warning = smtp_email_delivery_warning()
discord_ready, discord_detail = _discord_config_ready(runtime)
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
push_ready, push_detail = _push_config_ready(runtime)
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
checks = [
DiagnosticCheck(
key="magent-web",
label="Magent application",
category="Application",
description="Checks that the frontend application URL is responding.",
live_safe=True,
configured=True,
config_detail="ok",
target=application_target,
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
),
DiagnosticCheck(
key="magent-api",
label="Magent API",
category="Application",
description="Checks the Magent API health endpoint.",
live_safe=True,
configured=True,
config_detail="ok",
target=api_target,
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
),
DiagnosticCheck(
key="database",
label="SQLite database",
category="Application",
description="Runs SQLite integrity_check against the current Magent database.",
live_safe=True,
configured=True,
config_detail="ok",
target="sqlite",
runner=_run_database_check,
),
DiagnosticCheck(
key="seerr",
label="Seerr",
category="Media services",
description="Checks Seerr API reachability and version.",
live_safe=True,
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
config_detail="Seerr URL and API key are required.",
target=seerr_target,
runner=lambda runtime=runtime: _run_seerr_check(runtime),
),
DiagnosticCheck(
key="jellyfin",
label="Jellyfin",
category="Media services",
description="Checks Jellyfin system info with the configured API key.",
live_safe=True,
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
config_detail="Jellyfin URL and API key are required.",
target=jellyfin_target,
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
),
DiagnosticCheck(
key="sonarr",
label="Sonarr",
category="Media services",
description="Checks Sonarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
config_detail="Sonarr URL and API key are required.",
target=sonarr_target,
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
),
DiagnosticCheck(
key="radarr",
label="Radarr",
category="Media services",
description="Checks Radarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
config_detail="Radarr URL and API key are required.",
target=radarr_target,
runner=lambda runtime=runtime: _run_radarr_check(runtime),
),
DiagnosticCheck(
key="prowlarr",
label="Prowlarr",
category="Media services",
description="Checks Prowlarr health and flags warnings as degraded.",
live_safe=True,
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
config_detail="Prowlarr URL and API key are required.",
target=prowlarr_target,
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
),
DiagnosticCheck(
key="qbittorrent",
label="qBittorrent",
category="Media services",
description="Checks qBittorrent login and app version.",
live_safe=True,
configured=bool(
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
),
config_detail="qBittorrent URL, username, and password are required.",
target=qbittorrent_target,
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
),
DiagnosticCheck(
key="email",
label="SMTP email",
category="Notifications",
description="Sends a live test email using the configured SMTP provider.",
live_safe=False,
configured=email_ready,
config_detail=email_warning or email_detail,
target=smtp_target,
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
),
DiagnosticCheck(
key="discord",
label="Discord webhook",
category="Notifications",
description="Posts a live test message to the configured Discord webhook.",
live_safe=False,
configured=discord_ready,
config_detail=discord_detail,
target=discord_target,
runner=lambda runtime=runtime: _run_discord_check(runtime),
),
DiagnosticCheck(
key="telegram",
label="Telegram",
category="Notifications",
description="Sends a live test message to the configured Telegram chat.",
live_safe=False,
configured=telegram_ready,
config_detail=telegram_detail,
target=telegram_target,
runner=lambda runtime=runtime: _run_telegram_check(runtime),
),
DiagnosticCheck(
key="push",
label="Push/mobile provider",
category="Notifications",
description="Sends a live test message through the configured push provider.",
live_safe=False,
configured=push_ready,
config_detail=push_detail,
target=push_target,
runner=lambda runtime=runtime: _run_push_check(runtime),
),
DiagnosticCheck(
key="webhook",
label="Generic webhook",
category="Notifications",
description="Posts a live test payload to the configured generic webhook.",
live_safe=False,
configured=webhook_ready,
config_detail=webhook_detail,
target=webhook_target,
runner=lambda runtime=runtime: _run_webhook_check(runtime),
),
]
return checks
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
if not check.configured:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": False,
"status": _config_status(check.config_detail),
"message": check.config_detail,
"checked_at": _now_iso(),
"duration_ms": 0,
}
started = perf_counter()
checked_at = _now_iso()
try:
payload = await check.runner()
status = _clean_text(payload.get("status"), "up")
message = _clean_text(payload.get("message"), "Check passed")
detail = payload.get("detail")
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": status,
"message": message,
"detail": detail,
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except httpx.HTTPError as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": _http_error_detail(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except Exception as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": str(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
def get_diagnostics_catalog() -> Dict[str, Any]:
checks = _build_diagnostic_checks()
items = []
for check in checks:
items.append(
{
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"live_safe": check.live_safe,
"target": check.target,
"configured": check.configured,
"config_status": "configured" if check.configured else _config_status(check.config_detail),
"config_detail": "Ready to test." if check.configured else check.config_detail,
}
)
categories = sorted({item["category"] for item in items})
return {
"checks": items,
"categories": categories,
"generated_at": _now_iso(),
}
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
checks = _build_diagnostic_checks(recipient_email=recipient_email)
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
if selected:
checks = [check for check in checks if check.key.lower() in selected]
results = await asyncio.gather(*(_execute_check(check) for check in checks))
return {
"results": results,
"summary": _summary_from_results(results),
"checked_at": _now_iso(),
}
+25
View File
@@ -0,0 +1,25 @@
from typing import Any
def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
"""Join by collector download ID, never by fuzzy title matching.
A pack shares one transfer percentage; do not pretend its episodes have
individually measured progress.
"""
records = queue.get("records", []) if isinstance(queue, dict) else queue
labels: dict[str, set[str]] = {}
for row in records if isinstance(records, list) else []:
episode = row.get("episode") or {}
season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
if isinstance(season, int) and isinstance(number, int):
key = str(row.get("downloadId") or "").lower()
labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
for torrent in torrents:
episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
torrent["episodeLabels"] = episodes
torrent["episodeLabel"] = (
" · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
if episodes else None
)
return torrents
+197
View File
@@ -0,0 +1,197 @@
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
import asyncio
import json
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..feature_access import FEATURES
from . import identity_review as review
from .jellyfin_identity import source_key
NAME_REFERENCES = {
'signup_invites': ('created_by',),
'portal_items': ('created_by_username', 'assignee_username'),
'portal_comments': ('author_username',),
'portal_item_activity': ('actor_username',),
'platform_issues': ('reporter_username',),
'platform_issue_events': ('author_username',),
'requests_cache': ('requested_by', 'requested_by_norm'),
}
def account_state(conn, ids):
conn.row_factory = db.sqlite3.Row
placeholders = ','.join('?' for _ in ids)
return {table: [dict(row) for row in conn.execute(
f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
def identity_group(report, target):
identity = target['candidate_jellyfin_id']
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
def build_preview(report, local, runtime, state, user_id, keep_id=None):
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
if not target:
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
group = identity_group(report, target)
ids = {row['user']['id'] for row in group}
if len(ids) < 2:
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
jf_id = target['candidate_jellyfin_id']
source = source_key(runtime.jellyfin_base_url)
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
recommended = min(ids, key=lambda identity: (identity not in owned, identity))
keep_id = keep_id or recommended
if keep_id not in ids:
raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
problems = []
if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
problems.append('Restore all three media-service connections before consolidating accounts.')
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
for row in group:
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
for link in local['links']:
if link['local_user_id'] in ids:
if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
problems.append('A duplicate has a different saved Jellyfin identity or server.')
elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
for item in local['confirmations']:
if item['local_user_id'] in ids:
if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
or item['seerr_user_id'] != seerr_id):
problems.append('A saved confirmation points to a different identity or server.')
elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
problems.append('Another confirmation owns this identity.')
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
problems.append('An account outside this identity group also claims the identity.')
accounts = [account for account in state['users'] if account['id'] in ids]
kept = next(account for account in accounts if account['id'] == keep_id)
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
try:
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
except (ValueError, TypeError, AttributeError):
expiry = kept['expires_at']
problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
'is_blocked': any(account['is_blocked'] for account in accounts),
'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
for account in accounts]
return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
'issues': sorted(set(problems)), 'can_confirm': not problems,
'revision': review.digest([report['revision'], state, keep_id, proposed])}
async def prepare(user_id, keep_id=None):
report, local, runtime = await review.review_identities()
target = next((row for row in local['users'] if row['id'] == user_id), None)
if not target:
raise HTTPException(404, 'Account not found.')
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
with closing(db._connect()) as conn:
conn.execute('BEGIN')
if review.digest(review.snapshot(conn)) != review.digest(local):
raise HTTPException(409, 'Accounts changed during the check. Preview again.')
state = account_state(conn, ids)
return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
def consolidate(preview, report, local, runtime, state, admin):
if not preview['can_confirm']:
raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
ids = sorted(account['id'] for account in state['users'])
keep = preview['keep_id']
removed = [identity for identity in ids if identity != keep]
values = preview['proposed']
now = datetime.now(timezone.utc).isoformat()
with closing(db._connect()) as conn, conn:
conn.execute('BEGIN IMMEDIATE')
if (review.digest(review.snapshot(conn)) != review.digest(local)
or review.digest(account_state(conn, ids)) != review.digest(state)
or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
'proposed': values}
conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
(keep, json.dumps(archive, sort_keys=True), admin['username'], now))
names = {account['username'] for account in state['users']}
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table, columns in NAME_REFERENCES.items():
if table not in tables:
continue
for column in columns:
old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
for name in old_values:
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
for entry in activity:
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
for entry in activity:
conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
for name in names:
conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
for identity in removed:
# Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM users WHERE id=?', (identity,))
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
values['features']['invites'], values['expires_at'], last_login, keep))
for feature, enabled in values['features'].items():
if feature != 'invites':
conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
(keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
preview, report, local, runtime, state = await prepare(user_id, keep_id)
if revision is None:
return preview
if revision != preview['revision']:
raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
+33
View File
@@ -0,0 +1,33 @@
"""Shared claim and completion rules for the two durable email queues."""
import uuid
def queue_table(table: str) -> str:
if table not in {"email_recap_deliveries", "newsletter_deliveries"}:
raise ValueError("Unknown email queue")
return table
def claim(conn, table: str, now: float) -> dict | None:
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
WHERE state='sending' AND lease_until<?""", (now, now))
conn.execute(f"""UPDATE {table} SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
next_attempt_at=?, updated_at=?, detail='Email preparation interrupted.'
WHERE state='preparing' AND lease_until<?""", (now, now, now))
row = conn.execute(f"""SELECT * FROM {table} WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
if not row:
return None
claim_id = uuid.uuid4().hex
conn.execute(f"""UPDATE {table} SET state='preparing', claim=?, lease_until=?,
attempts=attempts+1, updated_at=? WHERE id=?""", (claim_id, now + 1800, now, row["id"]))
return dict(conn.execute(f"SELECT * FROM {table} WHERE id=?", (row["id"],)).fetchone())
def finish(conn, table: str, delivery: dict, state: str, detail: str, now: float, delay: int = 0):
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
(state, detail, now, now + delay, delivery["id"], delivery["claim"]))
+297
View File
@@ -0,0 +1,297 @@
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
import asyncio
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from urllib.parse import urlencode
from .. import db
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..runtime import get_runtime_settings
from . import recap_email as mail, recap_store as store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
from .monthly_reports import get_monthly_report, month_periods
logger = logging.getLogger(__name__)
class RecapError(Exception):
def __init__(self, detail: str, status: int = 409):
self.detail, self.status = detail, status
super().__init__(detail)
def worker_enabled() -> bool:
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
def delivery_ready() -> tuple[bool, str]:
config = store.settings()
if not config["public_url"]:
return False, "Set the application URL in Hosting & proxy for email links."
ready, detail = smtp_email_config_ready()
if not ready:
return False, detail
runtime = get_runtime_settings()
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
return False, "Connect Jellystat to generate viewing recaps."
if not worker_enabled():
return False, "Background automation is paused on this server."
return True, "Email delivery is configured."
def current_account(user: dict) -> dict:
account = db.get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
raise RecapError("This account cannot receive viewing recaps.", 403)
return account
def binding_matches(sub: dict, account: dict) -> bool:
runtime = get_runtime_settings()
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
and mail.valid_email(account.get("email"))
and account["email"].strip().casefold() == sub["email"].strip().casefold()
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
def active_subscription(account: dict) -> dict | None:
sub = store.subscription(account["id"])
if sub and sub["state"] != "off" and not binding_matches(sub, account):
store.disable(account["id"])
sub = store.subscription(account["id"])
return sub
def preferences(user: dict) -> dict:
account = current_account(user)
sub = active_subscription(account)
config = store.settings()
ready, detail = delivery_ready()
runtime = get_runtime_settings()
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
email = mail.valid_email(account.get("email"))
state = sub["state"] if sub else "off"
if state == "pending" and sub["confirmation_expires"] <= time.time():
state = "expired"
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
"automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
"can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
"resend_after": (sub["requested_at"] + 300) if sub else None}
async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
account = current_account(user)
preference = preferences(user)
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
if preference["state"] == "enabled":
store.set_automatic(account['id'], automatic)
return preferences(user)
if not preference["can_subscribe"]:
raise RecapError(preference["detail"])
config = store.settings()
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
rendered = mail.render_confirmation(account["username"], url)
try:
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
mail.message_id(uuid.uuid4().hex, config["public_url"]))
except mail.DeliveryError as exc:
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
sub = store.token_subscription(token, action)
if not sub:
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
if action == "unsubscribe":
if apply:
store.disable(sub["user_id"])
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
account = db.get_user_by_id(sub["user_id"])
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
or not binding_matches(sub, account)):
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
if apply and not store.confirm(sub, time.time()):
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
return {"action": action, "state": "enabled" if apply else "ready"}
def completed_month(month: str | None) -> str:
try:
period = month_periods(month, datetime.now(timezone.utc))
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
if period["is_partial"]:
raise RecapError("Choose a completed month for an email recap.", 422)
return period["month"]
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
"""Embed only signed artwork from this account's report; missing art is optional."""
import base64
import re
from .insights_artwork import get_artwork
runtime = get_runtime_settings()
images = []
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
async def picture(index, row):
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
if not match:
return
try:
data, mime = await get_artwork(account, runtime, *match.groups())
cid = f"recap-title-{index}@magent"
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
except Exception:
pass # An unavailable poster must never prevent a personal report.
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
if not preview:
rendered["inline_images"] = images
return rendered
async def preview(user: dict, month: str | None) -> dict:
account = current_account(user)
selected = completed_month(month)
config = store.settings()
if not config["public_url"]:
raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
try:
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
except HistoryLimitError as exc:
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
except (JellystatError, TimeoutError) as exc:
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
if report["state"] != "ready":
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub["state"] != "enabled":
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
selected = completed_month(month)
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
account = db.get_user_by_id(delivery["user_id"])
from ..feature_access import permissions
if not account or not permissions(account)["stats"]:
raise mail.DeliveryCancelled()
sub = active_subscription(account) if account else None
config = store.settings()
ready, _ = delivery_ready()
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
or config["public_url"] != delivery["public_url"]
or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery: dict) -> None:
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
try:
account, sub = eligible_delivery(delivery)
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
raise mail.DeliveryError("failed", "A complete personal report is not available.")
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
def before_data():
eligible_delivery(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
state, detail = "sent", "Accepted by the mail server."
except mail.DeliveryCancelled:
state, detail = "cancelled", "Consent, account details or email configuration changed."
except HistoryLimitError:
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
except (JellystatError, TimeoutError):
state, detail = "retry", "Viewing history is temporarily unavailable."
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
# Do not expose provider errors or private report content in history/logs.
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
if row and row["state"] == "sending":
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
if state == "retry":
if delivery["attempts"] >= 3:
state, detail = "failed", detail + " Stopped after three attempts."
else:
delay = 300 if delivery["attempts"] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once() -> None:
store.enqueue_due(datetime.now(timezone.utc))
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
async def run_email_recap_loop() -> None:
while True:
try:
await run_once()
except Exception as exc:
logger.error("email recap worker failed type=%s", type(exc).__name__)
await asyncio.sleep(30)
def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise RecapError('Confirm your profile email in email preferences before emailing a report.')
try:
selected = month_periods(month, datetime.now(timezone.utc))['month']
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
+369
View File
@@ -0,0 +1,369 @@
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
import asyncio
import copy
import hashlib
import json
import re
import sqlite3
import httpx
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .jellyfin_identity import source_key
MAX_USERS = 3000
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
def normalized_id(value):
value = str(value or "").lower().replace("-", "")
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def name_key(value):
return str(value or "").strip().casefold()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def config_digest(runtime):
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
def snapshot(conn):
conn.row_factory = sqlite3.Row
return {
"users": [dict(row) for row in conn.execute(
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
"links": [dict(row) for row in conn.execute(
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
"confirmations": [dict(row) for row in conn.execute(
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
# Detect settings changes between checking services and committing the reviewed links.
"config_revision": digest([tuple(row) for row in conn.execute(
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
}
def read_snapshot():
with closing(db._connect()) as conn:
return snapshot(conn)
async def jellyfin_directory(runtime):
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return {"state": "not_configured", "users": []}
try:
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
raise ValueError()
clean = []
seen = set()
for user in users:
user_id = normalized_id(user.get("Id"))
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
raise ValueError()
seen.add(user_id)
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
except Exception:
return {"state": "unavailable", "users": []}
async def seerr_directory(runtime):
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.base_url or not client.api_key:
return {"state": "not_configured", "users": []}
try:
users = []
seen = set()
expected_total = None
async with asyncio.timeout(20):
for skip in range(0, MAX_USERS, 100):
page = await client.get_users(take=100, skip=skip)
total = page["pageInfo"]["results"]
batch = page["results"]
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
raise ValueError()
if expected_total is not None and total != expected_total:
raise ValueError()
expected_total = total
for user in batch:
user_id = user.get("id")
if type(user_id) is not int or user_id <= 0 or user_id in seen:
raise ValueError()
seen.add(user_id)
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
if len(users) == total:
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
if len(batch) != 100 or len(users) > total:
raise ValueError()
except Exception:
pass
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
original = local
selections = selections or {}
if repair:
local = copy.deepcopy(local)
for user in local['users']:
if user['id'] in selections:
user['jellyseerr_user_id'] = None
local['links'] = [link for link in local['links'] if not (
link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
jf_by_name[name_key(row["name"])].append(row["id"])
seerr_by_id = {row["id"]: row for row in seerr["users"]}
seerr_by_jf = defaultdict(list)
for row in seerr["users"]:
if row["jellyfin_id"]:
seerr_by_jf[row["jellyfin_id"]].append(row)
current_source = source_key(runtime.jellyfin_base_url)
seerr_source = source_key(runtime.jellyseerr_base_url)
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
for user in local["users"]:
local_by_name[name_key(user["username"])].append(user["id"])
if user["jellyseerr_user_id"] is not None:
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
rows = []
for user in local["users"]:
issues = []
saved = confirmed.get(user["id"])
linked = links.get(user["id"])
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
by_name = jf_by_name.get(name_key(user["username"]), [])
basis = "none"
candidate = None
if saved:
candidate = saved["jellyfin_user_id"]
basis = "confirmed_id"
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
issues.append("The confirmed server or Seerr connection has changed.")
elif linked:
candidate, basis = linked, "stored_jellyfin_id"
elif stored_seerr and stored_seerr["jellyfin_id"]:
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if repair and user['id'] in selections and any(
item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
for item in original['confirmations']):
issues.append('The Jellyfin server changed. A server migration requires separate review.')
if user["id"] in selections:
chosen = selections[user["id"]]
if saved and chosen != saved["jellyfin_user_id"]:
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
candidate, basis = chosen, "admin_selected"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
issues.append("Multiple Magent rows share the stored Seerr ID.")
if len(by_name) > 1:
issues.append("This name matches multiple distinct Jellyfin IDs.")
if candidate and by_name and candidate not in by_name:
issues.append("The stored ID and current Jellyfin username point to different accounts.")
if linked and candidate and linked != candidate:
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
jf = jf_by_id.get(candidate)
if candidate and not jf and jellyfin["state"] == "available":
issues.append("The linked Jellyfin ID is absent from the current server.")
expected_seerr = seerr_by_jf.get(candidate, [])
if len(expected_seerr) > 1:
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
):
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
issues.append("The stored Seerr ID has changed since confirmation.")
js = jellystat.get(candidate, {"state": "not_checked"})
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
"can_confirm": False, "state": "unlinked"})
candidates = defaultdict(list)
for row in rows:
if row["candidate_jellyfin_id"]:
candidates[row["candidate_jellyfin_id"]].append(row)
for row in rows:
candidate = row["candidate_jellyfin_id"]
if len(candidates.get(candidate, [])) > 1:
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
if row["issues"]:
row["state"] = "conflict"
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
row["state"] = "unavailable"
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
row["state"] = "unlinked"
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
row["state"] = "confirmed"
else:
row["state"] = "ready"
row["can_confirm"] = True
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
report["checked_at"] = datetime.now(timezone.utc).isoformat()
return report
async def review_identities(selections=None, repair=False):
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
ids = {row["id"] for row in jf["users"]}
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
ids.discard(None)
if len(ids) > MAX_USERS:
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
rows = {row["user"]["id"]: row for row in report["rows"]}
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
now = datetime.now(timezone.utc).isoformat()
try:
with closing(db._connect()) as conn, conn:
conn.execute("BEGIN IMMEDIATE")
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
for user_id in user_ids:
row = rows[user_id]
jf_id = row["jellyfin"]["id"]
seerr_id = row["seerr"][0]["id"]
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_confirmations
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
seerr_id, now, admin["username"]))
if repair:
before_user = next(user for user in local['users'] if user['id'] == user_id)
before = {'seerr_user_id': before_user['jellyseerr_user_id'],
'links': [link for link in local['links'] if link['local_user_id'] == user_id],
'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
(report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_repairs
(local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
(user_id, json.dumps(before, sort_keys=True), json.dumps({
'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
return {"confirmed": len(user_ids), "confirmed_at": now}
async def confirm_identities(revision, user_ids, admin):
report, local, runtime = await review_identities()
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
if revision is not None:
if report["revision"] != revision:
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
return {"revision": report["revision"], "server_id": report["server_id"],
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
row = next(row for row in report['rows'] if row['user']['id'] == user_id)
importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
and not row['seerr'] and row['jellystat']['state'] == 'matched'
and report['services']['seerr'] == 'available')
if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
for account in report['seerr_users']):
importing = False
row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
report['revision'] = digest([report['revision'], create_seerr])
if revision is not None:
if report['revision'] != revision:
raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
if importing:
if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
try:
await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
# Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
# never delete an imported account if the local save is blocked or interrupted.
refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
if config_digest(fresh_runtime) != config_digest(runtime):
raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
try:
return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
except HTTPException as exc:
raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
before = next(user for user in local['users'] if user['id'] == user_id)
linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
and link['source'] == source_key(runtime.jellyfin_base_url)), None)
row['can_confirm'] = row['can_confirm'] or importing
return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
'action': 'import_seerr' if importing else 'repair_magent',
'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
'seerr_users': report['seerr_users'],
'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
+243
View File
@@ -0,0 +1,243 @@
import asyncio
import hashlib
import json
import math
import sqlite3
import time
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient, JellystatError
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user, linked_user_id
from .insights_artwork import item_id as artwork_item_id, with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
"v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
# Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
method = row.get("PlayMethod")
if method not in {"Transcode", "DirectStream"}:
return
info = row.get("TranscodingInfo")
if isinstance(info, str):
try:
info = json.loads(info)
except ValueError:
info = None
info = info if isinstance(info, dict) else {}
video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
if method == "Transcode" and video_present:
if info.get("IsVideoDirect") is False:
totals["video_minutes"] += duration
value = info.get("HardwareAccelerationType")
value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
if value in HARDWARE:
totals["hardware_video_minutes"] += duration
hardware[HARDWARE[value]] += duration
elif value == "none":
totals["software_video_minutes"] += duration
else:
totals["unknown_hardware_minutes"] += duration
elif info.get("IsVideoDirect") is not True:
totals["unknown_video_minutes"] += duration
if info.get("IsAudioDirect") is False:
totals["audio_minutes"] += duration
codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
audio_codecs[codec] += duration
elif info.get("IsAudioDirect") is not True:
totals["unknown_audio_minutes"] += duration
def _date(value) -> datetime:
try:
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError) as exc:
raise JellystatError("Jellystat returned an invalid history date") from exc
def _duration(value) -> float:
try:
result = float(value or 0)
if not math.isfinite(result) or result < 0:
raise ValueError()
return result
except (ValueError, TypeError, OverflowError) as exc:
raise JellystatError("Jellystat returned an invalid playback duration") from exc
async def resolve_identity(user: dict, runtime) -> str | None:
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
if identity:
return identity
if user.get("auth_provider") != "jellyfin":
return None
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return None
try:
users = await client.get_users()
except Exception as exc:
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
matches = [entry for entry in users if isinstance(entry, dict)
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
if len(matches) != 1 or not matches[0].get("Id"):
return None
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
operator = "<" if end_exclusive else "<="
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
params = [start.isoformat(), end.isoformat()]
if user.get("jellyseerr_user_id") is not None:
clause += " AND requested_by_id = ?"
params.append(user["jellyseerr_user_id"])
else:
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
params.append(user["username"].strip().lower())
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
counts = conn.execute(f"""SELECT COUNT(*) AS total,
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
COALESCE(SUM(status = 1), 0) AS pending,
COALESCE(SUM(status = 2), 0) AS approved,
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
return {**dict(counts), "recent": [dict(row) for row in recent]}
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
daily_seconds = defaultdict(float)
weekdays = [0.0] * 7
media_minutes = defaultdict(float)
longest_play = 0.0
clients = defaultdict(float)
methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
"unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
hardware, audio_codecs = defaultdict(float), defaultdict(float)
titles = {}
movie_ids, episode_ids, seen = set(), set(), set()
recent = []
seconds = 0.0
for row in history:
row_id = str(row.get("Id") or "")
if not row_id:
raise JellystatError("Jellystat returned history without an activity ID")
if row_id in seen:
continue
seen.add(row_id)
date = _date(row.get("ActivityDateInserted"))
# Defend against older upstream versions ignoring the range filter.
if date < start or (date >= end if end_exclusive else date > end):
continue
duration = _duration(row.get("PlaybackDuration"))
if duration <= 0:
continue
item_id = str(row.get("NowPlayingItemId") or row_id)
episode_id = row.get("EpisodeId")
library_type = library_types.get(str(row.get("ParentId")), "")
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
if media_type == "episode":
episode_ids.add(str(episode_id))
elif media_type == "movie":
movie_ids.add(item_id)
weekdays[date.weekday()] += duration / 60
media_minutes[media_type] += duration / 60
longest_play = max(longest_play, duration / 60)
seconds += duration
daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200]
clients[client] += duration
method = str(row.get("PlayMethod") or "Unknown")
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
methods[method] += duration
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
series = str(row.get("SeriesName") or "")[:500]
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
title["minutes"] += duration / 60
title["plays"] += 1
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
count = (last_date - start.date()).days + 1
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
longest = run = 0
for day in daily:
run = run + 1 if day["date"] in active_days else 0
longest = max(longest, run)
current = 0
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
while cursor.isoformat() in active_days:
current += 1
cursor -= timedelta(days=1)
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
for row in top:
row["minutes"] = round(row["minutes"], 1)
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
"episodes": len(episode_ids), "active_days": len(active_days),
"current_streak": current, "longest_streak": longest},
"patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
"longest_play_minutes": round(longest_play, 1),
"weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
"weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
"media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
"daily": daily, "top_titles": top,
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
"transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
"hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
"audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
"gpu_busy_minutes": None},
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
async def get_insights(user: dict, days: int) -> dict:
runtime = await asyncio.to_thread(get_runtime_settings)
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
requests = await asyncio.to_thread(request_summary, user, start, end)
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
"is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, days)
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
return {**base, **with_artwork(cached[1], user, runtime)}
history, libraries = await client.get_user_history(identity, start, end)
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
"period_start": start.isoformat(), "period_end": end.isoformat()}
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
return {**base, **with_artwork(data, user, runtime)}
+101
View File
@@ -0,0 +1,101 @@
"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
import asyncio
import hashlib
import hmac
import re
import time
from collections import OrderedDict
import httpx
from fastapi import HTTPException
from ..config import settings
TOKEN_SECONDS = 3600
MAX_IMAGE_BYTES = 1024 * 1024
MAX_CACHE_BYTES = 16 * 1024 * 1024
_cache = OrderedDict()
_downloads = asyncio.Semaphore(6)
def item_id(value):
value = str(value or "").replace("-", "").lower()
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def source(runtime):
return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
def signature(user, runtime, media_id, expires):
message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS
result = {**data}
for field in ("recent", "top_titles"):
rows = []
for play in data.get(field, []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
rows.append(row)
result[field] = rows
return result
def verify_artwork_token(user, runtime, media_id, token):
if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
raise HTTPException(404, "Artwork unavailable")
if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
raise HTTPException(403, "Artwork link is invalid or expired")
try:
expires_text, supplied = token.split(".", 1)
expires = int(expires_text)
except (ValueError, TypeError):
raise HTTPException(403, "Artwork link is invalid or expired") from None
now = int(time.time())
if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
raise HTTPException(403, "Artwork link is invalid or expired")
async def get_artwork(user, runtime, media_id, token):
verify_artwork_token(user, runtime, media_id, token)
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, "Artwork unavailable")
key = (source(runtime), media_id)
async with _downloads:
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
_cache.move_to_end(key)
return cached[1], cached[2]
try:
async with httpx.AsyncClient(timeout=8.0) as client:
async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
headers={"X-Emby-Token": runtime.jellyfin_api_key},
params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise ValueError()
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > MAX_IMAGE_BYTES:
raise ValueError()
if not content:
raise ValueError()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(404, "Artwork unavailable") from exc
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
_cache.popitem(last=False)
_cache[key] = (time.monotonic() + 600, bytes(content), content_type)
return bytes(content), content_type
File diff suppressed because it is too large Load Diff
+477
View File
@@ -0,0 +1,477 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from html import escape
import json
import logging
from typing import Any, Dict, Optional
from ..config import settings as env_settings
from ..db import (
add_portal_item_activity,
get_portal_item,
get_user_by_username,
list_portal_item_activity,
list_portal_items,
update_portal_item,
)
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
from .snapshot import build_snapshot
from .media_repair import evaluate_media_repair
logger = logging.getLogger(__name__)
_SYSTEM_USER = "Magent"
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_datetime(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
raw = item.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
state = _metadata(item).get("resolutionConfirmation")
return dict(state) if isinstance(state, dict) else {}
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
metadata = _metadata(item)
metadata["resolutionConfirmation"] = state
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
def _interval_delta(value: int, unit: str) -> timedelta:
safe_value = max(1, min(int(value), 365))
normalized_unit = str(unit or "days").strip().lower()
if normalized_unit == "weeks":
return timedelta(weeks=safe_value)
if normalized_unit == "months":
return timedelta(days=30 * safe_value)
return timedelta(days=safe_value)
def _workflow_settings() -> tuple[int, int, str]:
runtime = get_runtime_settings()
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
if interval_unit not in {"days", "weeks", "months"}:
interval_unit = "days"
return attempts, interval_value, interval_unit
def _app_url() -> str:
runtime = get_runtime_settings()
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
candidate = str(value or "").strip()
if candidate:
return candidate.rstrip("/")
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
def _issue_url(item_id: int) -> str:
return f"{_app_url()}/portal/issues?item={item_id}"
def _activity(
item_id: int,
event_type: str,
message: str,
*,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=actor_username,
actor_role=actor_role,
message=message,
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
)
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
raw = entry.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity):
# A rejected repair must not be proposed again simply because the same
# replacement file is still present. Wait for a NEW repair attempt.
if str(entry.get("event_type") or "") == "resolution_rejected":
return {}, activity
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue
tracking = _activity_metadata(entry).get("repairTracking")
if isinstance(tracking, dict):
return dict(tracking), activity
return {}, activity
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
jellyfin = dict(raw.get("jellyfin") or {})
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
return await evaluate_media_repair(
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
episodes=(raw.get("arr") or {}).get("episodes"),
)
def _close_issue(
item: Dict[str, Any],
*,
reason: str,
confirmed: bool,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
) -> Dict[str, Any]:
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "confirmed" if confirmed else "auto_closed",
"confirmedAt": now if confirmed else state.get("confirmedAt"),
"closedAt": now,
"nextContactAt": None,
"closedReason": reason,
}
)
updated = update_portal_item(
int(item["id"]),
status="closed",
issue_resolved_at=now,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be closed")
_activity(
int(item["id"]),
"resolution_confirmed" if confirmed else "issue_auto_closed",
reason,
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
maximum, interval_value, interval_unit = _workflow_settings()
state = issue_resolution_state(item)
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0:
return _close_issue(
item,
reason="Issue closed automatically because reporter confirmation emails are disabled.",
confirmed=False,
)
if attempts >= maximum:
return _close_issue(
item,
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
confirmed=False,
)
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
body_text = (
"Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\n\n"
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
f"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\n"
"Yes closes the report. No keeps it open for another look.\n\n"
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
)
body_html = (
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
'</td></tr></table></div>'
)
try:
await send_generic_email(
recipient_email=recipient,
subject=subject,
body_text=body_text,
body_html=body_html,
)
sent = True
except Exception as exc:
delivery_error = str(exc)
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
else:
delivery_error = "No email address is stored for the reporter."
now = _now()
state.update(
{
"status": "awaiting_confirmation",
"attemptsSent": attempt_number,
"maximumAttempts": maximum,
"lastContactAt": now.isoformat(),
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"lastDeliverySucceeded": sent,
"lastDeliveryError": delivery_error,
}
)
updated = update_portal_item(
int(item["id"]),
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation schedule could not be saved")
if sent:
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
else:
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
_activity(
int(item["id"]),
"confirmation_email_sent" if sent else "confirmation_email_failed",
message,
metadata={
"attempt": attempt_number,
"maximum": maximum,
"nextContactAt": state["nextContactAt"],
"deliveryError": delivery_error,
},
)
return updated
async def begin_issue_confirmation(
item_id: int,
*,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
now = _now().isoformat()
maximum, interval_value, interval_unit = _workflow_settings()
state = {
"status": "awaiting_confirmation",
"startedAt": now,
"attemptsSent": 0,
"maximumAttempts": maximum,
"lastContactAt": None,
"nextContactAt": now,
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"confirmedAt": None,
"closedAt": None,
}
updated = update_portal_item(
item_id,
status="awaiting_confirmation",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation workflow could not be started")
_activity(
item_id,
"resolution_proposed",
"The issue was marked fixed and sent to the reporter for confirmation.",
actor_username=actor_username,
actor_role=actor_role,
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
)
return await _contact_reporter(updated)
def respond_to_issue_confirmation(
item_id: int,
*,
resolved: bool,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
if str(item.get("status") or "").lower() != "awaiting_confirmation":
raise ValueError("This issue is not waiting for resolution confirmation")
if resolved:
return _close_issue(
item,
reason="The reporter confirmed that the issue is fixed.",
confirmed=True,
actor_username=actor_username,
actor_role=actor_role,
)
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "reported_still_broken",
"reporterResponseAt": now,
"nextContactAt": None,
"closedAt": None,
}
)
updated = update_portal_item(
item_id,
status="in_progress",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be reopened")
_activity(
item_id,
"resolution_rejected",
"The reporter said the issue is still happening. The issue was returned to In progress.",
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def process_active_media_repairs() -> Dict[str, int]:
items = list_portal_items(kind="issue", status="in_progress", limit=500)
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
for item in items:
tracking, activity = _repair_tracking(int(item["id"]))
if not tracking:
continue
result["checked"] += 1
try:
evidence = await _media_repair_evidence(tracking)
if evidence.get("complete"):
_activity(
int(item["id"]),
"repair_verified",
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
await begin_issue_confirmation(
int(item["id"]),
actor_username=_SYSTEM_USER,
actor_role="system",
)
result["completed"] += 1
continue
result["waiting"] += 1
if evidence.get("phase") == "indexing" and not any(
str(entry.get("event_type") or "") == "repair_imported"
for entry in activity
):
_activity(
int(item["id"]),
"repair_imported",
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
except Exception:
result["failed"] += 1
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
return result
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
current = (now or _now()).astimezone(timezone.utc)
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
maximum, _, _ = _workflow_settings()
for item in items:
state = issue_resolution_state(item)
due_at = _parse_datetime(state.get("nextContactAt"))
if due_at and due_at > current:
continue
try:
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0 or attempts >= maximum:
_close_issue(
item,
reason=(
"Issue closed automatically because reporter confirmation emails are disabled."
if maximum <= 0
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
),
confirmed=False,
)
result["closed"] += 1
else:
await _contact_reporter(item)
result["contacted"] += 1
except Exception:
result["failed"] += 1
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
return result
async def run_issue_confirmation_loop() -> None:
while True:
try:
repair_result = await process_active_media_repairs()
if repair_result["completed"] or repair_result["failed"]:
logger.info("automatic media repair sweep complete result=%s", repair_result)
result = await process_due_issue_confirmations()
if result["contacted"] or result["closed"] or result["failed"]:
logger.info("issue confirmation sweep complete result=%s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("issue confirmation sweep failed")
await asyncio.sleep(60)
+51
View File
@@ -0,0 +1,51 @@
"""Stable Jellyfin identities for private, user-scoped integrations."""
import hashlib
from contextlib import closing
from .. import db
def source_key(base_url: str | None) -> str:
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
def linked_user_id(username: str, base_url: str | None) -> str | None:
user = db.get_user_by_username(username)
if not user or not base_url:
return None
with closing(db._connect()) as conn, conn:
row = conn.execute(
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
(source_key(base_url), user["id"]),
).fetchone()
return row[0] if row else None
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
user = db.get_user_by_username(username)
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
# Reviewed identities are updated only through the admin confirmation workflow.
return
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
(source_key(base_url), user["id"], str(jellyfin_user_id)),
)
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
"""Resolve a verified upstream login to its existing local account."""
if not jellyfin_user_id or not base_url:
return None
with closing(db._connect()) as conn:
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
if len(rows) > 1:
from fastapi import HTTPException
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
return db.get_user_by_id(rows[0][0]) if rows else None
+62 -5
View File
@@ -1,10 +1,26 @@
import logging
from collections import Counter
from contextlib import closing
from .. import db
from .jellyfin_identity import source_key
from .identity_review import normalized_id, name_key
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import create_user_if_missing
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import (
extract_jellyseerr_user_email,
get_cached_jellyseerr_users,
save_jellyfin_users_cache,
)
logger = logging.getLogger(__name__)
@@ -17,15 +33,56 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
imported = 0
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
with closing(db._connect()) as conn:
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
for user in users:
if not isinstance(user, dict):
continue
name = user.get("Name")
if not name:
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
if not name or not jf_id or name_counts[name_key(name)] != 1:
continue
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
imported += 1
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
if len(matches) > 1:
continue
matched = matches[0] if matches else None
matched_id = matched.get('id') if matched else None
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
if len(owners) > 1:
continue
existing = db.get_user_by_id(owners[0]) if owners else None
if not existing and matched_id is not None:
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
if len(candidates) > 1:
continue
existing = candidates[0] if candidates else None
if not existing:
existing = get_user_by_username(name)
if existing:
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
if existing_links and any(value != jf_id for value in existing_links):
continue
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
continue
canonical = existing['username']
# Never overwrite a stored Seerr identity on name evidence.
if existing.get('jellyseerr_user_id') not in (None, matched_id):
continue
set_user_auth_provider(canonical, 'jellyfin')
else:
canonical = name
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
imported += 1
if matched_id is not None:
set_user_jellyseerr_id(canonical, matched_id)
link_user(canonical, jf_id, runtime.jellyfin_base_url)
return imported
+55
View File
@@ -0,0 +1,55 @@
"""Manual collector decisions and short-lived, request-bound selection receipts."""
from datetime import datetime, timedelta, timezone
import hashlib
import jwt
from fastapi import HTTPException
from ..config import settings
def can_override(user):
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
def decision(item):
reasons = [str(r) for r in (item.get('rejections') or [])]
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
'larger than', 'smaller than', 'size limit', 'release profile',
)) for reason in reasons)
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
return accepted, override, reasons
def source_id(url):
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
def issue_selection(release, request_id, user, source, item_id):
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
'indexer': release['indexerId'], 'title': release.get('title'),
'override': release['requiresOverride'], 'rejections': release['rejections'],
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
settings.jwt_secret, algorithm='HS256')
def verify_selection(payload, request_id, user, source, item_id):
try:
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
algorithms=['HS256'], audience='manual-release')
except jwt.InvalidTokenError as exc:
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
if receipt.get('override'):
if not can_override(user):
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
if payload.get('ignoreProfileLimits') is not True:
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
return receipt
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..runtime import get_runtime_settings
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
"""Old seeding jobs are not proof of a replacement download.
A same-hash retry is valid when it is downloading again or was added anew.
Without a completion/add timestamp, a completed legacy job cannot prove that.
"""
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
if not cycle:
return rows
cutoff = datetime.fromisoformat(cycle).timestamp()
def belongs(item: Dict[str, Any]) -> bool:
try:
progress = float(item.get("progress", 0))
completed = float(item.get("completion_on") or 0)
added = float(item.get("added_on") or 0)
except (TypeError, ValueError):
return False
return progress < 1 or max(completed, added) >= cutoff
return [item for item in rows if belongs(item)]
def _positive_ints(value: Any) -> list[int]:
if not isinstance(value, list):
return []
return [
int(item)
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item > 0
]
def _media_signature(item: Any) -> Dict[str, str]:
if not isinstance(item, dict):
return {}
result: Dict[str, str] = {}
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
value = item.get(key)
if isinstance(value, (dict, list)):
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
elif value is not None and str(value).strip():
result[key] = str(value).strip()
return result
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
previous = _media_signature(baseline)
if not previous:
return True
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
async def evaluate_media_repair(
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
*, episodes: Any = None,
) -> Dict[str, Any]:
request_id = str(tracking.get("requestId") or "").strip()
action_id = str(tracking.get("actionId") or "").strip()
media_type = str(tracking.get("mediaType") or "").strip().lower()
collector_id = tracking.get("collectorId")
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
jellyfin_item = jellyfin.get("item")
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
baselines = tracking.get("jellyfinBaseline")
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
found_at_start = tracking.get("jellyfinFoundAtStart") is True
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
if media_type == "movie":
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
current_signature = _media_signature(jellyfin_item)
if action_id == "replace_media" and found_at_start:
if not baselines or not _signature_changed(current_signature, baselines[0]):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
target_rows = tracking.get("episodes")
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
target_ids = {
int(item["id"])
for item in targets
if isinstance(item.get("id"), int) and int(item["id"]) > 0
}
target_pairs = {
(int(item["seasonNumber"]), int(item["episodeNumber"]))
for item in targets
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if not target_ids or not target_pairs:
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
runtime = get_runtime_settings()
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if episodes is None:
episodes = await sonarr.get_episodes(collector_id)
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else {}
imported = all(
episode_id in episode_map
and episode_map[episode_id].get("hasFile") is not False
and (
episode_map[episode_id].get("hasFile") is True
or (
isinstance(episode_map[episode_id].get("episodeFileId"), int)
and episode_map[episode_id]["episodeFileId"] > 0
)
)
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
for episode_id in target_ids
)
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
current_by_pair = {
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
for item in jellyfin_episodes
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
}
if not all(pair in current_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
if action_id == "replace_media" and found_at_start:
baseline_by_pair = {
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
for item in baselines
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if any(pair not in baseline_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
if not all(
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
for pair in target_pairs
):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
+149
View File
@@ -0,0 +1,149 @@
"""Personal calendar-month reports built from retained Jellystat history."""
import asyncio
import csv
import hashlib
import io
import re
import time
from datetime import datetime, timezone
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .insights import request_summary, resolve_identity, summarize
from .insights_artwork import with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
MONTH_COUNT = 24
def shift_month(value: datetime, offset: int) -> datetime:
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
def month_periods(month: str | None, now: datetime) -> dict:
now = now.astimezone(timezone.utc)
this_month = shift_month(now, 0)
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
selected = month if month is not None else available[1]
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
raise ValueError("Choose the current month or one of the previous 23 months.")
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
calendar_end = shift_month(start, 1)
end = min(calendar_end, now)
previous_start = shift_month(start, -1)
partial = end < calendar_end
previous_end = min(previous_start + (end - start), start) if partial else start
return {"month": selected, "available_months": available, "timezone": "UTC",
"period_start": start.isoformat(), "period_end": end.isoformat(),
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
"comparison_capped": partial and previous_start + (end - start) > start}
def change(current: float, previous: float) -> dict:
difference = round(current - previous, 1)
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
now = datetime.now(timezone.utc)
periods = month_periods(month, now)
runtime = await asyncio.to_thread(get_runtime_settings)
base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
# Cache playback only. Request ownership and request statuses are read afresh.
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
data = cached[1]
else:
history, libraries = await client.get_user_history(identity,
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
requests, previous_requests = await asyncio.gather(
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
changes = {name: change(data["summary"][name], data["previous_summary"][name])
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
changes["requests"] = change(requests["total"], previous_requests["total"])
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
"changes": changes}
def report_csv(report: dict) -> str:
"""Export normalized data only; protect text cells from spreadsheet formulas."""
output = io.StringIO(newline="")
writer = csv.writer(output)
def row(*cells):
safe = []
for cell in cells:
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
cell = "'" + cell
safe.append(cell)
writer.writerow(safe)
row("Magent monthly viewing report", report["month"])
row("Timezone", "UTC")
row("Period start (inclusive)", report["period_start"])
row("Period end (exclusive)", report["period_end"])
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
row("Comparison start (inclusive)", report["comparison_start"])
row("Comparison end (exclusive)", report["comparison_end"])
row("Generated at", report["updated_at"])
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
row()
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
for name, label in labels.items():
value = report["changes"][name]
row(label, value["current"], value["previous"], value["difference"], value["percent"])
row()
row("Date (UTC)", "Minutes watched")
for day in report["daily"]:
row(day["date"], day["minutes"])
row()
row("Most watched title", "Media type", "Minutes", "Plays")
for title in report["top_titles"]:
row(title["title"], title["type"], title["minutes"], title["plays"])
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
row()
row(label, "Playback minutes")
for entry in report[field]:
row(entry["name"], entry["minutes"])
row()
row("Transcoding", "Playback minutes")
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
("unknown_hardware_minutes", "Video hardware not recorded"),
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
row(label, report["transcoding"][field])
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
row()
row("Requests", "Count")
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
row(label, report["requests"][field])
return "\ufeff" + output.getvalue()
+202
View File
@@ -0,0 +1,202 @@
"""Bounded Jellyfin arrival snapshots, recipient access checks and email-safe posters."""
import asyncio
import hashlib
import io
import time
from collections import OrderedDict
from datetime import datetime, timezone
import httpx
from PIL import Image
from .insights_artwork import item_id
from .jellyfin_identity import source_key
MAX_ITEMS = 5000
PAGE_SIZE = 200
MAX_TITLES = 60
_posters = OrderedDict()
_poster_lock = asyncio.Semaphore(4)
class CatalogError(Exception):
pass
def date(value) -> datetime | None:
try:
result = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError):
return None
async def get_json(client, runtime, path, params=None):
try:
response = await client.get(runtime.jellyfin_base_url.rstrip('/') + path,
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise CatalogError('Jellyfin is temporarily unavailable. Please try again.') from exc
def group_arrivals(items: list[dict], start: datetime, end: datetime) -> list[dict]:
groups = {}
seen = set()
for row in items:
identity = item_id(row.get('Id'))
added = date(row.get('DateCreated'))
if (not identity or identity in seen or not added or not start <= added < end
or row.get('LocationType') == 'Virtual' or row.get('IsPlaceHolder')):
continue
kind = row.get('Type')
if kind not in {'Movie', 'Episode'}:
continue
parent = item_id(row.get('SeriesId')) if kind == 'Episode' else identity
if not parent:
continue
seen.add(identity)
title = str((row.get('SeriesName') if kind == 'Episode' else row.get('Name')) or '').strip()
if not title:
continue
entry = groups.setdefault(parent, {'id': parent, 'type': 'series' if kind == 'Episode' else 'movie',
'title': title[:250], 'year': row.get('ProductionYear') if kind == 'Movie' else None,
'overview': str(row.get('Overview') or '')[:500] if kind == 'Movie' else '',
'added_at': added.isoformat(), 'has_artwork': False, 'items': [], 'selected': False, 'featured': False})
entry['added_at'] = max(entry['added_at'], added.isoformat())
entry['has_artwork'] |= bool(row.get('SeriesPrimaryImageTag') if kind == 'Episode' else (row.get('ImageTags') or {}).get('Primary'))
entry['items'].append({'id': identity, 'season': row.get('ParentIndexNumber') if kind == 'Episode' else None,
'number': row.get('IndexNumber') if kind == 'Episode' else None})
return sorted(groups.values(), key=lambda row: (row['added_at'], row['id']), reverse=True)
async def collect(runtime, start: datetime, end: datetime, limit: int = 12) -> dict:
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise CatalogError('Connect Jellyfin before collecting new arrivals.')
rows, seen = [], set()
exhausted = False
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
server_id = item_id(info.get('Id')) if isinstance(info, dict) else None
if not server_id:
raise CatalogError('Jellyfin did not return its server identity.')
for offset in range(0, MAX_ITEMS, PAGE_SIZE):
payload = await get_json(client, runtime, '/Items', {'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode',
'SortBy': 'DateCreated,SortName', 'SortOrder': 'Descending', 'Fields': 'DateCreated,Overview',
'EnableUserData': 'false', 'IsMissing': 'false', 'IsPlaceHolder': 'false', 'Limit': PAGE_SIZE, 'StartIndex': offset})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Jellyfin returned an incomplete arrival list.')
page = payload['Items']
total = payload.get('TotalRecordCount')
if not isinstance(total, int) or total < offset + len(page):
raise CatalogError('Jellyfin returned an incomplete arrival count.')
for row in page:
if not isinstance(row, dict) or not item_id(row.get('Id')) or not date(row.get('DateCreated')):
raise CatalogError('Jellyfin returned an arrival without a valid identity or added date.')
identity = item_id(row['Id'])
if identity in seen:
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
seen.add(identity)
if rows and date(row['DateCreated']) > date(rows[-1]['DateCreated']):
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
rows.append(row)
if (not page or len(page) < PAGE_SIZE) and offset + len(page) < total:
raise CatalogError('Jellyfin returned an incomplete arrival page.')
if not page or any(date(row['DateCreated']) < start for row in page) or offset + len(page) >= total:
exhausted = True
break
if not exhausted:
raise CatalogError('More than 5,000 recent items were found. Choose a shorter arrival period; no partial edition was created.')
titles = group_arrivals(rows, start, end)
total = len(titles)
titles = titles[:MAX_TITLES]
for index, title in enumerate(titles):
title['selected'] = index < limit
return {'source': source_key(runtime.jellyfin_base_url), 'server_id': server_id,
'period_start': start.isoformat(), 'period_end': end.isoformat(), 'total_titles': total, 'titles': titles}
async def for_recipient(runtime, content: dict, jellyfin_id: str) -> dict:
"""Scope every ID lookup to a view Jellyfin permits this user to browse.
Jellyfin 10.11's AddUserToQuery skips its default library filter when ItemIds
is present. UserId alone is insufficient; ParentId supplies the allowed scope.
"""
if not item_id(jellyfin_id):
raise CatalogError('The recipient does not have a valid Jellyfin identity.')
selected = [entry for entry in content['titles'] if entry['selected']]
ids = sorted({identity for entry in selected for identity in [entry['id'], *(item['id'] for item in entry['items'])]})
allowed = set()
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
if not isinstance(info, dict) or source_key(runtime.jellyfin_base_url) != content['source'] or item_id(info.get('Id')) != content['server_id']:
raise CatalogError('The Jellyfin server changed. Create a new edition for the current library.')
user = await get_json(client, runtime, '/Users/' + jellyfin_id)
if not isinstance(user, dict) or item_id(user.get('Id')) != item_id(jellyfin_id) or not isinstance(user.get('Policy'), dict):
raise CatalogError('Could not verify the recipients Jellyfin account.')
if user['Policy'].get('IsDisabled') or user['Policy'].get('EnableMediaPlayback') is False:
return {**content, 'titles': [], 'recipient_disabled': True}
views = await get_json(client, runtime, '/UserViews', {'UserId': jellyfin_id, 'IncludeHidden': 'true', 'IncludeExternalContent': 'false'})
if not isinstance(views, dict) or not isinstance(views.get('Items'), list) or len(views['Items']) > 32:
raise CatalogError('Could not check the recipients library access.')
for view in views['Items']:
parent = item_id(view.get('Id')) if isinstance(view, dict) else None
if not parent:
raise CatalogError('Jellyfin returned a library without a valid identity.')
for offset in range(0, len(ids), 100):
chunk = ids[offset:offset + 100]
payload = await get_json(client, runtime, '/Items', {'UserId': jellyfin_id, 'ParentId': parent, 'Ids': ','.join(chunk),
'Recursive': 'true', 'Limit': len(chunk), 'EnableUserData': 'false', 'EnableImages': 'false',
'IsMissing': 'false', 'IsPlaceHolder': 'false'})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Could not check the recipients library access.')
allowed.update(item_id(item.get('Id')) for item in payload['Items'] if isinstance(item, dict))
titles = []
for entry in selected:
accessible = [item for item in entry['items'] if item['id'] in allowed]
if entry['id'] in allowed and accessible:
titles.append({**entry, 'items': accessible})
return {**content, 'titles': titles}
async def poster(runtime, identity: str) -> bytes | None:
if not item_id(identity):
return None
key = (source_key(runtime.jellyfin_base_url), hashlib.sha256(runtime.jellyfin_api_key.encode()).hexdigest(), identity)
async with _poster_lock:
cached = _posters.get(key)
if cached and cached[0] > time.monotonic():
_posters.move_to_end(key)
return cached[1]
result = None
try:
async with httpx.AsyncClient(timeout=10) as client:
async with client.stream('GET', runtime.jellyfin_base_url.rstrip('/') + f'/Items/{identity}/Images/Primary',
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params={'maxWidth': 160, 'maxHeight': 240, 'quality': 82, 'format': 'Jpg'}) as response:
response.raise_for_status()
data = bytearray()
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > 512 * 1024:
raise ValueError('Poster too large')
with Image.open(io.BytesIO(data)) as image:
if image.width * image.height > 4_000_000:
raise ValueError('Poster dimensions too large')
image.thumbnail((160, 240))
target = io.BytesIO()
image.convert('RGB').save(target, format='JPEG', quality=82)
result = target.getvalue()
except (httpx.HTTPError, ValueError, OSError, Image.DecompressionBombError):
pass
_posters[key] = (time.monotonic() + (1800 if result else 60), result)
while len(_posters) > 128:
_posters.popitem(last=False)
return result
async def posters(runtime, content: dict) -> dict:
titles = [entry for entry in content['titles'] if entry['selected'] and entry['has_artwork']]
results = await asyncio.gather(*(poster(runtime, entry['id']) for entry in titles))
return {entry['id']: data for entry, data in zip(titles, results) if data}
+74
View File
@@ -0,0 +1,74 @@
import base64
import html
from urllib.parse import urlencode
from .recap_email import document
def description(entry):
if entry['type'] == 'movie':
return f"Movie · {entry['year']}" if entry.get('year') else 'Movie'
seasons = sorted({item['season'] for item in entry['items'] if isinstance(item.get('season'), int)})
count = len(entry['items'])
labels = ', '.join('Specials' if value == 0 else str(value) for value in seasons[:8])
suffix = f" · {'Season' if len(seasons) == 1 else 'Seasons'} {labels}" if labels else ''
return f"{count} new {'episode' if count == 1 else 'episodes'}{suffix}"
def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
return {'subject': 'Confirm your Grizzlyflix newsletter subscription',
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
'body_html': document(title='Your next watch starts here.', intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
action='Confirm newsletter subscription', url=url, kicker='NEW ON GRIZZLYFLIX',
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
def render(content, images, public_url, playback_url, unsubscribe_url, *, preview=False, test=False):
esc = html.escape
titles = [entry for entry in content['titles'] if entry['selected']]
body, lines, attachments = [], [], []
intro = str(content.get('intro') or '').strip()
if intro:
body.append(f'<p style="font-size:15px;line-height:1.8;color:#e5e1e4;overflow-wrap:anywhere">{esc(intro).replace(chr(10), "<br>")}</p>')
lines += [intro, '']
sections = [('Featured picks', [entry for entry in titles if entry['featured']]),
('New movies', [entry for entry in titles if not entry['featured'] and entry['type'] == 'movie']),
('Fresh episodes', [entry for entry in titles if not entry['featured'] and entry['type'] == 'series'])]
for heading, entries in sections:
if not entries:
continue
body.append(f'<h2 style="font-size:20px;margin:28px 0 8px;color:#e5e1e4">{heading}</h2>')
lines += [heading, '']
for entry in entries:
watch = playback_url + '/web/index.html#!/details?' + urlencode({'id': entry['id'], 'serverId': content['server_id']})
image_data = images.get(entry['id'])
cid = f"newsletter-{entry['id']}@magent"
if image_data:
source = 'data:image/jpeg;base64,' + base64.b64encode(image_data).decode() if preview else 'cid:' + cid
poster = f'<img src="{source}" width="80" alt="{esc(entry["title"], quote=True)}" style="display:block;width:80px;height:auto;border-radius:7px;border:0">'
if not preview:
attachments.append({'cid': cid, 'data': image_data})
else:
poster = f'<div style="width:80px;height:112px;line-height:112px;background:#353039;color:#c7bdff;text-align:center;border-radius:7px;font-size:11px">{"TV" if entry["type"] == "series" else "MOVIE"}</div>'
details = description(entry)
overview = str(entry.get('overview') or '')[:180]
copy = f'<p style="margin:8px 0;font-size:12px;line-height:1.6;color:#bdb6c3">{esc(overview)}</p>' if overview and entry['featured'] else ''
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Grizzlyflix &#8599;</a></td></tr></table>''')
lines += [entry['title'], details, watch, '']
if not titles:
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in Grizzlyflix.</p>')
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
footer = f'You subscribed to the Grizzlyflix newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
subject = ('[Test] ' if test else '') + content['subject']
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Grizzlyflix: {playback_url}', '',
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']),
'body_html': document(title='Whats new on Grizzlyflix',
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
content=''.join(body), action='Explore Grizzlyflix', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
'inline_images': attachments}
+351
View File
@@ -0,0 +1,351 @@
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib
import json
import secrets
import uuid
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from . import email_queue
from .recap_store import read_one, transaction
from .public_urls import magent_public_url
class Conflict(ValueError):
pass
def init_schema(conn):
for sql in (
"""CREATE TABLE IF NOT EXISTS newsletter_settings (
id INTEGER PRIMARY KEY CHECK(id=1), enabled INTEGER NOT NULL DEFAULT 0,
weekday INTEGER NOT NULL DEFAULT 4, hour INTEGER NOT NULL DEFAULT 9, limit_titles INTEGER NOT NULL DEFAULT 12,
public_url TEXT NOT NULL DEFAULT '', intro TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
next_send_at REAL, generation_claim TEXT, generation_until REAL, generation_attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '')""",
"INSERT OR IGNORE INTO newsletter_settings (id, public_url) SELECT 1, public_url FROM email_recap_settings WHERE id=1",
"""CREATE TABLE IF NOT EXISTS newsletter_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS newsletter_editions (
id TEXT PRIMARY KEY, subject TEXT NOT NULL, intro TEXT NOT NULL, content_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'draft', origin TEXT NOT NULL DEFAULT 'manual',
weekly_key TEXT UNIQUE, send_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, created_by TEXT NOT NULL)""",
"""CREATE TABLE IF NOT EXISTS newsletter_versions (
edition_id TEXT NOT NULL, revision INTEGER NOT NULL, content_json TEXT NOT NULL,
PRIMARY KEY (edition_id, revision))""",
"""CREATE TABLE IF NOT EXISTS newsletter_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
edition_id TEXT NOT NULL, edition_revision INTEGER NOT NULL, kind TEXT NOT NULL,
email TEXT NOT NULL, subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_newsletter_queue ON newsletter_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email,''))) != LOWER(TRIM(COALESCE(OLD.email,''))) OR NEW.is_blocked=1
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM newsletter_subscriptions WHERE user_id=OLD.id;
UPDATE newsletter_deliveries SET state='cancelled', detail='Account removed.'
WHERE user_id=OLD.id AND state IN ('queued','retry','preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
):
conn.execute(sql)
def settings() -> dict:
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
result['public_url'] = magent_public_url(result['public_url'])
result['enabled'] = bool(result['enabled'])
return result
def public_settings() -> dict:
return {key: value for key, value in settings().items() if key in
{'enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision', 'next_send_at', 'last_error'}}
def next_due(now: datetime, weekday: int, hour: int) -> datetime:
now = now.astimezone(timezone.utc)
due = now.replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=(weekday - now.weekday()) % 7)
return due if due > now else due + timedelta(days=7)
def save_settings(values: dict, now: datetime):
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
if old['revision'] != values['revision']:
raise Conflict('The newsletter settings changed. Refresh before saving.')
due = next_due(now, values['weekday'], values['hour']).timestamp() if values['enabled'] else None
conn.execute("""UPDATE newsletter_settings SET enabled=?, weekday=?, hour=?, limit_titles=?, public_url=?, intro=?,
revision=revision+1, next_send_at=?, generation_claim=NULL, generation_until=NULL, generation_attempts=0, last_error='' WHERE id=1""",
(values['enabled'], values['weekday'], values['hour'], values['limit_titles'], values['public_url'], values['intro'], due))
if not values['enabled'] or any(old[key] != values[key] for key in ('weekday', 'hour', 'public_url')):
conn.execute("UPDATE newsletter_editions SET state='cancelled', updated_at=? WHERE origin='weekly' AND state IN ('scheduled','queued')", (now.timestamp(),))
conn.execute("""UPDATE newsletter_deliveries SET state='cancelled', detail='Weekly schedule paused or changed.'
WHERE state IN ('queued','retry','preparing') AND kind='edition'
AND edition_id IN (SELECT id FROM newsletter_editions WHERE state='cancelled')""")
return public_settings()
def subscription(user_id):
return read_one('SELECT * FROM newsletter_subscriptions WHERE user_id=?', (user_id,))
def disable(user_id):
with transaction() as conn:
conn.execute("UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled', detail='Newsletter subscription turned off.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (user_id,))
def request_confirmation(user, source, identity, now):
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute('SELECT requested_at FROM newsletter_subscriptions WHERE user_id=?', (user['id'],)).fetchone()
if old and old[0] > now - 300:
raise Conflict('Please wait five minutes before requesting another confirmation.')
conn.execute("""INSERT INTO newsletter_subscriptions (user_id,state,email,identity_source,identity_id,version,
confirmation_hash,confirmation_expires,requested_at,unsubscribe_token) VALUES (?,'pending',?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET state='pending',email=excluded.email,identity_source=excluded.identity_source,
identity_id=excluded.identity_id,version=excluded.version,confirmation_hash=excluded.confirmation_hash,
confirmation_expires=excluded.confirmation_expires,requested_at=excluded.requested_at,confirmed_at=NULL,
unsubscribe_token=excluded.unsubscribe_token""",
(user['id'], user['email'].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
return token
def token_subscription(token, action):
if action == 'confirm':
return read_one('SELECT * FROM newsletter_subscriptions WHERE confirmation_hash=?', (hashlib.sha256(token.encode()).hexdigest(),))
return read_one('SELECT * FROM newsletter_subscriptions WHERE unsubscribe_token=?', (token,))
def confirm(sub, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_subscriptions SET state='enabled',confirmed_at=?,confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users u JOIN jellyfin_user_links j ON j.local_user_id=u.id
WHERE u.id=newsletter_subscriptions.user_id AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(newsletter_subscriptions.email))
AND j.source=identity_source AND j.jellyfin_user_id=identity_id)""", (now, sub['user_id'], sub['version'], now))
return result.rowcount == 1
def unpack(row):
if row is None:
return None
result = dict(row)
result['content'] = json.loads(result.pop('content_json'))
return result
def edition(identity):
return unpack(read_one('SELECT * FROM newsletter_editions WHERE id=?', (identity,)))
def create_edition(content, subject, intro, creator, now):
identity = uuid.uuid4().hex
with transaction() as conn:
conn.execute('''INSERT INTO newsletter_editions (id,subject,intro,content_json,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,?,?)''', (identity, subject, intro, json.dumps(content), now, now, creator))
return edition(identity)
def editable(conn, identity, revision):
row = conn.execute('SELECT * FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if not row or row['revision'] != revision:
raise Conflict('This edition changed. Reload it before continuing.')
if row['state'] != 'draft':
raise Conflict('This edition is already scheduled or finished. Create a new draft to make changes.')
return unpack(row)
def update_edition(identity, revision, subject, intro, selections, now):
with transaction() as conn:
old = editable(conn, identity, revision)
titles = old['content']['titles']
selected = {entry['id']: entry for entry in selections}
if len(selected) != len(selections) or set(selected) != {entry['id'] for entry in titles}:
raise Conflict('The title selection does not match this draft. Reload the edition.')
if sum(bool(entry['selected']) for entry in selections) > 24 or sum(bool(entry['featured']) for entry in selections) > 3:
raise Conflict('Choose up to 24 titles and three featured picks.')
if any(entry['featured'] and not entry['selected'] for entry in selections):
raise Conflict('Featured picks must be included in the edition.')
for entry in titles:
entry.update(selected=selected[entry['id']]['selected'], featured=selected[entry['id']]['featured'])
conn.execute('UPDATE newsletter_editions SET subject=?,intro=?,content_json=?,revision=revision+1,updated_at=? WHERE id=?',
(subject, intro, json.dumps(old['content']), now, identity))
return edition(identity)
def snapshot(conn, row):
data = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
# Store only included titles; retries of a test retain the exact saved version.
data['titles'] = [entry for entry in data['titles'] if entry['selected']]
conn.execute('INSERT OR IGNORE INTO newsletter_versions (edition_id,revision,content_json) VALUES (?,?,?)',
(row['id'], row['revision'], json.dumps(data)))
def version(delivery):
row = read_one('SELECT content_json FROM newsletter_versions WHERE edition_id=? AND revision=?', (delivery['edition_id'], delivery['edition_revision']))
return json.loads(row['content_json']) if row else None
def publish(identity, revision, send_at, now):
with transaction() as conn:
previous = conn.execute('SELECT revision,state FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if previous and previous['revision'] == revision and previous['state'] in {'scheduled', 'queued', 'complete'}:
return edition(identity)
row = editable(conn, identity, revision)
if not any(entry['selected'] for entry in row['content']['titles']) and not row['intro'].strip():
raise Conflict('Add an announcement or select a title before sending.')
snapshot(conn, row)
conn.execute("UPDATE newsletter_editions SET state='scheduled',send_at=?,updated_at=? WHERE id=?", (send_at, now, identity))
return edition(identity)
def cancel(identity, now):
with transaction() as conn:
conn.execute("UPDATE newsletter_editions SET state='cancelled',updated_at=? WHERE id=? AND state IN ('draft','scheduled','queued')", (now, identity))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled',detail='Edition cancelled.',updated_at=? WHERE edition_id=? AND state IN ('queued','retry','preparing')", (now, identity))
return edition(identity)
def _enqueue(conn, sub, row, kind, key, public_url, now):
identity = uuid.uuid4().hex
conn.execute('''INSERT OR IGNORE INTO newsletter_deliveries (id,dedupe_key,user_id,edition_id,edition_revision,kind,email,
subscription_version,public_url,created_at,updated_at,next_attempt_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
(identity, key, sub['user_id'], row['id'], row['revision'], kind, sub['email'], sub['version'], public_url, now, now, now))
return conn.execute('SELECT id FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()[0]
def enqueue_test(sub, identity, revision, request_id, public_url, now):
key = f"test:{sub['user_id']}:{request_id}"
with transaction() as conn:
previous = conn.execute('SELECT id,edition_id,edition_revision FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()
if previous:
if previous['edition_id'] != identity or previous['edition_revision'] != revision:
raise Conflict('This test request was already used for another saved version.')
return previous['id']
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE id=? AND revision=?', (identity, revision)).fetchone())
if not row or row['state'] == 'cancelled':
raise Conflict('This edition changed or was cancelled. Reload it first.')
if conn.execute("SELECT 1 FROM newsletter_deliveries WHERE user_id=? AND kind='test' AND created_at>?", (sub['user_id'], now-300)).fetchone():
raise Conflict('Please wait five minutes between newsletter test emails.')
snapshot(conn, row)
return _enqueue(conn, sub, row, 'test', key, public_url, now)
def enqueue_due(now):
with transaction() as conn:
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
config['public_url'] = magent_public_url(config['public_url'])
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
for raw in rows:
row = unpack(raw)
subs = conn.execute("SELECT * FROM newsletter_subscriptions WHERE state='enabled' AND confirmed_at<=?", (row['send_at'],)).fetchall()
for sub in subs:
_enqueue(conn, sub, row, 'edition', f"edition:{row['id']}:{sub['user_id']}", config['public_url'], now)
conn.execute("UPDATE newsletter_editions SET state=?,updated_at=? WHERE id=?", ('queued' if subs else 'complete', now, row['id']))
def claim_delivery(now):
with transaction() as conn:
return email_queue.claim(conn, 'newsletter_deliveries', now)
def begin_sending(delivery, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_deliveries SET state='sending',updated_at=?,lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM newsletter_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=newsletter_deliveries.user_id AND s.state='enabled'
AND s.version=newsletter_deliveries.subscription_version AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM newsletter_settings WHERE id=1 AND public_url=newsletter_deliveries.public_url)
AND EXISTS (SELECT 1 FROM newsletter_editions e WHERE e.id=newsletter_deliveries.edition_id AND e.state!='cancelled')""",
(now, now+1800, delivery['id'], delivery['claim']))
return result.rowcount == 1
def finish(delivery, state, detail, now, delay=0):
with transaction() as conn:
email_queue.finish(conn, 'newsletter_deliveries', delivery, state, detail, now, delay)
def finish_editions(now):
with transaction() as conn:
conn.execute("""UPDATE newsletter_editions SET state='complete',updated_at=? WHERE state='queued'
AND NOT EXISTS (SELECT 1 FROM newsletter_deliveries d WHERE d.edition_id=newsletter_editions.id
AND d.kind='edition' AND d.state IN ('queued','preparing','sending','retry'))""", (now,))
def claim_weekly(now: datetime):
with transaction() as conn:
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
stamp = now.timestamp()
if not config['enabled'] or not config['next_send_at'] or config['next_send_at'] > stamp or (config['generation_until'] or 0) > stamp:
return None
claim = uuid.uuid4().hex
conn.execute('UPDATE newsletter_settings SET generation_claim=?,generation_until=?,generation_attempts=generation_attempts+1 WHERE id=1', (claim, stamp+600))
due = next_due(now, config['weekday'], config['hour']) - timedelta(days=7)
return {**config, 'generation_claim': claim, 'due': due, 'generation_attempts': config['generation_attempts']+1}
def complete_weekly(config, content, now: datetime, failure=''):
with transaction() as conn:
current = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
if not current['enabled'] or current['revision'] != config['revision'] or current['generation_claim'] != config['generation_claim']:
return
if failure:
retry = config['generation_attempts'] < 3
conn.execute('''UPDATE newsletter_settings SET generation_claim=NULL,generation_until=?,last_error=?,next_send_at=?,
generation_attempts=? WHERE id=1''', (now.timestamp()+300 if retry else None, failure,
current['next_send_at'] if retry else next_due(now, config['weekday'], config['hour']).timestamp(),
config['generation_attempts'] if retry else 0))
return
identity = uuid.uuid4().hex
due = config['due']
empty = not content['titles']
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
(identity, f"Whats new on Grizzlyflix · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty:
snapshot(conn, row)
conn.execute('''UPDATE newsletter_settings SET next_send_at=?,generation_claim=NULL,generation_until=NULL,
generation_attempts=0,last_error=? WHERE id=1''',
(next_due(now, config['weekday'], config['hour']).timestamp(), 'No new arrivals for the weekly edition; no email was queued.' if empty else ''))
def overview(offset=0):
with closing(db._connect()) as conn:
import sqlite3
conn.row_factory = sqlite3.Row
rows = conn.execute('SELECT * FROM newsletter_editions ORDER BY created_at DESC,id LIMIT 30').fetchall()
editions = []
for raw in rows:
row = unpack(raw)
content = row.pop('content')
row.update(period_start=content['period_start'], period_end=content['period_end'], titles=sum(entry['selected'] for entry in content['titles']))
editions.append(row)
deliveries = conn.execute('''SELECT d.id,d.edition_id,e.subject,d.kind,d.email,d.state,d.attempts,d.updated_at,d.next_attempt_at,
d.detail,u.username FROM newsletter_deliveries d LEFT JOIN users u ON u.id=d.user_id
LEFT JOIN newsletter_editions e ON e.id=d.edition_id ORDER BY d.created_at DESC,d.id LIMIT 50 OFFSET ?''', (offset,)).fetchall()
subscribers = conn.execute("SELECT COUNT(*) FROM newsletter_subscriptions WHERE state='enabled'").fetchone()[0]
total = conn.execute('SELECT COUNT(*) FROM newsletter_deliveries').fetchone()[0]
return {'editions': editions, 'deliveries': [dict(row) for row in deliveries], 'subscribers': subscribers, 'total': total}
+271
View File
@@ -0,0 +1,271 @@
"""Weekly new-arrival newsletters, manual editions and separate opt-in delivery."""
import asyncio
import logging
import time
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode, urlsplit
from .. import db
from ..runtime import get_runtime_settings
from . import email_recaps, newsletter_catalog as catalog, newsletter_email as template, newsletter_store as store
from . import recap_email as mail, recap_store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
logger = logging.getLogger(__name__)
NewsletterError = email_recaps.RecapError
def playback_url(runtime) -> str:
value = str(runtime.jellyfin_public_url or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if parsed.scheme in {'https', 'http'} and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment) and not any(c.isspace() or c in '<>"\\' for c in value):
return value
except ValueError:
pass
return ''
def delivery_ready(public_url=None):
config = store.settings()
if not (public_url if public_url is not None else config['public_url']):
return False, 'Set the application URL in Hosting & proxy for newsletter email links.'
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
return False, 'Connect Jellyfin to collect new arrivals.'
if not playback_url(runtime):
return False, 'Set the public Jellyfin address in Jellyfin settings for Watch links.'
ready, detail = smtp_email_config_ready()
if not ready:
return ready, detail
if not email_recaps.worker_enabled():
return False, 'Background automation is paused on this server.'
return True, 'Newsletter delivery is configured.'
def account_for(user):
account = db.get_user_by_username(user.get('username', ''))
if not account or account.get('is_blocked') or account.get('is_expired'):
raise NewsletterError('This account cannot receive newsletters.', 403)
return account
def active_subscription(account):
sub = store.subscription(account['id'])
if sub and sub['state'] != 'off' and not email_recaps.binding_matches(sub, account):
store.disable(account['id'])
sub = store.subscription(account['id'])
return sub
def preferences(user):
account = account_for(user)
sub = active_subscription(account)
runtime = get_runtime_settings()
ready, detail = delivery_ready()
linked = bool(linked_user_id(account['username'], runtime.jellyfin_base_url))
email = mail.valid_email(account.get('email'))
config = store.settings()
state = sub['state'] if sub else 'off'
if state == 'pending' and sub['confirmation_expires'] <= time.time():
state = 'expired'
return {'state': state, 'email': account.get('email'), 'can_subscribe': ready and linked and bool(email),
'detail': detail if not ready else 'Save a valid profile email address.' if not email else
'Link your Jellyfin account so newsletter titles match your library access.' if not linked else 'New arrivals and featured picks, in your inbox.',
'schedule_enabled': config['enabled'], 'next_send_at': config['next_send_at'], 'weekday': config['weekday'], 'hour': config['hour'],
'resend_after': sub['requested_at'] + 300 if sub else None}
async def subscribe(user):
account = account_for(user)
preference = preferences(user)
if preference['state'] == 'enabled':
return preference
if not preference['can_subscribe']:
raise NewsletterError(preference['detail'])
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account['username'], runtime.jellyfin_base_url), time.time())
except store.Conflict as exc:
raise NewsletterError(str(exc), 429) from exc
# The click supplies separate newsletter consent. Reuse a still-valid confirmed address if available.
recap = recap_store.subscription(account['id'])
if recap and recap['state'] == 'enabled' and email_recaps.binding_matches(recap, account):
if store.confirm(store.subscription(account['id']), time.time()):
return {**preferences(user), 'message': 'Newsletter subscription is on, using your confirmed profile email.'}
config = store.settings()
url = config['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'confirm', 'token': token})
try:
await asyncio.to_thread(mail.send_email, account['email'].strip(), template.render_confirmation(account['username'], url),
mail.message_id(uuid.uuid4().hex, config['public_url']))
except mail.DeliveryError as exc:
raise NewsletterError('Could not confirm delivery of the verification email. Check your inbox; another can be requested in five minutes.', 502) from exc
return {**preferences(user), 'message': 'Check your inbox and confirm within 24 hours to turn on newsletters.'}
def token_action(token, action, apply=False):
sub = store.token_subscription(token, action)
if not sub:
raise NewsletterError('This newsletter link is invalid or has already been used. Open Profile to manage your subscription.', 410)
if action == 'unsubscribe':
if apply:
store.disable(sub['user_id'])
return {'action': action, 'state': 'off' if apply or sub['state'] == 'off' else 'ready'}
account = db.get_user_by_id(sub['user_id'])
if sub['state'] != 'pending' or sub['confirmation_expires'] <= time.time() or not email_recaps.binding_matches(sub, account):
raise NewsletterError('This confirmation expired or your account changed. Request a new newsletter link in Profile.', 410)
if apply and not store.confirm(sub, time.time()):
raise NewsletterError('This confirmation is no longer available. Request a new newsletter link in Profile.', 410)
return {'action': action, 'state': 'enabled' if apply else 'ready'}
async def collect(start, end, limit):
runtime = get_runtime_settings()
result = await asyncio.wait_for(catalog.collect(runtime, start, end, limit), timeout=180)
return {**result, 'playback_url': playback_url(runtime)}
async def create_draft(user, days):
end = datetime.now(timezone.utc)
config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
return store.create_edition(content, f"Whats new on Grizzlyflix · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
def require_edition(identity, revision=None):
row = store.edition(identity)
if not row:
raise NewsletterError('Newsletter edition not found.', 404)
if revision is not None and row['revision'] != revision:
raise NewsletterError('This edition changed. Reload it before continuing.')
return row
async def preview(identity, revision):
row = require_edition(identity, revision)
runtime = get_runtime_settings()
config = store.settings()
if not config['public_url'] or not playback_url(runtime):
raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.')
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
rendered = template.render(content, images, config['public_url'], content['playback_url'], config['public_url'] + '/profile#newsletters', preview=True)
rendered.pop('inline_images')
return {'id': row['id'], 'revision': row['revision'], **rendered}
def queue_test(user, identity, revision, request_id):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
account = account_for(user)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise NewsletterError('Subscribe to newsletters and confirm your email in Profile before sending yourself a test.')
delivery_id = store.enqueue_test(sub, identity, revision, request_id, store.settings()['public_url'], time.time())
return {'id': delivery_id, 'message': 'Test queued for your confirmed newsletter email. Delivery history will show the result.'}
def publish(identity, revision, send_at):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
row = require_edition(identity, revision)
runtime = get_runtime_settings()
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection changed. Create a fresh draft before sending.')
now = datetime.now(timezone.utc)
when = now if send_at is None else send_at
if when.tzinfo is None:
raise NewsletterError('Choose a send time with an explicit timezone.', 422)
when = when.astimezone(timezone.utc)
if send_at is not None and not now + timedelta(seconds=30) <= when <= now + timedelta(days=90):
raise NewsletterError('Schedule the edition at least 30 seconds ahead and within the next 90 days.', 422)
return store.publish(identity, revision, when.timestamp(), now.timestamp())
def eligible(delivery):
account = db.get_user_by_id(delivery['user_id'])
sub = active_subscription(account) if account else None
ready, _ = delivery_ready()
if not ready or not sub or sub['state'] != 'enabled' or sub['version'] != delivery['subscription_version'] or sub['email'] != delivery['email'] or not email_recaps.binding_matches(sub, account) or store.settings()['public_url'] != delivery['public_url']:
raise mail.DeliveryCancelled()
row = store.edition(delivery['edition_id'])
if not row or row['state'] == 'cancelled':
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery):
state, detail, delay = 'failed', 'Could not prepare this newsletter.', 0
try:
_, sub = eligible(delivery)
content = store.version(delivery)
runtime = get_runtime_settings()
if not content or content['playback_url'] != playback_url(runtime) or content['source'] != source_key(runtime.jellyfin_base_url):
raise mail.DeliveryCancelled()
content = await asyncio.wait_for(catalog.for_recipient(runtime, content, sub['identity_id']), timeout=120)
if content.get('recipient_disabled') or (not content['titles'] and not content['intro'].strip()):
state, detail = 'skipped', 'No selected titles are available to this account.'
else:
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
unsubscribe = delivery['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
rendered = template.render(content, images, delivery['public_url'], content['playback_url'], unsubscribe, test=delivery['kind'] == 'test')
def before_data():
eligible(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery['email'], rendered, mail.message_id(delivery['id'], delivery['public_url']), before_data)
state, detail = 'sent', 'Accepted by the mail server.'
except mail.DeliveryCancelled:
state, detail = 'cancelled', 'Subscription, account, edition or email settings changed.'
except (catalog.CatalogError, TimeoutError):
state, detail = 'retry', 'Jellyfin content or library access could not be checked.'
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
logger.error('newsletter delivery error id=%s type=%s', delivery['id'], type(exc).__name__)
current = store.read_one('SELECT state FROM newsletter_deliveries WHERE id=?', (delivery['id'],))
if current and current['state'] == 'sending':
state, detail = 'unknown', 'Delivery outcome is unknown; check the mail server.'
if state == 'retry':
if delivery['attempts'] >= 3:
state, detail = 'failed', detail + ' Stopped after three attempts.'
else:
delay = 300 if delivery['attempts'] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once():
if delivery_ready()[0]:
config = store.claim_weekly(datetime.now(timezone.utc))
if config:
try:
content = await collect(config['due'] - timedelta(days=7), config['due'], config['limit_titles'])
store.complete_weekly(config, content, datetime.now(timezone.utc))
except (catalog.CatalogError, TimeoutError):
store.complete_weekly(config, None, datetime.now(timezone.utc), 'Could not collect a complete weekly edition from Jellyfin. No newsletter was queued.')
store.enqueue_due(time.time())
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
store.finish_editions(time.time())
async def run_newsletter_loop():
while True:
try:
await run_once()
except Exception as exc:
logger.error('newsletter worker failed type=%s', type(exc).__name__)
await asyncio.sleep(30)
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from copy import deepcopy
from datetime import datetime, timezone
import re
import threading
import time
import uuid
from typing import Any, Dict, Optional
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
_OPERATION_TTL_SECONDS = 15 * 60
_MAX_OPERATIONS = 500
_MAX_EVENTS = 60
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
"magent_operation_id", default=None
)
_operations: Dict[str, Dict[str, Any]] = {}
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
def _prune_locked(now_monotonic: float) -> None:
expired = [
operation_id
for operation_id, operation in _operations.items()
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
]
for operation_id in expired:
_operations.pop(operation_id, None)
if len(_operations) <= _MAX_OPERATIONS:
return
oldest = sorted(
_operations,
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
)
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
_operations.pop(operation_id, None)
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
now_monotonic = time.monotonic()
now_iso = _now_iso()
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
with _lock:
_prune_locked(now_monotonic)
_operations[operation_id] = {
"id": operation_id,
"label": normalized_label,
"path": path,
"status": "running",
"started_at": now_iso,
"updated_at": now_iso,
"updated_monotonic": now_monotonic,
"duration_ms": None,
"events": [
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Your action has been received. Magent is starting the checks.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": None,
}
],
}
return _current_operation_id.set(operation_id)
def reset_operation(token: Token) -> None:
_current_operation_id.reset(token)
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
operation_id = _current_operation_id.get()
if not operation_id:
return None
event_id = uuid.uuid4().hex
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return None
operation["events"].append(
{
"id": event_id,
"service": service,
"state": "active",
"message": message or f"Contacting {service}",
"started_at": now_iso,
"finished_at": None,
"duration_ms": None,
"status_code": None,
"started_monotonic": now_monotonic,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
return event_id
def finish_remote_call(
event_id: Optional[str],
*,
success: bool,
status_code: Optional[int] = None,
message: Optional[str] = None,
) -> None:
operation_id = _current_operation_id.get()
if not operation_id or not event_id:
return
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
event = next(
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
None,
)
if not event:
return
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "complete" if success else "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["status_code"] = status_code
event["message"] = message or (
f"{event['service']} responded successfully."
if success
else f"{event['service']} returned an error."
)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
for event in operation["events"]:
if event.get("state") == "active":
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
started = datetime.fromisoformat(str(operation["started_at"]))
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
operation["status"] = "complete" if success else "error"
operation["status_code"] = status_code
operation["duration_ms"] = round(duration_ms, 1)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
operation["events"].append(
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"This action has finished. Check the request status for what happens next."
if success
else "This action could not be completed. Open the activity details to see which step needs attention."
),
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": status_code,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
normalized = normalize_operation_id(operation_id)
if not normalized:
return None
with _lock:
operation = _operations.get(normalized)
if not operation:
return None
result = deepcopy(operation)
result.pop("updated_monotonic", None)
for event in result.get("events", []):
event.pop("started_monotonic", None)
return result
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..db import (
create_password_reset_token,
delete_expired_password_reset_tokens,
get_password_reset_token,
get_user_by_jellyseerr_id,
get_user_by_username,
get_users_by_username_ci,
mark_password_reset_token_used,
set_user_auth_provider,
set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from .invite_email import send_password_reset_email
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
logger = logging.getLogger(__name__)
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
class PasswordResetUnavailableError(RuntimeError):
pass
def _normalize_handles(value: object) -> list[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
if not users:
return None
requested = str(requested_identifier or "").strip().lower()
def _rank(user: dict) -> tuple[int, int, int, int]:
provider = str(user.get("auth_provider") or "local").strip().lower()
role = str(user.get("role") or "user").strip().lower()
username = str(user.get("username") or "").strip().lower()
return (
0 if role == "admin" else 1,
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
0 if username == requested else 1,
)
return sorted(users, key=_rank)[0]
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
target_handles = set(_normalize_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email"):
value = user.get(key)
if target_handles.intersection(_normalize_handles(value)):
return user
return None
async def _fetch_all_seerr_users() -> list[dict]:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return []
users: list[dict] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
if isinstance(local_user, dict):
stored_email = str(local_user.get("email") or "").strip()
if "@" in stored_email:
return stored_email
username = str(local_user.get("username") or "").strip()
if "@" in username:
return username
if isinstance(seerr_user, dict):
email = str(seerr_user.get("email") or "").strip()
if "@" in email:
return email
return None
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
normalized_identifier = str(identifier or "").strip()
if not normalized_identifier:
return None
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
)
seerr_users: list[dict] | None = None
seerr_user: dict | None = None
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_users = await _fetch_all_seerr_users()
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not local_user:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
if seerr_user:
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
try:
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
except (TypeError, ValueError):
seerr_user_id = None
if seerr_user_id is not None:
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
if not local_user:
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
if not isinstance(candidate, str) or not candidate.strip():
continue
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
)
if local_user:
break
if not local_user:
return None
auth_provider = resolve_user_auth_provider(local_user)
username = str(local_user.get("username") or "").strip()
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
if isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not seerr_user:
seerr_user = _find_matching_seerr_user(username, seerr_users)
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
return None
if auth_provider == "jellyseerr":
runtime = get_runtime_settings()
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
try:
jellyfin_user = await jellyfin_client.find_user_by_name(username)
except Exception:
jellyfin_user = None
if isinstance(jellyfin_user, dict):
auth_provider = "jellyfin"
if auth_provider not in {"local", "jellyfin"}:
return None
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
}
def _token_record_is_usable(record: Optional[dict]) -> bool:
if not isinstance(record, dict):
return False
if record.get("is_used"):
return False
if record.get("is_expired"):
return False
return True
def _mask_email(email: str) -> str:
candidate = str(email or "").strip()
if "@" not in candidate:
return "valid reset link"
local_part, domain = candidate.split("@", 1)
if not local_part:
return f"***@{domain}"
if len(local_part) == 1:
return f"{local_part}***@{domain}"
return f"{local_part[0]}***{local_part[-1]}@{domain}"
async def request_password_reset(
identifier: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier)
if not target:
logger.info("password reset requested with no eligible match")
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
create_password_reset_token(
token,
target["username"],
target["recipient_email"],
target["auth_provider"],
expires_at,
requested_by_ip=requested_by_ip,
requested_user_agent=requested_user_agent,
)
await send_password_reset_email(
recipient_email=target["recipient_email"],
username=target["username"],
token=token,
expires_at=expires_at,
auth_provider=target["auth_provider"],
)
return {
"status": "ok",
"issued": True,
"username": target["username"],
"recipient_email": target["recipient_email"],
"auth_provider": target["auth_provider"],
"expires_at": expires_at,
}
def verify_password_reset_token(token: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
return {
"status": "ok",
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
"auth_provider": record.get("auth_provider"),
"expires_at": record.get("expires_at"),
}
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
username = str(record.get("username") or "").strip()
if not username:
raise ValueError("Password reset link is invalid or has expired.")
stored_user = normalize_user_auth_provider(get_user_by_username(username))
if not stored_user:
raise ValueError("Password reset link is invalid or has expired.")
auth_provider = resolve_user_auth_provider(stored_user)
if auth_provider == "jellyseerr":
auth_provider = "jellyfin"
if auth_provider == "local":
set_user_password(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
set_user_auth_provider(username, "local")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=local", username)
return {"status": "ok", "provider": "local", "username": username}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
jellyfin_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jellyfin_user)
if not user_id:
raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password)
increment_user_auth_version(username)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin", "username": username}
raise ValueError("Password reset is not available for this sign-in provider.")
+29
View File
@@ -0,0 +1,29 @@
"""Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
def valid_public_url(value):
value = str(value or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if (parsed.scheme in {'http', 'https'} and parsed.hostname
and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
and (parsed.port is None or parsed.port > 0)
and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
return value
except ValueError:
pass
return ''
def magent_public_url(legacy_url=''):
runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_url', None)
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy)
if str(application or '').strip():
return valid_public_url(application)
# Preserve pre-existing installations until Hosting & proxy has been configured.
return valid_public_url(legacy_url)
+198
View File
@@ -0,0 +1,198 @@
"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
import html
import re
import smtplib
import ssl
from contextlib import suppress
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP as SMTP_POLICY
from email.utils import formataddr, formatdate
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
class DeliveryError(Exception):
def __init__(self, state: str, detail: str):
self.state, self.detail = state, detail
super().__init__(detail)
class DeliveryCancelled(Exception):
pass
def valid_email(value: str | None) -> str | None:
value = str(value or "").strip()
if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
and all(32 < ord(char) < 127 for char in value)):
return value
return None
def month_label(value: str) -> str:
return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
def number(value: float) -> str:
return f"{value:,.0f}"
def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str, kicker: str = 'YOUR MONTH IN VIEWING') -> str:
esc = html.escape
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>{esc(title)}</title><style>@media(max-width:280px){{.email-metrics td{{display:block!important;width:auto!important;padding:16px 0!important}}.email-metrics tr{{display:block!important}}}}</style></head>
<body style="margin:0;padding:0;background:#131315;color:#e5e1e4;font-family:Arial,Helvetica,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#131315"><tr><td align="center" style="padding:24px 12px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;table-layout:fixed;background:#1c1b1d;border:1px solid #363338;border-radius:16px">
<tr><td style="padding:32px 24px 8px;color:#c7bdff;font-size:12px;letter-spacing:2px;font-weight:bold">MAGENT <span style="color:#918b98;letter-spacing:0">/ {esc(kicker)}</span></td></tr>
<tr><td style="padding:12px 24px"><h1 style="margin:0 0 16px;font-size:32px;line-height:1.2;color:#f3eef6">{esc(title)}</h1><p style="margin:0;color:#bdb6c3;font-size:15px;line-height:1.7;overflow-wrap:anywhere">{esc(intro)}</p></td></tr>
<tr><td style="padding:12px 24px">{content}</td></tr>
<tr><td style="padding:20px 24px 32px"><a href="{esc(url, quote=True)}" style="display:inline-block;padding:15px 22px;border-radius:8px;background:#c7bdff;color:#211b30;text-decoration:none;font-size:14px;font-weight:bold">{esc(action)} &#8599;</a></td></tr>
</table><table role="presentation" width="600" style="width:100%;max-width:600px"><tr><td style="padding:22px 18px;color:#a69fac;font-size:12px;line-height:1.7;text-align:center">{footer}</td></tr></table>
</td></tr></table></body></html>'''
def render_confirmation(username: str, url: str) -> dict:
title = "Your month, delivered."
intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
body = document(title=title, intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.</p>',
action="Confirm email recaps", url=url,
footer="This link expires in 24 hours. If you did not request this, ignore this email.<br>No viewing history will be emailed until you confirm.")
return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
esc = html.escape
month = month_label(report["month"])
previous = month_label(report["comparison_month"])
if report.get('is_partial'):
month += ' so far'
previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
summary = report["summary"]
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
cells, lines = [], []
for label, key, value in metrics:
change = report["changes"][key]
difference = change["difference"]
comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else ''}{number(abs(difference))}")
if change["percent"] is not None and difference:
comparison += f" ({'+' if difference > 0 else ''}{abs(change['percent']):g}%)"
comparison += f" from {previous}"
lines.append(f"{label}: {number(value)}. {comparison}.")
cells.append(f'<td width="50%" valign="top" style="padding:16px 10px;border-bottom:1px solid #363338"><span style="color:#bdb6c3;font-size:12px">{label}</span><br><strong style="display:block;margin:10px 0;color:#e0d8ff;font-size:30px">{number(value)}</strong><span style="color:#a69fac;font-size:11px;line-height:1.6">{esc(comparison)}</span></td>')
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
patterns = report.get("patterns", {})
if patterns:
detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
lines.append(detail)
content += f'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
peak = max(1, *(row["minutes"] for row in rows))
content += f'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
for row in rows:
width = round(row["minutes"] / peak * 100)
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0">&nbsp;</td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
content += '</table>'
top = report.get("top_titles", [])[:3]
if top:
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
for item in top:
artwork = item.get("email_artwork", "")
if artwork.startswith(("cid:", "data:image/")):
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
else:
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
report_url = f"{public_url}/insights/reports?month={report['month']}"
intro = f"Hi {username}, heres your {month} in viewing. A little look back at the stories you spent time with."
footer = f'You enabled personal report emails from Magent.<br>Based on retained Jellystat history. Calendar months use UTC; request statuses are current.<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from recaps</a> · <a href="{esc(public_url + "/profile#monthly-recaps", quote=True)}" style="color:#c7bdff">Email preferences</a>'
if requested:
intro = 'You requested this report. ' + intro
if test:
intro = "This is your test recap. " + intro
body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
*(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
"""Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
"""
runtime = get_runtime_settings()
sender = valid_email(runtime.magent_notify_email_from_address)
if not sender or not valid_email(recipient):
raise DeliveryError("failed", "A valid sender and recipient email are required.")
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
message["To"], message["Subject"] = recipient, rendered["subject"]
message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
message.set_content(rendered["body_text"])
message.add_alternative(rendered["body_html"], subtype="html")
html_part = message.get_payload()[-1]
for attachment in rendered.get('inline_images', []):
html_part.add_related(
attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
payload = message.as_bytes()
smtp, stage = None, "connect"
try:
kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
if runtime.magent_notify_email_use_ssl:
smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
context=ssl.create_default_context(), **kwargs)
else:
smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
smtp.ehlo_or_helo_if_needed()
if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
smtp.starttls(context=ssl.create_default_context())
smtp.ehlo()
if runtime.magent_notify_email_smtp_username:
smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
code, reply = smtp.mail(sender)
if code != 250:
raise smtplib.SMTPResponseException(code, reply)
code, reply = smtp.rcpt(recipient)
if code not in (250, 251):
raise smtplib.SMTPResponseException(code, reply)
before_data()
stage = "data"
code, reply = smtp.data(payload)
if code != 250:
raise smtplib.SMTPDataError(code, reply)
stage = "accepted"
except smtplib.SMTPResponseException as exc:
state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
except (OSError, smtplib.SMTPException) as exc:
state = "unknown" if stage == "data" else "retry"
detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
raise DeliveryError(state, detail) from exc
finally:
if smtp:
# A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
with suppress(Exception):
smtp.quit()
with suppress(Exception):
smtp.close()
def message_id(delivery_id: str, public_url: str) -> str:
host = urlsplit(public_url).hostname or "magent.local"
return f"<magent-recap-{delivery_id}@{host}>"
+247
View File
@@ -0,0 +1,247 @@
"""Durable consent, schedule and delivery records for personal email recaps."""
import hashlib
import secrets
import sqlite3
import uuid
from contextlib import closing, contextmanager
from datetime import datetime
from .. import db
from .monthly_reports import shift_month
from . import email_queue
from .public_urls import magent_public_url
def init_schema(conn: sqlite3.Connection) -> None:
for statement in (
"""CREATE TABLE IF NOT EXISTS email_recap_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
"INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
"""CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS email_recap_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
OR NEW.is_blocked = 1
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
):
conn.execute(statement)
columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
if 'automatic_monthly' not in columns:
conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
@contextmanager
def transaction():
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
conn.execute("BEGIN IMMEDIATE")
yield conn
def read_one(sql: str, args=()) -> dict | None:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
def settings() -> dict:
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
row["public_url"] = magent_public_url(row["public_url"])
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
def next_due(now: datetime, day: int, hour: int) -> datetime:
due = shift_month(now, 0).replace(day=day, hour=hour)
return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
def save_settings(values: dict, now: datetime) -> dict:
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
due = old["next_send_at"]
if not values["enabled"]:
due = None
elif not old["enabled"] or changed:
due = next_due(now, values["day"], values["hour"]).timestamp()
conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
(values["enabled"], values["day"], values["hour"], values["public_url"], due))
if not values["enabled"] or changed:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
return settings()
def subscription(user_id: int) -> dict | None:
return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
def disable(user_id: int) -> None:
with transaction() as conn:
conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
if old and old["requested_at"] > now - 300:
raise ValueError("Please wait five minutes before requesting another confirmation email.")
conn.execute("""INSERT INTO email_recap_subscriptions
(user_id, state, email, identity_source, identity_id, version, confirmation_hash,
confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
return token
def token_subscription(token: str, action: str) -> dict | None:
if action == "confirm":
return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),))
return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
def confirm(sub: dict, now: float) -> bool:
with transaction() as conn:
# Recheck address and blocked state in the same transaction as the consent write.
result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
(now, sub["user_id"], sub["version"], now))
return result.rowcount == 1
def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
delivery_id = uuid.uuid4().hex
conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
(id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
key = f"{kind}:{sub['user_id']}:{request_id}"
with transaction() as conn:
existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
if existing:
if existing['month'] != month or existing['subscription_version'] != sub['version']:
raise ValueError('This send request was already used. Refresh before requesting another report.')
return existing[0]
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') AND created_at>?",
(sub["user_id"], now - 300)).fetchone()
if recent:
raise ValueError("Please wait five minutes between report emails.")
return _enqueue(conn, sub, month, kind, key, public_url, now)
def enqueue_due(now: datetime) -> int:
with transaction() as conn:
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
config["public_url"] = magent_public_url(config["public_url"])
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
return 0
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
if due > now:
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
month = shift_month(due, -1).strftime("%Y-%m")
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 AND confirmed_at<=?", (due.timestamp(),)).fetchall()
before = conn.total_changes
for sub in subs:
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
count = conn.total_changes - before
conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
(next_due(now, config["day"], config["hour"]).timestamp(),))
return count
def claim_delivery(now: float) -> dict | None:
with transaction() as conn:
return email_queue.claim(conn, "email_recap_deliveries", now)
def begin_sending(delivery: dict, now: float) -> bool:
with transaction() as conn:
# Consent may have changed while the report or SMTP connection was being prepared.
result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
AND (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
return result.rowcount == 1
def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
with transaction() as conn:
email_queue.finish(conn, "email_recap_deliveries", delivery, state, detail, now, delay)
def history(limit: int = 50, offset: int = 0) -> dict:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
def set_automatic(user_id: int, enabled: bool):
with transaction() as conn:
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
if not enabled:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
def personal_history(user_id: int) -> list[dict]:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
+129
View File
@@ -0,0 +1,129 @@
"""Explicit original-language requests without changing shared quality defaults."""
import asyncio
import copy
import hashlib
import json
import re
import httpx
from fastapi import HTTPException
_profile_lock = asyncio.Lock()
_prefix = "Magent Original "
def language_info(details):
code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
return None
return {"code": code}
def profile_body(profile):
return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
def profile_name(body):
return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
def is_original_profile(profile):
return ((profile.get("language") or {}).get("id") == -2
and profile.get("name") == profile_name(profile_body(profile)))
async def original_profile(client, default_id):
# Reuse immutable copies; never edit a profile already used by other titles.
async with _profile_lock:
try:
profiles = await client.get_quality_profiles()
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
if not isinstance(profiles, list):
raise HTTPException(502, "Radarr returned invalid quality profiles.")
default = next((p for p in profiles if p.get("id") == default_id), None)
if not default:
raise HTTPException(409, "The default quality profile changed. Reload the request.")
body = profile_body(default)
body["language"] = {"id": -2, "name": "Original"}
name = profile_name(body)
match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
if match:
return match["id"]
try:
result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
async def apply_original_to_movie(client, tmdb_id):
movies = await client.get_movie_by_tmdb_id(tmdb_id)
if not isinstance(movies, list):
raise HTTPException(502, "Radarr did not return the movie list.")
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
if not matches:
return None
if len(matches) != 1:
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
movie = matches[0]
profile_id = await original_profile(client, movie['qualityProfileId'])
if movie['qualityProfileId'] != profile_id:
movie['qualityProfileId'] = profile_id
await client.update_movie(movie)
verified = await client.get_movie(movie['id'])
if not verified or verified.get('qualityProfileId') != profile_id:
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
return profile_id
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
command_id = command.get('id') if isinstance(command, dict) else None
if not isinstance(command_id, int):
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
for attempt in range(attempts):
state = await client.get(f'/api/v3/command/{command_id}')
status = str((state or {}).get('status', '')).lower()
queue = await client.get_queue(movie_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('movieId') == movie_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
if matching:
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
if status in {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
if status == 'completed':
movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
# Command completion precedes download-client queue refresh. Keep polling.
pass
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
if not ids:
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
for attempt in range(attempts):
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
queue = await client.get_queue(series_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('seriesId') == series_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
if matching:
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
# Even completed commands can precede Sonarr's download queue refresh.
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
+41
View File
@@ -0,0 +1,41 @@
"""State-changing requests may originate only from explicitly configured sites.
The public Hosting & proxy URL can be stored in the database, while the CORS
environment setting still has its localhost default on an upgraded install.
Never infer a trusted origin from request Host or forwarded headers.
"""
from urllib.parse import urlsplit
from ..config import settings
from .public_urls import magent_public_url, valid_public_url
def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
value = str(value or "")
if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
return None
if "?" in value or "#" in value:
return None
validated = valid_public_url(value)
if not validated:
return None
parsed = urlsplit(value)
if parsed.username is not None or parsed.password is not None:
return None
if not configured_url and parsed.path:
return None
return (
parsed.scheme.lower(),
parsed.hostname.lower(),
parsed.port or (443 if parsed.scheme == "https" else 80),
)
def is_allowed_request_origin(origin: str) -> bool:
candidate = _origin(origin)
if candidate is None:
return False
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True
return candidate == _origin(magent_public_url(), configured_url=True)
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__)
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
parsed = _parse_iso(cached_at)
if not parsed:
return False
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(minutes=max_age_minutes)
def _load_cached_users(
cache_key: str, cache_at_key: str, max_age_minutes: int
) -> Optional[List[Dict[str, Any]]]:
cached_at = get_setting(cache_at_key)
if not _cache_is_fresh(cached_at, max_age_minutes):
return None
raw = get_setting(cache_key)
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return None
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return None
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
payload = json.dumps(users, ensure_ascii=True)
set_setting(cache_key, payload)
set_setting(cache_at_key, _now_iso())
def _normalized_handles(value: Any) -> List[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
candidate_to_id: Dict[str, int] = {}
for user in users:
if not isinstance(user, dict):
continue
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
for key in ("username", "email", "displayName", "name"):
for handle in _normalized_handles(user.get(key)):
candidate_to_id.setdefault(handle, user_id)
return candidate_to_id
def find_matching_jellyseerr_user(
identifier: str, users: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
target_handles = set(_normalized_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email", "displayName", "name"):
if target_handles.intersection(_normalized_handles(user.get(key))):
return user
return None
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(user, dict):
return None
value = user.get("email")
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int]
) -> Optional[int]:
for handle in _normalized_handles(username):
matched = candidate_map.get(handle)
if matched is not None:
return matched
return None
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("id") or user.get("userId") or user.get("Id"),
"email": user.get("email"),
"username": user.get("username"),
"displayName": user.get("displayName"),
"name": user.get("name"),
}
)
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
logger.debug("Cached Seerr users: %s", len(normalized))
return normalized
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyfin users: %s", len(normalized))
return normalized
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
coverage==7.16.1
pip-audit==2.10.1
ruff==0.16.8
+11 -8
View File
@@ -1,9 +1,12 @@
fastapi==0.115.0
uvicorn==0.30.6
httpx==0.27.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
fastapi==0.134.0
uvicorn==0.41.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.14.2
PyJWT==2.13.0
passlib==1.7.4
python-multipart==0.0.9
Pillow==10.4.0
argon2-cffi==25.1.0
cryptography==50.0.1
python-multipart==0.0.31
Pillow==12.3.0
prometheus-client==0.22.1
+24
View File
@@ -0,0 +1,24 @@
import unittest
from pydantic import ValidationError
from backend.app.api_models import PasswordResetRequest, SignupRequest
class ApiRequestModelTests(unittest.TestCase):
def test_signup_rejects_unknown_fields(self) -> None:
with self.assertRaises(ValidationError):
SignupRequest(
invite_code="invite",
username="viewer",
password="strong password",
unexpected="value",
)
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
self.assertEqual(request.new_password, " leading and trailing ")
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
class _ArrClient:
async def get_root_folders(self):
return [{"id": 7, "path": "/media/tv"}]
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
async def test_resolves_numeric_root_folder_id(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
async def test_preserves_configured_path(self) -> None:
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
async def test_rejects_missing_root_folder_id(self) -> None:
with self.assertRaises(RootFolderNotFoundError):
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
from contextlib import ExitStack
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from backend.app.config import settings
from backend.app.models import NormalizedState, RequestType, Snapshot
from backend.app.services import snapshot as snapshot_service
from backend.app.services.collector_search import read_search_status, search_status
def command(name="MoviesSearch", status="started", **body):
return {"name": name, "status": status, "body": body}
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
def test_movie_search_is_scoped_to_the_movie(self):
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
def test_queued_search_and_running_search_priority(self):
queued = command(status="queued", movieIds=[12])
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
def test_terminal_commands_are_not_searching(self):
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
with self.subTest(state=state):
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
def test_numeric_statuses(self):
for state, expected in [(0, "queued"), (1, "searching")]:
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
def test_series_and_season_searches(self):
for name in ["SeriesSearch", "SeasonSearch"]:
with self.subTest(name=name):
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
def test_episode_search_uses_episode_ids_not_numbers(self):
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
with self.subTest(name=name):
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
for payload in [None, {}, {"error": "unavailable"}]:
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
async def test_check_is_read_only_with_a_short_timeout(self):
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
async def test_service_failure_is_unknown_not_idle(self):
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
class LibrarySearchPresentationTests(unittest.TestCase):
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
arr_state="added", download_state="not_started", jellyfin=False):
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
state=NormalizedState.added_to_arr)
return snapshot_service._build_presentation(
snapshot, approved=True, arr_state=arr_state,
arr_details={"search": {"state": search}, "availability": {
"available": available, "missing": missing, "total": available + missing,
}}, prowlarr_state="ok",
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
jellyfin_found=jellyfin, jellyfin_link=None,
)
def stage(self, presentation, stage_id="library"):
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
def test_card_uses_actual_search_state(self):
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
with self.subTest(state=state):
presentation = self.presentation(state)
library = self.stage(presentation)
self.assertEqual(library["stateLabel"], badge)
self.assertEqual(library["state"], style)
self.assertEqual(library["searchStatus"], state)
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
self.assertEqual(library["available"], 0)
self.assertEqual(library["missing"], 1)
def test_partial_tv_retains_counts_and_search_activity(self):
for state in ["idle", "searching", "queued", "unavailable"]:
with self.subTest(state=state):
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
library = self.stage(presentation)
self.assertEqual(library["state"], "partial")
self.assertEqual(library["searchStatus"], state)
self.assertIn("22 of 24 episodes collected", library["summary"])
self.assertNotIn("is still looking", presentation["status"]["meaning"])
def test_collected_titles_dont_look_stuck_searching(self):
for jellyfin in [True, False]:
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
self.assertEqual(library["state"], "complete")
self.assertIn("no search needed", library["summary"])
def test_download_has_its_own_state_without_claiming_searching(self):
library = self.stage(self.presentation("idle", download_state="downloading"))
self.assertEqual(library["stateLabel"], "Downloading")
self.assertIn("Not currently searching", library["summary"])
def test_an_old_missing_download_does_not_mark_search_complete(self):
search = self.stage(self.presentation("idle", download_state="missing"), "search")
self.assertEqual(search["state"], "waiting")
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
for media_type in [RequestType.movie, RequestType.tv]:
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
collector = SimpleNamespace(
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
get_queue=AsyncMock(return_value={"records": []}),
get=AsyncMock(return_value=commands),
)
mocks = {
"get_runtime_settings": runtime,
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
"get_request_cache_by_id": None,
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
"JellyfinClient": SimpleNamespace(configured=lambda: False),
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
"SonarrClient": collector, "RadarrClient": collector,
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
"get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
}
for name, value in mocks.items():
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
snapshot = await snapshot_service.build_snapshot("12")
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
self.assertEqual(library["searchStatus"], expected)
+182
View File
@@ -0,0 +1,182 @@
import json
import sqlite3
import unittest
from unittest.mock import AsyncMock, patch
from types import SimpleNamespace
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.feature_access import permissions, update_permissions
from backend.app.routers import identities
from backend.app.services import duplicate_accounts as duplicates, identity_review as review
from backend.app.services.jellyfin_identity import link_user
from backend.tests.test_backend_quality import TempDatabaseMixin
JF, SERVER = 'a' * 32, 'b' * 32
class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
self.keep = db.get_user_by_username('Viewer')['id']
with db._connect() as conn:
self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
link_user('Viewer', JF, 'http://jf')
self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
mocked = patch.object(review, name, return_value=value)
mocked.start(); self.addCleanup(mocked.stop)
mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
return_value={JF: {'state': 'matched', 'id': JF}})
mocked.start(); self.addCleanup(mocked.stop)
async def test_consolidation_preserves_history_and_restrictive_access(self):
with db._connect() as conn:
conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
before = review.read_snapshot()
preview = await duplicates.repair_duplicates(self.extra)
self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
self.assertTrue(preview['can_confirm'], preview['issues'])
self.assertEqual(preview['keep_id'], self.keep)
self.assertNotIn('old-hash', json.dumps(preview))
result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
self.assertEqual(result['consolidated'], 1)
self.assertIsNone(db.get_user_by_id(self.extra))
user = db.get_user_by_username('Viewer')
self.assertEqual(user['id'], self.keep)
self.assertFalse(user['auto_search_enabled'])
self.assertFalse(permissions(user)['issues'])
self.assertTrue(user['is_expired'])
self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
self.assertEqual(len(archive['users']), 2)
self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
report, _, _ = await review.review_identities()
self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
async def test_choose_other_row_retains_its_settings_and_moves_link(self):
with db._connect() as conn:
conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
preview = await duplicates.repair_duplicates(self.keep, self.extra)
self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
async def test_changed_permission_or_identity_rejects_stale_preview(self):
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
update_permissions({'stats': False}, 'Viewer')
with self.assertRaises(HTTPException) as caught:
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
self.assertEqual(caught.exception.status_code, 409)
self.assertIsNotNone(db.get_user_by_id(self.extra))
self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
with self.assertRaises(HTTPException):
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
with db._connect() as conn:
conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
with db._connect() as conn:
conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
with db._connect() as conn:
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
with db._connect() as conn:
conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
with self.assertRaises(sqlite3.IntegrityError):
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
self.assertIsNotNone(db.get_user_by_id(self.extra))
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
async def test_creation_rejects_case_and_whitespace_variants(self):
for name in ('viewer', 'VIEWER', ' Viewer '):
self.assertFalse(db.create_user_if_missing(name, 'unused'))
with self.assertRaises(sqlite3.IntegrityError):
db.create_user(name, 'unused')
async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
async def test_concurrent_imports_create_only_one_normalized_account(self):
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
self.assertEqual(sorted(results), [False, True])
def seed_delivery(self, state='queued'):
with db._connect() as conn:
for prefix in ('email_recap', 'newsletter'):
for identity in (self.keep, self.extra):
conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
(identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
self.seed_delivery()
preview = await duplicates.repair_duplicates(self.extra)
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
with db._connect() as conn:
for prefix in ('email_recap', 'newsletter'):
delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
self.assertEqual(delivery, (self.keep, 'cancelled'))
subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
self.assertEqual(subs, [(self.keep, 'enabled')])
async def test_sending_email_blocks_repair_without_removing_accounts(self):
self.seed_delivery('sending')
preview = await duplicates.repair_duplicates(self.extra)
with self.assertRaises(HTTPException) as caught:
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
self.assertEqual(caught.exception.status_code, 409)
self.assertIsNotNone(db.get_user_by_id(self.extra))
async def test_duplicate_endpoints_are_admin_only(self):
app = FastAPI(); app.include_router(identities.router)
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
with TestClient(app) as client:
for path in ('check', 'confirm'):
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
with db._connect() as conn:
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
preview = await duplicates.repair_duplicates(self.keep)
self.assertTrue(preview['can_confirm'], preview['issues'])
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
self.assertIsNone(db.get_user_by_id(self.extra))
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
+575
View File
@@ -0,0 +1,575 @@
import asyncio
import json
import re
import smtplib
import socketserver
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from email import policy
from email.parser import BytesParser
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlsplit
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import HistoryLimitError, JellystatError
from backend.app.routers import recaps as router
from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
from backend.app.services.jellyfin_identity import link_user, source_key
from backend.app.services.monthly_reports import change, month_periods, shift_month
from backend.tests.test_backend_quality import TempDatabaseMixin
def fixture_report():
periods = month_periods(None, datetime.now(timezone.utc))
summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
changes['requests'] = change(3, 2)
return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
{'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
def runtime():
return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
class RecapFixture(TempDatabaseMixin):
def setUp(self):
super().setUp()
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
link_user('viewer', 'jf-viewer', 'http://jellyfin')
self.user = db.get_user_by_username('viewer')
self.runtime = runtime()
for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
(recaps, 'smtp_email_config_ready', (True, 'ok'))]:
mocked = patch.object(target, name, return_value=value)
mocked.start(); self.addCleanup(mocked.stop)
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
env.start(); self.addCleanup(env.stop)
self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
store.save_settings(self.config, datetime.now(timezone.utc))
self.report = fixture_report()
def subscribe(self, timestamp=None):
now = time.time() if timestamp is None else timestamp
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
sub = store.subscription(self.user['id'])
self.assertTrue(store.confirm(sub, now + 1))
return store.subscription(self.user['id']), token
def queue(self, sub=None, request_id='request-1'):
if sub is None:
sub, _ = self.subscribe()
return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
def delivery(self, delivery_id):
return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
result = await recaps.subscribe(self.user)
self.assertEqual(result['state'], 'pending')
report.assert_not_called()
recipient, rendered, _ = sender.call_args.args
self.assertEqual(recipient, 'viewer@example.test')
self.assertNotIn('Severance', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
token = parse_qs(urlsplit(url).fragment)['token'][0]
self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'confirm', apply=True)
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'unsubscribe', apply=True)
async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
with self.assertRaises(recaps.RecapError) as exc:
await recaps.subscribe(self.user)
self.assertEqual(exc.exception.status, 502)
self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
with patch.object(mail, 'send_email') as sender:
with self.assertRaises(recaps.RecapError) as exc:
await recaps.subscribe(self.user)
self.assertEqual(exc.exception.status, 429)
sender.assert_not_called()
def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
sub, _ = self.subscribe()
delivery_id = self.queue(sub)
token = sub['unsubscribe_token']
self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
recaps.token_action(token, 'unsubscribe', apply=True)
self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
def test_expired_confirmation_does_not_subscribe(self):
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'confirm', apply=True)
def test_email_change_back_does_not_restore_consent(self):
self.subscribe()
db.set_user_email('viewer', 'changed@example.test')
db.set_user_email('viewer', 'viewer@example.test')
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
def test_changed_link_or_source_requires_new_consent(self):
self.subscribe()
with store.transaction() as conn:
conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
with store.transaction() as conn:
conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
self.runtime.jellyfin_base_url = 'http://other-jellyfin'
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
def test_missing_email_or_stored_identity_cannot_subscribe(self):
db.set_user_email('viewer', None)
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
db.set_user_email('viewer', 'viewer@example.test')
with store.transaction() as conn:
conn.execute('DELETE FROM jellyfin_user_links')
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
def test_confirmation_rechecks_email_atomically(self):
store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
old = store.subscription(self.user['id'])
db.set_user_email('viewer', 'different@example.test')
self.assertFalse(store.confirm(old, time.time()))
class RecapScheduleTests(RecapFixture, unittest.TestCase):
def test_defaults_are_paused_and_no_users_are_opted_in(self):
self.assertFalse(store.settings()['enabled'])
self.assertEqual(store.history()['subscribers'], 0)
self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
def test_utc_next_send_month_end_leap_year_and_new_year(self):
for now, expected in [
(datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
(datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
(datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
(datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
before = datetime(2026, 8, 30, tzinfo=timezone.utc)
self.subscribe(before.timestamp())
config = store.save_settings({**self.config, 'enabled': True}, before)
self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
db.create_user('late', 'Example-Password123!', email='late@example.test')
late = db.get_user_by_username('late')
store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
now = datetime(2026, 9, 5, tzinfo=timezone.utc)
with ThreadPoolExecutor(max_workers=4) as pool:
counts = list(pool.map(store.enqueue_due, [now] * 4))
self.assertEqual(sum(counts), 1)
rows = store.history()['deliveries']
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['month'], '2026-08')
self.assertEqual(rows[0]['email'], 'viewer@example.test')
# Revisit the same due date after a restart: the durable unique key still wins.
with store.transaction() as conn:
conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
self.assertEqual(store.enqueue_due(now), 0)
def test_long_downtime_does_not_backfill_multiple_months(self):
before = datetime(2026, 5, 1, tzinfo=timezone.utc)
self.subscribe(before.timestamp())
store.save_settings({**self.config, 'enabled': True}, before)
self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
now = datetime(2026, 9, 9, tzinfo=timezone.utc)
self.subscribe(now.timestamp())
result = store.save_settings({**self.config, 'enabled': True}, now)
self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
self.assertEqual(store.enqueue_due(now), 0)
store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
store.save_settings(self.config, now)
self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
self.assertIsNone(store.settings()['next_send_at'])
class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def run_claim(self):
delivery = store.claim_delivery(time.time())
self.assertIsNotNone(delivery)
await recaps.process_delivery(delivery)
async def test_private_report_is_delivered_once_using_confirmed_account(self):
delivery_id = self.queue()
sent = []
def capture(recipient, rendered, message_id, before_data):
before_data()
self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
sent.append((recipient, rendered, message_id))
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
await recaps.run_once()
await recaps.run_once()
self.assertEqual(len(sent), 1)
self.assertEqual(sent[0][0], 'viewer@example.test')
self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
def test_concurrent_claim_and_test_deduplication(self):
sub, _ = self.subscribe()
with ThreadPoolExecutor(max_workers=4) as pool:
ids = list(pool.map(lambda _: self.queue(sub), range(4)))
rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
self.assertEqual(len(set(ids)), 1)
self.assertEqual(sum(row is not None for row in rows), 1)
with self.assertRaises(ValueError):
self.queue(sub, 'another-click')
async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
delivery_id = self.queue()
async def report(*args):
db.set_user_email('viewer', 'other@example.test')
return self.report
def transport(recipient, rendered, message_id, before_data):
before_data()
self.fail('Private data must not reach SMTP DATA after an address change')
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_stats_permission_revoked_during_report_cancels_email(self):
from backend.app.feature_access import update_permissions
delivery_id = self.queue()
db.set_user_role('viewer', 'user')
async def report(*args):
update_permissions({'stats': False}, 'viewer')
return self.report
def transport(recipient, rendered, message_id, before_data):
before_data()
self.fail('Report must not be sent after stats permission is revoked')
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
for kind in ['blocked', 'expired', 'deleted']:
with self.subTest(kind=kind):
# Each subcase starts with a fresh account and confirmed subscription.
db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
account = db.get_user_by_username(kind)
link_user(kind, f'jf-{kind}', 'http://jellyfin')
store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
store.confirm(store.subscription(account['id']), time.time())
delivery_id = self.queue(store.subscription(account['id']), kind)
with store.transaction() as conn:
if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
await recaps.run_once()
sender.assert_not_called(); report.assert_not_called()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
for attempt in range(1, 4):
await self.run_claim()
row = self.delivery(delivery_id)
self.assertEqual(row['attempts'], attempt)
self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
if attempt < 3:
self.assertGreater(row['next_attempt_at'], time.time() + 250)
with store.transaction() as conn:
conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
self.assertIsNone(store.claim_delivery(time.time()))
async def test_ambiguous_smtp_failure_never_automatically_retries(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
self.assertIsNone(store.claim_delivery(time.time() + 86400))
def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
delivery_id = self.queue()
first = store.claim_delivery(time.time())
second = store.claim_delivery(time.time() + 1801)
self.assertNotEqual(first['claim'], second['claim'])
self.assertFalse(store.begin_sending(first, time.time()))
self.assertTrue(store.begin_sending(second, time.time()))
store.claim_delivery(time.time() + 1801)
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
store.finish(first, 'sent', 'Old worker', time.time())
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
async def test_partial_or_over_limit_report_is_not_emailed(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
await self.run_claim()
sender.assert_not_called()
self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
class RecapApiTests(RecapFixture, unittest.TestCase):
def setUp(self):
super().setUp()
app = FastAPI()
app.include_router(router.router)
self.app = app
self.client = TestClient(app)
self.addCleanup(self.client.close)
def login(self, role='admin'):
self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
def test_authentication_roles_and_recipient_override(self):
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
self.login('user')
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
self.login()
result = self.client.get('/admin/email-recaps')
self.assertEqual(result.status_code, 200)
self.assertEqual(result.headers['cache-control'], 'no-store')
self.assertNotIn('PRIVATE-STATS-KEY', result.text)
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
self.assertEqual(result.status_code, 422)
result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
self.assertEqual(result.status_code, 422)
def test_url_and_schedule_validation_do_not_write_partial_settings(self):
self.login()
for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
self.assertEqual(result.status_code, 422, value)
for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
self.assertEqual(store.settings()['public_url'], self.config['public_url'])
self.assertFalse(store.settings()['enabled'])
def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
self.login()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
result = self.client.get('/admin/email-recaps/preview')
self.assertEqual(result.status_code, 200)
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
self.assertNotIn('PRIVATE-TOKEN', result.text)
sender.assert_not_called()
payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
self.subscribe()
with patch.object(mail, 'send_email') as sender:
first = self.client.post('/admin/email-recaps/test', json=payload)
second = self.client.post('/admin/email-recaps/test', json=payload)
self.assertEqual(first.status_code, 202)
self.assertEqual(first.json()['id'], second.json()['id'])
sender.assert_not_called()
def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
self.login(); sub, token = self.subscribe()
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
self.assertEqual(result.status_code, 422)
self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
self.assertEqual(result.status_code, 200)
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
class RecapEmailTests(unittest.TestCase):
def setUp(self):
self.runtime = runtime()
patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
patched.start(); self.addCleanup(patched.stop)
self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
def fake_smtp(self):
smtp = MagicMock()
smtp.mail.return_value = (250, b'OK')
smtp.rcpt.return_value = (250, b'OK')
smtp.data.return_value = (250, b'Accepted')
return smtp
def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
report = fixture_report()
report['top_titles'][0]['title'] = '<img src=x onerror=alert(1)>'
rendered = mail.render_recap(report, '<script>alert(1)</script>', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
self.assertNotIn('<script>', rendered['body_html'])
self.assertNotIn('<img src=x', rendered['body_html'])
self.assertIn('&lt;script&gt;', rendered['body_html'])
self.assertNotIn('PRIVATE-TOKEN', str(rendered))
self.assertIn('Unsubscribe', rendered['body_text'])
self.assertIn('UTC', rendered['body_text'])
self.assertIn('1,500', rendered['body_html'])
def test_mailbox_validation_rejects_injection_and_multiple_recipients(self):
for value in ['a@example.test\r\nBcc:b@example.test', 'a@example.test,b@example.test', 'Name <a@example.test>', 'x@', 'a;b@example.test']:
self.assertIsNone(mail.valid_email(value))
def test_smtp_acceptance_survives_quit_error_and_preserves_mime_message_id(self):
smtp = self.fake_smtp()
smtp.quit.side_effect = smtplib.SMTPServerDisconnected('after acceptance')
before = MagicMock()
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', before)
before.assert_called_once()
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
self.assertEqual(message['Message-ID'], '<stable@example.test>')
self.assertEqual(message['To'], 'viewer@example.test')
self.assertIsNone(message['Bcc'])
self.assertIn('1,500', message.get_body(('plain',)).get_content())
self.assertIn('<!doctype html>', message.get_body(('html',)).get_content())
def test_temporary_permanent_and_ambiguous_delivery_failures(self):
for operation, failure, expected in [
('mail', (451, b'temporary PRIVATE-KEY'), 'retry'), ('rcpt', (550, b'bad recipient'), 'failed'),
('data', (451, b'retry'), 'retry'), ('data', smtplib.SMTPServerDisconnected('lost after DATA'), 'unknown'),
('rcpt', smtplib.SMTPServerDisconnected('lost before DATA'), 'retry')]:
smtp = self.fake_smtp()
if isinstance(failure, Exception): getattr(smtp, operation).side_effect = failure
else: getattr(smtp, operation).return_value = failure
with self.subTest(operation=operation, expected=expected), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
with self.assertRaises(mail.DeliveryError) as exc:
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>')
self.assertEqual(exc.exception.state, expected)
self.assertNotIn('PRIVATE-KEY', exc.exception.detail)
def test_consent_cancellation_happens_before_smtp_data(self):
smtp = self.fake_smtp()
with patch.object(mail.smtplib, 'SMTP', return_value=smtp), self.assertRaises(mail.DeliveryCancelled):
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', MagicMock(side_effect=mail.DeliveryCancelled))
smtp.data.assert_not_called()
def test_real_smtp_is_captured_locally_without_external_delivery(self):
messages = []
class Capture(socketserver.StreamRequestHandler):
def handle(self):
self.wfile.write(b'220 local capture\r\n')
while line := self.rfile.readline():
command = line.split(b' ', 1)[0].strip().upper()
if command in (b'EHLO', b'HELO'):
self.wfile.write(b'250-localhost\r\n250 SIZE 1000000\r\n')
elif command == b'DATA':
self.wfile.write(b'354 Send content\r\n')
data = []
while (part := self.rfile.readline()) != b'.\r\n':
if not part: return
data.append(part[1:] if part.startswith(b'..') else part)
messages.append(b''.join(data))
self.wfile.write(b'250 Captured\r\n')
elif command == b'QUIT':
self.wfile.write(b'221 Bye\r\n'); return
else:
self.wfile.write(b'250 OK\r\n')
with socketserver.TCPServer(('127.0.0.1', 0), Capture) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.runtime.magent_notify_email_smtp_port = server.server_address[1]
try:
mail.send_email('viewer@example.test', self.rendered, '<local-capture@example.test>')
finally:
server.shutdown(); thread.join(timeout=5)
self.assertEqual(len(messages), 1)
parsed = BytesParser(policy=policy.default).parsebytes(messages[0])
self.assertEqual(parsed['Message-ID'], '<local-capture@example.test>')
self.assertIn('Severance', parsed.get_body(('html',)).get_content())
if __name__ == '__main__':
unittest.main()
class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_new_confirmation_defaults_to_manual_without_changing_schedule(self):
with patch.object(mail, 'send_email'):
result = await recaps.subscribe(self.user)
self.assertFalse(result['automatic_monthly'])
self.assertFalse(store.settings()['enabled'])
self.assertEqual(result['state'], 'pending')
with self.assertRaises(recaps.RecapError):
recaps.queue_personal(self.user, None, 'pending')
async def test_manual_current_month_delivers_with_monthly_schedule_off(self):
sub, _ = self.subscribe()
store.set_automatic(self.user['id'], False)
month = datetime.now(timezone.utc).strftime('%Y-%m')
queued = recaps.queue_personal(self.user, month, 'manual-1')
self.assertEqual(recaps.queue_personal(self.user, month, 'manual-1')['id'], queued['id'])
report = {**self.report, **month_periods(month, datetime.now(timezone.utc))}
def send(recipient, rendered, message_id, before_data):
before_data()
self.assertEqual(recipient, self.user['email'])
self.assertIn('so far', rendered['subject'])
self.assertNotIn('[Test]', rendered['subject'])
with patch.object(recaps, 'get_monthly_report', new_callable=AsyncMock, return_value=report), patch.object(mail, 'send_email', side_effect=send):
await recaps.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(self.delivery(queued['id'])['state'], 'sent')
self.assertFalse(store.settings()['enabled'])
self.assertFalse(store.subscription(self.user['id'])['automatic_monthly'])
with self.assertRaises(recaps.RecapError) as error:
recaps.queue_personal(self.user, month, 'manual-2')
self.assertEqual(error.exception.status, 429)
async def test_automatic_opt_out_cancels_scheduled_but_keeps_manual(self):
sub, _ = self.subscribe()
with store.transaction() as conn:
scheduled = store._enqueue(conn, sub, self.report['month'], 'scheduled', 'scheduled-fixture', self.config['public_url'], time.time())
manual = recaps.queue_personal(self.user, None, 'manual')
store.set_automatic(self.user['id'], False)
self.assertEqual(self.delivery(scheduled)['state'], 'cancelled')
self.assertEqual(self.delivery(manual['id'])['state'], 'queued')
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
now = datetime.now(timezone.utc)
store.save_settings({**self.config, 'enabled': True}, now)
self.assertEqual(store.enqueue_due(now + timedelta(days=40)), 0)
async def test_changed_identity_cancels_manual_delivery(self):
self.subscribe()
queued = recaps.queue_personal(self.user, None, 'manual')
delivery = store.claim_delivery(time.time())
db.set_user_email('viewer', 'changed@example.test')
with patch.object(mail, 'send_email') as send:
await recaps.process_delivery(delivery)
send.assert_not_called()
self.assertEqual(self.delivery(queued['id'])['state'], 'cancelled')
async def test_regular_user_can_only_send_to_self(self):
self.subscribe()
app = FastAPI(); app.include_router(router.router)
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'stats': True}}
client = TestClient(app)
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
self.assertEqual(client.post('/profile/email-recaps/send', json={**body, **extra}).status_code, 422)
self.assertEqual(client.post('/profile/email-recaps/send', json=body).status_code, 202)
response = client.get('/profile/email-recaps')
self.assertEqual(response.headers['cache-control'], 'no-store')
self.assertEqual(len(response.json()['deliveries']), 1)
+146
View File
@@ -0,0 +1,146 @@
import unittest
from unittest.mock import AsyncMock, patch
from backend.app.config import settings
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.feature_access import FEATURES, permissions, update_permissions
from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
from backend.app.security import create_access_token
from backend.tests.test_backend_quality import TempDatabaseMixin
class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
def setUp(self):
super().setUp()
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
secret.start()
self.addCleanup(secret.stop)
access = patch.object(
requests,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
db.create_user('feature-viewer', 'Example-password123!', role='user')
db.create_user('feature-admin', 'Example-password123!', role='admin')
self.user = db.get_user_by_username('feature-viewer')
app = FastAPI()
for module in (admin, auth, events, insights, portal, recaps, requests):
app.include_router(module.router)
self.client = TestClient(app)
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
def test_defaults_persist_and_invites_share_existing_setting(self):
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
update_permissions({'stats': False, 'invites': True}, self.user['username'])
db.init_db()
fresh = db.get_user_by_username(self.user['username'])
self.assertTrue(fresh['invite_management_enabled'])
self.assertFalse(permissions(fresh)['stats'])
db.set_user_invite_management_enabled(self.user['username'], False)
self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
endpoints = [
('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
]
for method, path, payload in endpoints:
with self.subTest(path=path, method=method):
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
self.assertEqual(self.client.get('/auth/profile').status_code, 200)
def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
response = self.client.put('/admin/users/features/bulk', json={'issues': False})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['updated'], 1)
self.assertFalse(permissions(self.user)['issues'])
self.assertTrue(permissions(self.user)['requests'])
self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
update_permissions({'issues': False}, self.user['username'])
for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
self.assertEqual(self.client.get(path).status_code, 403)
self.assertEqual(self.client.get('/portal/requests').status_code, 200)
self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
overview = self.client.get('/portal/overview?kind=issue')
self.assertEqual(overview.status_code, 200)
self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
update_permissions({'stats': False}, self.user['username'])
db.delete_user_by_username(self.user['username'])
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
def test_open_request_stream_closes_after_permission_revocation(self):
import asyncio
from unittest.mock import AsyncMock
from types import SimpleNamespace
async def scenario():
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
iterator = response.body_iterator
self.assertIn('retry', await anext(iterator))
update_permissions({'requests': False}, self.user['username'])
with self.assertRaises(StopAsyncIteration):
await anext(iterator)
asyncio.run(scenario())
def test_legacy_portal_kind_normalization_cannot_bypass_permissions(self):
update_permissions({'requests': False, 'new_requests': False, 'issues': True}, self.user['username'])
for kind in ['request', 'REQUEST', ' Request ', ' ', '']:
with self.subTest(kind=kind):
self.assertEqual(self.client.get('/portal/items', params={'kind': kind}).status_code, 403)
self.assertEqual(self.client.get('/portal/overview', params={'kind': kind}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
def test_manual_override_permission_is_checked_again_at_download(self):
from types import SimpleNamespace
from unittest.mock import AsyncMock
from backend.app.models import Snapshot, RequestType
from backend.app.services import manual_releases
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
collector.grab_release.assert_not_awaited()
update_permissions({'ignore_profile_limits':True},self.user['username'])
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
update_permissions({'ignore_profile_limits':False},self.user['username'])
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
collector.grab_release.assert_awaited_once()
+472
View File
@@ -0,0 +1,472 @@
import json
import unittest
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import JellystatClient
from backend.app.routers import identities
from backend.app.services import identity_review as review
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
JF = "a" * 32
OTHER = "b" * 32
SERVER = "c" * 32
ADMIN = {"username": "admin", "role": "admin"}
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
self.user_id = db.get_user_by_username("Georgia")["id"]
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
def build(self):
local = review.read_snapshot()
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
def row(self, report):
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
async def test_manual_selection_resolves_different_username_without_guessing(self):
self.jf['users'][0]['name'] = 'Different Jellyfin name'
before = review.read_snapshot()
report, _ = self.build()
self.assertEqual(self.row(report)['state'], 'unlinked')
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot(), local)
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
self.jf['users'][0]['name'] = 'Different'
for state in ['missing', 'unavailable', 'not_configured']:
self.js[JF] = {'state': state}
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertFalse(self.row(report)['can_confirm'])
self.js[JF] = {'state': 'matched', 'id': JF}
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertEqual(self.row(report)['state'], 'conflict')
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException) as error:
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
self.assertEqual(error.exception.status_code, 404)
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
before = review.read_snapshot()
preview = await review.resolve_identity(self.user_id, JF)
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException) as error:
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(error.exception.status_code, 409)
self.assertEqual(review.read_snapshot(), before)
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
self.assertEqual(result['confirmed'], 1)
async def test_repair_replaces_wrong_local_link_and_records_before_after(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
db.set_user_jellyseerr_id('Georgia', 999)
before = review.read_snapshot()
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20)
with closing(db._connect()) as conn:
audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone()
self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999)
self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF)
self.assertEqual(audit[2], 'admin')
async def test_repair_preserves_duplicate_ownership_and_server_guards(self):
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
with closing(db._connect()) as conn, conn:
conn.execute('DELETE FROM users WHERE username=?', ('Owner',))
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['server_id'] = OTHER
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
async def test_repair_does_not_invent_missing_seerr_identity(self):
self.seerr['users'][0]['jellyfin_id'] = OTHER
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertEqual(self.row(report)['state'], 'unlinked')
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_audit_failure_rolls_back_links(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
with closing(db._connect()) as conn, conn:
conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END")
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_rechecks_revision_and_updates_confirmed_ids(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['users'][0]['id'] = OTHER
self.seerr['users'][0]['jellyfin_id'] = OTHER
self.js = {OTHER: {'state': 'matched', 'id': OTHER}}
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
preview = await review.repair_identity(self.user_id, OTHER)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN)
await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER)
async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
return []
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post:
before = review.read_snapshot()
blocked = await review.repair_identity(self.user_id, JF)
self.assertFalse(blocked['row']['can_confirm'])
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertEqual(preview['action'], 'import_seerr')
self.assertTrue(preview['row']['can_confirm'])
post.assert_not_called()
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False)
post.assert_not_called()
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]})
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25)
async def test_failed_import_never_writes_local_links_or_retries(self):
self.seerr['users'] = []
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post:
before = review.read_snapshot()
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertEqual(error.exception.status_code, 502)
self.assertEqual(post.await_count, 1)
self.assertEqual(review.read_snapshot(), before)
async def test_import_preserves_upstream_account_when_local_save_is_blocked(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
db.set_user_jellyseerr_id('Georgia', 999)
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \
patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertIn('Seerr import completed', error.exception.detail)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999)
self.assertEqual(review.read_snapshot()['confirmations'], [])
delete.assert_not_called()
async def test_import_blocks_existing_name_with_different_jellyfin_id(self):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}]
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertFalse(preview['row']['can_confirm'])
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_not_called()
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
row = self.row(report)
self.assertEqual(row["basis"], "suggested_username")
self.assertEqual(row["state"], "ready")
self.assertEqual(row["seerr"][0]["id"], 20)
self.assertEqual(before, review.read_snapshot())
serialized = json.dumps(report)
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
self.assertNotIn(private, serialized)
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
report, local = self.build()
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
saved = review.read_snapshot()["confirmations"][0]
self.assertEqual(saved["jellyfin_server_id"], SERVER)
self.assertEqual(saved["confirmed_by"], "admin")
db.set_user_jellyseerr_id("Georgia", 999)
link_user("Georgia", OTHER, "http://other-server")
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
refreshed, _ = self.build()
self.assertEqual(self.row(refreshed)["state"], "confirmed")
self.assertFalse(self.row(refreshed)["can_confirm"])
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
db.set_user_jellyseerr_id("Georgia", 20)
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
report, local = self.build()
self.assertEqual(len(db.get_all_users()), 1)
self.assertEqual(len(report["rows"]), 2)
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot()["confirmations"], [])
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
db.set_user_jellyseerr_id("Georgia", 21)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertFalse(self.row(report)["can_confirm"])
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
# Legacy duplicate predates the normalized-name creation guard.
with db._connect() as conn:
conn.execute("INSERT INTO users(username,password_hash,role,auth_provider,created_at) VALUES('georgia','unused','user','jellyfin','2026-01-01')")
report, _ = self.build()
self.assertEqual(report["counts"]["conflict"], 2)
self.assertEqual(report["counts"]["ready"], 0)
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
self.jf["users"].append({"id": OTHER, "name": "local"})
db.create_user("local", "password", auth_provider="local")
report, _ = self.build()
for row in report["rows"]:
if row["user"]["id"] != self.user_id:
self.assertIsNone(row["candidate_jellyfin_id"])
self.assertFalse(row["can_confirm"])
async def test_missing_or_unavailable_services_never_confirm(self):
for state in ["missing", "unavailable", "not_configured"]:
self.js[JF] = {"state": state}
report, _ = self.build()
self.assertFalse(self.row(report)["can_confirm"])
self.js = {JF: {"state": "matched", "id": JF}}
self.seerr = {"state": "unavailable", "users": []}
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "unavailable")
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.seerr["users"].pop()
with closing(db._connect()) as conn, conn:
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_wrong_stored_id_is_not_silently_replaced(self):
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
async def test_account_changes_reject_whole_save(self):
report, local = self.build()
db.set_user_jellyseerr_id("Georgia", 99)
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
self.assertEqual(review.read_snapshot()["confirmations"], [])
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
async def test_settings_changes_reject_save(self):
report, local = self.build()
db.set_setting("jellyfin_base_url", "http://changed")
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
async def test_save_reads_real_runtime_settings_inside_transaction(self):
from backend.app.runtime import get_runtime_settings
for key in review.CONFIG_KEYS:
db.set_setting(key, getattr(self.runtime, key))
report, local = self.build()
with patch.object(review, "get_runtime_settings", get_runtime_settings):
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
second_id = db.get_user_by_username("Second")["id"]
self.jf["users"].append({"id": OTHER, "name": "Second"})
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
self.js[OTHER] = {"state": "matched", "id": OTHER}
with closing(db._connect()) as conn, conn:
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
report, local = self.build()
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
after = review.read_snapshot()
self.assertEqual(after["confirmations"], [])
self.assertEqual(after["links"], [])
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
report, local = self.build()
changed = {**report, "revision": "f" * 64}
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
patch.object(review, "save_confirmations") as save:
with self.assertRaises(HTTPException) as raised:
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
save.assert_not_called()
async def test_different_server_cannot_reuse_confirmed_id(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf["server_id"] = OTHER
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
a, _ = self.build()
b, _ = self.build()
self.assertEqual(a["revision"], b["revision"])
self.seerr["users"][0]["id"] = 99
c, _ = self.build()
self.assertNotEqual(a["revision"], c["revision"])
async def test_seerr_directory_requires_complete_unique_pages(self):
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
result = await review.seerr_directory(self.runtime)
self.assertEqual(result["state"], "available")
self.assertEqual(len(result["users"]), 101)
self.assertEqual(get.await_args.kwargs["skip"], 100)
for broken in [[], users[:2], users[:1] * 100]:
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
async def test_jellyfin_server_and_directory_must_agree(self):
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
async def test_unconfigured_client_never_calls_upstream(self):
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
result = await JellystatClient(None, None).check_user_ids([JF])
http.assert_not_called()
self.assertEqual(result[JF]["state"], "not_configured")
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
ids = [f"{i:032x}" for i in range(1, 6)]
def handler(request):
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
user_id = json.loads(request.content)["userid"]
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
if user_id == ids[1]: return httpx.Response(200, content=b"")
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
return httpx.Response(503, text="PRIVATE error")
real = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
self.assertNotIn("PRIVATE", json.dumps(data))
class IdentityRouteTests(unittest.TestCase):
def client(self, role=None):
app = FastAPI()
app.include_router(identities.router)
if role:
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
return TestClient(app)
def test_admin_only_read_and_write(self):
for role, status in [(None, 401), ("user", 403)]:
client = self.client(role)
self.assertEqual(client.get("/admin/identities").status_code, status)
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
def test_resolution_requires_admin_and_strict_ids(self):
for endpoint in ['check', 'confirm']:
body = {'user_id': 1, 'jellyfin_user_id': JF}
if endpoint == 'confirm': body['revision'] = 'a' * 64
for role, status in [(None, 401), ('user', 403)]:
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
self.assertEqual(self.client(role).post('/admin/identities/repair/' + endpoint, json=body).status_code, status)
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
self.assertEqual(self.client('admin').post('/admin/identities/repair/' + endpoint, json={**body, **invalid}).status_code, 422)
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
self.assertEqual(result.headers['cache-control'], 'no-store')
def test_no_store_and_no_browser_supplied_identity(self):
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
response = self.client("admin").get("/admin/identities")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)
+48
View File
@@ -0,0 +1,48 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from backend.app import db
from backend.app.services import jellyfin_sync
from backend.app.services.jellyfin_identity import link_user, user_for_identity
from backend.app.routers import admin
from backend.tests.test_backend_quality import TempDatabaseMixin
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
auto_search_enabled=False, email='kept@example.test')
original = db.get_user_by_username('old@example.test')
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
kept = user_for_identity('a' * 32, 'http://jf')
self.assertEqual(kept['id'], original['id'])
self.assertFalse(kept['auto_search_enabled'])
self.assertEqual(kept['email'], 'kept@example.test')
self.assertIsNone(db.get_user_by_username('NewName'))
self.assertEqual(kept['auth_provider'], 'jellyfin')
async def test_resync_no_longer_deletes_accounts(self):
db.create_user('Keep', 'Password-123456!')
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
patch.object(admin, 'delete_non_admin_users') as delete:
result = await admin.jellyseerr_users_resync()
self.assertEqual(result['cleared'], 0)
delete.assert_not_called()
self.assertIsNotNone(db.get_user_by_username('Keep'))
def test_jellyfin_lookup_is_scoped_to_server(self):
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
link_user('Viewer', 'a' * 32, 'http://jf')
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
+201
View File
@@ -0,0 +1,201 @@
import json
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
from backend.app.routers import admin, insights as router
from backend.app.services import insights
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
def play(id="play-1", **extra):
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
async def history(self, handler, **kwargs):
original = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
self.assertNotIn("secret-api-key", str(request.url))
if request.url.path == "/base/api/getLibraries":
return httpx.Response(200, json=LIBRARIES)
self.assertEqual(request.method, "POST")
self.assertEqual(request.url.path, "/base/api/getUserHistory")
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
self.assertNotIn("search", request.url.params)
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
history, libraries = await self.history(handler)
self.assertEqual(len(calls), 3)
self.assertEqual(len(history), 2)
self.assertEqual(libraries, LIBRARIES)
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
for payload, exception in [
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
({"results": []}, JellystatError),
({"pages": 51, "results": []}, HistoryLimitError),
({"pages": 2, "results": []}, JellystatError),
({"pages": 0, "results": [play()]}, JellystatError),
]:
with self.subTest(payload=payload):
def handler(request):
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
with self.assertRaises(exception):
await self.history(handler)
async def test_empty_history_is_valid(self):
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
self.assertEqual(result, [])
async def test_upstream_failure_is_sanitized(self):
with self.assertRaises(JellystatError) as error:
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
self.assertNotIn("private", str(error.exception))
self.assertNotIn("secret-api-key", str(error.exception))
class SummaryTests(unittest.TestCase):
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
rows = [play(), play(), play("rewatch"),
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
play("zero", PlaybackDuration=0)]
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
"active_days": 3, "current_streak": 3, "longest_streak": 3})
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
self.assertEqual(len(data["recent"]), 5)
self.assertNotIn("PRIVATE", json.dumps(data))
def test_invalid_durations_do_not_become_zero_or_nan(self):
for value in [-1, "NaN", "Infinity", "nonsense"]:
with self.subTest(value=value), self.assertRaises(JellystatError):
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
def test_empty_history_has_zero_filled_days(self):
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(result["summary"]["minutes"], 0)
self.assertEqual(len(result["daily"]), 8)
self.assertEqual(result["summary"]["current_streak"], 0)
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
insights._cache.clear()
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
link_user("viewer", "jf-original", "http://jellyfin/")
link_user("viewer", "jf-replacement", "http://jellyfin")
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
remote.assert_not_called()
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(remote.await_count, 1)
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
for request_id, seerr_id in [(1, 42), (2, 99)]:
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
self.assertEqual(report["total"], 1)
self.assertEqual(report["recent"][0]["request_id"], 1)
async def test_cache_isolated_by_identity_period_and_configuration(self):
link_user("viewer", "jf-viewer", "http://jellyfin")
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
link_user("second", "jf-second", "http://jellyfin")
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
await insights.get_insights(USER, 7)
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 1)
await insights.get_insights({**USER, "username": "second"}, 7)
await insights.get_insights(USER, 30)
self.runtime.jellystat_api_key = "rotated-key"
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 4)
async def test_disabled_integration_never_calls_upstream(self):
self.runtime.jellystat_api_key = None
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
result = await insights.get_insights(USER, 30)
self.assertEqual(result["state"], "not_configured")
self.assertIsNone(result["summary"])
remote.assert_not_called()
async def test_settings_mask_jellystat_credential(self):
db.set_setting("jellystat_api_key", "private-stats-key")
result = await admin.list_settings()
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
self.assertTrue(setting["sensitive"])
self.assertTrue(setting["isSet"])
self.assertNotIn("private-stats-key", json.dumps(result))
class InsightsRouteTests(unittest.TestCase):
def app(self, authenticated=True):
app = FastAPI()
app.include_router(router.router)
if authenticated:
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
return TestClient(app)
def test_requires_authentication(self):
self.assertEqual(self.app(False).get("/insights").status_code, 401)
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
client = self.app()
for days in [7, 30, 90, 365]:
response = client.get(f"/insights?days={days}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-store")
report.assert_awaited_with({**USER, "features": {"stats": True}}, 365)
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
def test_errors_do_not_leak_upstream_details(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
response = self.app().get("/insights")
self.assertEqual(response.status_code, 502)
self.assertNotIn("PRIVATE", response.text)
+165
View File
@@ -0,0 +1,165 @@
import asyncio
import base64
import copy
import unittest
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app.routers import insights as router
from backend.app.services import insights, insights_artwork as artwork
from backend.tests.test_insights import NOW, LIBRARIES, USER, play
ITEM = "a" * 32
OTHER = "b" * 32
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=")
def transcode(activity, minutes, video_direct=False, audio_direct=False, hardware="nvenc", **kwargs):
return play(activity, NowPlayingItemId=ITEM, PlaybackDuration=minutes * 60, PlayMethod="Transcode",
TranscodingInfo={"IsVideoDirect": video_direct, "IsAudioDirect": audio_direct,
"HardwareAccelerationType": hardware, "VideoCodec": "h264", "AudioCodec": "aac"}, **kwargs)
class TranscodingSummaryTests(unittest.TestCase):
def test_gpu_audio_software_and_unknown_time_are_separate_and_deduplicated(self):
gpu = transcode("gpu", 10)
rows = [gpu, dict(gpu), transcode("audio-only", 5, video_direct=True),
transcode("software", 3, audio_direct=True, hardware="none"),
transcode("remux", 2, video_direct=True, audio_direct=True),
{**transcode("stale", 1), "PlayMethod": "DirectPlay"},
transcode("unknown-hardware", 2, audio_direct=True, hardware=None),
{**transcode("unknown-streams", 1), "TranscodingInfo": None},
transcode("old", 100, ActivityDateInserted=(NOW - timedelta(days=31)).isoformat())]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=30), NOW)
stats = result["transcoding"]
self.assertEqual(stats["hardware_video_minutes"], 10)
self.assertEqual(stats["audio_minutes"], 15)
self.assertEqual(stats["video_minutes"], 15)
self.assertEqual(stats["software_video_minutes"], 3)
self.assertEqual(stats["unknown_hardware_minutes"], 2)
self.assertEqual(stats["unknown_video_minutes"], 1)
self.assertEqual(stats["unknown_audio_minutes"], 1)
self.assertEqual(stats["hardware"], [{"name": "NVIDIA NVENC", "minutes": 10}])
self.assertIsNone(stats["gpu_busy_minutes"])
def test_audio_media_cannot_accumulate_video_gpu_time(self):
row = transcode("music", 4, ParentId="music")
row["TranscodingInfo"]["VideoCodec"] = None
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 4)
self.assertEqual(result["video_minutes"], 0)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_direct_stream_counts_audio_but_does_not_claim_video_encoding(self):
row = {**transcode("stream", 5), "PlayMethod": "DirectStream"}
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_numeric_hardware_enum_and_legacy_json_are_supported(self):
import json
row = transcode("enum", 5, hardware=3)
row["TranscodingInfo"] = json.dumps(row["TranscodingInfo"])
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["hardware_video_minutes"], 5)
row["TranscodingInfo"] = "invalid JSON"
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["unknown_video_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_episode_artwork_uses_series_id_and_invalid_ids_are_ignored(self):
rows = [play("episode", EpisodeId=OTHER, NowPlayingItemId=ITEM),
play("invalid", NowPlayingItemId="../../secret")]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
indexed = {row["id"]: row for row in result["recent"]}
self.assertEqual(indexed["episode"]["artwork_item_id"], ITEM)
self.assertIsNone(indexed["invalid"]["artwork_item_id"])
class ArtworkTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="PRIVATE-API-KEY")
self.secret = patch.object(artwork.settings, "jwt_secret", "test-artwork-signing-secret")
self.secret.start()
self.addCleanup(self.secret.stop)
artwork._cache.clear()
artwork._downloads = asyncio.Semaphore(6)
def url_and_token(self, user=USER):
data = {"recent": [{"id": "play-1", "artwork_item_id": ITEM}]}
before = copy.deepcopy(data)
result = artwork.with_artwork(data, user, self.runtime)
self.assertEqual(data, before)
row = result["recent"][0]
self.assertNotIn("artwork_item_id", row)
self.assertNotIn("PRIVATE-API-KEY", row["artwork_url"])
return row["artwork_url"], parse_qs(urlsplit(row["artwork_url"]).query)["token"][0]
async def test_ticket_is_bound_to_user_item_server_credentials_and_time(self):
with patch.object(artwork.time, "time", return_value=1000):
_, token = self.url_and_token()
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for user, runtime, media_id in [({**USER, "username": "different"}, self.runtime, ITEM),
(USER, self.runtime, OTHER), (USER, SimpleNamespace(jellyfin_base_url="http://other", jellyfin_api_key="PRIVATE-API-KEY"), ITEM),
(USER, SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="changed"), ITEM)]:
with self.assertRaises(HTTPException) as raised:
artwork.verify_artwork_token(user, runtime, media_id, token)
self.assertEqual(raised.exception.status_code, 403)
with patch.object(artwork.time, "time", return_value=5000), self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for invalid in ["invalid", "1.", "1." + "\u2603" * 64]:
with self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, invalid)
async def test_private_proxy_returns_image_and_validates_before_cache_access(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.url.path, f"/Items/{ITEM}/Images/Primary")
self.assertNotIn("PRIVATE", str(request.url))
self.assertEqual(request.headers["X-Emby-Token"], "PRIVATE-API-KEY")
return httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
real = httpx.AsyncClient
_, token = self.url_and_token()
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
with self.assertRaises(HTTPException):
await artwork.get_artwork({**USER, "username": "someone-else"}, self.runtime, ITEM, token)
self.assertEqual(len(calls), 1)
async def test_non_images_missing_images_and_large_images_fail_closed(self):
_, token = self.url_and_token()
real = httpx.AsyncClient
for status, body, mime in [(404, b"PRIVATE", "text/plain"), (200, b"<svg>PRIVATE</svg>", "image/svg+xml"),
(200, b"x" * (artwork.MAX_IMAGE_BYTES + 1), "image/png")]:
transport = httpx.MockTransport(lambda request: httpx.Response(status, content=body, headers={"Content-Type": mime}))
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=transport, **kwargs)):
with self.assertRaises(HTTPException) as raised:
await artwork.get_artwork(USER, self.runtime, ITEM, token)
self.assertEqual(raised.exception.status_code, 404)
self.assertNotIn("PRIVATE", raised.exception.detail)
self.assertEqual(len(artwork._cache), 0)
class ArtworkRouteTests(unittest.TestCase):
def test_authentication_and_private_response_headers(self):
app = FastAPI()
app.include_router(router.router)
client = TestClient(app)
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
with patch.object(router, "get_runtime_settings", return_value=None), \
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
self.assertEqual(result.status_code, 200)
self.assertEqual(result.content, PNG)
self.assertEqual(result.headers["cache-control"], "private, max-age=600")
self.assertEqual(result.headers["vary"], "Cookie, Authorization")
self.assertEqual(result.headers["x-content-type-options"], "nosniff")
+54
View File
@@ -0,0 +1,54 @@
import asyncio
import unittest
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException, Response
from backend.app import db
from backend.app.routers import auth
from backend.tests.test_backend_quality import TempDatabaseMixin
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def signup(self, code, username, **extra):
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
async def test_email_invite_binds_account_and_cannot_be_reused(self):
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
self.assertEqual(invite['max_uses'], 1)
public = auth._public_invite_payload(invite)
self.assertTrue(public['email_bound'])
self.assertNotIn('recipient@example.com', str(public))
await self.signup('EMAILTEST', 'first-user')
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
with self.assertRaises(HTTPException):
await self.signup('EMAILTEST', 'second-user')
async def test_email_invite_rejects_recipient_override(self):
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
with self.assertRaises(HTTPException):
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
async def test_manual_invite_requires_and_saves_email(self):
db.create_signup_invite(code='MANUALTEST', max_uses=3)
for email in ['', 'invalid']:
with self.assertRaises(HTTPException):
await self.signup('MANUALTEST', 'manual-user', email=email)
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
async def test_failed_creation_releases_reservation(self):
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
with self.assertRaises(HTTPException):
await self.signup('FAILTEST', 'failed-user')
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
async def test_single_use_reservation_is_atomic(self):
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
self.assertEqual(sum(results), 1)
+55
View File
@@ -0,0 +1,55 @@
import json
import unittest
from unittest.mock import AsyncMock, patch
from backend.app import db
from backend.app.services import issue_resolution as service
from backend.tests.test_backend_quality import TempDatabaseMixin
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def issue(self):
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
self.start(item["id"])
return item
def start(self, item_id):
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
async def test_importing_or_unverified_media_does_not_email_reporter(self):
self.issue()
for phase in ["collecting", "indexing", "unavailable"]:
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
await service.process_active_media_repairs()
begin.assert_not_awaited()
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
item = self.issue()
with (
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
):
await service.process_active_media_repairs()
await service.process_active_media_repairs()
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
content = email.await_args.kwargs
self.assertIn("YES — it works", content["body_html"])
self.assertIn("NO — still broken", content["body_html"])
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
self.assertIn("Broken &lt;movie&gt;", content["body_html"])
self.assertNotIn("<movie>", content["body_html"])
self.assertIn("Confirm your answer in Magent", content["body_text"])
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
await service.process_active_media_repairs()
await service.process_due_issue_confirmations()
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
self.start(item["id"])
await service.process_active_media_repairs()
self.assertEqual(email.await_count, 2)
+30
View File
@@ -0,0 +1,30 @@
import unittest
from unittest.mock import patch
import httpx
from backend.app.clients.jellyfin import JellyfinClient
from backend.app.services.snapshot import jellyfin_item_matches_request
from backend.app.models import RequestType
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
calls=[]
def handle(request):
calls.append(request)
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
return httpx.Response(200,json={'Items':items})
original=httpx.AsyncClient
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
self.assertEqual([i['Id'] for i in matches],['live'])
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
def match(item,payload=None):
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))

Some files were not shown because too many files have changed in this diff Show More