# Architecture This document maps the application, explains how its components fit together, and links each subsystem to its detailed source of truth. ## The big picture Plembfin is a **self-hosted Node.js app** in the style of Sonarr/Radarr/ Jellyseerr. One long-running `node server/server.js` process serves: - the **web UI** - a plain ES-module SPA in `public/` with no framework, bundler, or build step - the **API** - every route lives under `/api/*`, dispatched by one hand-written router - the **scheduler** - an in-process `setInterval` tick that runs sync work every minute All state lives in a local **SQLite** database (`data/plembfin.db`, WAL mode) and a local media folder (`data/media/`). There is no external database, no cloud functions, and no separate production environment. What the app does: it receives play/stop/scrobble **webhooks** from Plex, Emby, and Jellyfin, records watch history, and **propagates watched/unwatched state and resume progress to the other platforms** so all three stay in sync. On top of that it provides a dashboard (Now Playing + recent history), Movies/TV Shows library browsers, a stats page, an upcoming-episodes calendar, rich media detail pages (TMDB/TVDB metadata, cast, trailers, artwork), Jellyseerr/Overseerr requesting, and a backup system. Personal ratings are a separate optional domain: Plembfin remains their local canonical store, while a durable rating queue can exchange ratings with Plex, Emby, Jellyfin, and Trakt without entering the watched-state pipeline. Personal watchlist membership is another isolated domain: `personal_watchlist` is the canonical present-set, while provider-specific observations, tombstones, queues, and complete-snapshot runs drive safe projections to Plex, Emby, and Jellyfin. ### Performance-sensitive page loading The SPA keeps secondary data out of the first paint. Upcoming starts with the current month and one either side, then extends the calendar a month at a time as the user scrolls toward either end of the loaded range; search results are cached in the session and stale searches cannot replace newer ones; person filmography watch-state data loads after the profile shell. Logs and changelog history retain complete source data for export and refresh, but render a bounded initial view. Preserve these loading, stale-request, and retry guards when adding new metadata requests. ## Subsystem map | Area | Files | Source | | --- | --- | --- | | API routing | `server/src/index.js` (`dispatch()` route table) plus the owning `server/src/routes/*.js` module | this doc | | Webhook parsing or phases | `server/src/utils/parsers.js`, `handleWebhook` in `server/src/routes/sync.js` | [webhooks.md](webhooks.md) | | Watched/unwatched propagation between platforms | `server/src/utils/syncOrchestrator.js`, platform clients | [webhooks.md](webhooks.md) | | Per-provider historical watched-sync policy and outcomes | `server/src/utils/watchSyncPolicy.js`, `public/modules/plex-history-policy.js` | [settings.md](settings.md), [decisions.md](decisions.md) | | Plex API calls, Plex WebSocket listener | `server/src/utils/plexClient.js`, `plexNotificationListener.js` | [plex.md](plex.md) | | Emby API calls | `server/src/utils/embyClient.js` | [emby.md](emby.md) | | Jellyfin API calls | `server/src/utils/jellyfinClient.js` | [jellyfin.md](jellyfin.md) | | Background/scheduled sync, catch-up sync, provider Up Next feeds | `server/src/scheduler.js`, `server/src/scheduled.js`, `server/src/utils/upNextRepository.js` | [scheduled-sync.md](scheduled-sync.md) | | Now Playing (dashboard live sessions) | `handleNowPlaying` in `server/src/routes/sync.js`, `server/src/utils/liveSessions.js`, `liveSessionPoller.js`, `activeSessions.js`, `public/modules/sync.js` | [now-playing.md](now-playing.md) | | Dashboard rendering | `public/modules/dashboard.js` | [dashboard.md](dashboard.md) | | Up Next queue, provider push, dismissals | `public/modules/up-next.js`, `server/src/utils/upNextService.js`, `upNextProviderSync.js`, `upNextRailSeed.js`, `upNextSeedLedger.js`, `upNextDismissals.js`, `upNextLibraryLookup.js` | [dashboard.md](dashboard.md) | | Settings changelog channels | `public/modules/changelog-channels.js`, `handleChangelog` in `routes/maintenance.js` | this document | | Sidebar sync indicator, Sync Activity page | `public/modules/status-indicators.js`, `public/modules/sync-activity.js`, `handleSyncHistory` / `handleSyncActivity` in `routes/sync.js` | [dashboard.md](dashboard.md) | | Movies library page | `public/modules/explorer.js`, `queryMovies` in `dataRepo.js` | [movies.md](movies.md) | | TV Shows library page | `public/modules/explorer.js`, `queryShows`, `showProgressCache.js`, `nextAiringCache.js` | [tv-shows.md](tv-shows.md) | | Upcoming episode calendar | `public/modules/upcoming.js`, `handleUpcoming` in `routes/metadata.js`, `upcomingCalendarCache.js`, `nextAiringCache.js` | [upcoming.md](upcoming.md) | | Movie/show/person detail pages | `public/modules/media-detail*.js`, `media-person.js` | [media-detail.md](media-detail.md) | | Personal media pages | `public/modules/personal-media.js`, `public/modules/personal-media-metadata.js`, `handlePersonalMedia` in `routes/personal.js` | [frontend.md](frontend.md) | | Personal rating sync | `server/src/utils/personalRatingSync.js`, `personalRatingRepository.js`, provider clients, `public/modules/rating-sync-settings.js` | [personal-ratings.md](personal-ratings.md) | | Plex watchlist sync | `server/src/utils/personalWatchlistSync.js`, `plexWatchlistClient.js`, `routes/watchlistSync.js`, `public/modules/watchlist-sync-settings.js` | this document, [personal-watchlist.md](personal-watchlist.md) | | History page, Search page | `public/modules/explorer.js`, `handleHistory` in `routes/media.js`, `handleMediaSearch` in `routes/metadata.js` | [history-search.md](history-search.md) | | Stats page | `public/modules/stats.js`, `getWatchStats` in `dataRepo.js` | [stats.md](stats.md) | | TMDB/TVDB/Fanart/OMDb metadata | `server/src/routes/metadata.js`, `server/src/utils/tmdbGateway.js`, `tvdbGateway.js`, `fanartGateway.js`, `omdbGateway.js` | [metadata.md](metadata.md) | | Posters, backdrops, logos, artwork caching | `server/src/utils/posterCache.js`, `server/src/utils/mediaArtwork.js`, `handlePoster` in `routes/metadata.js`, `public/modules/images.js` | [posters-artwork.md](posters-artwork.md) | | Backups (all three subsystems) | `server/src/routes/backups.js`, `server/src/utils/backup.js`, `watchHistoryBackups.js`, `plembfinBackups.js`, `backupDestinations/`, `public/modules/tools-backups.js` | [backups.md](backups.md) | | Settings pages, connection config | `server/src/routes/admin.js`, `server/src/utils/configStore.js`, `public/modules/settings-shell.js`, `public/modules/settings-ui.js`, `public/modules/settings-services.js`, `public/modules/rating-sync-settings.js`, `public/modules/tools-backups.js` | [settings.md](settings.md) | | Login, sessions, API key, webhook secret | `server/src/utils/auth.js`, `server/src/appConfig.js`, `public/modules/auth.js` | [auth.md](auth.md) | | Pristine-install account claim, guided `/setup` wizard, dashboard checklist | `server/src/routes/onboarding.js`, `server/src/utils/onboardingStore.js`, `onboardingImportCoordinator.js`, `public/modules/onboarding.js` | [onboarding.md](onboarding.md) | | SPA routing, view switching, module layout | `public/app.js`, `public/modules/state.js`, `app-events.js` | [frontend.md](frontend.md) | | Database tables and their meaning | `server/src/schema.sql`, `server/src/db.js` | [sqlite-schema.md](sqlite-schema.md) | | Build check, CI, Docker, release/changelog pipeline | `scripts/`, `.github/workflows/`, `Dockerfile` | [development.md](development.md) | | Production security posture | - | [hardening.md](hardening.md), [security-checklist.md](security-checklist.md) | | "Something is broken, where do I look?" | - | [troubleshooting.md](troubleshooting.md) | Frontend module placement rules, file-size limits, module ownership, and dependency direction are defined in [frontend.md](frontend.md). ## Complete file map Repository files relevant to the application, build, and operations, grouped by directory. ### Repository root | File | What it is | | --- | --- | | `README.md` | User-facing GitHub readme: features, setup guide, configuration reference, screenshots. | | `changelog.json` | Bundled release history. `scripts/promote-alpha-to-main.js` appends an entry and bumps the version locally, as part of "Force to main"; served verbatim at `GET /changelog.json` and consumed by the in-app changelog/update check. | | `package.json` | Dependencies and npm scripts (`start`, `dev`, `test`, `build`, `docs:check`, `demo:verify`, `seed:demo`, `prepare`). Version is set locally by `scripts/promote-alpha-to-main.js` as part of "Force to main". | | `package-lock.json` | Locked dependency tree. Version field is CI-managed alongside `package.json`. | | `Dockerfile` | `node:25-trixie-slim` image: installs prod deps, copies `server/`, `public/`, `changelog.json`, creates the non-root `plembfin` user, and uses the separate SQLite worker-health probe so synchronous maintenance cannot make the container fail its HTTP healthcheck. | | `docker-compose.yml` | Base compose file: port 5055, `./data:/data` volume, admin env vars, `no-new-privileges`, resource limits. | | `docker-compose.secure.yml` | Hardened overlay: read-only rootfs, tmpfs `/tmp`, required env vars (`ADMIN_PASSWORD`, `SESSION_SECRET`, `API_KEY`, `WEBHOOK_SECRET`), forces `COOKIE_SECURE=true`. | | `.dockerignore` | Excludes `node_modules`, `data`, `docs`, `scratch`, markdown, and secrets from the Docker build context while whitelisting the required runtime scripts. | | `.env.example` | Commented template of every supported environment variable - copy to `.env` (loaded by `server/src/env.js`). The variables are documented under [Environment variables](#environment-variables) below. | | `website/` | Static Astro documentation site. Its build reads the root release data and shared assets, runs the website checks, and outputs `website/dist/` for Cloudflare Pages. | | `.editorconfig` | Editor whitespace/indent conventions. | | `.gitattributes` | Normalizes line endings to LF; marks image formats binary. | | `.gitignore` | Ignores `node_modules`, `data/`, logs, local env files. | | `.githooks/commit-msg`, `.githooks/pre-push` | Git hooks installed by `scripts/install-git-hooks.js`: release commit messages must contain meaningful changelog bullets; a same-name push merges `origin/` first, a push to `develop` validates its committed changelog with `rebuild-develop-changelog.js --check`, and every push runs `npm run build`. Cross-ref pushes skip the sync step because their branch state was reconciled by the promotion workflow. | | `.claude/skills/` | Repository-local release, publishing, and local-development procedures referenced by `CLAUDE.md`, including the `push-website-live` and `start-website` website workflows. | | `LICENSE.md` | Project license. | | `SECURITY.md` | Vulnerability reporting policy. | | `CONTRIBUTING.md` | Contribution guidelines. | | `CODE_OF_CONDUCT.md` | Community code of conduct. | ### `.github/` | File | What it is | | --- | --- | | `workflows/update-changelog.yml` (workflow name "Publish Main Release") | On every push to `main`: runs the full build gate, reads the version already committed by "Force to main" (`scripts/promote-alpha-to-main.js`, run locally before the push), builds/pushes a multi-architecture Docker image to GHCR tagged `latest` + the version, creates or updates the matching formatted GitHub Release through `scripts/publish-github-release.js`, then deploys the exact version tag to the OCI-hosted public demo and verifies it. Does not write anything back to the branch. | | `workflows/docker-publish-alpha.yml`, `workflows/docker-publish-develop.yml` | The same shape for `alpha`/`develop`: verify, then build/push a rolling image (`alpha`/`alpha-`, `develop`/`develop-`) using the build number already committed by "Force to alpha" / "Push to git". Alpha also creates or updates its matching numbered GitHub prerelease with generated notes. Neither writes anything back to its branch. `docker-publish-develop.yml` has `paths-ignore` for `changelog.develop.json`/`changelog.alpha.json`, since "Force to alpha" step 4 pushes to `develop` with only those two files changed - the app's own live remote-fetch changelog comparison reads that file straight from GitHub regardless of whether a new image was built, so that push was never meant to also trigger a full rebuild. | | `workflows/docker-publish.yml` | Manual (`workflow_dispatch`) Docker build & push to GHCR, without touching the changelog. | | `workflows/security.yml` | `npm audit` (high+) and CodeQL on push/PR/daily schedule. | | `workflows/secret-scan.yml` | TruffleHog verified-secret scan on push/PR. | | `dependabot.yml` | Automated dependency update PRs. | | `ISSUE_TEMPLATE/bug_report.md`, `ISSUE_TEMPLATE/feature_request.md`, `ISSUE_TEMPLATE/config.yml` | GitHub issue forms. | | `PULL_REQUEST_TEMPLATE.md` | PR description template. | ### `docs/` See [README.md](README.md) for the documentation index, including this file (`architecture.md`) and the per-feature sources of truth. `decisions.md` records why the non-obvious design and release-pipeline calls were made, and what was rejected; the feature docs describe current behavior, so read `decisions.md` before reversing something that looks unnecessarily cautious. `docs/screenshots/` holds the PNG screenshots embedded in the root README (`bio.png`, `history.png`, `media.png`, `movies.png`, `now-playing.png`, `part-watched.png`, `search.png`, `stats.png`, `tvshows.png`). ### `server/` | File | What it is | | --- | --- | | `server.js` | **Process entrypoint.** Express app: access logging (rotating `data/logs/access.log`, secrets redacted), security headers + dynamic CSP, rate limiters, raw-body capture for `/api/*` → `dispatch()`, static mounts for `/media` and `public/`, `/health`, `/changelog.json`, SPA fallback, the per-minute scheduler tick, the Plex notification listener startup, and graceful shutdown. | | `src/index.js` | **The API router.** `dispatch()` strips `/api/` and routes paths to `handleX` functions exported by `src/routes/*.js`. The full route table is the body of `dispatch()`; feature behavior belongs in the owning route module. | | `src/db.js` | Opens `data/plembfin.db` via better-sqlite3 (WAL), applies concurrency-safe migrations, and exposes helpers plus the SQLite-backed cache version observed by every process. | | `src/schema.sql` | Authoritative table definitions. See [sqlite-schema.md](sqlite-schema.md). | | `src/appConfig.js` | Resolves admin credentials + secrets from env / `data/config.json` (scrypt password hash, generated API key / webhook secret / session secret), warns about insecure config at startup, exports `AUTH`, `verifyWebhookToken`, `rotateWebhookSecret`, `updateAdminCredentials`. | | `src/env.js` | Minimal `.env` loader (`loadLocalEnv`) - parses `/.env` without a dotenv dependency; env vars already set take precedence. | | `src/paths.js` | Resolves `DATA_DIR` and every path under it (`MEDIA_DIR`, `POSTERS_DIR`, `BACKDROPS_DIR`, `PROFILES_DIR`, backup dirs, `DB_PATH`, `CONFIG_PATH`, `PUBLIC_DIR`); `ensureDataDirs()` creates them. | | `src/scheduled.js` | The background sync engine: live-session polling → `live_tracking_cache`, completed-session detection, resume-progress replication, per-platform catch-up sync (recently watched + resumable + provider Up Next feeds), Plembfin-canonical dispatch/reconciliation queue, Plex drift repair, `runScheduledSync` and `runForceSync`. See [scheduled-sync.md](scheduled-sync.md). | | `src/scheduler.js` | Scheduler wrapper and Plex notification listener lifecycle: per-minute tick orchestration, scheduled backup runs, watchlist/rating worker hooks, cache-backed metadata warm-up/backfill, next-airing cache refresh, startup/shutdown listener control, and Plex library-item unwatch callback. | ### `server/src/routes/` | File | What it is | | --- | --- | | `admin.js` | Settings/admin API handlers: config, appearance, Seerr/app links, connection tests, and Plex notification probe. | | `backups.js` | Backup API handlers for portable import/export (`/api/import`, `/api/backup/export`, `/api/backup/import`), encrypted full backups (`/api/plembfin-backups`), and watch-history backup actions (`/api/watch-backups`). | | `media.js` | Library and history handlers: history, movies, shows/show detail, delete/update watch records, transactional show rematching, merge shows, full watchstate replay, and missing-telemetry clearing. | | `metadata.js` | Poster proxy and metadata/search handlers: TMDB details/search/season/images/person/poster/profile, the remote-artwork caching proxy, TVDB search/images, Fanart images, media search, Discover feeds and recommendation exclusions, Upcoming episodes, cached Up Next, YouTube metadata, and OMDb ratings. | | `sync.js` | Sync/runtime handlers: webhook ingestion, manual watch/unwatch, playback progress, retry sync, sync job/history listing, Now Playing, active sessions, cron sync, library-wide planner Force Sync, Settings library Force Sync modes and status polling, title-scoped detail-page Force Sync modes and status polling, and stop-force-sync. | | `ratingSync.js` | Authenticated personal-rating status, snapshot, local-push, and retry actions. | | `watchlistSync.js` | Authenticated personal-watchlist status, reconcile/retry actions, compatibility preview endpoint, and paged activity. | | `maintenance.js` | Maintenance/admin utility handlers: ping, changelog/update check, diagnostic logs, cross-platform match reporting, backfill/repair/dedup/rematch, cache stats, cache clearing, plus the read-only `episode-title-audit` and `episode-title-backfill` Database Repairs endpoints that restore real episode names onto watch rows that only stored a coordinate. | | `wipeData.js` | Wipe data handlers (`GET /api/wipe-data/preview`, `POST /api/wipe-data`): Watch History, Personal Watchlist, Sync History & Logs, Everything Tracked, and Wipe All / Fresh Start (also clears every remaining table, deletes cached artwork, and resets `data/config.json` via `appConfig.js`'s `resetAdminAccount()`). Kept separate from `maintenance.js`, which is already near its size limit. | | `mediaAuth.js` | Browser-session-only Plex account, Emby account, and Jellyfin Quick Connect/account flows; verifies identities and persists encrypted managed connections. | | `trackerAuth.js` | Trakt device authorization, initial-state policy, connection status/disconnect, and manual tracker synchronization. | | `liveUpdates.js` | Authenticated streaming endpoint that emits shared history, Up Next-cache, and Discover-cache version changes so open pages refresh as local state or feed snapshots change. The history version it reports is the sum of the watch-history and resume-position generations, so a page still sees a resume position move even though that write no longer invalidates any derived cache. | | `onboarding.js` | Guided-setup API: aggregated `/api/setup/status`, step/acknowledgement persistence, background-import start/cancel, completion, restart, and checklist dismissal. | ### `server/src/utils/` | File | What it is | | --- | --- | | `dataRepo.js` | **The data repository** - pure SQLite. All prepared-statement CRUD for watch history, playstate, playback progress, live tracking cache; the memoized derived caches (`getCachedHistory/Movies/Shows`, `getWatchStats`); canonical show-poster enrichment; `mediaKeyFor` canonical keys; query functions behind `/api/history`, `/api/movies`, `/api/shows`, `/api/show`; dedup/merge/rematch/backfill helpers. | | `parsers.js` | Webhook normalization: `parsePlexWebhook` (multipart), `parseEmbyWebhook`, `parseJellyfinWebhook`, `parseCustomWebhook` → a unified `media` object with a `phase` field (`active`/`completed`/`ended`/`unplayed`/`ignored`). Also `parsePlexGuids`, `normalizeProviderIds`, `decodeHtmlEntities`, `buildPlexMediaFromMetadata`. See [webhooks.md](webhooks.md). | | `resumeAuthority.js` | Shared ordering rules for resume candidates versus canonical watched/unwatched state: same-position acknowledgement matching, reliable source/receipt timestamps, stored-progress deletion authority, and Emby/Jellyfin ambiguous `UserDataSaved` phase resolution. | | `syncOrchestrator.js` | Cross-platform propagation: `syncMediaPlaystate` / `syncMediaUnplayedPlaystate` / `syncMediaProgress` fan out normal events to the other platforms' clients, while `syncCanonicalPlaystate` replays Plembfin's state to every configured destination; all use `TARGETS_BY_SOURCE` routing, echo-loop detection via `loopStore.checkAndClaim`, and result summaries written to telemetry. | | `watchSyncPolicy.js` | The provider matrix for one outgoing watched action: resolves its explicit intent (`live`, `manual`, `historical`, `import`, `restore`), decides per target whether to `send`, and names the terminal outcomes (`sent`, `already_matching`, `skipped_by_policy`, `unsupported`, `failed`). Owns the canonical play date Emby and Jellyfin receive, and the Plex-only historical-sync policy enforced by both `syncOrchestrator.js` and `markPlexPlayed`. Imports `tuning.js` only, so any provider adapter can enforce the matrix. | | `personalRatingIdentity.js` | Normalizes movie/show/episode rating identity; episode keys use parent show identity plus season/episode while retaining leaf provider IDs for writes. | | `personalRatingRepository.js` | SQLite repository for canonical rating source observations, latest-intent queue rows, provider echo markers, and per-provider sync runs. | | `personalRatingSync.js` | Optional two-way personal-rating snapshots, Plembfin-authoritative conflict handling, complete-snapshot clears, durable queue delivery, retries, and the independent scheduler hook. | | `personalWatchlistIdentity.js` | Canonical movie/TV watchlist identity and provider/title aliases; episode rows are intentionally excluded from the first watchlist release. | | `personalWatchlistRepository.js` | Canonical watchlist mutations/tombstones, provider ownership ledger, durable queue, sync runs, activity, restore gate, and completed-watch removal hook. | | `personalWatchlistSync.js` | Bounded provider snapshots, safe first-run union, complete-snapshot removal safety, provider-addition fanout, owned-container cleanup, queue delivery, retries, and scheduler integration. | | `upNextIdentity.js` | Shared Up Next identity normalizer and deterministic ordering/merge rules: verified movie IDs, provider-series-plus-SxxExx episode keys, native-ID fallbacks, source-ID preservation, and resume-over-next-up reconciliation. | | `upNextRepository.js` | Generation-based SQLite source ledger for provider Resume/Continue Watching/Next Up feeds. Activates only complete snapshots, preserves last-good rows on failures, exposes redacted feed status, and advances `up_next` invalidation when active source content changes. | | `upNextService.js` | Builds the unified dashboard projection from canonical local resume/playstate, provider observations, and bounded released-episode metadata fallback; emits stable public queue items without raw provider payloads. | | `upNextAutoSync.js` | Queues coalesced, durable worker pushes when the unified Up Next projection changes, fingerprints the last successful queue, and reruns once when a queue mutation lands during a push. | | `upNextLibraryLookup.js` | Shared Up Next media-descriptor builder and cached Plex/Emby/Jellyfin library resolution. Lets the projection prove an unwatched next episode exists in a real library, and lets the authoritative push resolve the item it needs to add. | | `upNextRailSeed.js` | Clear-only compatibility migration for 6%-of-runtime positions written by older builds. Current Up Next pushes refresh Plex Continue Watching, Emby Continue Watching (Resume), and Jellyfin Next Up from verified watched predecessors without writing synthetic progress. | | `providerItemIds.js` | Resolves a media object's native ids for one provider, refusing a bare `provider_item_id` that belongs to a different one. Prevents an outbound write landing on an unrelated title. | | `upNextDismissals.js` | Server-side Up Next dismissals: alias plus coordinate identity, projection filter, and restore. Replaces the browser-local map; see `docs/decisions.md` entry 23. | | `recommendationExclusions.js` | Server-side movie/TV exclusions for the personalized Discover recommendation rail, including identity normalization, bounded persistence, and recommendation filtering. | | `upNextSeedLedger.js` | Legacy ledger for identifying and suppressing positions written by older native-rail seed builds while they are migrated out. | | `plexWatchlistClient.js` | Plex account-level Universal Watchlist adapter with native read/write capability probing and RSS read-only fallback. | | `traktAppConfig.js` | Supplies the bundled Plembfin Trakt device application, applies optional `TRAKT_CLIENT_ID` / `TRAKT_CLIENT_SECRET` overrides, validates the personal-app fallback, and hydrates runtime requests without persisting application credentials in tracker records. | | `credentialVault.js` | AES-256-GCM envelope for provider credentials, backed by `PLEMBFIN_CREDENTIAL_KEY` or the generated `data/credential.key`. | | `mediaConnectionRepo.js` | CRUD and runtime adaptation for encrypted Plex/Emby/Jellyfin account connections. | | `embyLikeAuth.js` | Emby/Jellyfin authentication exchange and verified user identity helpers. | | `plexAuth.js` | Plex PIN authorization, account verification, resource discovery, and server selection. | | `plexFetch.js` | Plex-account HTTP boundary with Plex client identity headers and structured failures. | | `plexTokenManager.js` | Managed Plex account/server-token validity checks and refresh/recovery. | | `trackerConnectionRepo.js` | Encrypted tracker connection and expiring device-flow persistence. | | `onboardingStore.js` | `accountClaimed`/onboarding progress persisted in its own `settings` row; the atomic (SQLite immediate-transaction) account-claim check-and-write; pristine-vs-upgraded install detection. | | `onboardingImportCoordinator.js` | Starts/cancels the safe, additive background pulls onboarding offers per media server (`forceSyncLibraryState({mode:"pull"})`) and for Trakt (`pollConnectedTrackers`), tracking progress in the onboarding state without taking the global force-sync lock. | | `rateLimit.js` | Minimal in-memory sliding-window rate limiter shared by `/api/login` and `/api/auth/claim`. | | `trackerDispatcher.js` | Sends canonical watched/unwatched/rewatch changes to active trackers with echo suppression. Trakt's history is a play log with no update semantics - `POST /sync/history` only ever adds a play - so a canonical replay (`source: "manual"`, e.g. Force Sync or a watched-date correction) first removes any existing Trakt plays for that item before adding the corrected one, instead of stacking a duplicate. A genuine watch reported by a media server still just adds. | | `trackerSync.js` | Compares complete Trakt watched snapshots with stored tracker state and feeds additions, removals, and changed timestamps into canonical transitions. A large, simultaneous drop in one show's episodes (Trakt returning a rate-limited or truncated but still well-formed watched-progress response) is held back rather than trusted as a real unwatch - it only propagates once the same episodes are still missing on a second consecutive poll (`partitionSuspiciousUnwatches`, `SUSPICIOUS_SHOW_DROP_MIN_COUNT`/`_FRACTION`). | | `traktClient.js` | Trakt device OAuth, refresh, paged watched-history reads, watched-history write client, and personal rating snapshot/write adapter. | | `watchStateTransitions.js` | Shared transactional watched/unwatched transition boundary used by tracker and media-server inputs. | | `syncMatchReport.js` | Pure aggregation of current watch-history telemetry into per-platform unmatched-media counts, movie/episode splits, and bounded samples used by Sync Activity's unresolved-match issue view. | | `mediaForceSync.js` | Detail-page Force Sync: title-scoped Plex/Emby/Jellyfin watched-state lookup, Set Plembfin as Source of Truth (push)/Import Watched Status (pull) modes, explicit import of remote-only records on pull, provenance/telemetry, and target-filtered canonical propagation. A remote item whose played flag has no reliable played date is skipped rather than imported with a fabricated current-time date. The modal's separate Push Personal Rating action uses `ratingSync/push` and does not enter this watched-state worker. The library-wide Force Sync planner remains remote-only-safe. Items are processed with bounded concurrency (`runWithConcurrency` in `concurrency.js`) so a show with many seasons doesn't sync one episode at a time. An all-destinations push completes the local media-server phase first and then drains a separate two-item Trakt phase, merging both phases into one final result and preserving each canonical remove/add pair during cancellation. | | `libraryForceSync.js` | Settings Force Sync: library-wide push (Set Plembfin as Source of Truth)/pull (Import Watched Status) operations, remote watched-state collection, and target-filtered canonical propagation. Also processes items and resume positions with bounded concurrency. | | `concurrency.js` | `runWithConcurrency(items, handler, limit)` - a small bounded worker pool used by Force Sync to process multiple items at once. Safe to raise: outbound HTTP calls are still throttled per host by the outbound governor (`outboundGovernor.js`), so this only shortens wall-clock time. | | `mediaForceSyncActivity.js` | Bounded in-memory activity ledger used by the detail-page and Settings Force Sync status/cancellation endpoints to stream operation lines, cancellation state, and final results to the UI. | | `tuning.js` | Import-free runtime accessors for watched threshold, minimum resume position, active-session TTL, outbound timeout, and the app-marked watched-flag policy; reads environment defaults and applies validated Settings overrides. | | `manualWatchReview.js` | Durable deduplicated queue for provider watched flags that require an administrator decision, including safe media snapshots and review status. | | `plexClient.js` | Plex HTTP client: find items by GUID/title, mark played/unplayed, set resume progress, fetch watched/resumable/account-scoped Continue Watching/metadata/episodes, and read/write personal ratings; username→accountID resolution with memoization. Token always sent as `X-Plex-Token` header. See [plex.md](plex.md). | | `plexNotificationListener.js` | Plex real-time WebSocket listener (`/:/websockets/notifications`): detects watched/unwatched changes the webhook can never deliver, reconnects with backoff, debounces per ratingKey; also recognizes the `playing` notification type to poke the live session poller (see `liveSessionPoller.js`) the instant a session's state changes; plus `probePlexNotificationSocket` for the System Integrity Check. | | `embyClient.js` | Emby HTTP client (same operation set as Plex client, `X-Emby-Token` auth, provider-ID `AnyProviderIdEquals` lookups), including user-scoped Resume/Next Up feeds and personal rating snapshots/writes. See [emby.md](emby.md). | | `jellyfinClient.js` | Jellyfin HTTP client (same shape as Emby client; uses the modern `Authorization: MediaBrowser ... Token="..."` header and preserves existing manual/API-account credential values); includes user-scoped Resume/Next Up feeds, retains duplicate quality copies so all matching items receive playstate, and uses the same isolated queue adapter for rating operations. See [jellyfin.md](jellyfin.md). | | `liveSessions.js` | Polls Plex/Emby/Jellyfin `sessions` endpoints for what's playing now (`fetchLiveSessions`, reports per-platform fetch failures separately from a genuinely empty result), normalizes them (`buildCacheRow`, `sessionIdentity`, `hydrateCachedSession`) for `live_tracking_cache`. Feeds Now Playing and completed-session detection. | | `liveSessionPoller.js` | Independent, activity-adaptive timer (10s while something's playing, 45s while idle) that runs `refreshLiveSessions()` (in `scheduled.js`) outside the once-a-minute scheduler tick, so Now Playing updates in seconds instead of up to a minute. `poke()` lets the Plex notification listener and `handleNowPlaying` trigger an immediate refresh. See [now-playing.md](now-playing.md). | | `activeSessions.js` | The `active_sessions` table (webhook `active`-phase sessions, configurable 5-minute TTL by default, enforced on read). | | `loopStore.js` | SQLite-backed loop-detection KV (`loop_keys` table) with TTL; `checkAndClaim` runs check+claim in one transaction so concurrent webhooks can't both pass. | | `syncFlags.js` | `watchedPlayedSyncEnabled()` - global kill-switch for watched/played propagation via `WATCHED_PLAYED_SYNC_ENABLED`. | | `configStore.js` | The `settings` SQLite row: media-server connection config (Plex/Emby/Jellyfin/Seerr/TMDB/Fanart/TVDB/YouTube/OMDb), sync-tuning overrides, and the disabled-by-default personal rating sync section with secret-preserving merges (`mergeIncomingConfig`), browser-safe shape (`publicMediaConfig`), URL/range validation; plus `runtime_state` helpers and the `sync_history` log. | | `auth.js` | Session cookie sign/verify (HMAC, 7-day TTL), API-key matching, `requireAdmin`, and the auth route handlers (`login`, `logout`, `auth/status`, `auth/apikey`, `auth/webhook-secret`, `auth/credentials`, `auth/sessions/revoke-all`). See [auth.md](auth.md). | | `outbound.js` | `fetchWithTimeout` (configurable 10s default - **all** server-side outbound HTTP must use it; enforced by the build check), `normalizeHttpUrl`, and `assertSafeOutboundUrl`. The shared boundary permits configured LAN media servers while rejecting unsafe schemes, embedded credentials, cloud-metadata targets, and unsafe redirect targets; credentials are removed from cross-origin redirects. | | `http.js` | `sendJson` / `sendOptions` / `methodNotAllowed` / `notFound` response helpers. Same-origin only - no CORS headers are ever sent. | | `httpPerformance.js` | Response compression (gzip over eligible text/JSON above 1KB, with the live update stream excluded), the CSP image-origin memo keyed to the settings revision, and the public asset cache headers. A request carrying the canonical `?v=` query is one immutable version of a file and is cached for a year; anything else revalidates, and `index.html` and the manifest are never cached hard. | | `statsPayload.js` | Shapes the stats response around the period actually being viewed: full totals, a lightweight `{period, label}` index of every year and month, and only the selected report. The page previously received every ranked report on every load. | | `requestBody.js` | `readJson` and `readFormData` (urlencoded + multipart via busboy) over the raw body captured by `server.js`. | | `diagnosticLogger.js` | Wraps `console.log/warn/error` and writes captured lines (secrets redacted) to the `diagnostic_log` table for Settings → Logs (`/api/diagnostic-logs`). Batches writes, caps the table at 20,000 rows, and prunes the `data/logs` JSONL archive on boot. | | `logVerbose.js` | `LOG_VERBOSE` flag plus `traceLog()`, used to keep per-request tracing (Plex GUID lookups, search fallbacks) out of the log unless explicitly enabled. | | `cacheTelemetry.js` | Derived-cache rebuild counters: per cache, how many rebuilds, total/max/mean milliseconds, and which labelled generation change each was for. `timeCacheRebuild`/`timeCacheRebuildAsync` wrap the rebuild itself, so a cache hit costs nothing. Logs per rebuild under `PLEMBFIN_DEBUG_CACHE_REBUILDS`; `cacheRebuildTelemetry()` returns the snapshot. | | `posterCache.js` | Artwork fetch-resize-store pipeline: downloads a remote image (Plex token moved to a header), resizes with sharp to webp (poster 340w / backdrop 1600w / profile 780w / logo 800w), writes to `data/media/s/`, records metadata in `poster_cache` with negative caching for missing/failed. See [posters-artwork.md](posters-artwork.md). | | `tmdbGateway.js` | TMDB API gateway + SQLite caches (`tmdb_metadata_cache`, `tmdb_search_cache`, `tmdb_person_cache`): details, search, seasons, people, images, library prewarm, request throttling and in-flight dedupe. For TV it merges TVDB structural data - see [metadata.md](metadata.md). | | `tvdbGateway.js` | TheTVDB v4 gateway (built-in shared project key, optional personal key): series/season/episode data, title search, artwork; raw responses cached in `tvdb_metadata_cache` / `tvdb_season_cache`; `shapeTvdbSeriesAsTmdb` adapts TVDB shapes to TMDB-style fields. | | `fanartGateway.js` | Fanart.tv gateway (built-in shared key + optional personal `client_key`): best/all posters, backdrops, HD logos for movies (by TMDB id) and TV (by TVDB id). | | `omdbGateway.js` | OMDb gateway: IMDb rating + vote count by IMDb id, cached 7 days in `omdb_cache`. | | `tmdbClient.js` | Tiny wrapper `fetchPosterFromTmdb(row)` used by the poster pipeline's TMDB fallback. | | `nextAiringCache.js` | File-backed cache (`data/next-airing-cache.json`) of each show's next episode air date + status, so the TV Shows page can sort by "next airing" without live TVDB/TMDB calls. TTL 6h for active shows, 7d for ended. | | `upcomingCalendarCache.js` | Persistent month-level episode calendar (`data/upcoming-calendar-cache.json`): serves cached results, builds historical months once, checks current/future results for changes, and merges newly tracked shows without rebuilding existing entries. | | `upNextCache.js` | Persistent unified dashboard queue snapshot (`data/up-next-cache.json`): serves a warm mixed movie/episode result, revalidates stale canonical/source-ledger data in the background, and advances the shared `up_next` cache generation when the completed snapshot changes. | | `showProgressCache.js` | File-backed cache (`data/tv_progress_cache.json`) of per-show watched/total episode counts. Burst updates share one flush, reuse recent metadata totals, and atomically replace the file after synchronous persistence. In split `web` + `worker` deployments each process still owns a private whole-file snapshot, so concurrent writers can overwrite one another's unrelated show updates; SQLite watch history remains authoritative and a later recalculation repairs the cache. | | `backup.js` | Portable full-backup format: exports/imports the core SQLite tables as versioned JSON collections (paged export, batched import, optional reset). Used by Settings → Backup / restore and the encrypted backup subsystem. | | `watchHistoryBackups.js` | Watch-history-only backup subsystem: gzip JSON of `watch_history` + `playstate` + `playback_progress` with checksum manifest, independent daily local and remote schedules with separate retention counts, dry-run/merge/replace restore, remote destination management (secrets kept server-side, redacted in every API response), cron-sync pausing around restores. See [backups.md](backups.md). | | `plembfinBackups.js` | Full encrypted backup subsystem: AES-256-GCM (PBKDF2) encrypted export of the entire portable backup, daily scheduling + retention, optional remote mirroring. | | `backupDestinations/index.js` | Adapter registry: `folder`, `webdav`, `s3` (also `backblaze`), `onedrive`, `dropbox` - all sharing `testConnection / upload / list / download / delete`. | | `backupDestinations/folder.js` | Local/mounted-folder destination adapter. | | `backupDestinations/webdav.js` | WebDAV destination adapter (basic auth). | | `backupDestinations/s3.js` | S3-compatible destination adapter with its own SigV4 signer (AWS S3, Backblaze B2, MinIO…). | | `backupDestinations/onedrive.js` | OneDrive adapter using the Microsoft device-code OAuth flow (app-folder scope, refresh-token persistence). | | `backupDestinations/dropbox.js` | Dropbox adapter using the manual no-redirect OAuth code flow (refresh-token persistence). | ### `public/` | File | What it is | | --- | --- | | `index.html` | The single HTML shell: nav tabs (Dashboard / Movies / TV Shows / Upcoming / History / Stats / Settings), one `view-panel` section per view, all modals/dialogs, and `modulepreload` links for every module. Element IDs here are what `bindElements()` queries. | | `app.js` | **Frontend orchestrator** (keep under 3,000 lines): startup, theme init, backend warm-up ping, `bindElements`, SPA routing (`handleRouting`/`navigateTo`/`selectView`), auth flow wiring, and the callback objects handed to each module's `init*` function. Feature logic belongs in `public/modules/`, not here. | | `styles.css` | All styling for the app, including responsive/mobile rules (mobile ≤ 760px must be verified for any layout change). Declares the self-hosted `@font-face` rules for the fonts in `fonts/`. | | `theme-boot.js` | Tiny classic script loaded with a blocking `