Ampelos dashboard: the web face, and the owner of the schema
Split out of the single Ampelos repository. Next.js app, Drizzle schema and migrations, brand art, planning notes. What left: scripts/, which was the agent's job library misfiled under web/ and imported nothing from src/; and deploy/truenas, whose broadcast posts to the scan listener on :3427 -- an agent script -- so it belongs beside the thing it talks to. This repository keeps the schema. The agent speaks raw SQL against the same tables and holds no copy of it, so a rename here can break it silently where it used to be one commit. The README says so, and the agent carries a snapshot to check against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,991 @@
|
||||
# Ampelos Media System Planning
|
||||
|
||||
## Purpose
|
||||
|
||||
Ampelos is intended to become a single media control plane that combines the useful automation pieces of the *arr ecosystem with the existing `tv-manager` archive workflows.
|
||||
|
||||
The goal is not to expose Sonarr and Radarr as the source of truth. Ampelos should compute desired media state from user demand, media age/status, storage policy, and archive availability, then drive downstream systems to make that state real.
|
||||
|
||||
## Current Starting Point
|
||||
|
||||
The existing `tv-manager` project lives at `/srv/Ampelos/tv-manager`.
|
||||
|
||||
It currently manages local filesystem state across:
|
||||
|
||||
- Main/live media paths served by Plex.
|
||||
- Backup media paths intended to be a 1:1 copy of main/live.
|
||||
- Archive media paths that are not normally exposed in Plex.
|
||||
- TV and movie media types.
|
||||
|
||||
Current capabilities include:
|
||||
|
||||
- Scanning primary, backup, and archive locations.
|
||||
- Comparing inventory between locations.
|
||||
- Restoring missing media from backup to primary.
|
||||
- Moving shows or selected seasons into archive.
|
||||
- Deduplicating archive files.
|
||||
- Normalizing season and episode names.
|
||||
- Transcoding archive media to 720p h265.
|
||||
- A Flask web UI and JSON/SSE endpoints for the above workflows.
|
||||
|
||||
The current SQLite cache is only a scan cache. It should not become the authoritative database for the new system.
|
||||
|
||||
## High-Level Product Vision
|
||||
|
||||
Ampelos should provide one unified control panel for media requests, watch intent, library policy, and backend automation.
|
||||
|
||||
Users should be able to:
|
||||
|
||||
- Log in through Authentik at `auth.sticknife.com`.
|
||||
- Browse/search media in an Ombi-like interface.
|
||||
- Add shows and movies they are interested in.
|
||||
- Maintain a limited "Watching Now" list.
|
||||
- See request and availability status without managing automation details directly.
|
||||
|
||||
Admins should be able to:
|
||||
|
||||
- Define media retention and quality rules.
|
||||
- See why each show/movie is in its current state.
|
||||
- Override computed state when needed.
|
||||
- Monitor archive sync, restore, import, dedup, and transcode-agent jobs.
|
||||
- Keep Plex-facing storage small while preserving broad availability in archive.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Ampelos computes desired state. Plex, trackers, download clients, and archive tools are implementation details.
|
||||
|
||||
Users should not manually manage monitor status, quality targets, storage routing, or retention decisions. Ampelos derives all of those from policy.
|
||||
|
||||
## Stack Decisions
|
||||
|
||||
Ampelos should use a Node.js web application for the main frontend and product surface.
|
||||
|
||||
Rationale:
|
||||
|
||||
- The user-facing site will be exposed to `sticknife_users`, so the frontend experience matters.
|
||||
- Node.js gives a stronger path to a polished request/search/watchlist UI.
|
||||
- The interface should feel closer to Ombi's user experience than to the current `tv-manager` admin table.
|
||||
|
||||
Backend and agent work can still use Python where it is a better fit.
|
||||
|
||||
Expected split:
|
||||
|
||||
- Node.js frontend and web application for users/admins.
|
||||
- PostgreSQL database on `mimir`.
|
||||
- Python workers/agents where useful for filesystem, hardware, scanning, transcode coordination, and reuse of current `tv-manager` logic.
|
||||
- Native Ampelos TV/movie automation modules, built from scratch and inspired by *arr concepts but not derived from their code.
|
||||
|
||||
Open implementation choice:
|
||||
|
||||
- Exact Python worker shape: service process, job runner, or agent package.
|
||||
|
||||
## Storage Topology
|
||||
|
||||
Ampelos should explicitly model three storage tiers.
|
||||
|
||||
### Main / Live
|
||||
|
||||
Main/live storage is the Plex-facing active library.
|
||||
|
||||
Expected contents:
|
||||
|
||||
- Mostly full-resolution content.
|
||||
- Actively airing TV.
|
||||
- Recently ended TV according to policy.
|
||||
- Recent movies according to policy.
|
||||
- User-requested and Watching Now content.
|
||||
|
||||
Main/live is optimized for availability and normal playback. It is not the most trusted copy.
|
||||
|
||||
### Backup
|
||||
|
||||
Backup storage should be a 1:1 copy of main/live.
|
||||
|
||||
The backup server uses raidz, consumes more power, and is trusted more than main/live's snapraid storage. Because of that, it should be treated as the safer active copy, but not necessarily always online.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Every file that belongs in main/live should eventually exist in backup.
|
||||
- Backup drift should be tracked in the database.
|
||||
- Backup sync work should queue while the backup server is unavailable.
|
||||
- Ampelos should surface anything missing from backup as a protection gap.
|
||||
|
||||
### Archive
|
||||
|
||||
Archive storage holds content no longer in active circulation.
|
||||
|
||||
Expected contents:
|
||||
|
||||
- Older TV seasons not currently promoted.
|
||||
- Older movies outside the active movie retention policy.
|
||||
- Plex watchlist items that are wanted but not actively promoted.
|
||||
- Compressed 720p archive copies where possible.
|
||||
|
||||
Archive is optimized for low data footprint and broad recoverability, not immediate full-quality playback.
|
||||
|
||||
## File Inventory and Deduplication
|
||||
|
||||
Ampelos should improve on the current `tv-manager` dedup behavior by making file inventory and dedup jobs database-backed and cross-storage-aware.
|
||||
|
||||
### File Inventory
|
||||
|
||||
Ampelos should track observed files across main/live, backup, and archive in the database.
|
||||
|
||||
For each file, the inventory should eventually capture:
|
||||
|
||||
- Storage tier: main/live, backup, or archive.
|
||||
- Media association: series, season, episode, or movie.
|
||||
- Path and normalized relative path.
|
||||
- Filename.
|
||||
- Size.
|
||||
- Modified time.
|
||||
- Optional content hash or partial hash.
|
||||
- Parsed quality, codec, resolution, and release metadata where available.
|
||||
- Whether the file is expected, extra, missing, duplicate, or queued for action.
|
||||
|
||||
The database should not blindly trust stale scan records. Each record needs freshness metadata tied to the storage availability and scan time.
|
||||
|
||||
### Dedup Role
|
||||
|
||||
Deduplication should live on Ampelos.
|
||||
|
||||
Ampelos should:
|
||||
|
||||
- Compare duplicate candidates across all three storage tiers.
|
||||
- Apply quality/selection concepts inspired by *arr (codec, resolution, release group preference) to choose which duplicate to keep.
|
||||
- Prefer keeping files based on storage policy, media state, quality, codec, resolution, release metadata, and trust level.
|
||||
- Detect duplicate episodes or movies even when filenames differ.
|
||||
- Support manual overrides that exclude specific files/releases from dedup consideration.
|
||||
- Track rename/delete decisions as durable jobs instead of immediately mutating offline storage.
|
||||
- Execute or dispatch those jobs when the relevant storage is available.
|
||||
- Keep audit history for every deletion or rename decision.
|
||||
|
||||
Deletion should be conservative. Ampelos should only delete a candidate when the retained copy is verified in the correct tier and the policy reason is clear.
|
||||
|
||||
Dedup should start in review-only mode during development and debugging, but the intended production model is not permanently review-only. Once rules are proven, Ampelos should execute safe dedup actions automatically while still allowing review/override for ambiguous cases.
|
||||
|
||||
Example override case:
|
||||
|
||||
- A show may intentionally have multiple variants, such as black-and-white and color cuts of the same episode. Ampelos needs a way to mark one or both files as intentionally retained so dedup does not collapse them into a single copy.
|
||||
|
||||
### Deferred Storage Actions
|
||||
|
||||
Because backup/archive storage may be offline, dedup should create pending actions rather than assuming immediate access.
|
||||
|
||||
Potential action types:
|
||||
|
||||
- Delete duplicate file.
|
||||
- Rename file.
|
||||
- Move file between tiers.
|
||||
- Restore archive file to main/live.
|
||||
- Copy main/live file to backup.
|
||||
- Mark missing expected copy.
|
||||
- Reprobe file metadata.
|
||||
|
||||
Each action should have:
|
||||
|
||||
- Target storage tier.
|
||||
- Required availability condition.
|
||||
- Reason code.
|
||||
- Safety preconditions.
|
||||
- Dry-run output.
|
||||
- Execution result.
|
||||
- Audit events.
|
||||
|
||||
### Transcoding Role
|
||||
|
||||
Transcoding should move out of Ampelos into a dedicated transcode agent.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- The transcode server boots only when backup/archive storage is online.
|
||||
- Ampelos queues or flags archive transcode candidates.
|
||||
- The transcode agent claims work from the database or receives jobs from Ampelos.
|
||||
- The agent compresses eligible archive media to 720p h265.
|
||||
- Ampelos records the result and updates observed file inventory.
|
||||
|
||||
Ampelos should own the policy and job state. The transcode agent should own the CPU/GPU-heavy execution.
|
||||
|
||||
## Major External Systems
|
||||
|
||||
### Authentik
|
||||
|
||||
Authentication should be delegated to Authentik at `auth.sticknife.com`.
|
||||
|
||||
Expected model:
|
||||
|
||||
- OIDC login.
|
||||
- Ampelos trusts Authentik identity and group claims.
|
||||
- Local user records map external identities to preferences, watch lists, permissions, and quotas.
|
||||
- `sticknife_users` maps to normal user access.
|
||||
- `sticknife_admins` maps to full admin access.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Exact OIDC issuer URL.
|
||||
- Whether regular users can request all media or only manage their own lists.
|
||||
|
||||
### Mimir Database
|
||||
|
||||
The new system should use the database on `mimir` as its authoritative store.
|
||||
|
||||
Expected model:
|
||||
|
||||
- PostgreSQL.
|
||||
- Single database named `ampelos`.
|
||||
- Separate tables or schemas can model TV, movies, music, users, jobs, file inventory, and policy data.
|
||||
- No new feature should depend on SQLite as source of truth.
|
||||
- Local caches are acceptable for expensive scans or external API responses, but must be rebuildable.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Connection method and credentials.
|
||||
- Migration tool.
|
||||
- Backup expectations.
|
||||
|
||||
### Sonarr and Radarr
|
||||
|
||||
Ampelos should not fork, adapt, or treat Sonarr and Radarr as API backends. The *arr data model is fundamentally incompatible with Ampelos's requirements:
|
||||
|
||||
- Sonarr enforces one canonical file per episode. Intentional variants (e.g. black-and-white and color cuts of the same episode) cannot be represented.
|
||||
- Sonarr and Radarr have no model for multi-tier storage with different quality targets per tier.
|
||||
- Both services derive "missing" state from live filesystem visibility. Offline archive storage would be misread as missing content, triggering unwanted re-downloads.
|
||||
- Quality profiles are per-series, not per-storage-tier.
|
||||
|
||||
Ampelos should instead build its own native TV and movie automation, taking *arr's concepts as reference and inspiration rather than as source material.
|
||||
|
||||
Ampelos-native responsibilities:
|
||||
|
||||
- Series and movie metadata integration (TVDB and TMDB APIs directly).
|
||||
- Release name parsing using `guessit` (Python) or equivalent.
|
||||
- Release search via the indexer backend (Jackett or Prowlarr).
|
||||
- Release selection and quality decision logic driven by Ampelos policy and desired state.
|
||||
- Download client integration (qBittorrent, Transmission).
|
||||
- Import pipeline with full storage-tier awareness.
|
||||
- File organization and naming.
|
||||
- Upgrade logic driven by the desired-state model rather than quality profiles.
|
||||
- Multi-tier inventory: intentional variants, offline archive state, and cross-tier dedup handled natively.
|
||||
|
||||
Sonarr and Radarr should be retired from the stack as Ampelos automation reaches feature parity. No Sonarr/Radarr API compatibility layer is needed.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Whether TV and movie automation share one internal policy engine with media-type adapters, or use parallel but separate modules.
|
||||
- Exact TVDB/TMDB integration approach and fallback behavior when metadata sources disagree.
|
||||
|
||||
### Plex
|
||||
|
||||
Plex remains the consumption surface.
|
||||
|
||||
Ampelos should reason about what belongs in Plex-facing storage versus archive.
|
||||
|
||||
Expected Plex-facing media:
|
||||
|
||||
- "Watching Now" TV shows.
|
||||
- High-definition currently airing or recently ended shows according to policy.
|
||||
- Movies that are actively requested or within the configured freshness window.
|
||||
|
||||
Archive-only media should not normally be visible in Plex unless restored or promoted.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Whether Ampelos should read Plex watch history, active sessions, or user libraries directly.
|
||||
- Whether existing Plex watchlists remain an input signal.
|
||||
- Whether Plex availability should be polled or inferred from filesystem and Ampelos inventory state.
|
||||
|
||||
## Existing Compose Services
|
||||
|
||||
The current Docker Compose stack at `/home/odin/docker/compose/docker-compose.yml` is a useful map of what Ampelos may eventually fold into one control panel.
|
||||
|
||||
The near-term goal should not be to rewrite every service. The practical path is to absorb the UX, policy, queueing, and explanation layers first, while keeping specialized services as backend adapters where they are already good at their jobs.
|
||||
|
||||
### Ombi
|
||||
|
||||
Current role:
|
||||
|
||||
- User-facing request portal.
|
||||
- Media discovery and request workflow.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Absorb into Ampelos.
|
||||
- Replace with the Ampelos user-facing request, browse, and Watching Now UI.
|
||||
- Preserve the useful product behavior: simple user requests without exposing automation internals.
|
||||
- Ombi's UX is a strong reference point for the normal user interface.
|
||||
- Existing Ombi code may be reused where licensing and architecture make sense, with Ampelos adding Watching Now and the new policy model.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Retire once Ampelos has request UX and Authentik login.
|
||||
|
||||
### Sonarr
|
||||
|
||||
Current role:
|
||||
|
||||
- TV series metadata.
|
||||
- Monitoring.
|
||||
- Release search.
|
||||
- Download/import coordination.
|
||||
- TV file organization.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Fork or extract the useful TV automation internals.
|
||||
- Ampelos should own the policy that decides monitor status, quality profile, tags, root folder, storage tier, and dedup behavior.
|
||||
- Sonarr's existing mechanics are useful source material, but the long-term UI and policy should live in Ampelos.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Fork-first path for the TV automation layer.
|
||||
- API compatibility may still be useful temporarily during migration.
|
||||
|
||||
### Radarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Movie metadata.
|
||||
- Monitoring.
|
||||
- Release search.
|
||||
- Download/import coordination.
|
||||
- Movie file organization.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Same as Sonarr: retire in favor of native Ampelos movie automation.
|
||||
- Ampelos computes movie retention, quality target, and Plex-facing/archive state natively.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Retired once the `automation.movies` module reaches feature parity.
|
||||
|
||||
### Jackett
|
||||
|
||||
Current role:
|
||||
|
||||
- Indexer bridge supporting a wide range of public and private trackers.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Likely primary indexer backend. Jackett has better compatibility with certain indexers than Prowlarr and may be the better fit depending on which trackers are in use.
|
||||
- Ampelos queries Jackett's API for release search and should surface indexer health.
|
||||
- Ampelos does not reimplement indexer protocol handling.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Durable backend dependency for release search.
|
||||
|
||||
Open decision:
|
||||
|
||||
- Confirm whether Jackett or Prowlarr better covers the active indexer set. One should be chosen as the primary; running both long-term adds maintenance overhead for no clear gain.
|
||||
|
||||
### Prowlarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Indexer management for *arr apps.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Secondary indexer backend option. May be preferred if its indexer coverage and management UI prove more useful than Jackett for the active tracker set.
|
||||
- Ampelos can query Prowlarr's API for release search in the same way as Jackett.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Either replaces Jackett as the primary indexer backend, or is retired once a single indexer backend is chosen.
|
||||
|
||||
### FlareSolverr
|
||||
|
||||
Current role:
|
||||
|
||||
- Cloudflare/challenge solving helper for indexers.
|
||||
- Compose service key is `flaresolver`, while the container/image use `flaresolverr`.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Keep as backend infrastructure.
|
||||
- Ampelos should only surface health/config dependency where indexers require it.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Remains external unless indexer handling is deeply folded into Ampelos.
|
||||
|
||||
### Transmission OpenVPN
|
||||
|
||||
Current role:
|
||||
|
||||
- Torrent download client behind VPN.
|
||||
- Writes downloads under `/Niflheim/Downloads`.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Keep as backend infrastructure.
|
||||
- Ampelos should track download-client health, queue state, completed imports, and failure reasons directly via the client API.
|
||||
- VPN credentials and network policy should remain outside normal user workflows.
|
||||
|
||||
Likely future:
|
||||
|
||||
- External download backend.
|
||||
- Admin status surfaced in Ampelos.
|
||||
|
||||
Security note:
|
||||
|
||||
- The compose file currently contains VPN credentials in plaintext. When this stack is revised, move secrets into an environment file or secret manager and avoid copying them into Ampelos config.
|
||||
|
||||
### qBittorrent PIA Container
|
||||
|
||||
Current role:
|
||||
|
||||
- Commented-out alternate VPN torrent client.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Treat as an alternate download backend option, not core Ampelos scope.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Decide on one torrent backend and expose it through a generic download-client adapter.
|
||||
|
||||
### Bazarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Subtitle management for movies and TV.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Keep as a backend initially.
|
||||
- Ampelos should surface subtitle availability and allow admin-level subtitle policy, but not immediately reimplement subtitle provider/search logic.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Backend adapter.
|
||||
- Potentially absorbed later if subtitle policy becomes central.
|
||||
|
||||
### Lidarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Music library automation.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Defer from the first TV/movie scope.
|
||||
- The architecture should leave room for media-type adapters beyond TV and movies.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Possible later module once TV/movie policy is stable.
|
||||
|
||||
### slskd
|
||||
|
||||
Current role:
|
||||
|
||||
- Soulseek daemon for music downloads.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Keep as backend infrastructure for music workflows.
|
||||
- Defer until music support is intentionally designed.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Backend adapter under a future music module.
|
||||
|
||||
### Soularr
|
||||
|
||||
Current role:
|
||||
|
||||
- Bridge between Lidarr and slskd.
|
||||
- Periodic script-driven matching/download workflow.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Defer from first scope.
|
||||
- If music is added, Ampelos can absorb the dashboard/status/policy layer while leaving slskd as backend.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Candidate for folding into a future music automation module.
|
||||
|
||||
### Soularr Dashboard
|
||||
|
||||
Current role:
|
||||
|
||||
- Custom dashboard for Soularr logs and failures.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Absorb dashboard/status behavior if music support is added.
|
||||
- The broader pattern is relevant now: Ampelos should centralize job logs, failures, and retry workflows.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Replace with Ampelos admin job views.
|
||||
|
||||
### Chaptarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Book/audiobook automation.
|
||||
- Can use PostgreSQL but currently appears self-contained.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Defer from first TV/movie scope.
|
||||
- Keep in mind as another future media-type adapter.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Possible books/audiobooks module after TV/movie foundations.
|
||||
|
||||
### Readarr
|
||||
|
||||
Current role:
|
||||
|
||||
- Commented-out book automation.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Treat as deferred/legacy book automation.
|
||||
- Do not include in first Ampelos build unless book scope is pulled forward.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Reevaluate alongside Chaptarr.
|
||||
|
||||
### Watchtower
|
||||
|
||||
Current role:
|
||||
|
||||
- Commented-out automatic container updater.
|
||||
|
||||
Ampelos direction:
|
||||
|
||||
- Not a media workflow dependency.
|
||||
- Ampelos may eventually expose deployment/version status, but should not own automatic updates initially.
|
||||
|
||||
Likely future:
|
||||
|
||||
- Keep outside Ampelos.
|
||||
|
||||
## Service Folding Strategy
|
||||
|
||||
Initial Ampelos scope should focus on TV and movies:
|
||||
|
||||
- Replace Ombi as the user-facing request and Watching Now interface.
|
||||
- Build native TV and movie automation inspired by *arr concepts: metadata integration, release parsing, release search via Jackett, download client orchestration, and a tier-aware import pipeline.
|
||||
- Retire Sonarr and Radarr as Ampelos automation reaches feature parity.
|
||||
- Use Jackett/FlareSolverr/download clients as backend infrastructure.
|
||||
- Integrate the current `tv-manager` storage/archive workflows.
|
||||
- Add durable database-backed jobs and audit trails.
|
||||
|
||||
Secondary scope:
|
||||
|
||||
- Subtitle visibility and policy through Bazarr.
|
||||
- Better indexer/download health visibility.
|
||||
- Music support through Lidarr/slskd/Soularr concepts.
|
||||
- Books/audiobooks through Chaptarr or Readarr concepts.
|
||||
|
||||
This produces a layered system: one Node.js product shell and policy brain, native Ampelos TV/movie automation inspired by *arr, backend infrastructure adapters (Jackett, download clients, Plex, Authentik), and Python agents where hardware and filesystem work is better handled outside the web app.
|
||||
|
||||
## Demand Sources
|
||||
|
||||
Ampelos should aggregate media demand from multiple sources.
|
||||
|
||||
### Plex Watchlists
|
||||
|
||||
Existing watchlist scraping should continue.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- Watchlisted TV should sync on a slower weekly cycle.
|
||||
- Default watchlist quality should be archive-oriented 720p.
|
||||
- Default watchlist items should go straight to archive, not Plex-facing primary storage.
|
||||
|
||||
Rationale:
|
||||
|
||||
This preserves broad user interest without letting every casual watchlist entry consume high-quality Plex-facing storage.
|
||||
|
||||
### Watching Now
|
||||
|
||||
"Watching Now" is a short, intentional list managed through Ampelos.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Each user starts with 5 TV show slots and 10 movie slots.
|
||||
- Any show on any user's Watching Now list is promoted.
|
||||
- The whole show should become available quickly in at least archive-quality 720p.
|
||||
- Ampelos should restore existing 720p copies from archive to Plex-facing storage when available.
|
||||
- Ampelos should return the show to Sonarr/tracker management so higher quality can be acquired or upgraded when policy allows.
|
||||
- Ampelos should notify users when they add something already covered by automatic live rules, such as currently airing TV or recent movies.
|
||||
- Users should still be allowed to add automatically covered items because Watching Now can imply broader availability than the automatic rule. Example: season 11 may be airing and live, but a user may want season 2 restored from archive.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Whether admins can grant larger limits.
|
||||
- Whether a user can pin a single season instead of the whole show.
|
||||
|
||||
### Currently Airing
|
||||
|
||||
"Currently Airing" should override default archival behavior for TV.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Any show that is still running, or ended less than one year ago, is treated as currently relevant.
|
||||
- The currently airing season should be live at the highest available quality as episodes release.
|
||||
- The most recent completed season should be live at the highest available quality.
|
||||
- For a show that stopped airing within the last year, the final season is retained in live.
|
||||
- Older seasons default to archive unless also promoted by Watching Now.
|
||||
|
||||
Worked example of the three live-retention cases:
|
||||
|
||||
| Show state | Retained live |
|
||||
| --- | --- |
|
||||
| Still airing | Current season + most recent completed season |
|
||||
| Ended < 1 year ago | Final season |
|
||||
| Ended >= 1 year ago | Nothing, unless Watching Now or admin override |
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Metadata source for show status and end date.
|
||||
- Exact definition of "most recent completed season".
|
||||
- Whether the one-year cutoff is configurable globally.
|
||||
|
||||
### Admin Overrides
|
||||
|
||||
Admins should be able to pin or force state when policy is insufficient.
|
||||
|
||||
Potential override types:
|
||||
|
||||
- Force Plex availability.
|
||||
- Force archive-only.
|
||||
- Force quality profile.
|
||||
- Force monitor/unmonitor.
|
||||
- Force ignore.
|
||||
- Temporary promotion with expiry.
|
||||
|
||||
Overrides should be auditable and explainable in the UI.
|
||||
|
||||
## Desired State Model
|
||||
|
||||
Ampelos should represent media state as computed desired state plus observed actual state.
|
||||
|
||||
### Desired State
|
||||
|
||||
For each series, season, episode, movie, and file group where applicable, Ampelos should compute:
|
||||
|
||||
- Wanted or unwanted.
|
||||
- Plex-facing or archive-only.
|
||||
- Minimum acceptable quality.
|
||||
- Preferred quality.
|
||||
- Whether Ampelos automation should actively seek or upgrade it.
|
||||
- Target root folder.
|
||||
- Archive restore requirement.
|
||||
- Archive eviction requirement.
|
||||
- Reason codes explaining the decision.
|
||||
|
||||
Example TV reason codes:
|
||||
|
||||
- `watching_now`
|
||||
- `currently_airing`
|
||||
- `plex_watchlist_archive`
|
||||
- `recently_ended`
|
||||
- `admin_override`
|
||||
- `stale_archive_only`
|
||||
- `quota_limited`
|
||||
|
||||
### Actual State
|
||||
|
||||
Actual state should be observed from:
|
||||
|
||||
- Database records.
|
||||
- Filesystem scans.
|
||||
- Existing `tv-manager` inventory logic.
|
||||
- Download client state.
|
||||
- Plex availability where needed.
|
||||
- Archive worker job results.
|
||||
|
||||
Observed state should be refreshable and repairable. If an external system drifts, Ampelos should detect and reconcile it.
|
||||
|
||||
## TV Policy Draft
|
||||
|
||||
Default policy:
|
||||
|
||||
- Plex watchlist TV: wanted in archive at 720p.
|
||||
- Not in any demand source: archive-only if already present; otherwise not wanted.
|
||||
- Watching Now: wanted in Plex-facing storage, full show restored at 720p minimum, monitored for upgrades.
|
||||
- Currently airing or ended less than one year ago: most recent completed season available in high definition, older seasons archive-only unless promoted.
|
||||
- Admin override: wins over automated policy unless explicitly expired.
|
||||
|
||||
Quality draft:
|
||||
|
||||
- Archive default: 720p h265.
|
||||
- Plex active default: highest available quality according to policy.
|
||||
- 4K should be preferred for media marked live where available, with storage policy still able to constrain exceptional cases.
|
||||
|
||||
Storage movement draft:
|
||||
|
||||
- Archive restore happens before tracker upgrades when an acceptable archive copy exists.
|
||||
- Demotion from live happens only when the item is not Watching Now, not recent/current, not admin-pinned, and has been safely archived.
|
||||
|
||||
Terminology, because the earlier wording ("archive eviction") read as deleting
|
||||
from the archive when it meant the opposite:
|
||||
|
||||
- **Restore** — archive to live.
|
||||
- **Demotion** — live to archive, once a verified archive copy exists. This is
|
||||
what the rule above governs.
|
||||
- **Eviction** — deleting a redundant copy. Only ever applied to a duplicate,
|
||||
never to the canonical copy of an item.
|
||||
- Existing `tv-manager` move/restore workflows are good candidates for the first archive adapter implementation.
|
||||
- Existing `tv-manager` dedup logic should be evolved into database-backed cross-storage dedup planning.
|
||||
- Existing `tv-manager` transcode logic should be moved behind a dedicated transcode-agent interface rather than kept as an Ampelos in-process worker.
|
||||
|
||||
## Movie Policy Draft
|
||||
|
||||
Movies likely need looser retention rules because they consume less space relative to TV.
|
||||
|
||||
Initial draft:
|
||||
|
||||
- Keep movies in Plex-facing storage if they are on any user's list.
|
||||
- Keep movies in Plex-facing storage if they are newer than a configurable age threshold, probably 5 years.
|
||||
- Keep movies in Plex-facing storage permanently if they are on the admin-managed classics list.
|
||||
- Archive older, lower-demand movies where useful.
|
||||
- Be more liberal than TV with Plex-facing availability.
|
||||
- Prefer 4K/highest available quality for movies marked live.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- Whether movie watchlists should always remain Plex-facing.
|
||||
- What movie age threshold should apply.
|
||||
|
||||
## Archive Sync Model
|
||||
|
||||
Bragi/archive storage may not be continuously online.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Weekly sync windows are acceptable for low-priority watchlist archival.
|
||||
- Bragi can power on for a bounded sync cycle, exchange data, then shut down.
|
||||
- Users cannot trigger Bragi wake/sync.
|
||||
- Ampelos tracks requested changes in the database and enacts them during scheduled Bragi availability.
|
||||
- Watching Now and currently airing changes should show a clear "queued until next sync" status when they require Bragi.
|
||||
|
||||
Open decisions:
|
||||
|
||||
- How Ampelos detects Bragi availability.
|
||||
- How the weekly schedule is configured and reported.
|
||||
|
||||
## Service Architecture Draft
|
||||
|
||||
Ampelos should likely be built as a single service with clear internal modules before splitting anything apart.
|
||||
|
||||
Proposed modules:
|
||||
|
||||
- `auth`: OIDC/Auth session handling and roles.
|
||||
- `catalog`: metadata search and normalized media records.
|
||||
- `users`: user profiles, quotas, and preferences.
|
||||
- `demand`: watchlists, Watching Now, requests, and source signals.
|
||||
- `policy`: computes desired state from demand and rules.
|
||||
- `automation.tv`: native Ampelos TV automation — metadata, release search, download orchestration, and tier-aware import for TV.
|
||||
- `automation.movies`: native Ampelos movie automation — same responsibilities scoped to movies.
|
||||
- `integrations.plex`: Plex state adapter.
|
||||
- `integrations.archive`: wrapper around current tv-manager capabilities.
|
||||
- `storage`: storage tier definitions, availability, scan state, and file inventory.
|
||||
- `dedup`: cross-storage duplicate detection and safe action planning.
|
||||
- `transcode`: transcode-agent job coordination and result tracking.
|
||||
- `jobs`: durable job queue and worker execution.
|
||||
- `ui`: unified control panel and request interface.
|
||||
- `admin`: policy, audit, and operations views.
|
||||
|
||||
Ampelos builds its own TV/movie automation natively, taking *arr concepts as reference rather than inheriting their code. The `automation.tv` and `automation.movies` modules should be isolated behind clear internal boundaries so the policy engine and UI do not become tightly coupled to their implementation details.
|
||||
|
||||
## Database Concepts
|
||||
|
||||
Potential core tables:
|
||||
|
||||
- `users`
|
||||
- `user_identities`
|
||||
- `media_items`
|
||||
- `series`
|
||||
- `seasons`
|
||||
- `episodes`
|
||||
- `movies`
|
||||
- `external_ids`
|
||||
- `demand_sources`
|
||||
- `watchlist_items`
|
||||
- `watching_now_items`
|
||||
- `classics`
|
||||
- `admin_overrides`
|
||||
- `policy_runs`
|
||||
- `desired_states`
|
||||
- `observed_states`
|
||||
- `jobs`
|
||||
- `job_events`
|
||||
- `arr_instances`
|
||||
- `arr_mappings`
|
||||
- `archive_locations`
|
||||
- `storage_tiers`
|
||||
- `storage_availability`
|
||||
- `file_inventory`
|
||||
- `file_identity_groups`
|
||||
- `dedup_candidates`
|
||||
- `storage_actions`
|
||||
- `transcode_jobs`
|
||||
- `audit_events`
|
||||
|
||||
Important database properties:
|
||||
|
||||
- External IDs should be first-class, especially TVDB/TMDB/IMDb IDs.
|
||||
- Desired state should keep reason codes so the UI can explain decisions.
|
||||
- Jobs should be durable so sync work survives restarts.
|
||||
- File inventory should be rebuildable from scans.
|
||||
- File action history should survive rescans so deletion and rename decisions remain auditable.
|
||||
|
||||
## UI Concepts
|
||||
|
||||
Regular user views:
|
||||
|
||||
- Search and browse media.
|
||||
- Add/remove from personal lists.
|
||||
- Manage Watching Now slots.
|
||||
- See when an item is already covered by current-airing or recent-movie policy before using a slot.
|
||||
- See availability: available now, restoring, queued, archive-only, not available.
|
||||
- See basic quality/season availability without exposing automation internals.
|
||||
|
||||
Admin views:
|
||||
|
||||
- Demand dashboard.
|
||||
- Policy explanation per media item.
|
||||
- Current desired vs actual state.
|
||||
- Forked TV/movie automation reconciliation status.
|
||||
- Archive restore/evict queue.
|
||||
- Storage availability and protection gaps.
|
||||
- Dedup candidate review and pending storage actions.
|
||||
- Worker and transcode-agent status/logs.
|
||||
- User quotas and overrides.
|
||||
- Quality and retention rules.
|
||||
|
||||
The UI should be a real app, not a thin wrapper around backend automation tools.
|
||||
|
||||
UI priority:
|
||||
|
||||
- Build a utilitarian admin interface and a simple user interface in parallel.
|
||||
- The admin interface should expose correctness, jobs, policy decisions, and storage state.
|
||||
- The user interface should stay simple and Ombi-like, focused on search, requests, availability, and Watching Now.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0: Planning and Inventory
|
||||
|
||||
- Finalize policy rules.
|
||||
- Document current tv-manager behavior.
|
||||
- Decide database and migration tooling.
|
||||
- Choose the indexer backend (Jackett vs Prowlarr) and confirm active indexer coverage.
|
||||
- Plan the native TV/movie automation module boundaries.
|
||||
- Confirm Authentik OIDC claim names for `sticknife_users` and `sticknife_admins`.
|
||||
- Choose the Node.js framework and frontend architecture.
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
- Create the new Ampelos service skeleton under `/srv/Ampelos`.
|
||||
- Connect to Authentik for login.
|
||||
- Connect to the database on `mimir`.
|
||||
- Add migrations.
|
||||
- Add durable jobs.
|
||||
- Model storage tiers and storage availability.
|
||||
- Import or wrap current tv-manager scan/restore/archive functionality.
|
||||
- Start recording file inventory in the database.
|
||||
|
||||
### Phase 2: Demand and Policy Engine
|
||||
|
||||
- Model users, watchlists, Watching Now, and admin overrides.
|
||||
- Add TV policy computation.
|
||||
- Store desired state with reason codes.
|
||||
- Add admin explanation view.
|
||||
|
||||
### Phase 3: Native TV/Movie Automation
|
||||
|
||||
- Integrate TVDB and TMDB directly for series and movie metadata.
|
||||
- Integrate the chosen indexer backend (Jackett or Prowlarr) for release search.
|
||||
- Add release name parsing using `guessit` or equivalent.
|
||||
- Build release selection logic driven by Ampelos desired state and quality policy.
|
||||
- Add download client integration (qBittorrent, Transmission).
|
||||
- Build the import pipeline with full storage-tier awareness: correct tier routing, intentional variant support, and offline archive handling.
|
||||
- Add file organization and naming.
|
||||
- Add upgrade monitoring driven by the policy engine rather than external quality profiles.
|
||||
- Begin retiring Sonarr and Radarr once TV and movie automation reaches parity.
|
||||
|
||||
### Phase 4: Archive Operations
|
||||
|
||||
- Queue archive restores and evictions.
|
||||
- Integrate existing tv-manager move/restore workflows.
|
||||
- Add database-backed cross-storage dedup planning.
|
||||
- Add deferred rename/delete/copy actions for offline storage.
|
||||
- Add transcode-agent job coordination instead of local transcoding.
|
||||
- Add Bragi availability and sync-window behavior.
|
||||
- Add failure recovery and audit logs.
|
||||
|
||||
### Phase 5: User-Facing Request App
|
||||
|
||||
- Add Ombi-like search and request UX.
|
||||
- Add Watching Now management.
|
||||
- Add availability and queue status.
|
||||
- Add movie policy.
|
||||
- Add user notifications for items already covered by automatic live/recent rules.
|
||||
|
||||
### Phase 6: Broader Media and Deep Cleanup
|
||||
|
||||
- Preserve Ampelos policy engine as the source of truth.
|
||||
- Remove or retire replaced legacy services once feature parity is sufficient.
|
||||
- Evaluate subtitles, music, books, and audiobooks as later media-type modules.
|
||||
|
||||
## Risks and Design Constraints
|
||||
|
||||
- Building native TV/movie automation from scratch is substantial scope. Release name parsing, quality selection edge cases, and download client quirks represent years of accumulated handling in *arr. `guessit` covers most parsing, but expect edge cases.
|
||||
- The `automation.tv` and `automation.movies` modules should stay behind clean internal boundaries so implementation complexity does not leak into the policy engine and UI.
|
||||
- Archive restore and evict operations are destructive enough to require audit logs and dry-run tooling.
|
||||
- Filesystem scans alone are not enough for rich request UX; external metadata IDs need to be normalized early.
|
||||
- Bragi offline windows require durable queued work and clear UI status.
|
||||
- Backup/archive offline windows require deferred storage actions with explicit preconditions.
|
||||
- Multiple users can demand the same item for different reasons; policy must aggregate demand cleanly.
|
||||
- The new service should not inherit the current SQLite cache as persistent app state.
|
||||
- Deduplication across tiers can become destructive if identity matching is weak; early versions should favor flagging and review over aggressive deletion.
|
||||
- Main/live and backup have different trust and power profiles, so "duplicate" does not always mean "safe to delete."
|
||||
|
||||
## Resolved Early Decisions
|
||||
|
||||
1. Main product surface should be Node.js, with Python retained for backend hardware/filesystem agents where useful.
|
||||
2. Database should be PostgreSQL on `mimir`, using a single database named `ampelos`.
|
||||
3. Authentik groups are `sticknife_users` for normal users and `sticknife_admins` for full admins.
|
||||
4. Ampelos should build native TV/movie automation inspired by *arr concepts, not fork or API-adapt Sonarr/Radarr. The *arr data model cannot represent multi-tier storage, offline archive inventory, or intentional file variants.
|
||||
5. Dedup should start review-only during development, then graduate to active execution for proven safe rules.
|
||||
6. Watching Now quotas start at 5 TV shows and 10 movies per user.
|
||||
7. Users should be warned when adding items already covered by automatic current/recent rules, but not blocked.
|
||||
8. Movies newer than the configured recent threshold stay live; admin-managed classics stay live permanently.
|
||||
9. 4K/highest available quality is preferred for media marked live.
|
||||
10. Currently airing episodes and the most recent completed season stay live at highest available quality.
|
||||
11. The recently ended TV cutoff starts at one year.
|
||||
12. Bragi runs on a scheduled weekly cycle; users cannot trigger wake/sync.
|
||||
13. Ampelos needs both a utilitarian admin interface and a simple Ombi-like user interface.
|
||||
14. The Node.js framework is Next.js. The admin-heavy UI with server-side job state, file inventory, and policy data is a good fit for the App Router/RSC model.
|
||||
15. The database migration tool is Drizzle. The schema is complex enough to warrant readable plain-SQL migrations, and Drizzle provides TypeScript type safety in the Node app while the Python workers can consume the raw SQL files independently.
|
||||
16. The movie "recent" age threshold is 5 years, provisional until the resulting live footprint is measured.
|
||||
17. Watching Now promotes the entire show, never a single season. Recent-season availability for currently airing shows is handled by the automatic rule, not by user pinning.
|
||||
18. 4K/highest-available is a general live-tier preference, not movie-specific. It applies to any media marked live.
|
||||
19. **Plex watchlist** demand (the scraped list, `watchlist_items`) alone does not promote to live; it targets archive at 720p on a weekly cycle. This is distinct from **Watching Now** (`watching_now_items`), the quota-limited list managed in Ampelos, which does promote to live. Other rules (currently airing, Watching Now, admin override) can still place a watchlisted item live. "Default" in the Plex Watchlists section means exactly this, not "never Plex-facing".
|
||||
20. Move verification and dedup are two separate processes with different identity strategies:
|
||||
- **Move**: full hash verification before anything is deleted. Slow is acceptable; moves are infrequent after the initial reconciliation.
|
||||
- **Dedup**: decided from the canonical tier recorded in the tracking database, not from hashing.
|
||||
- If the canonical tier is archive and a copy reappears in live (redownloaded, then flagged for archival again), the live copy is deleted even when it is higher quality. Canonical placement wins over quality.
|
||||
- If duplication occurs within live/backup (an upgrade was acquired), the highest-quality copy is kept.
|
||||
21. Backup mirrors live. Mirror means live is a subset of backup, not that the two are identical: files present on backup but not live are never deleted, they are moved to live or archive according to the retention rules.
|
||||
22. Bragi is the bare-metal host that runs the edda and silenus guests. The archive sync model still applies at that level; the power on/off cycle is manual until automated.
|
||||
|
||||
## Remaining Decisions Needed
|
||||
|
||||
1. Which indexer backend should be primary: Jackett or Prowlarr? Jackett has better compatibility with some trackers; Prowlarr has a more modern management UI. One should be chosen and the other retired to avoid maintaining both.
|
||||
4. Which metadata provider should Ampelos use for search and status: TMDB, TVDB, or a combination? What is the fallback behavior when they disagree? (In practice TMDB is what is implemented and populated; TVDB is only reached by the smoke test.)
|
||||
5. How much of the existing `tv-manager` code should be reused directly versus moved behind a new archive adapter?
|
||||
7. How should the transcode agent claim work: database polling, Ampelos API, message queue, or a simpler job lease table? (Currently neither: `agent/ampelos-transcoder.mjs` walks and probes the archive itself.)
|
||||
|
||||
Resolved and moved to Resolved Early Decisions: 6 (see #20), 8 (see #16), 9 (see #17).
|
||||
@@ -0,0 +1,99 @@
|
||||
# ampelos-dashboard
|
||||
|
||||
The web face of Ampelos: inventory, calendar, requests, the link-verification
|
||||
panel, and the admin surfaces that drive the rest of the system. Next.js 16 App
|
||||
Router, Postgres via Drizzle, Authentik for sign-in.
|
||||
|
||||
It is also the **owner of the database schema**. See "Contracts" below, because
|
||||
that ownership is now split across two repositories.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env.local # then fill it in
|
||||
npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Production is a systemd unit (`ampelos.service`) running `next start` on :3000
|
||||
behind the reverse proxy for ampelos.sticknife.com.
|
||||
|
||||
Rebuilding in place is a single step, because `next build` replaces the build
|
||||
that `next start` is currently serving — do them together or the site is broken
|
||||
in between:
|
||||
|
||||
```bash
|
||||
npm run build && sudo systemctl restart ampelos.service
|
||||
```
|
||||
|
||||
## Contracts with ampelos-agent
|
||||
|
||||
The other half of the system is [ampelos-agent][agent]: acquisition, the
|
||||
filesystem maintenance jobs, and the satellites reporting in from other hosts.
|
||||
It does not import anything from this repository, and this repository does not
|
||||
import anything from it. They meet in exactly two places, and both are easy to
|
||||
break by accident now that they are versioned separately.
|
||||
|
||||
### 1. The database schema — owned here
|
||||
|
||||
`src/db/schema/` and `src/db/migrations/` are the definition. The agent speaks
|
||||
raw SQL against the same tables and holds no copy of the schema.
|
||||
|
||||
So a migration here can break the agent silently. The agent carries
|
||||
`scripts/check-schema.mjs`, a snapshot of the 24 tables and the load-bearing
|
||||
columns it reads; run it there after any migration that renames or removes
|
||||
something:
|
||||
|
||||
```bash
|
||||
cd ../ampelos-agent && npm run check:schema
|
||||
```
|
||||
|
||||
Adding tables and columns is always safe. Renaming and dropping are not.
|
||||
|
||||
### 2. Five HTTP endpoints
|
||||
|
||||
Three the agent calls here, and they are how the satellites report in:
|
||||
|
||||
| Endpoint | Caller | Breaks if this app is down |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/agents/heartbeat` | `ampelos-herald.sh` on each host | Heartbeats stop, so the maintenance container never mounts edda, so backup and archive jobs skip themselves |
|
||||
| `POST /api/agents/status` | `ampelos-mountd.sh` | Mount state stops being reported |
|
||||
| `POST /api/agents/corrupt-files` | `ampelos-transcoder.mjs` | Unreadable files are logged locally instead |
|
||||
|
||||
Two this app calls there, via `AMPELOS_INDEXER_URL` (see `src/lib/indexer.ts`):
|
||||
|
||||
| Call | Used by | Breaks if the indexer is down |
|
||||
| --- | --- | --- |
|
||||
| search releases | `admin/search-actions.ts` | The manual search panel returns an error |
|
||||
| grab release | `admin/search-actions.ts` | Manual grabs fail; the automatic loop is unaffected |
|
||||
|
||||
Both directions authenticate with `AMPELOS_AGENT_TOKEN`, which must be the same
|
||||
value in both repositories' environments and belongs in neither's git history.
|
||||
|
||||
### What still works when the other half is stopped
|
||||
|
||||
Everything here reads from Postgres, so with the agent stopped the dashboard
|
||||
renders normally — inventory, calendar, verification — it simply goes stale, and
|
||||
manual search and grab fail. With the dashboard stopped, the agent keeps
|
||||
downloading, importing, scanning and reaping; only the edda-mounted tiers pause,
|
||||
for the heartbeat reason above.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/app/(admin)/` — the admin surfaces. Server Actions are reachable by
|
||||
direct POST, so every one of them checks the session itself.
|
||||
- `src/db/` — schema, migrations, client.
|
||||
- `media/` — source brand art. `public/` holds what the app actually serves.
|
||||
|
||||
The TrueNAS health-broadcast timer used to live here under `deploy/`. It posts
|
||||
to the scan listener on :3427, which is an agent script, so it moved to
|
||||
`ampelos-agent/deploy/truenas/` with the thing it talks to.
|
||||
|
||||
## Working on it
|
||||
|
||||
Read `AGENTS.md` first. Next.js 16 differs from what a model is likely to
|
||||
remember, and the local documentation in `node_modules/next/dist/docs/` is the
|
||||
authority.
|
||||
|
||||
[agent]: ../ampelos-agent
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema/index.ts",
|
||||
out: "./src/db/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["10.24.88.95", "ampelos.sticknife.com"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "image.tmdb.org",
|
||||
pathname: "/t/p/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "ampelos-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The web face of Ampelos, and the owner of the database schema. The scanners, probes and repair jobs that used to live in scripts/ are now ampelos-agent, which runs without this.",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "node --env-file=.env.local node_modules/.bin/drizzle-kit generate",
|
||||
"db:migrate": "node --env-file=.env.local node_modules/.bin/drizzle-kit migrate",
|
||||
"db:studio": "node --env-file=.env.local node_modules/.bin/drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.9",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"pg": "^8.21.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 761 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 230 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
// The sidebar. A client component for one reason: the current path is the only
|
||||
// thing here that cannot be known on the server, and without it every nav item
|
||||
// looks identical no matter which page you are on.
|
||||
//
|
||||
// It sets aria-current="page", which is both the accessible signal and what the
|
||||
// selected styling in globals.css keys off, so the two cannot disagree.
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type NavItem = { href: string; label: string };
|
||||
|
||||
function isCurrent(pathname: string, href: string) {
|
||||
// /admin is a prefix of every other admin route, so it only matches exactly.
|
||||
// Everything else matches its subtree, so /admin/inventory/series/<id> still
|
||||
// highlights Inventory.
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname === href || pathname.startsWith(href + "/");
|
||||
}
|
||||
|
||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{items.map((item) => {
|
||||
const current = isCurrent(pathname, item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={current ? "page" : undefined}
|
||||
className="admin-nav-button block px-3 py-2 font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import { db } from "@/db/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { ManualSearch } from "../manual-search";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type CalendarRow = {
|
||||
kind: "episode" | "movie";
|
||||
media_item_id: string;
|
||||
title: string;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
episode_title: string | null;
|
||||
date: string;
|
||||
has_file: boolean;
|
||||
grab_status: string | null;
|
||||
wanted: boolean;
|
||||
};
|
||||
|
||||
// The agenda view's window. Backwards matters as much as forwards: the useful
|
||||
// question is rarely "what airs next month", it is "what aired last week that I
|
||||
// still do not have".
|
||||
const DAYS_BEHIND = 14;
|
||||
const DAYS_AHEAD = 28;
|
||||
|
||||
// Weeks start on Monday, so a weekend reads as the block it is rather than
|
||||
// being split across two rows.
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
// Beyond this a cell would grow tall enough to break the grid's rhythm, so the
|
||||
// rest are counted and reachable in the agenda.
|
||||
const MAX_PER_CELL = 4;
|
||||
|
||||
function singleParam(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dates
|
||||
//
|
||||
// All of this is done in UTC on 'YYYY-MM-DD' strings. The database hands back
|
||||
// days, not instants, and dragging them through a local-timezone Date is how
|
||||
// an episode ends up on the wrong side of midnight.
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function toYmd(value: number) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fromYmd(value: string) {
|
||||
return Date.parse(value + "T00:00:00Z");
|
||||
}
|
||||
|
||||
/** Monday-based index: Monday 0 … Sunday 6. */
|
||||
function weekdayIndex(value: number) {
|
||||
return (new Date(value).getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function addDays(value: number, days: number) {
|
||||
return value + days * DAY_MS;
|
||||
}
|
||||
|
||||
function monthKey(value: string) {
|
||||
return value.slice(0, 7);
|
||||
}
|
||||
|
||||
/** Shift a 'YYYY-MM' key by whole months, without touching day-of-month. */
|
||||
function shiftMonth(key: string, delta: number) {
|
||||
const [year, month] = key.split("-").map(Number);
|
||||
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||
return shifted.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
function parseMonthParam(value: string | undefined, fallback: string) {
|
||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full grid for a month: whole weeks, so it starts on the Monday on or
|
||||
* before the 1st and ends on the Sunday on or after the last day.
|
||||
*/
|
||||
function monthGridRange(key: string) {
|
||||
const [year, month] = key.split("-").map(Number);
|
||||
const first = Date.UTC(year, month - 1, 1);
|
||||
const last = Date.UTC(year, month, 0);
|
||||
return {
|
||||
from: toYmd(addDays(first, -weekdayIndex(first))),
|
||||
to: toYmd(addDays(last, 6 - weekdayIndex(last))),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
|
||||
/**
|
||||
* Everything dated in a range, and what the library has of it.
|
||||
*
|
||||
* Presence, grab state and wantedness are scalar subqueries rather than joins.
|
||||
* A title can easily have several live files, several desired_states rows (a
|
||||
* series-wide rule plus an episode override) and more than one grab, and any of
|
||||
* those as a join would silently duplicate calendar entries.
|
||||
*/
|
||||
function calendarQuery(from: string, to: string) {
|
||||
return sql`
|
||||
with in_window as (
|
||||
select
|
||||
'episode'::text as kind,
|
||||
mi.id as media_item_id,
|
||||
mi.title,
|
||||
s.season_number,
|
||||
e.episode_number,
|
||||
e.title as episode_title,
|
||||
-- Formatted in SQL, not in JS. node-postgres hands back a Date object
|
||||
-- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts
|
||||
-- and compares as nonsense against an ISO day -- which quietly labelled
|
||||
-- every past episode as still upcoming.
|
||||
to_char(e.air_date, 'YYYY-MM-DD') as date,
|
||||
exists (
|
||||
select 1 from storage_files sf
|
||||
join storage_tiers t on t.id = sf.tier_id
|
||||
where sf.episode_id = e.id and t.tier = 'live' and sf.missing_at is null
|
||||
) as has_file,
|
||||
(
|
||||
select g.status from grabs g
|
||||
where g.media_item_id = mi.id
|
||||
and coalesce(g.season_number, s.season_number) = s.season_number
|
||||
and coalesce(g.episode_number, e.episode_number) = e.episode_number
|
||||
and g.status in ('queued','downloading','completed','importing')
|
||||
order by g.created_at desc
|
||||
limit 1
|
||||
) as grab_status,
|
||||
exists (
|
||||
select 1 from desired_states ds
|
||||
where ds.media_item_id = mi.id
|
||||
and (ds.season_number is null or ds.season_number = s.season_number)
|
||||
and (ds.episode_number is null or ds.episode_number = e.episode_number)
|
||||
and ds.wanted
|
||||
-- An episode that has not aired is ALWAYS unmonitored: the
|
||||
-- classifier sets monitored = false so the fetcher does not chase
|
||||
-- a release that cannot exist yet. Requiring monitored here read
|
||||
-- that as "we do not care about it" and emptied the calendar of
|
||||
-- everything after today -- 123 upcoming episodes, every one of
|
||||
-- them wanted. Monitored still decides what shows for episodes
|
||||
-- that HAVE aired, where false really does mean stopped caring.
|
||||
and (ds.monitored or e.air_date > current_date)
|
||||
) as wanted
|
||||
from episodes e
|
||||
join seasons s on s.id = e.season_id
|
||||
join series se on se.id = s.series_id
|
||||
join media_items mi on mi.id = se.id
|
||||
where e.air_date between ${from}::date and ${to}::date
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
'movie'::text,
|
||||
mi.id,
|
||||
mi.title,
|
||||
null::int,
|
||||
null::int,
|
||||
null::text,
|
||||
to_char(m.release_date, 'YYYY-MM-DD'),
|
||||
exists (
|
||||
select 1 from storage_files sf
|
||||
join storage_tiers t on t.id = sf.tier_id
|
||||
where sf.media_item_id = mi.id and t.tier = 'live' and sf.missing_at is null
|
||||
),
|
||||
(
|
||||
select g.status from grabs g
|
||||
where g.media_item_id = mi.id
|
||||
and g.status in ('queued','downloading','completed','importing')
|
||||
order by g.created_at desc
|
||||
limit 1
|
||||
),
|
||||
exists (
|
||||
select 1 from desired_states ds
|
||||
where ds.media_item_id = mi.id and ds.wanted
|
||||
-- Same rule for a film that is not out yet.
|
||||
and (ds.monitored or m.release_date > current_date)
|
||||
)
|
||||
from movies m
|
||||
join media_items mi on mi.id = m.id
|
||||
where m.release_date between ${from}::date and ${to}::date
|
||||
)
|
||||
select * from in_window
|
||||
order by date asc, title asc, season_number asc, episode_number asc
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presentation
|
||||
|
||||
// "Missing" is only alarming once the thing has actually aired, which is why
|
||||
// the state is decided against today rather than from presence alone.
|
||||
function stateOf(row: CalendarRow, today: string) {
|
||||
if (row.has_file) return "have" as const;
|
||||
if (row.grab_status) return "grabbing" as const;
|
||||
if (row.date > today) return "upcoming" as const;
|
||||
return "missing" as const;
|
||||
}
|
||||
|
||||
type EntryState = ReturnType<typeof stateOf>;
|
||||
|
||||
const STATE_BADGE: Record<EntryState, string> = {
|
||||
have: "border-admin-good text-admin-good",
|
||||
grabbing: "border-admin-accent text-admin-accent",
|
||||
upcoming: "border-admin-line text-admin-muted",
|
||||
missing: "border-admin-warn text-admin-warn",
|
||||
};
|
||||
|
||||
// In the grid there is no room for a badge, so state is carried by a colour bar
|
||||
// down the leading edge of each chip.
|
||||
const STATE_BAR: Record<EntryState, string> = {
|
||||
have: "border-l-2 border-admin-good",
|
||||
grabbing: "border-l-2 border-admin-accent",
|
||||
upcoming: "border-l-2 border-admin-line",
|
||||
missing: "border-l-2 border-admin-warn",
|
||||
};
|
||||
|
||||
const STATE_LABEL: Record<EntryState, string> = {
|
||||
have: "On live",
|
||||
grabbing: "Downloading",
|
||||
upcoming: "Expected",
|
||||
missing: "Missing",
|
||||
};
|
||||
|
||||
function entryCode(row: CalendarRow) {
|
||||
return row.kind === "episode"
|
||||
? "S" + String(row.season_number ?? 0).padStart(2, "0") +
|
||||
"E" + String(row.episode_number ?? 0).padStart(2, "0")
|
||||
: "Film";
|
||||
}
|
||||
|
||||
function entryHref(row: CalendarRow) {
|
||||
return row.kind === "episode"
|
||||
? "/admin/inventory/series/" + row.media_item_id
|
||||
: "/admin/inventory?q=" + encodeURIComponent(row.title);
|
||||
}
|
||||
|
||||
function dayHeading(date: string, today: string) {
|
||||
const label = new Date(date + "T00:00:00Z").toLocaleDateString("en-GB", {
|
||||
weekday: "long", day: "numeric", month: "long", timeZone: "UTC",
|
||||
});
|
||||
return date === today ? label + " — today" : label;
|
||||
}
|
||||
|
||||
function monthLabel(key: string) {
|
||||
return new Date(key + "-01T00:00:00Z").toLocaleDateString("en-GB", {
|
||||
month: "long", year: "numeric", timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
function hrefFor(params: { view?: string; month?: string; filter?: string }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.view && params.view !== "month") search.set("view", params.view);
|
||||
if (params.month) search.set("month", params.month);
|
||||
if (params.filter === "all") search.set("filter", "all");
|
||||
const value = search.toString();
|
||||
return value ? "/admin/calendar?" + value : "/admin/calendar";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function AdminCalendarPage({ searchParams }: PageProps) {
|
||||
const resolved = (await searchParams) ?? {};
|
||||
const monitoredOnly = singleParam(resolved.filter) !== "all";
|
||||
const view = singleParam(resolved.view) === "agenda" ? "agenda" : "month";
|
||||
|
||||
// Today comes from the database's clock, not the server's, so the "today"
|
||||
// marker agrees with the air dates the same database supplied.
|
||||
const { rows: [{ today }] } = await db.execute<{ today: string }>(
|
||||
sql`select to_char(current_date, 'YYYY-MM-DD') as today`,
|
||||
);
|
||||
|
||||
const month = parseMonthParam(singleParam(resolved.month), monthKey(today));
|
||||
const range = view === "month"
|
||||
? monthGridRange(month)
|
||||
: {
|
||||
from: toYmd(addDays(fromYmd(today), -DAYS_BEHIND)),
|
||||
to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)),
|
||||
};
|
||||
|
||||
const { rows } = await db.execute<CalendarRow>(calendarQuery(range.from, range.to));
|
||||
|
||||
// "Monitored" keeps anything we want, already hold, or are fetching. A title
|
||||
// that is present but no longer monitored still belongs on the calendar --
|
||||
// hiding it would make the library look emptier than it is.
|
||||
const visible = monitoredOnly
|
||||
? rows.filter((row) => row.wanted || row.has_file || row.grab_status)
|
||||
: rows;
|
||||
|
||||
const byDay = new Map<string, CalendarRow[]>();
|
||||
for (const row of visible) {
|
||||
const list = byDay.get(row.date);
|
||||
if (list) list.push(row);
|
||||
else byDay.set(row.date, [row]);
|
||||
}
|
||||
|
||||
const missingCount = visible.filter((row) => stateOf(row, today) === "missing").length;
|
||||
const upcomingCount = visible.filter((row) => stateOf(row, today) === "upcoming").length;
|
||||
|
||||
const filter = monitoredOnly ? "monitored" : "all";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Release Calendar</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
When episodes and films are due, and what the library actually holds of each.{" "}
|
||||
{upcomingCount} still to come,{" "}
|
||||
<span className={missingCount ? "text-admin-warn" : undefined}>
|
||||
{missingCount} aired and missing
|
||||
</span>
|
||||
{view === "month" ? " this month" : " in the next " + DAYS_AHEAD + " days"}.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/downloads" className="admin-nav-button px-3 py-2 text-sm font-medium">
|
||||
Downloads
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">View</span>
|
||||
{[
|
||||
{ value: "month", label: "Month", blurb: "a calendar grid, one cell per day" },
|
||||
{ value: "agenda", label: "Agenda", blurb: "a dated list with search buttons" },
|
||||
].map((option) => (
|
||||
<Link
|
||||
key={option.value}
|
||||
prefetch={false}
|
||||
href={hrefFor({ view: option.value, month: option.value === "month" ? month : undefined, filter })}
|
||||
aria-current={option.value === view ? "true" : undefined}
|
||||
title={"Show " + option.blurb}
|
||||
className={
|
||||
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
|
||||
(option.value === view ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Show</span>
|
||||
{[
|
||||
{ value: "monitored", label: "Monitored", blurb: "only what is wanted, held, or downloading" },
|
||||
{ value: "all", label: "Everything dated", blurb: "every episode and film with a date" },
|
||||
].map((option) => (
|
||||
<Link
|
||||
key={option.value}
|
||||
prefetch={false}
|
||||
href={hrefFor({ view, month: view === "month" ? month : undefined, filter: option.value })}
|
||||
aria-current={option.value === filter ? "true" : undefined}
|
||||
title={"Show " + option.blurb}
|
||||
className={
|
||||
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
|
||||
(option.value === filter ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The legend is what makes the grid readable at all: in a cell there is
|
||||
no room to spell out a state, so the colour bar has to be decodable. */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-admin-muted">
|
||||
{(Object.keys(STATE_LABEL) as EntryState[]).map((state) => (
|
||||
<span key={state} className="flex items-center gap-1.5">
|
||||
<span className={"inline-block h-3 w-0 " + STATE_BAR[state]} aria-hidden />
|
||||
{STATE_LABEL[state]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "month" ? (
|
||||
<MonthGrid month={month} today={today} byDay={byDay} filter={filter} />
|
||||
) : (
|
||||
<Agenda byDay={byDay} today={today} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MonthGrid({
|
||||
month,
|
||||
today,
|
||||
byDay,
|
||||
filter,
|
||||
}: {
|
||||
month: string;
|
||||
today: string;
|
||||
byDay: Map<string, CalendarRow[]>;
|
||||
filter: string;
|
||||
}) {
|
||||
const { from, to } = monthGridRange(month);
|
||||
const start = fromYmd(from);
|
||||
const cellCount = Math.round((fromYmd(to) - start) / DAY_MS) + 1;
|
||||
const days = Array.from({ length: cellCount }, (_, index) => toYmd(addDays(start, index)));
|
||||
|
||||
return (
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-5 py-3">
|
||||
<h2 className="font-serif text-xl font-semibold">{monthLabel(month)}</h2>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link prefetch={false} href={hrefFor({ month: shiftMonth(month, -1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
|
||||
← Previous
|
||||
</Link>
|
||||
<Link prefetch={false} href={hrefFor({ month: monthKey(today), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
|
||||
Today
|
||||
</Link>
|
||||
<Link prefetch={false} href={hrefFor({ month: shiftMonth(month, 1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
|
||||
Next →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrolls rather than squeezing: seven readable columns matter more than
|
||||
fitting a narrow window, and the page body must not scroll sideways. */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[56rem]">
|
||||
<div className="grid grid-cols-7 border-b border-admin-line bg-admin-subpanel">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div key={day} className="px-2 py-2 text-center text-[10px] font-semibold uppercase tracking-[0.18em] text-admin-muted">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((date, index) => {
|
||||
const entries = byDay.get(date) ?? [];
|
||||
const inMonth = monthKey(date) === month;
|
||||
const isToday = date === today;
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className={
|
||||
"min-h-28 border-b border-r border-admin-line p-1.5 " +
|
||||
// The last column has the panel edge; the last row has the
|
||||
// panel bottom. Both would otherwise double up.
|
||||
(index % 7 === 6 ? "border-r-0 " : "") +
|
||||
(inMonth ? "" : "opacity-45 ") +
|
||||
(isToday ? "bg-admin-missing" : "")
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className={"text-xs " + (isToday ? "font-bold text-admin-accent" : "text-admin-muted")}>
|
||||
{Number(date.slice(8, 10))}
|
||||
</span>
|
||||
{entries.length > MAX_PER_CELL ? (
|
||||
<span className="text-[10px] text-admin-muted">{entries.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{entries.slice(0, MAX_PER_CELL).map((row) => {
|
||||
const state = stateOf(row, today);
|
||||
return (
|
||||
<Link
|
||||
key={row.kind + row.media_item_id + entryCode(row)}
|
||||
href={entryHref(row)}
|
||||
title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] +
|
||||
(row.episode_title ? "\n" + row.episode_title : "")}
|
||||
className={
|
||||
"block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " +
|
||||
STATE_BAR[state]
|
||||
}
|
||||
>
|
||||
<span className="block truncate text-[11px] text-admin-text">{row.title}</span>
|
||||
<span className="block truncate text-[10px] text-admin-muted">{entryCode(row)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{entries.length > MAX_PER_CELL ? (
|
||||
<Link
|
||||
href={hrefFor({ view: "agenda", filter })}
|
||||
className="block px-1.5 text-[10px] text-admin-accent hover:underline"
|
||||
>
|
||||
+{entries.length - MAX_PER_CELL} more
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The dated list. This is the view that can act: a grid cell has no room for a
|
||||
* search panel, so grabbing something lives here.
|
||||
*/
|
||||
function Agenda({ byDay, today }: { byDay: Map<string, CalendarRow[]>; today: string }) {
|
||||
const days = [...byDay.entries()];
|
||||
|
||||
if (!days.length) {
|
||||
return (
|
||||
<section className="admin-panel">
|
||||
<p className="p-5 text-sm text-admin-muted">
|
||||
Nothing is dated in this window. Try “Everything dated” — titles that are not monitored are hidden.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="divide-y divide-admin-line">
|
||||
{days.map(([date, entries]) => (
|
||||
<div key={date} className={date === today ? "bg-admin-subpanel" : ""}>
|
||||
<div className="flex items-baseline justify-between border-b border-admin-line px-5 py-2">
|
||||
<h2 className={"font-serif text-lg font-semibold " + (date === today ? "text-admin-accent" : "")}>
|
||||
{dayHeading(date, today)}
|
||||
</h2>
|
||||
<span className="text-xs text-admin-muted">{entries.length} scheduled</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-admin-line">
|
||||
{entries.map((row) => {
|
||||
const state = stateOf(row, today);
|
||||
const code = entryCode(row);
|
||||
return (
|
||||
<div
|
||||
key={row.kind + row.media_item_id + code}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 text-sm"
|
||||
>
|
||||
<span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span>
|
||||
|
||||
<Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent">
|
||||
{row.title}
|
||||
</Link>
|
||||
|
||||
{row.episode_title ? (
|
||||
<span className="min-w-0 truncate text-admin-muted">{row.episode_title}</span>
|
||||
) : null}
|
||||
|
||||
<span className={"ml-auto rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide " + STATE_BADGE[state]}>
|
||||
{STATE_LABEL[state]}
|
||||
</span>
|
||||
|
||||
{/* Searching for something unaired would ask indexers for a
|
||||
release that does not exist, so the button only appears
|
||||
once there is something to find. */}
|
||||
{state === "missing" ? (
|
||||
<div className="w-full">
|
||||
<ManualSearch
|
||||
mediaItemId={row.media_item_id}
|
||||
seasonNumber={row.season_number}
|
||||
episodeNumber={row.episode_number}
|
||||
label={row.title + " " + code}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { corruptFiles } from "@/db/schema";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
if (!session.user.isAdmin) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
// Approving does not delete anything here. It records the human decision; the
|
||||
// reporting agent picks the row up and performs the removal, because Ampelos
|
||||
// mounts these tiers read-only on purpose.
|
||||
export async function approveCorruptFile(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return;
|
||||
|
||||
await db
|
||||
.update(corruptFiles)
|
||||
.set({ status: "approved", reviewedBy: session.user.id, reviewedAt: new Date() })
|
||||
.where(and(eq(corruptFiles.id, id), inArray(corruptFiles.status, ["pending", "dismissed"])));
|
||||
|
||||
revalidatePath("/admin/corrupt");
|
||||
}
|
||||
|
||||
export async function dismissCorruptFile(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return;
|
||||
|
||||
// Dismissed rows are not re-flagged by later agent reports, so a file judged
|
||||
// fine stays quiet instead of reappearing every run.
|
||||
await db
|
||||
.update(corruptFiles)
|
||||
.set({ status: "dismissed", reviewedBy: session.user.id, reviewedAt: new Date() })
|
||||
.where(and(eq(corruptFiles.id, id), inArray(corruptFiles.status, ["pending", "approved", "failed"])));
|
||||
|
||||
revalidatePath("/admin/corrupt");
|
||||
}
|
||||
|
||||
export async function approveAllPending() {
|
||||
const session = await requireAdmin();
|
||||
|
||||
// Deliberately scoped to rows that are pending right now. Anything an agent
|
||||
// reports after this click still needs its own approval.
|
||||
await db
|
||||
.update(corruptFiles)
|
||||
.set({ status: "approved", reviewedBy: session.user.id, reviewedAt: new Date() })
|
||||
.where(eq(corruptFiles.status, "pending"));
|
||||
|
||||
revalidatePath("/admin/corrupt");
|
||||
}
|
||||
|
||||
export async function retryFailedDeletion(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return;
|
||||
|
||||
await db
|
||||
.update(corruptFiles)
|
||||
.set({ status: "approved", deleteError: null })
|
||||
.where(and(eq(corruptFiles.id, id), eq(corruptFiles.status, "failed")));
|
||||
|
||||
revalidatePath("/admin/corrupt");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { db } from "@/db/client";
|
||||
import { corruptFiles, users } from "@/db/schema";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { approveAllPending, approveCorruptFile, dismissCorruptFile, retryFailedDeletion } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatBytes(value: bigint | null) {
|
||||
const bytes = Number(value ?? 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "unknown";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
pending: "border-admin-warn text-admin-warn",
|
||||
approved: "border-admin-accent text-admin-accent",
|
||||
deleted: "border-admin-line text-admin-muted",
|
||||
dismissed: "border-admin-line text-admin-muted",
|
||||
failed: "border-admin-warn text-admin-warn",
|
||||
};
|
||||
|
||||
export default async function AdminCorruptPage() {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: corruptFiles.id,
|
||||
fullPath: corruptFiles.fullPath,
|
||||
agent: corruptFiles.agent,
|
||||
tier: corruptFiles.tier,
|
||||
reason: corruptFiles.reason,
|
||||
sizeBytes: corruptFiles.sizeBytes,
|
||||
status: corruptFiles.status,
|
||||
firstDetectedAt: corruptFiles.firstDetectedAt,
|
||||
lastDetectedAt: corruptFiles.lastDetectedAt,
|
||||
reviewedAt: corruptFiles.reviewedAt,
|
||||
deletedAt: corruptFiles.deletedAt,
|
||||
deleteError: corruptFiles.deleteError,
|
||||
reviewerName: users.displayName,
|
||||
})
|
||||
.from(corruptFiles)
|
||||
.leftJoin(users, eq(users.id, corruptFiles.reviewedBy))
|
||||
.orderBy(desc(corruptFiles.lastDetectedAt))
|
||||
.limit(500);
|
||||
|
||||
const [counts] = await db
|
||||
.select({
|
||||
pending: sql<number>`count(*) filter (where ${corruptFiles.status} = 'pending')::int`,
|
||||
approved: sql<number>`count(*) filter (where ${corruptFiles.status} = 'approved')::int`,
|
||||
deleted: sql<number>`count(*) filter (where ${corruptFiles.status} = 'deleted')::int`,
|
||||
failed: sql<number>`count(*) filter (where ${corruptFiles.status} = 'failed')::int`,
|
||||
reclaimable: sql<string>`coalesce(sum(${corruptFiles.sizeBytes}) filter (where ${corruptFiles.status} in ('pending','approved')), 0)::text`,
|
||||
})
|
||||
.from(corruptFiles);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Integrity</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Corrupt Files</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
Files an agent could not read — either the container will not decode, or ffprobe cannot open it at all.
|
||||
Approving records your decision; the agent that reported the file performs the deletion, because Ampelos
|
||||
mounts these tiers read-only so no scan can damage the backup. Nothing is removed without an approval here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
{ label: "Awaiting review", value: counts?.pending ?? 0 },
|
||||
{ label: "Approved, not yet deleted", value: counts?.approved ?? 0 },
|
||||
{ label: "Deleted", value: counts?.deleted ?? 0 },
|
||||
{ label: "Delete failed", value: counts?.failed ?? 0 },
|
||||
{ label: "Space reclaimable", value: formatBytes(BigInt(counts?.reclaimable ?? "0")) },
|
||||
].map((tile) => (
|
||||
<div key={tile.label} className="admin-panel p-4">
|
||||
<p className="text-xs text-admin-muted">{tile.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{tile.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{counts?.pending ? (
|
||||
<form action={approveAllPending} className="admin-panel flex flex-wrap items-center gap-3 p-4">
|
||||
<p className="text-sm text-admin-muted">
|
||||
Approve all {counts.pending} files currently awaiting review. Anything reported after this still needs its own approval.
|
||||
</p>
|
||||
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
|
||||
Approve all pending
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Reported files</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-admin-line">
|
||||
{rows.length ? rows.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 px-5 py-4 xl:grid-cols-[1fr_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={"rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[row.status] ?? "border-admin-line text-admin-muted")}>
|
||||
{row.status}
|
||||
</span>
|
||||
<span className="text-xs text-admin-muted">{row.agent}{row.tier ? ` · ${row.tier}` : ""}</span>
|
||||
<span className="text-xs text-admin-muted">{formatBytes(row.sizeBytes)}</span>
|
||||
</div>
|
||||
<p className="mt-2 break-all text-sm">{row.fullPath}</p>
|
||||
<p className="mt-1 break-all text-xs text-admin-muted">{row.reason}</p>
|
||||
<p className="mt-1 text-xs text-admin-muted">
|
||||
first seen {formatDate(row.firstDetectedAt)} · last seen {formatDate(row.lastDetectedAt)}
|
||||
{row.reviewedAt ? ` · reviewed ${formatDate(row.reviewedAt)}${row.reviewerName ? " by " + row.reviewerName : ""}` : ""}
|
||||
{row.deletedAt ? ` · deleted ${formatDate(row.deletedAt)}` : ""}
|
||||
</p>
|
||||
{row.deleteError ? (
|
||||
<p className="mt-1 break-all text-xs text-admin-warn">delete failed: {row.deleteError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-start gap-2">
|
||||
{row.status === "pending" ? (
|
||||
<>
|
||||
<form action={approveCorruptFile}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Approve deletion</button>
|
||||
</form>
|
||||
<form action={dismissCorruptFile}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Keep</button>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
{row.status === "approved" ? (
|
||||
<form action={dismissCorruptFile}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Cancel approval</button>
|
||||
</form>
|
||||
) : null}
|
||||
{row.status === "failed" ? (
|
||||
<form action={retryFailedDeletion}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Retry deletion</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="p-5 text-sm text-admin-muted">No agent has reported an unreadable file. Good.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { db } from "@/db/client";
|
||||
import { mediaItems, users, watchingNowItems, watchlistItems } from "@/db/schema";
|
||||
import { desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(value: Date) {
|
||||
return value.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminDemandPage() {
|
||||
const [watchingNow, watchlist] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: watchingNowItems.id,
|
||||
title: mediaItems.title,
|
||||
mediaType: mediaItems.mediaType,
|
||||
year: mediaItems.year,
|
||||
displayName: users.displayName,
|
||||
scope: watchingNowItems.scope,
|
||||
seasonNumber: watchingNowItems.seasonNumber,
|
||||
slotNumber: watchingNowItems.slotNumber,
|
||||
addedAt: watchingNowItems.addedAt,
|
||||
})
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(users, eq(users.id, watchingNowItems.userId))
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(isNull(watchingNowItems.removedAt))
|
||||
.orderBy(desc(watchingNowItems.addedAt))
|
||||
.limit(100),
|
||||
db
|
||||
.select({
|
||||
id: watchlistItems.id,
|
||||
title: mediaItems.title,
|
||||
mediaType: mediaItems.mediaType,
|
||||
year: mediaItems.year,
|
||||
displayName: users.displayName,
|
||||
source: watchlistItems.source,
|
||||
addedAt: watchlistItems.addedAt,
|
||||
})
|
||||
.from(watchlistItems)
|
||||
.innerJoin(users, eq(users.id, watchlistItems.userId))
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchlistItems.mediaItemId))
|
||||
.where(isNull(watchlistItems.removedAt))
|
||||
.orderBy(desc(watchlistItems.addedAt))
|
||||
.limit(100),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Intent</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Demand</h1>
|
||||
</div>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Watching Now</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{watchingNow.length ? watchingNow.map((item) => (
|
||||
<div key={item.id} className="grid gap-3 px-5 py-4 md:grid-cols-[1fr_0.8fr_0.7fr_0.5fr_0.8fr] md:items-center">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}{item.year ? " (" + item.year + ")" : ""}</p>
|
||||
<p className="text-sm text-admin-muted">{item.mediaType === "movie" ? "Movie" : "TV"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-admin-muted">{item.displayName}</p>
|
||||
<p className="text-sm text-admin-muted">{item.scope}{item.seasonNumber ? " season " + item.seasonNumber : ""}</p>
|
||||
<p className="text-sm text-admin-muted">{item.slotNumber ? "Slot " + item.slotNumber : "No slot"}</p>
|
||||
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
|
||||
</div>
|
||||
)) : <p className="p-5 text-sm text-admin-muted">No active Watching Now demand.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Watchlist</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{watchlist.length ? watchlist.map((item) => (
|
||||
<div key={item.id} className="grid gap-3 px-5 py-4 md:grid-cols-[1fr_0.8fr_0.6fr_0.8fr] md:items-center">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}{item.year ? " (" + item.year + ")" : ""}</p>
|
||||
<p className="text-sm text-admin-muted">{item.mediaType === "movie" ? "Movie" : "TV"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-admin-muted">{item.displayName}</p>
|
||||
<p className="text-sm text-admin-muted">{item.source}</p>
|
||||
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
|
||||
</div>
|
||||
)) : <p className="p-5 text-sm text-admin-muted">No active watchlist demand.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use server";
|
||||
|
||||
// Server actions for the Downloads page.
|
||||
//
|
||||
// Every one of these re-checks the session. Server Actions are reachable by
|
||||
// direct POST, not only through the buttons on the page, so the layout's admin
|
||||
// guard is not sufficient protection on its own -- it only governs rendering.
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { grabs, releaseBlocklist } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { pauseTorrent, resumeTorrent, removeTorrent } from "@/lib/qbittorrent";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only act on torrents Ampelos grabbed.
|
||||
*
|
||||
* The page can only render our own grabs, so a hash arriving here that has no
|
||||
* row is either a stale form or someone posting by hand. Either way it must not
|
||||
* reach qBittorrent: /Niflheim/Downloads also holds torrents the user added
|
||||
* themselves, and this endpoint is not going to be the thing that deletes one.
|
||||
*/
|
||||
async function requireOwnGrab(infoHash: string) {
|
||||
const [grab] = await db.select().from(grabs).where(eq(grabs.infoHash, infoHash)).limit(1);
|
||||
if (!grab) throw new Error("No grab recorded for that torrent");
|
||||
return grab;
|
||||
}
|
||||
|
||||
export async function pauseAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const infoHash = String(formData.get("infoHash") ?? "");
|
||||
await requireOwnGrab(infoHash);
|
||||
await pauseTorrent(infoHash);
|
||||
revalidatePath("/admin/downloads");
|
||||
}
|
||||
|
||||
export async function resumeAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const infoHash = String(formData.get("infoHash") ?? "");
|
||||
await requireOwnGrab(infoHash);
|
||||
await resumeTorrent(infoHash);
|
||||
revalidatePath("/admin/downloads");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop seeding and drop the download-side copy.
|
||||
*
|
||||
* Safe after import: that copy is a hardlink, so deleting it leaves the library
|
||||
* entry pointing at the same inode. Before import it is the only copy, and the
|
||||
* page labels the button accordingly rather than relying on the user to
|
||||
* remember which state the grab is in.
|
||||
*/
|
||||
export async function removeAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const infoHash = String(formData.get("infoHash") ?? "");
|
||||
const grab = await requireOwnGrab(infoHash);
|
||||
|
||||
const imported = grab.status === "imported";
|
||||
await removeTorrent(infoHash, true);
|
||||
|
||||
await db
|
||||
.update(grabs)
|
||||
.set({
|
||||
// An imported grab has done its job; the seed is simply released early.
|
||||
// A grab removed before import has been abandoned.
|
||||
status: imported ? "imported" : "failed",
|
||||
statusDetail: imported
|
||||
? "seed released from the dashboard"
|
||||
: "removed from the dashboard before import",
|
||||
seedReleasedAt: sql`now() at time zone 'utc'`,
|
||||
seedReleaseReason: "manual",
|
||||
updatedAt: sql`now() at time zone 'utc'`,
|
||||
})
|
||||
.where(eq(grabs.id, grab.id));
|
||||
|
||||
revalidatePath("/admin/downloads");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse this release from now on.
|
||||
*
|
||||
* Without it, the next auto search finds the same broken release, scores it the
|
||||
* same way, and grabs it again -- forever.
|
||||
*/
|
||||
export async function blocklistAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const infoHash = String(formData.get("infoHash") ?? "");
|
||||
const grab = await requireOwnGrab(infoHash);
|
||||
|
||||
await db
|
||||
.insert(releaseBlocklist)
|
||||
.values({
|
||||
infoHash,
|
||||
releaseTitle: grab.releaseTitle,
|
||||
mediaItemId: grab.mediaItemId,
|
||||
reason: "blocked from the dashboard",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/downloads");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an entire release group.
|
||||
*
|
||||
* Possible only because the organizer keeps the group legible in the filename.
|
||||
* A group that reliably ships desynced audio or mislabelled quality has to be
|
||||
* refusable wholesale, or every new release from it must be caught by hand.
|
||||
*/
|
||||
export async function blockGroupAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const group = String(formData.get("group") ?? "").trim();
|
||||
if (!group) throw new Error("No release group to block");
|
||||
|
||||
await db
|
||||
.insert(releaseBlocklist)
|
||||
.values({
|
||||
releaseGroup: group.toLowerCase(),
|
||||
releaseTitle: `group: ${group}`,
|
||||
reason: "group blocked from the dashboard",
|
||||
permanent: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/downloads");
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { db } from "@/db/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
torrentStates,
|
||||
transferInfo,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatEta,
|
||||
type TorrentState,
|
||||
} from "@/lib/qbittorrent";
|
||||
import { pauseAction, resumeAction, removeAction, blocklistAction, blockGroupAction } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// What qBittorrent shows is a torrent name. What this page shows is what the
|
||||
// torrent is FOR -- which title, which episode, why this release was chosen and
|
||||
// where it ended up. That join is the reason this exists rather than an iframe
|
||||
// of qBittorrent's own UI, which structurally cannot know any of it.
|
||||
type GrabRow = {
|
||||
id: string;
|
||||
info_hash: string;
|
||||
release_title: string;
|
||||
status: string;
|
||||
status_detail: string | null;
|
||||
progress: number | null;
|
||||
size_bytes: string | null;
|
||||
indexer_name: string;
|
||||
origin: string | null;
|
||||
score: number | null;
|
||||
score_reasons: string[] | null;
|
||||
source: string;
|
||||
imported_path: string | null;
|
||||
imported_at: Date | null;
|
||||
seed_released_at: Date | null;
|
||||
created_at: Date;
|
||||
title: string;
|
||||
media_type: string;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
file_count: number;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
queued: "border-admin-line text-admin-muted",
|
||||
downloading: "border-admin-accent text-admin-accent",
|
||||
completed: "border-admin-accent text-admin-accent",
|
||||
importing: "border-admin-accent text-admin-accent",
|
||||
imported: "border-admin-good text-admin-good",
|
||||
failed: "border-admin-warn text-admin-warn",
|
||||
orphaned: "border-admin-warn text-admin-warn",
|
||||
};
|
||||
|
||||
function episodeLabel(row: GrabRow) {
|
||||
if (row.season_number === null) return row.media_type === "movie" ? "Movie" : "Series";
|
||||
const season = String(row.season_number).padStart(2, "0");
|
||||
if (row.episode_number === null) return `Season ${season}`;
|
||||
return `S${season}E${String(row.episode_number).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// The group is the last dash-delimited token of a scene name. Extracting it
|
||||
// here is what makes a one-click group ban possible from this page.
|
||||
function releaseGroup(title: string): string | null {
|
||||
return title.match(/-([A-Za-z0-9]+)$/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// The page answers two questions that do not belong in one list: what is still
|
||||
// coming, and what is still seeding. Sorted together by date they interleave,
|
||||
// and the in-flight work -- the only part anyone can act on -- ends up buried.
|
||||
//
|
||||
// Every view but `done` hides grabs whose seed has been released. That is the
|
||||
// end of a grab's life: the file is in the library and qBittorrent holds
|
||||
// nothing, so the row is a record, not a job. They stay reachable under Done
|
||||
// rather than being deleted.
|
||||
const IN_FLIGHT = sql`g.status in ('queued','downloading','completed','importing')`;
|
||||
|
||||
const VIEWS = {
|
||||
active: {
|
||||
label: "Downloading",
|
||||
blurb: "queued, downloading, or waiting to be imported",
|
||||
where: IN_FLIGHT,
|
||||
// Nearly-finished first. Percentage is what makes one of these worth
|
||||
// looking at, and a date sort hides it behind whatever was grabbed last.
|
||||
order: sql`coalesce(g.progress, 0) desc, g.created_at desc`,
|
||||
empty: "Nothing is downloading. Searches from the Inventory page appear here.",
|
||||
},
|
||||
seeding: {
|
||||
label: "Seeding",
|
||||
blurb: "imported, still seeding, still holding a qBittorrent slot",
|
||||
where: sql`g.status = 'imported' and g.seed_released_at is null`,
|
||||
order: sql`g.imported_at desc nulls last`,
|
||||
empty: "Nothing is seeding. Imports release their seed as soon as the copy lands.",
|
||||
},
|
||||
problems: {
|
||||
label: "Problems",
|
||||
blurb: "failed or orphaned, and not yet cleared",
|
||||
where: sql`g.status in ('failed','orphaned') and g.seed_released_at is null`,
|
||||
order: sql`g.updated_at desc`,
|
||||
empty: "Nothing has failed.",
|
||||
},
|
||||
done: {
|
||||
label: "Done",
|
||||
blurb: "seed released — finished, kept for the record",
|
||||
where: sql`g.seed_released_at is not null`,
|
||||
order: sql`g.seed_released_at desc`,
|
||||
empty: "Nothing has finished yet.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ViewKey = keyof typeof VIEWS;
|
||||
|
||||
function isViewKey(value: unknown): value is ViewKey {
|
||||
return typeof value === "string" && value in VIEWS;
|
||||
}
|
||||
|
||||
type CountRow = {
|
||||
active: number;
|
||||
seeding: number;
|
||||
problems: number;
|
||||
done: number;
|
||||
seeding_bytes: string | null;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default async function AdminDownloadsPage({ searchParams }: PageProps) {
|
||||
const resolvedSearchParams = (await searchParams) ?? {};
|
||||
const requestedView = Array.isArray(resolvedSearchParams.view)
|
||||
? resolvedSearchParams.view[0]
|
||||
: resolvedSearchParams.view;
|
||||
const view: ViewKey = isViewKey(requestedView) ? requestedView : "active";
|
||||
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
db.execute<GrabRow>(sql`
|
||||
select g.id, g.info_hash, g.release_title, g.status, g.status_detail, g.progress,
|
||||
g.size_bytes::text, g.indexer_name, g.origin, g.score, g.score_reasons,
|
||||
g.source, g.imported_path, g.imported_at, g.seed_released_at, g.created_at,
|
||||
mi.title, mi.media_type, g.season_number, g.episode_number,
|
||||
(select count(*)::int from grab_files gf where gf.grab_id = g.id) as file_count
|
||||
from grabs g
|
||||
join media_items mi on mi.id = g.media_item_id
|
||||
where ${VIEWS[view].where}
|
||||
order by ${VIEWS[view].order}
|
||||
limit 200`),
|
||||
// Counted from the same fragments the views filter on, so a tab can never
|
||||
// promise a number the list then fails to show.
|
||||
db.execute<CountRow>(sql`
|
||||
select count(*) filter (where ${VIEWS.active.where})::int as active,
|
||||
count(*) filter (where ${VIEWS.seeding.where})::int as seeding,
|
||||
count(*) filter (where ${VIEWS.problems.where})::int as problems,
|
||||
count(*) filter (where ${VIEWS.done.where})::int as done,
|
||||
coalesce(sum(g.size_bytes) filter (where ${VIEWS.seeding.where}), 0)::text as seeding_bytes
|
||||
from grabs g`),
|
||||
]);
|
||||
|
||||
const counts: CountRow = countRows[0] ?? {
|
||||
active: 0, seeding: 0, problems: 0, done: 0, seeding_bytes: "0",
|
||||
};
|
||||
|
||||
// Ask qBittorrent only about torrents we grabbed. Enumerating the client
|
||||
// would also surface hand-added torrents, which are deliberately not this
|
||||
// page's business.
|
||||
const liveHashes = rows
|
||||
.filter((row) => row.seed_released_at === null)
|
||||
.map((row) => row.info_hash);
|
||||
const [{ states, error: qbtError }, transfer] = await Promise.all([
|
||||
torrentStates(liveHashes),
|
||||
transferInfo(),
|
||||
]);
|
||||
|
||||
const reclaimable = Number(counts.seeding_bytes ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl font-semibold">Downloads</h1>
|
||||
<p className="mt-1 text-sm text-admin-muted">
|
||||
Torrents Ampelos grabbed, and what each one is for. Anything added by hand in
|
||||
qBittorrent is not shown and is never touched from here.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-admin-muted">Down</dt>
|
||||
<dd className="font-mono">{transfer ? formatSpeed(transfer.dlSpeed) : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-admin-muted">Up</dt>
|
||||
<dd className="font-mono">{transfer ? formatSpeed(transfer.upSpeed) : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-admin-muted">Active</dt>
|
||||
<dd className="font-mono">{counts.active}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-admin-muted">Seeding</dt>
|
||||
<dd className="font-mono">{counts.seeding}</dd>
|
||||
</div>
|
||||
<div>
|
||||
{/* Under copy-mode imports this is real disk, not a hardlink: the
|
||||
download copy is separate data from the library copy, so every
|
||||
byte here is reclaimed by releasing the seed. */}
|
||||
<dt className="text-xs uppercase tracking-wider text-admin-muted">Held by seeds</dt>
|
||||
<dd className="font-mono">{formatBytes(reclaimable)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(Object.keys(VIEWS) as ViewKey[]).map((key) => {
|
||||
const count =
|
||||
key === "active" ? counts.active
|
||||
: key === "seeding" ? counts.seeding
|
||||
: key === "problems" ? counts.problems
|
||||
: counts.done;
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
prefetch={false}
|
||||
href={key === "active" ? "/admin/downloads" : `/admin/downloads?view=${key}`}
|
||||
aria-current={key === view ? "page" : undefined}
|
||||
title={VIEWS[key].blurb}
|
||||
// The selected fill comes from aria-current in globals.css, so
|
||||
// only the unselected state needs a class here.
|
||||
className={
|
||||
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
|
||||
(key === view ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{VIEWS[key].label}
|
||||
<span className="ml-2 font-mono opacity-70">{count}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{qbtError && (
|
||||
<p className="rounded border border-admin-warn px-4 py-3 text-sm text-admin-warn">
|
||||
qBittorrent is not reachable ({qbtError}). The records below are from the database;
|
||||
live progress and the controls will not work until it is back.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-admin-line px-4 py-6 text-sm text-admin-muted">
|
||||
{VIEWS[view].empty}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[64rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Release</th>
|
||||
<th className="py-2 pr-4">State</th>
|
||||
<th className="py-2 pr-4 text-right">Size</th>
|
||||
<th className="py-2 pr-4">Source</th>
|
||||
<th className="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const live: TorrentState | undefined = states.get(row.info_hash.toLowerCase());
|
||||
const group = releaseGroup(row.release_title);
|
||||
const progress = live?.progress ?? row.progress ?? 0;
|
||||
const inFlight = ["queued", "downloading", "completed", "importing"].includes(row.status);
|
||||
|
||||
return (
|
||||
<tr key={row.id} className="border-b border-admin-line/50 align-top">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium">{row.title}</div>
|
||||
<div className="text-xs text-admin-muted">{episodeLabel(row)}</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-mono text-xs break-all">{row.release_title}</div>
|
||||
{row.score !== null && (
|
||||
// Why this release, in the terms the engine actually used.
|
||||
// An automatic choice that cannot explain itself is
|
||||
// indistinguishable from a random one.
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-admin-muted">
|
||||
score {row.score}
|
||||
</summary>
|
||||
<div className="mt-1 font-mono text-[11px] text-admin-muted">
|
||||
{(row.score_reasons ?? []).join(" ")}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{row.imported_path && (
|
||||
<div className="mt-1 font-mono text-[11px] text-admin-good break-all">
|
||||
→ {row.imported_path}
|
||||
{row.file_count > 1 && ` (+${row.file_count - 1} more)`}
|
||||
</div>
|
||||
)}
|
||||
{row.status_detail && (
|
||||
<div className="mt-1 text-[11px] text-admin-muted">{row.status_detail}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 pr-4">
|
||||
<span
|
||||
className={`inline-block rounded border px-2 py-0.5 text-[11px] uppercase tracking-wide ${
|
||||
STATUS_STYLE[row.status] ?? "border-admin-line text-admin-muted"
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
{inFlight && (
|
||||
<div className="mt-1 text-xs text-admin-muted">
|
||||
{progress}%
|
||||
{live && !live.isComplete && (
|
||||
<>
|
||||
{" · "}
|
||||
{formatSpeed(live.dlSpeed)}
|
||||
{" · "}
|
||||
{formatEta(live.eta)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{row.seed_released_at && (
|
||||
<div className="mt-1 text-[11px] text-admin-muted">seed released</div>
|
||||
)}
|
||||
{row.status === "imported" && !row.seed_released_at && live && (
|
||||
<div className="mt-1 text-[11px] text-admin-muted">
|
||||
seeding · ratio {live.ratio?.toFixed(2) ?? "—"}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 pr-4 text-right font-mono text-xs">
|
||||
{formatBytes(row.size_bytes ? Number(row.size_bytes) : live?.sizeBytes ?? null)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 pr-4 text-xs">
|
||||
<div>{row.indexer_name}</div>
|
||||
{/* Knaben aggregates other trackers, so its name is not
|
||||
where the release actually lives. */}
|
||||
{row.origin && row.origin !== row.indexer_name && (
|
||||
<div className="text-admin-muted">via {row.origin}</div>
|
||||
)}
|
||||
<div className="text-admin-muted">{row.source}</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{live && !live.isComplete && (
|
||||
<form action={live.isPaused ? resumeAction : pauseAction}>
|
||||
<input type="hidden" name="infoHash" value={row.info_hash} />
|
||||
<button type="submit" className="admin-nav-button px-2 py-1 text-xs">
|
||||
{live.isPaused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!row.seed_released_at && (
|
||||
<form action={removeAction}>
|
||||
<input type="hidden" name="infoHash" value={row.info_hash} />
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-nav-button px-2 py-1 text-xs"
|
||||
// The label differs before and after import
|
||||
// because the consequence does. Imports copy
|
||||
// rather than hardlink, so after import there
|
||||
// are two real files and this deletes the
|
||||
// download one; before import there is only one.
|
||||
title={
|
||||
row.status === "imported"
|
||||
? "Stops seeding and deletes the download copy, freeing its disk. The library has its own copy."
|
||||
: "Deletes the partial download. Nothing has been imported yet, so this discards it."
|
||||
}
|
||||
>
|
||||
{row.status === "imported" ? "Release seed" : "Cancel"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form action={blocklistAction}>
|
||||
<input type="hidden" name="infoHash" value={row.info_hash} />
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-nav-button px-2 py-1 text-xs"
|
||||
title="Never choose this exact release again."
|
||||
>
|
||||
Block
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{group && (
|
||||
<form action={blockGroupAction}>
|
||||
<input type="hidden" name="group" value={group} />
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-nav-button px-2 py-1 text-xs"
|
||||
title={`Never choose any release from ${group} again.`}
|
||||
>
|
||||
Block {group}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { classics, externalIds, watchingNowItems } from "@/db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function optionalString(value: FormDataEntryValue | null) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
if (!session.user.isAdmin) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function addTimelessClassic(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const mediaItemId = optionalString(formData.get("mediaItemId"));
|
||||
const note = optionalString(formData.get("note"));
|
||||
|
||||
if (!mediaItemId) return;
|
||||
|
||||
await db
|
||||
.insert(classics)
|
||||
.values({ mediaItemId, note, addedBy: session.user.id })
|
||||
.onConflictDoUpdate({
|
||||
target: classics.mediaItemId,
|
||||
set: { note, addedBy: session.user.id, addedAt: new Date() },
|
||||
});
|
||||
|
||||
await db
|
||||
.update(watchingNowItems)
|
||||
.set({ removedAt: new Date() })
|
||||
.where(and(eq(watchingNowItems.mediaItemId, mediaItemId), isNull(watchingNowItems.removedAt)));
|
||||
|
||||
revalidatePath("/admin/inventory");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm, or withdraw confirmation, that a title's TMDB link is the right one.
|
||||
*
|
||||
* Deliberately a person's act. The link was originally derived by matching a
|
||||
* folder name against search results, and no automatic pass over those links
|
||||
* can settle the question -- believing a machine's second guess is how the
|
||||
* first one went unnoticed. Everything that reads the flag (the renamer, most
|
||||
* of all) is trusting a human, which is the only reason it is worth trusting.
|
||||
*/
|
||||
export async function setTmdbLinkVerified(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const mediaItemId = optionalString(formData.get("mediaItemId"));
|
||||
const verified = formData.get("verified") === "1";
|
||||
|
||||
if (!mediaItemId) return;
|
||||
|
||||
await db
|
||||
.update(externalIds)
|
||||
.set(
|
||||
verified
|
||||
? { verifiedAt: new Date(), verifiedBy: session.user.id }
|
||||
: { verifiedAt: null, verifiedBy: null },
|
||||
)
|
||||
.where(and(eq(externalIds.mediaItemId, mediaItemId), eq(externalIds.source, "tmdb")));
|
||||
|
||||
revalidatePath("/admin/inventory");
|
||||
}
|
||||
|
||||
export async function removeTimelessClassic(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const mediaItemId = optionalString(formData.get("mediaItemId"));
|
||||
|
||||
if (!mediaItemId) return;
|
||||
|
||||
await db.delete(classics).where(eq(classics.mediaItemId, mediaItemId));
|
||||
|
||||
revalidatePath("/admin/inventory");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
type BulkAction = "add" | "remove";
|
||||
|
||||
function selectedMediaIds() {
|
||||
return Array.from(document.querySelectorAll<HTMLInputElement>('input[data-inventory-select="true"]:checked'))
|
||||
.map((input) => input.value)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function InventoryBulkActions() {
|
||||
const router = useRouter();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function run(action: BulkAction) {
|
||||
const mediaItemIds = selectedMediaIds();
|
||||
if (!mediaItemIds.length) {
|
||||
setMessage("Select at least one title first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const response = await fetch("/api/admin/inventory/classics", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action, mediaItemIds }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setMessage("Bulk update failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage(action === "add" ? "Marked selected titles as Timeless." : "Removed Timeless from selected titles.");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-panel flex flex-wrap items-center gap-2 p-4">
|
||||
<p className="mr-2 text-sm text-admin-muted">Bulk</p>
|
||||
<button disabled={isPending} onClick={() => run("add")} className="admin-nav-button px-3 py-2 text-sm font-medium disabled:opacity-60" type="button">
|
||||
Mark Timeless
|
||||
</button>
|
||||
<button disabled={isPending} onClick={() => run("remove")} className="admin-nav-button px-3 py-2 text-sm font-medium disabled:opacity-60" type="button">
|
||||
Remove Timeless
|
||||
</button>
|
||||
{message ? <p className="basis-full text-xs text-admin-muted">{message}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Live search. Typing rewrites the `q` param so the server re-queries the whole
|
||||
// table rather than filtering only the rows on the current page — otherwise
|
||||
// "sou" would miss South Park whenever it sits on a later page.
|
||||
export function InventorySearch({
|
||||
kind,
|
||||
tier,
|
||||
initialQuery,
|
||||
placeholder,
|
||||
label,
|
||||
}: {
|
||||
kind: string;
|
||||
tier: string;
|
||||
initialQuery: string;
|
||||
placeholder: string;
|
||||
label: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState(initialQuery);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const latest = useRef(initialQuery);
|
||||
|
||||
useEffect(() => {
|
||||
// Only navigate when the debounced value differs from what the URL holds,
|
||||
// so re-renders coming back from the server do not loop.
|
||||
if (value === latest.current) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
latest.current = value;
|
||||
const params = new URLSearchParams();
|
||||
if (kind !== "movie") params.set("kind", kind);
|
||||
// Searching must not silently drop the tier filter back to live.
|
||||
if (tier !== "live") params.set("tier", tier);
|
||||
if (value.trim()) params.set("q", value.trim());
|
||||
const queryString = params.toString();
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(queryString ? "/admin/inventory?" + queryString : "/admin/inventory", {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, kind, tier, router]);
|
||||
|
||||
return (
|
||||
<div className="admin-panel flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<label className="sr-only" htmlFor="inventory-q">{label}</label>
|
||||
<input
|
||||
id="inventory-q"
|
||||
name="q"
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="min-h-10 flex-1 rounded-md border border-admin-line bg-admin-subpanel px-3 text-sm text-admin-text outline-none focus:border-admin-accent"
|
||||
/>
|
||||
<span className={"text-xs " + (isPending ? "text-admin-accent" : "text-admin-muted")}>
|
||||
{isPending ? "Searching…" : value ? "Filtered" : "All"}
|
||||
</span>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue("")}
|
||||
className="px-3 py-2 text-sm font-medium text-admin-accent"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { ManualSearch } from "../manual-search";
|
||||
import { setTmdbLinkVerified } from "./actions";
|
||||
import { cancelReplacement, requestReplacement } from "./series/replace-actions";
|
||||
|
||||
export type InventoryRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
seasonCount: string;
|
||||
episodeCount: string;
|
||||
fileCount: string;
|
||||
totalBytes: string;
|
||||
qualities: string;
|
||||
codecs: string;
|
||||
isClassic: boolean;
|
||||
// Every tier this title has files on, regardless of the active tier filter.
|
||||
tiers: { tier: string; files: number }[];
|
||||
// 0 means nothing here has been read by ffprobe, so quality/codec are
|
||||
// filename guesses rather than measurements.
|
||||
probedFiles: number;
|
||||
// The TMDB id this title is linked to, and whether a person has confirmed
|
||||
// that link is the right one. Null id means nothing is linked at all.
|
||||
tmdbId: string | null;
|
||||
tmdbVerified: boolean;
|
||||
};
|
||||
|
||||
// "ok" mirrored at the same size · "stale" present but a different size ·
|
||||
// "missing" no backup copy · "n/a" archive, which is not mirrored.
|
||||
export type BackupState = "ok" | "stale" | "missing" | "n/a";
|
||||
|
||||
export type FileEntry = {
|
||||
id: string;
|
||||
relativePath: string;
|
||||
sizeBytes: number;
|
||||
quality: string | null;
|
||||
codec: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
tier: string | null;
|
||||
edition: string | null;
|
||||
probed: boolean;
|
||||
backup: BackupState;
|
||||
replaceRequested: boolean;
|
||||
};
|
||||
|
||||
export type EpisodeEntry = {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string | null;
|
||||
airDate: string | null;
|
||||
files: FileEntry[];
|
||||
};
|
||||
|
||||
export type SeasonEntry = {
|
||||
id: string;
|
||||
number: number;
|
||||
episodes: EpisodeEntry[];
|
||||
};
|
||||
|
||||
function formatBytes(value: number | string | null) {
|
||||
const bytes = Number(value ?? 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
||||
}
|
||||
|
||||
function seasonLabel(seasonNumber: number) {
|
||||
return seasonNumber === 0 ? "Specials" : "Season " + String(seasonNumber).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Live is the tier that matters most, so it reads as good; archive is merely
|
||||
// informational; backup missing is what a mirror problem looks like.
|
||||
const TIER_BADGE: Record<string, string> = {
|
||||
live: "border-admin-good text-admin-text",
|
||||
backup: "border-admin-line text-admin-muted",
|
||||
archive: "border-admin-accent text-admin-accent",
|
||||
};
|
||||
|
||||
// Backup coverage for a whole episode: worst state of its live files wins, so
|
||||
// a season that is half-mirrored never reads as fully backed up.
|
||||
function episodeBackupState(files: FileEntry[]): BackupState {
|
||||
const live = files.filter((file) => file.tier === "live");
|
||||
if (!live.length) return "n/a";
|
||||
if (live.some((file) => file.backup === "missing")) return "missing";
|
||||
if (live.some((file) => file.backup === "stale")) return "stale";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function BackupFlair({ state }: { state: BackupState }) {
|
||||
if (state === "n/a") return null;
|
||||
if (state === "ok") {
|
||||
return (
|
||||
<span title="Mirrored to backup at the same size" className="text-xs font-semibold text-admin-good">
|
||||
✓ backed up
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === "stale") {
|
||||
return (
|
||||
<span title="A backup copy exists at this path but its size differs from live" className="text-xs font-semibold text-admin-warn">
|
||||
! backup differs
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span title="No backup copy of this file" className="text-xs font-semibold text-admin-warn">
|
||||
✗ not backed up
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryTable({
|
||||
kind,
|
||||
rows,
|
||||
query,
|
||||
page,
|
||||
tier,
|
||||
link,
|
||||
expandedId,
|
||||
seasons,
|
||||
movieFiles,
|
||||
}: {
|
||||
kind: "movie" | "tv";
|
||||
rows: InventoryRow[];
|
||||
query: string;
|
||||
page: number;
|
||||
tier: string;
|
||||
link: string;
|
||||
expandedId: string | null;
|
||||
seasons: SeasonEntry[];
|
||||
movieFiles: FileEntry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const columnCount = kind === "tv" ? 11 : 10;
|
||||
|
||||
function hrefFor(nextExpanded: string | null) {
|
||||
const params = new URLSearchParams();
|
||||
if (kind !== "movie") params.set("kind", kind);
|
||||
if (tier !== "live") params.set("tier", tier);
|
||||
if (link !== "any") params.set("md", link);
|
||||
if (query) params.set("q", query);
|
||||
if (page > 1) params.set("page", String(page));
|
||||
if (nextExpanded) params.set("expanded", nextExpanded);
|
||||
const value = params.toString();
|
||||
return value ? "/admin/inventory?" + value : "/admin/inventory";
|
||||
}
|
||||
|
||||
function toggleRow(id: string) {
|
||||
const next = id === expandedId ? null : id;
|
||||
startTransition(() => router.replace(hrefFor(next), { scroll: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-admin-line bg-admin-subpanel text-xs uppercase tracking-[0.18em] text-admin-muted">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3">Pick</th>
|
||||
<th className="min-w-72 px-3 py-3">Title</th>
|
||||
{kind === "tv" ? (
|
||||
<>
|
||||
<th className="px-3 py-3">Seasons</th>
|
||||
<th className="px-3 py-3">Episodes</th>
|
||||
</>
|
||||
) : (
|
||||
<th className="px-3 py-3">Year</th>
|
||||
)}
|
||||
<th className="px-3 py-3">Files</th>
|
||||
<th className="px-3 py-3">Size</th>
|
||||
<th className="px-3 py-3">Quality</th>
|
||||
<th className="px-3 py-3">Codec</th>
|
||||
<th className="px-3 py-3">Tiers</th>
|
||||
<th className="px-3 py-3">TMDB</th>
|
||||
<th className="px-3 py-3">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{rows.length ? rows.map((item) => {
|
||||
const isExpanded = item.id === expandedId;
|
||||
return (
|
||||
<tbody key={item.id} className={"border-b border-admin-line " + (item.isClassic ? "bg-admin-subpanel shadow-[inset_4px_0_0_rgba(204,177,95,0.95)]" : "")}>
|
||||
<tr
|
||||
onClick={() => toggleRow(item.id)}
|
||||
aria-expanded={isExpanded}
|
||||
className={"cursor-pointer align-top hover:bg-admin-subpanel " + (isExpanded ? "bg-admin-subpanel" : "")}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
{/* Selecting for bulk actions must not toggle the row. */}
|
||||
<input
|
||||
data-inventory-select="true"
|
||||
type="checkbox"
|
||||
value={item.id}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="h-4 w-4 accent-[var(--admin-accent)]"
|
||||
aria-label={"Select " + item.title}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<p className="font-medium text-admin-text">
|
||||
<span aria-hidden className="mr-2 inline-block w-3 text-admin-muted">{isExpanded ? "▾" : "▸"}</span>
|
||||
{item.title}
|
||||
{/* The series page exists and nothing linked to it, so the
|
||||
only way in was to know the URL. The row itself is the
|
||||
expander, so this has to be its own target and has to
|
||||
stop the click reaching the row. */}
|
||||
{kind === "tv" ? (
|
||||
<Link
|
||||
href={`/admin/inventory/series/${item.id}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="ml-2 text-xs font-normal text-admin-muted underline decoration-dotted underline-offset-2 transition hover:text-admin-accent"
|
||||
>
|
||||
full detail
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-1 max-w-2xl pl-5 text-xs text-admin-muted">
|
||||
{item.overview ?? "No canonical overview stored yet."}
|
||||
</p>
|
||||
</td>
|
||||
{kind === "tv" ? (
|
||||
<>
|
||||
<td className="whitespace-nowrap px-3 py-3">{item.seasonCount}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">{item.episodeCount}</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">{item.year ?? "Unknown"}</td>
|
||||
)}
|
||||
<td className="whitespace-nowrap px-3 py-3">{item.fileCount}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">{formatBytes(item.totalBytes)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">
|
||||
{item.qualities || "Unknown"}
|
||||
{item.probedFiles === 0 ? (
|
||||
<span title="No file here has been read by ffprobe — quality is guessed from the filename" className="ml-2 text-xs text-admin-warn">
|
||||
unverified
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">{item.codecs || "Unknown"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.tiers.length ? item.tiers.map((entry) => (
|
||||
<span
|
||||
key={entry.tier}
|
||||
title={entry.files + " file(s) on " + entry.tier}
|
||||
className={
|
||||
"rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide " +
|
||||
(TIER_BADGE[entry.tier] ?? "border-admin-line text-admin-muted")
|
||||
}
|
||||
>
|
||||
{entry.tier} {entry.files}
|
||||
</span>
|
||||
)) : <span className="text-xs text-admin-muted">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
{/* The button lives inside the row, and the row is a toggle.
|
||||
Stopping the click here is what keeps confirming a link
|
||||
from also expanding the record underneath it. */}
|
||||
<td className="whitespace-nowrap px-3 py-3" onClick={(event) => event.stopPropagation()}>
|
||||
{item.tmdbId ? (
|
||||
<form action={setTmdbLinkVerified} className="flex items-center gap-2">
|
||||
<input type="hidden" name="mediaItemId" value={item.id} />
|
||||
<input type="hidden" name="verified" value={item.tmdbVerified ? "0" : "1"} />
|
||||
<a
|
||||
href={"https://www.themoviedb.org/" + (kind === "tv" ? "tv" : "movie") + "/" + item.tmdbId}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open this title on TMDB to check the link"
|
||||
className="text-xs text-admin-accent underline-offset-2 hover:underline"
|
||||
>
|
||||
{item.tmdbId}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
title={item.tmdbVerified ? "Withdraw confirmation of this link" : "Confirm this link is the right title"}
|
||||
className={
|
||||
"rounded-md border px-2 py-1 text-xs font-semibold " +
|
||||
(item.tmdbVerified
|
||||
? "border-admin-good text-admin-text"
|
||||
: "border-admin-line text-admin-muted hover:border-admin-accent hover:text-admin-accent")
|
||||
}
|
||||
>
|
||||
{item.tmdbVerified ? "Verified" : "Verify"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<span title="No TMDB id is linked to this title at all" className="text-xs text-admin-warn">none</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
{item.isClassic
|
||||
? <span className="rounded-md border border-admin-accent bg-admin-subpanel px-2 py-1 text-xs font-semibold text-admin-accent">Timeless</span>
|
||||
: <span className="text-admin-muted">Managed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{isExpanded ? (
|
||||
<tr>
|
||||
<td colSpan={columnCount} className="px-3 pb-3">
|
||||
{isPending ? <p className="px-2 py-3 text-xs text-admin-muted">Loading…</p> : null}
|
||||
|
||||
{kind === "tv" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href={"/admin/inventory/series/" + item.id}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="admin-nav-button px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Open full series view
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Indent level 1: seasons */}
|
||||
{seasons.map((season) => {
|
||||
const missing = season.episodes.filter((episode) => !episode.files.length).length;
|
||||
const held = season.episodes.filter((episode) => episode.files.length);
|
||||
const unbacked = held.filter((episode) => episodeBackupState(episode.files) !== "ok" && episodeBackupState(episode.files) !== "n/a").length;
|
||||
return (
|
||||
<div key={season.id} className="ml-6 rounded-md border border-admin-line bg-admin-subpanel">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
|
||||
<p className="text-sm font-semibold">{seasonLabel(season.number)}</p>
|
||||
<p className="text-xs text-admin-muted">
|
||||
{season.episodes.length} episodes
|
||||
{missing ? <span className="text-admin-warn"> · {missing} missing</span> : null}
|
||||
{unbacked ? <span className="text-admin-warn"> · {unbacked} not backed up</span> : null}
|
||||
{held.length && !unbacked ? <span className="text-admin-good"> · fully backed up</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Indent level 2: episodes */}
|
||||
<div className="divide-y divide-admin-line">
|
||||
{season.episodes.map((episode) => {
|
||||
const hasFile = episode.files.length > 0;
|
||||
return (
|
||||
<div key={episode.id} className={"ml-6 px-3 py-2 " + (hasFile ? "" : "bg-admin-missing")}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-mono text-xs text-admin-muted">
|
||||
{"E" + String(episode.number).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-sm">{episode.title ?? "Untitled episode"}</span>
|
||||
{episode.airDate ? (
|
||||
<span className="text-xs text-admin-muted">{episode.airDate}</span>
|
||||
) : null}
|
||||
{hasFile ? (
|
||||
<BackupFlair state={episodeBackupState(episode.files)} />
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-admin-muted">
|
||||
Not in collection
|
||||
</span>
|
||||
)}
|
||||
{/* Offered on every episode, not only the missing ones: the
|
||||
other reason to search is replacing a copy that is present
|
||||
but bad. */}
|
||||
<span className="ml-auto">
|
||||
<ManualSearch
|
||||
mediaItemId={item.id}
|
||||
seasonNumber={season.number}
|
||||
episodeNumber={episode.number}
|
||||
label={item.title + " S" + String(season.number).padStart(2, "0") + "E" + String(episode.number).padStart(2, "0")}
|
||||
compact
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{episode.files.map((file) => (
|
||||
<div key={file.id} className="mt-1 flex flex-wrap items-center gap-2 text-xs text-admin-muted">
|
||||
{file.tier ? (
|
||||
<span className={"rounded border px-1.5 py-0.5 uppercase " + (TIER_BADGE[file.tier] ?? "border-admin-line")}>
|
||||
{file.tier}
|
||||
</span>
|
||||
) : null}
|
||||
{file.edition ? (
|
||||
<span className="rounded border border-admin-accent px-1.5 py-0.5 font-semibold uppercase text-admin-accent">
|
||||
{file.edition === "bw" ? "B&W" : file.edition}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-semibold text-admin-text">{file.quality ?? "Unknown"}</span>
|
||||
{file.width && file.height ? <span>{file.width}x{file.height}</span> : null}
|
||||
<span>{file.codec ?? "Unknown"}</span>
|
||||
{file.probed ? null : (
|
||||
<span title="Not read by ffprobe — quality and codec are guessed from the filename" className="text-admin-warn">
|
||||
unverified
|
||||
</span>
|
||||
)}
|
||||
<span>{formatBytes(file.sizeBytes)}</span>
|
||||
<span className="break-all">{file.relativePath}</span>
|
||||
{/* Live only: a replacement request
|
||||
is about the copy being served,
|
||||
and the reaper never looks at any
|
||||
other tier. */}
|
||||
{file.tier === "live" ? (
|
||||
file.replaceRequested ? (
|
||||
<form action={cancelReplacement} className="contents">
|
||||
<input type="hidden" name="fileId" value={file.id} />
|
||||
<span className="font-semibold text-admin-warn">replacing</span>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-admin-line px-1.5 py-0.5 transition hover:text-admin-text"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={requestReplacement} className="contents">
|
||||
<input type="hidden" name="fileId" value={file.id} />
|
||||
<button
|
||||
type="submit"
|
||||
title="Look for a better copy, and delete this one once it arrives"
|
||||
className="rounded border border-admin-line px-1.5 py-0.5 transition hover:border-admin-accent hover:text-admin-accent"
|
||||
>
|
||||
replace
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!seasons.length && !isPending ? (
|
||||
<p className="ml-6 px-3 py-2 text-xs text-admin-muted">No seasons recorded for this series.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-6 space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<ManualSearch mediaItemId={item.id} label={item.title} compact />
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line rounded-md border border-admin-line bg-admin-subpanel">
|
||||
{movieFiles.map((file) => (
|
||||
<div key={file.id} className="grid gap-2 px-3 py-2 text-xs lg:grid-cols-[5rem_1fr_7rem_6rem_6rem_6rem]">
|
||||
<span>
|
||||
{file.tier ? (
|
||||
<span className={"rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase " + (TIER_BADGE[file.tier] ?? "border-admin-line text-admin-muted")}>
|
||||
{file.tier}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="break-all text-admin-muted">{file.relativePath}</span>
|
||||
<span>{formatBytes(file.sizeBytes)}</span>
|
||||
<span>{file.quality ?? "Unknown"}{file.edition ? ` (${file.edition === "bw" ? "B&W" : file.edition})` : ""}</span>
|
||||
<span>{file.width && file.height ? `${file.width}x${file.height}` : "—"}</span>
|
||||
<span>{file.codec ?? "Unknown"}</span>
|
||||
</div>
|
||||
))}
|
||||
{!movieFiles.length && !isPending ? (
|
||||
<p className="px-3 py-2 text-xs text-admin-muted">
|
||||
{tier === "all" ? "No files on any tier." : "No files on the " + tier + " tier."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
);
|
||||
}) : (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={columnCount} className="p-5 text-sm text-admin-muted">
|
||||
{kind === "tv" ? "No main television inventory records matched." : "No main movie inventory records matched."}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { db } from "@/db/client";
|
||||
import { classics, episodes, mediaItems, seasons, storageFiles, storageTiers } from "@/db/schema";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { InventoryBulkActions } from "./bulk-actions";
|
||||
import { InventorySearch } from "./inventory-search";
|
||||
import { InventoryTable, type FileEntry, type SeasonEntry } from "./inventory-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type InventoryKind = "movie" | "tv";
|
||||
|
||||
// Which tier's files decide whether a title is listed at all, and what the
|
||||
// Files/Size/Quality columns describe. Defaults to live so the page keeps
|
||||
// meaning what it used to mean.
|
||||
const TIER_FILTERS = [
|
||||
{ value: "live", label: "Live", blurb: "titles with files on the live tier" },
|
||||
{ value: "backup", label: "Backup", blurb: "titles with files on the backup mirror" },
|
||||
{ value: "archive", label: "Archive", blurb: "titles with files on the archive tier" },
|
||||
{ value: "all", label: "All tiers", blurb: "titles with files anywhere" },
|
||||
] as const;
|
||||
|
||||
type TierFilter = (typeof TIER_FILTERS)[number]["value"];
|
||||
|
||||
// Whether the TMDB link behind a title has been checked by a person.
|
||||
//
|
||||
// Almost every id here was derived by matching a folder name against TMDB
|
||||
// search, and that has been wrong in ways nothing downstream could see. This
|
||||
// filter exists so the checking can be done a few at a time rather than as one
|
||||
// impossible sitting -- ampelos-agent scripts/check-tmdb-links.mjs ranks which ones deserve
|
||||
// the attention first.
|
||||
const LINK_FILTERS = [
|
||||
{ value: "any", label: "Any", blurb: "every title" },
|
||||
{ value: "unverified", label: "Unverified", blurb: "titles whose TMDB link nobody has confirmed" },
|
||||
{ value: "verified", label: "Verified", blurb: "titles whose TMDB link has been confirmed" },
|
||||
] as const;
|
||||
|
||||
type LinkFilter = (typeof LINK_FILTERS)[number]["value"];
|
||||
|
||||
const KIND_COPY = {
|
||||
movie: {
|
||||
label: "Movies",
|
||||
heading: "Main Movie Inventory",
|
||||
mediaType: "movie" as const,
|
||||
searchLabel: "Search movies",
|
||||
searchPlaceholder: "Search main movies",
|
||||
blurb:
|
||||
"Dense canonical movie inventory backed by live storage files. Click a row to inspect its files, or select rows for bulk policy changes.",
|
||||
},
|
||||
tv: {
|
||||
label: "Television",
|
||||
heading: "Main Television Inventory",
|
||||
mediaType: "tv_series" as const,
|
||||
searchLabel: "Search series",
|
||||
searchPlaceholder: "Search main series",
|
||||
blurb:
|
||||
"Canonical series inventory backed by live storage files and TMDB metadata. Click a row to expand its seasons and episodes; episodes with no file on any tier are highlighted.",
|
||||
},
|
||||
} satisfies Record<InventoryKind, unknown>;
|
||||
|
||||
function singleParam(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function parseKind(value: string | string[] | undefined): InventoryKind {
|
||||
return singleParam(value) === "tv" ? "tv" : "movie";
|
||||
}
|
||||
|
||||
function parseTier(value: string | string[] | undefined): TierFilter {
|
||||
const raw = singleParam(value);
|
||||
const match = TIER_FILTERS.find((entry) => entry.value === raw);
|
||||
return match ? match.value : "live";
|
||||
}
|
||||
|
||||
function parseLink(value: string | string[] | undefined): LinkFilter {
|
||||
const raw = singleParam(value);
|
||||
const match = LINK_FILTERS.find((entry) => entry.value === raw);
|
||||
return match ? match.value : "any";
|
||||
}
|
||||
|
||||
function parsePage(value: string | string[] | undefined) {
|
||||
const page = Number.parseInt(singleParam(value) ?? "1", 10);
|
||||
return Number.isInteger(page) && page > 0 ? page : 1;
|
||||
}
|
||||
|
||||
function inventoryHref(kind: InventoryKind, query: string, page: number, tier: TierFilter, link: LinkFilter = "any") {
|
||||
const params = new URLSearchParams();
|
||||
if (kind !== "movie") params.set("kind", kind);
|
||||
if (tier !== "live") params.set("tier", tier);
|
||||
if (link !== "any") params.set("md", link);
|
||||
if (query) params.set("q", query);
|
||||
if (page > 1) params.set("page", String(page));
|
||||
const value = params.toString();
|
||||
return value ? "/admin/inventory?" + value : "/admin/inventory";
|
||||
}
|
||||
|
||||
export default async function AdminInventoryPage({ searchParams }: PageProps) {
|
||||
const resolvedSearchParams = (await searchParams) ?? {};
|
||||
const kind = parseKind(resolvedSearchParams.kind);
|
||||
const copy = KIND_COPY[kind];
|
||||
const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
|
||||
const expandedId = singleParam(resolvedSearchParams.expanded) ?? null;
|
||||
const tier = parseTier(resolvedSearchParams.tier);
|
||||
const link = parseLink(resolvedSearchParams.md);
|
||||
const page = parsePage(resolvedSearchParams.page);
|
||||
const pageSize = 100;
|
||||
|
||||
// "all" means every tier, so it contributes no predicate at all.
|
||||
const tierFilter = tier === "all" ? undefined : eq(storageTiers.tier, tier);
|
||||
|
||||
// Season/episode counts come from the files themselves, so the numbers
|
||||
// describe what is actually on the selected tier.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: mediaItems.id,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
overview: mediaItems.overview,
|
||||
seasonCount: sql<string>`count(distinct ${seasons.id})::text`,
|
||||
episodeCount: sql<string>`count(distinct ${episodes.id})::text`,
|
||||
fileCount: sql<string>`count(${storageFiles.id})::text`,
|
||||
totalBytes: sql<string>`coalesce(sum(${storageFiles.sizeBytes}), 0)::text`,
|
||||
qualities: sql<string>`coalesce(string_agg(distinct ${storageFiles.quality}, ', ' order by ${storageFiles.quality}) filter (where ${storageFiles.quality} is not null), '')`,
|
||||
codecs: sql<string>`coalesce(string_agg(distinct ${storageFiles.codec}, ', ' order by ${storageFiles.codec}) filter (where ${storageFiles.codec} is not null), '')`,
|
||||
// Quality/codec are only trustworthy once ffprobe has read the file. Where
|
||||
// nothing is probed they are filename guesses, and this library's
|
||||
// filenames are demonstrably wrong — so say so rather than showing a
|
||||
// guess next to a measurement as if they were peers.
|
||||
probedFiles: sql<number>`count(*) filter (where ${storageFiles.probedAt} is not null)::int`,
|
||||
isClassic: sql<boolean>`bool_or(${classics.id} is not null)`,
|
||||
// Scalar subqueries rather than another join: external_ids has no unique
|
||||
// constraint per (item, source), and a second tmdb row would silently
|
||||
// double every count in this query.
|
||||
tmdbId: sql<string | null>`(select ei.external_id from external_ids ei
|
||||
where ei.media_item_id = ${mediaItems.id} and ei.source = 'tmdb'
|
||||
limit 1)`,
|
||||
tmdbVerified: sql<boolean>`exists (select 1 from external_ids ei
|
||||
where ei.media_item_id = ${mediaItems.id}
|
||||
and ei.source = 'tmdb'
|
||||
and ei.verified_at is not null)`,
|
||||
})
|
||||
.from(mediaItems)
|
||||
.innerJoin(storageFiles, eq(storageFiles.mediaItemId, mediaItems.id))
|
||||
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.leftJoin(episodes, eq(episodes.id, storageFiles.episodeId))
|
||||
.leftJoin(seasons, eq(seasons.id, episodes.seasonId))
|
||||
.leftJoin(classics, eq(classics.mediaItemId, mediaItems.id))
|
||||
.where(
|
||||
and(
|
||||
eq(mediaItems.mediaType, copy.mediaType),
|
||||
tierFilter,
|
||||
isNull(storageFiles.missingAt),
|
||||
query ? ilike(mediaItems.title, "%" + query + "%") : undefined,
|
||||
// A title with no tmdb row at all counts as unverified: nobody has
|
||||
// confirmed anything about it, which is exactly what the filter asks.
|
||||
link === "unverified"
|
||||
? sql`not exists (select 1 from external_ids ei
|
||||
where ei.media_item_id = ${mediaItems.id}
|
||||
and ei.source = 'tmdb'
|
||||
and ei.verified_at is not null)`
|
||||
: link === "verified"
|
||||
? sql`exists (select 1 from external_ids ei
|
||||
where ei.media_item_id = ${mediaItems.id}
|
||||
and ei.source = 'tmdb'
|
||||
and ei.verified_at is not null)`
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(mediaItems.id)
|
||||
.orderBy(desc(sql`bool_or(${classics.id} is not null)`), mediaItems.title)
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
|
||||
// Deliberately NOT tier-filtered: the point of this column is to show where a
|
||||
// title lives across every tier even while the table is filtered to one of
|
||||
// them, so "on archive but not live" is visible at a glance.
|
||||
const visibleIds = rows.map((row) => row.id);
|
||||
const tierCountRows = visibleIds.length
|
||||
? await db
|
||||
.select({
|
||||
mediaItemId: storageFiles.mediaItemId,
|
||||
tier: storageTiers.tier,
|
||||
files: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(storageFiles)
|
||||
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(and(inArray(storageFiles.mediaItemId, visibleIds), isNull(storageFiles.missingAt)))
|
||||
.groupBy(storageFiles.mediaItemId, storageTiers.tier)
|
||||
: [];
|
||||
|
||||
const tierCounts = new Map<string, { tier: string; files: number }[]>();
|
||||
for (const row of tierCountRows) {
|
||||
if (!row.mediaItemId) continue;
|
||||
const list = tierCounts.get(row.mediaItemId) ?? [];
|
||||
list.push({ tier: row.tier, files: row.files });
|
||||
tierCounts.set(row.mediaItemId, list);
|
||||
}
|
||||
|
||||
const tierOrder = ["live", "backup", "archive"];
|
||||
const tableRows = rows.map((row) => ({
|
||||
...row,
|
||||
tiers: (tierCounts.get(row.id) ?? []).sort(
|
||||
(a, b) => tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier),
|
||||
),
|
||||
}));
|
||||
|
||||
const expandedItem = expandedId ? rows.find((row) => row.id === expandedId) : null;
|
||||
|
||||
// Television expansion lists every episode TMDB knows about, not just the
|
||||
// ones with files, so gaps in the collection are visible.
|
||||
let seasonTree: SeasonEntry[] = [];
|
||||
let movieFiles: FileEntry[] = [];
|
||||
|
||||
if (expandedItem && kind === "tv") {
|
||||
const treeRows = await db
|
||||
.select({
|
||||
seasonId: seasons.id,
|
||||
seasonNumber: seasons.seasonNumber,
|
||||
episodeId: episodes.id,
|
||||
episodeNumber: episodes.episodeNumber,
|
||||
episodeTitle: episodes.title,
|
||||
airDate: episodes.airDate,
|
||||
fileId: storageFiles.id,
|
||||
relativePath: storageFiles.relativePath,
|
||||
sizeBytes: storageFiles.sizeBytes,
|
||||
quality: storageFiles.quality,
|
||||
codec: storageFiles.codec,
|
||||
width: storageFiles.width,
|
||||
height: storageFiles.height,
|
||||
edition: storageFiles.edition,
|
||||
replaceRequestedAt: storageFiles.replaceRequestedAt,
|
||||
probedAt: storageFiles.probedAt,
|
||||
tier: storageTiers.tier,
|
||||
})
|
||||
.from(seasons)
|
||||
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
|
||||
.leftJoin(storageFiles, and(eq(storageFiles.episodeId, episodes.id), isNull(storageFiles.missingAt)))
|
||||
.leftJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(eq(seasons.seriesId, expandedItem.id))
|
||||
.orderBy(desc(seasons.seasonNumber), asc(episodes.episodeNumber), asc(storageFiles.relativePath));
|
||||
|
||||
// Backup coverage is judged per file: same relative path AND same byte
|
||||
// size. A path that exists at a different size is a stale mirror, which is
|
||||
// worth surfacing separately from "not backed up at all".
|
||||
const backupRows = await db
|
||||
.select({ relativePath: storageFiles.relativePath, sizeBytes: storageFiles.sizeBytes })
|
||||
.from(storageFiles)
|
||||
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(
|
||||
and(
|
||||
eq(storageTiers.tier, "backup"),
|
||||
isNull(storageFiles.missingAt),
|
||||
eq(storageFiles.mediaItemId, expandedItem.id),
|
||||
),
|
||||
);
|
||||
|
||||
const backupIndex = new Map(backupRows.map((row) => [row.relativePath, String(row.sizeBytes ?? "")]));
|
||||
|
||||
const seasonIndex = new Map<string, SeasonEntry>();
|
||||
const episodeIndex = new Map<string, SeasonEntry["episodes"][number]>();
|
||||
|
||||
for (const row of treeRows) {
|
||||
let season = seasonIndex.get(row.seasonId);
|
||||
if (!season) {
|
||||
season = { id: row.seasonId, number: row.seasonNumber, episodes: [] };
|
||||
seasonIndex.set(row.seasonId, season);
|
||||
seasonTree.push(season);
|
||||
}
|
||||
|
||||
let episode = episodeIndex.get(row.episodeId);
|
||||
if (!episode) {
|
||||
episode = {
|
||||
id: row.episodeId,
|
||||
number: row.episodeNumber,
|
||||
title: row.episodeTitle,
|
||||
airDate: row.airDate,
|
||||
files: [],
|
||||
};
|
||||
episodeIndex.set(row.episodeId, episode);
|
||||
season.episodes.push(episode);
|
||||
}
|
||||
|
||||
// Backup is a mirror of live, not a separate copy anyone would ever play.
|
||||
// Listing it duplicated every episode with a byte-identical row, and
|
||||
// because backup is unprobed its quality/codec are filename guesses — so
|
||||
// the two rows disagreed on files that are the same bytes. Report backup
|
||||
// as coverage on the live file instead of as a file of its own.
|
||||
if (row.fileId && row.tier !== "backup") {
|
||||
const backupSize = backupIndex.get(row.relativePath ?? "");
|
||||
episode.files.push({
|
||||
id: row.fileId,
|
||||
relativePath: row.relativePath ?? "",
|
||||
// bigint is not serialisable across the server/client boundary.
|
||||
sizeBytes: Number(row.sizeBytes ?? 0),
|
||||
quality: row.quality,
|
||||
codec: row.codec,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
edition: row.edition,
|
||||
tier: row.tier,
|
||||
probed: row.probedAt !== null,
|
||||
replaceRequested: row.replaceRequestedAt !== null,
|
||||
// Only live is mirrored to backup; archive is itself the long-term
|
||||
// copy, so "not backed up" is not a finding there.
|
||||
backup:
|
||||
row.tier !== "live"
|
||||
? "n/a"
|
||||
: backupSize === undefined
|
||||
? "missing"
|
||||
: backupSize === String(row.sizeBytes ?? "")
|
||||
? "ok"
|
||||
: "stale",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Newest season first, Specials last.
|
||||
seasonTree = seasonTree.sort((a, b) => {
|
||||
if (a.number === 0) return 1;
|
||||
if (b.number === 0) return -1;
|
||||
return b.number - a.number;
|
||||
});
|
||||
}
|
||||
|
||||
if (expandedItem && kind === "movie") {
|
||||
const fileRows = await db
|
||||
.select({
|
||||
id: storageFiles.id,
|
||||
relativePath: storageFiles.relativePath,
|
||||
sizeBytes: storageFiles.sizeBytes,
|
||||
quality: storageFiles.quality,
|
||||
codec: storageFiles.codec,
|
||||
width: storageFiles.width,
|
||||
height: storageFiles.height,
|
||||
edition: storageFiles.edition,
|
||||
replaceRequestedAt: storageFiles.replaceRequestedAt,
|
||||
probedAt: storageFiles.probedAt,
|
||||
tier: storageTiers.tier,
|
||||
})
|
||||
.from(storageFiles)
|
||||
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(
|
||||
and(
|
||||
tierFilter,
|
||||
isNull(storageFiles.missingAt),
|
||||
eq(storageFiles.mediaItemId, expandedItem.id),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(storageTiers.tier), asc(storageFiles.relativePath));
|
||||
|
||||
movieFiles = fileRows.map((row) => ({
|
||||
id: row.id,
|
||||
relativePath: row.relativePath,
|
||||
sizeBytes: Number(row.sizeBytes ?? 0),
|
||||
quality: row.quality,
|
||||
codec: row.codec,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
edition: row.edition,
|
||||
tier: row.tier,
|
||||
probed: row.probedAt !== null,
|
||||
replaceRequested: row.replaceRequestedAt !== null,
|
||||
backup: "n/a" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Inventory</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">{copy.heading}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">{copy.blurb}</p>
|
||||
</div>
|
||||
<Link href="/admin/storage" className="admin-nav-button px-3 py-2 text-sm font-medium">Storage tiers</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(KIND_COPY) as InventoryKind[]).map((value) => (
|
||||
<Link
|
||||
key={value}
|
||||
prefetch={false}
|
||||
href={inventoryHref(value, query, 1, tier, link)}
|
||||
aria-current={value === kind ? "page" : undefined}
|
||||
// The selected fill comes from aria-current in globals.css, so
|
||||
// only the unselected state needs a class here.
|
||||
className={
|
||||
"admin-nav-button px-4 py-2 text-sm font-medium " +
|
||||
(value === kind ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{KIND_COPY[value].label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Tier</span>
|
||||
{TIER_FILTERS.map((entry) => (
|
||||
<Link
|
||||
key={entry.value}
|
||||
prefetch={false}
|
||||
href={inventoryHref(kind, query, 1, entry.value, link)}
|
||||
aria-current={entry.value === tier ? "true" : undefined}
|
||||
title={"Show " + entry.blurb}
|
||||
className={
|
||||
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
|
||||
(entry.value === tier ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">TMDB link</span>
|
||||
{LINK_FILTERS.map((entry) => (
|
||||
<Link
|
||||
key={entry.value}
|
||||
prefetch={false}
|
||||
href={inventoryHref(kind, query, 1, tier, entry.value)}
|
||||
aria-current={entry.value === link ? "true" : undefined}
|
||||
title={"Show " + entry.blurb}
|
||||
className={
|
||||
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
|
||||
(entry.value === link ? "" : "text-admin-muted")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_auto]">
|
||||
<InventorySearch
|
||||
key={kind + ":" + tier}
|
||||
kind={kind}
|
||||
tier={tier}
|
||||
initialQuery={query}
|
||||
label={copy.searchLabel}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
/>
|
||||
<InventoryBulkActions />
|
||||
</div>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="flex flex-col gap-2 border-b border-admin-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-semibold">{copy.label}</h2>
|
||||
<p className="mt-1 text-sm text-admin-muted">
|
||||
Showing page {page} with {rows.length} records — {TIER_FILTERS.find((entry) => entry.value === tier)?.blurb}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-admin-muted">
|
||||
{page > 1 ? <Link prefetch={false} href={inventoryHref(kind, query, page - 1, tier, link)} className="text-admin-accent">Previous</Link> : <span>Previous</span>}
|
||||
<span>Page {page}</span>
|
||||
{rows.length === pageSize ? <Link prefetch={false} href={inventoryHref(kind, query, page + 1, tier, link)} className="text-admin-accent">Next</Link> : <span>Next</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryTable
|
||||
kind={kind}
|
||||
rows={tableRows}
|
||||
query={query}
|
||||
page={page}
|
||||
tier={tier}
|
||||
link={link}
|
||||
expandedId={expandedItem?.id ?? null}
|
||||
seasons={seasonTree}
|
||||
movieFiles={movieFiles}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { db } from "@/db/client";
|
||||
import { classics, episodes, mediaItems, seasons, storageFiles, storageTiers } from "@/db/schema";
|
||||
import { cancelReplacement, requestReplacement } from "../replace-actions";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { addTimelessClassic, removeTimelessClassic } from "../../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function formatBytes(value: string | number | bigint | null) {
|
||||
const bytes = typeof value === "bigint" ? Number(value) : Number(value ?? 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (!seconds || seconds <= 0) return null;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.round((seconds % 3600) / 60);
|
||||
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function seasonLabel(seasonNumber: number) {
|
||||
return seasonNumber === 0 ? "Specials" : "Season " + String(seasonNumber).padStart(2, "0");
|
||||
}
|
||||
|
||||
const TIER_STYLES: Record<string, string> = {
|
||||
live: "border-admin-good text-admin-good",
|
||||
backup: "border-admin-line text-admin-muted",
|
||||
archive: "border-admin-accent text-admin-accent",
|
||||
};
|
||||
|
||||
function TierBadge({ tier }: { tier: string | null }) {
|
||||
if (!tier) return null;
|
||||
return (
|
||||
<span className={"rounded-md border px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide " + (TIER_STYLES[tier] ?? "border-admin-line text-admin-muted")}>
|
||||
{tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminSeriesPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [item] = await db
|
||||
.select({
|
||||
id: mediaItems.id,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
overview: mediaItems.overview,
|
||||
isClassic: sql<boolean>`${classics.id} is not null`,
|
||||
classicNote: classics.note,
|
||||
})
|
||||
.from(mediaItems)
|
||||
.leftJoin(classics, eq(classics.mediaItemId, mediaItems.id))
|
||||
.where(and(eq(mediaItems.id, id), eq(mediaItems.mediaType, "tv_series")))
|
||||
.limit(1);
|
||||
|
||||
if (!item) notFound();
|
||||
|
||||
// Left join the files so an episode with no copy on any tier still renders.
|
||||
const rows = await db
|
||||
.select({
|
||||
seasonId: seasons.id,
|
||||
seasonNumber: seasons.seasonNumber,
|
||||
episodeId: episodes.id,
|
||||
episodeNumber: episodes.episodeNumber,
|
||||
episodeTitle: episodes.title,
|
||||
fileId: storageFiles.id,
|
||||
relativePath: storageFiles.relativePath,
|
||||
sizeBytes: storageFiles.sizeBytes,
|
||||
quality: storageFiles.quality,
|
||||
replaceRequestedAt: storageFiles.replaceRequestedAt,
|
||||
codec: storageFiles.codec,
|
||||
width: storageFiles.width,
|
||||
height: storageFiles.height,
|
||||
edition: storageFiles.edition,
|
||||
audioCodec: storageFiles.audioCodec,
|
||||
audioChannels: storageFiles.audioChannels,
|
||||
durationSeconds: storageFiles.durationSeconds,
|
||||
probedAt: storageFiles.probedAt,
|
||||
tier: storageTiers.tier,
|
||||
})
|
||||
.from(seasons)
|
||||
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
|
||||
.leftJoin(storageFiles, and(eq(storageFiles.episodeId, episodes.id), isNull(storageFiles.missingAt)))
|
||||
.leftJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(eq(seasons.seriesId, item.id))
|
||||
.orderBy(desc(seasons.seasonNumber), asc(episodes.episodeNumber), asc(storageFiles.relativePath));
|
||||
|
||||
// Backup mirrors live, so a backup row is byte-identical to a live one at the
|
||||
// same path. Listing it doubled every episode — and because backup is not
|
||||
// probed, its quality/codec are filename guesses that disagreed with live's
|
||||
// measured values on files that are the same bytes. Report it as coverage.
|
||||
const backupRows = await db
|
||||
.select({ relativePath: storageFiles.relativePath, sizeBytes: storageFiles.sizeBytes })
|
||||
.from(storageFiles)
|
||||
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
|
||||
.where(
|
||||
and(
|
||||
eq(storageTiers.tier, "backup"),
|
||||
isNull(storageFiles.missingAt),
|
||||
eq(storageFiles.mediaItemId, item.id),
|
||||
),
|
||||
);
|
||||
|
||||
const backupIndex = new Map(backupRows.map((row) => [row.relativePath, String(row.sizeBytes ?? "")]));
|
||||
|
||||
type Row = (typeof rows)[number];
|
||||
type Episode = { id: string; number: number; title: string | null; files: Row[] };
|
||||
type Season = { id: string; number: number; episodes: Episode[] };
|
||||
|
||||
const seasonList: Season[] = [];
|
||||
const seasonIndex = new Map<string, Season>();
|
||||
const episodeIndex = new Map<string, Episode>();
|
||||
|
||||
for (const row of rows) {
|
||||
let season = seasonIndex.get(row.seasonId);
|
||||
if (!season) {
|
||||
season = { id: row.seasonId, number: row.seasonNumber, episodes: [] };
|
||||
seasonIndex.set(row.seasonId, season);
|
||||
seasonList.push(season);
|
||||
}
|
||||
|
||||
let episode = episodeIndex.get(row.episodeId);
|
||||
if (!episode) {
|
||||
episode = { id: row.episodeId, number: row.episodeNumber, title: row.episodeTitle, files: [] };
|
||||
episodeIndex.set(row.episodeId, episode);
|
||||
season.episodes.push(episode);
|
||||
}
|
||||
|
||||
if (row.fileId && row.tier !== "backup") episode.files.push(row);
|
||||
}
|
||||
|
||||
// "ok" mirrored at the same size · "stale" present at a different size ·
|
||||
// "missing" no backup copy · "n/a" archive, which is not mirrored to backup.
|
||||
function backupState(files: Row[]): "ok" | "stale" | "missing" | "n/a" {
|
||||
const live = files.filter((file) => file.tier === "live");
|
||||
if (!live.length) return "n/a";
|
||||
let worst: "ok" | "stale" | "missing" = "ok";
|
||||
for (const file of live) {
|
||||
const mirrored = backupIndex.get(file.relativePath ?? "");
|
||||
if (mirrored === undefined) return "missing";
|
||||
if (mirrored !== String(file.sizeBytes ?? "")) worst = "stale";
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// Newest season first, but Specials belong at the bottom rather than the top.
|
||||
seasonList.sort((a, b) => {
|
||||
if (a.number === 0) return 1;
|
||||
if (b.number === 0) return -1;
|
||||
return b.number - a.number;
|
||||
});
|
||||
|
||||
const totalEpisodes = seasonList.reduce((sum, season) => sum + season.episodes.length, 0);
|
||||
const totalFiles = rows.filter((row) => row.fileId).length;
|
||||
const totalBytes = rows.reduce((sum, row) => sum + (row.fileId ? Number(row.sizeBytes ?? 0) : 0), 0);
|
||||
const missingEpisodes = seasonList.reduce(
|
||||
(sum, season) => sum + season.episodes.filter((episode) => !episode.files.length).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Link href="/admin/inventory?kind=tv" className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">
|
||||
← Television inventory
|
||||
</Link>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">{item.title}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
|
||||
{seasonList.length} seasons
|
||||
</span>
|
||||
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
|
||||
{totalEpisodes} episodes
|
||||
</span>
|
||||
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
|
||||
{totalFiles} files / {formatBytes(totalBytes)}
|
||||
</span>
|
||||
{missingEpisodes ? (
|
||||
<span className="rounded-md border border-admin-warn bg-admin-subpanel px-2 py-1 font-semibold text-admin-warn">
|
||||
{missingEpisodes} without a file
|
||||
</span>
|
||||
) : null}
|
||||
{item.isClassic ? (
|
||||
<span className="rounded-md border border-admin-accent bg-admin-subpanel px-2 py-1 font-semibold text-admin-accent">
|
||||
Timeless Classic
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 max-w-4xl text-sm text-admin-muted">{item.overview ?? "No canonical overview stored yet."}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full shrink-0 rounded-md border border-admin-line bg-admin-subpanel p-4 lg:w-72">
|
||||
{item.isClassic ? (
|
||||
<form action={removeTimelessClassic} className="space-y-3">
|
||||
<input type="hidden" name="mediaItemId" value={item.id} />
|
||||
<p className="text-sm text-admin-muted">Locked in main and blocked from Watch Now.</p>
|
||||
{item.classicNote ? <p className="text-sm">{item.classicNote}</p> : null}
|
||||
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-sm font-medium">Remove Classic Lock</button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={addTimelessClassic} className="space-y-3">
|
||||
<label className="block text-sm font-medium" htmlFor="series-note">Timeless note</label>
|
||||
<input type="hidden" name="mediaItemId" value={item.id} />
|
||||
<textarea id="series-note" name="note" rows={3} className="w-full rounded-md border border-admin-line bg-admin-subpanel p-2 text-sm text-admin-text outline-none focus:border-admin-accent" placeholder="Why this stays on main forever" />
|
||||
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-sm font-medium">Mark Timeless Classic</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{seasonList.map((season, index) => {
|
||||
const seasonFiles = season.episodes.flatMap((episode) => episode.files);
|
||||
const seasonBytes = seasonFiles.reduce((sum, file) => sum + Number(file.sizeBytes ?? 0), 0);
|
||||
const seasonMissing = season.episodes.filter((episode) => !episode.files.length).length;
|
||||
const tiers = Array.from(new Set(seasonFiles.map((file) => file.tier).filter(Boolean))) as string[];
|
||||
|
||||
return (
|
||||
<details key={season.id} open={index === 0} className="admin-panel overflow-hidden">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center justify-between gap-3 px-5 py-4 hover:bg-admin-subpanel">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-xl font-semibold">{seasonLabel(season.number)}</h2>
|
||||
{tiers.map((tier) => <TierBadge key={tier} tier={tier} />)}
|
||||
{seasonMissing ? (
|
||||
<span className="rounded-md border border-admin-warn px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-admin-warn">
|
||||
{seasonMissing} missing
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-admin-muted">
|
||||
{season.episodes.length} episodes · {seasonFiles.length} files · {formatBytes(seasonBytes)}
|
||||
</p>
|
||||
</summary>
|
||||
|
||||
<div className="divide-y divide-admin-line border-t border-admin-line">
|
||||
{season.episodes.map((episode) => {
|
||||
const hasFile = episode.files.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={episode.id}
|
||||
className={hasFile ? "px-5 py-3" : "bg-admin-missing px-5 py-3"}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-mono text-sm text-admin-muted">
|
||||
{"E" + String(episode.number).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{episode.title ?? "Untitled episode"}</span>
|
||||
{hasFile ? (
|
||||
(() => {
|
||||
const state = backupState(episode.files);
|
||||
if (state === "n/a") return null;
|
||||
if (state === "ok") {
|
||||
return <span title="Mirrored to backup at the same size" className="text-xs font-semibold text-admin-good">✓ backed up</span>;
|
||||
}
|
||||
if (state === "stale") {
|
||||
return <span title="A backup copy exists at this path but its size differs from live" className="text-xs font-semibold text-admin-warn">! backup differs</span>;
|
||||
}
|
||||
return <span title="No backup copy of this file" className="text-xs font-semibold text-admin-warn">✗ not backed up</span>;
|
||||
})()
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-admin-muted">Not in collection</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{episode.files.map((file) => {
|
||||
const resolution = file.width && file.height ? `${file.width}x${file.height}` : null;
|
||||
const duration = formatDuration(file.durationSeconds);
|
||||
const audio = file.audioCodec
|
||||
? file.audioCodec + (file.audioChannels ? ` ${file.audioChannels}ch` : "")
|
||||
: null;
|
||||
return (
|
||||
<div key={file.fileId} className="mt-2 rounded-md border border-admin-line bg-admin-subpanel px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<TierBadge tier={file.tier} />
|
||||
{file.edition ? (
|
||||
<span className="rounded-md border border-admin-accent px-1.5 py-0.5 font-semibold uppercase text-admin-accent">
|
||||
{file.edition === "bw" ? "B&W" : file.edition}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-semibold">{file.quality ?? "Unknown"}</span>
|
||||
{resolution ? <span className="text-admin-muted">{resolution}</span> : null}
|
||||
<span className="text-admin-muted">{file.codec ?? "Unknown"}</span>
|
||||
{audio ? <span className="text-admin-muted">{audio}</span> : null}
|
||||
{duration ? <span className="text-admin-muted">{duration}</span> : null}
|
||||
<span className="text-admin-muted">{formatBytes(file.sizeBytes)}</span>
|
||||
{file.probedAt ? null : (
|
||||
<span className="text-admin-muted italic">from filename, not probed</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 break-all text-xs text-admin-muted">{file.relativePath}</p>
|
||||
{/* Live only. A request is about the copy being
|
||||
served, and flagging a backup or archive file
|
||||
would mark something the reaper never looks at
|
||||
and so do nothing at all. */}
|
||||
{file.tier === "live" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{file.replaceRequestedAt ? (
|
||||
<>
|
||||
<span className="text-xs font-semibold text-admin-warn">
|
||||
Replacement wanted — at the front of the queue
|
||||
</span>
|
||||
<form action={cancelReplacement}>
|
||||
<input type="hidden" name="fileId" value={file.fileId ?? ""} />
|
||||
<input type="hidden" name="seriesId" value={id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-admin-line px-2 py-0.5 text-xs text-admin-muted transition hover:text-admin-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form action={requestReplacement}>
|
||||
<input type="hidden" name="fileId" value={file.fileId ?? ""} />
|
||||
<input type="hidden" name="seriesId" value={id} />
|
||||
<button
|
||||
type="submit"
|
||||
title="Look for a better copy, and delete this one once it arrives"
|
||||
className="rounded border border-admin-line px-2 py-0.5 text-xs text-admin-muted transition hover:border-admin-accent hover:text-admin-accent"
|
||||
>
|
||||
Replace this copy
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use server";
|
||||
|
||||
// Ask for a better copy of one episode.
|
||||
//
|
||||
// Three things happen, and they are deliberately three rather than one big
|
||||
// one: the file is flagged, the episode's search cooldown is cleared, and the
|
||||
// page is revalidated. What does NOT happen here is the download. The fetch
|
||||
// loop owns searching and grabbing, it applies the identity gate and the size
|
||||
// rules, and reproducing any of that in a server action would mean two places
|
||||
// deciding what is worth grabbing.
|
||||
//
|
||||
// So the button's promise is: this copy is marked, the episode is at the front
|
||||
// of the queue, and the next fetch cycle will go looking. Not "it is
|
||||
// downloading now".
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { searchRuns, storageFiles, storageTiers } from "@/db/schema";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
if (!session.user.isAdmin) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
function optionalString(value: FormDataEntryValue | null) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function requestReplacement(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const fileId = optionalString(formData.get("fileId"));
|
||||
const seriesId = optionalString(formData.get("seriesId"));
|
||||
if (!fileId) return;
|
||||
|
||||
// Live only. A request is about the copy being served; flagging a backup or
|
||||
// archive file would mark something the reaper will never touch and quietly
|
||||
// do nothing at all.
|
||||
const [flagged] = await db
|
||||
.update(storageFiles)
|
||||
.set({ replaceRequestedAt: new Date(), replaceRequestedBy: session.user.id })
|
||||
.where(and(
|
||||
eq(storageFiles.id, fileId),
|
||||
isNull(storageFiles.missingAt),
|
||||
sql`${storageFiles.tierId} in (select id from ${storageTiers} where tier = 'live')`,
|
||||
))
|
||||
.returning({ episodeId: storageFiles.episodeId, mediaItemId: storageFiles.mediaItemId });
|
||||
|
||||
// Clear the search cooldown so the next cycle actually looks. Without this a
|
||||
// title searched in the last six hours would be skipped, and the button
|
||||
// would appear to do nothing for most of a working day.
|
||||
if (flagged?.mediaItemId) {
|
||||
await db.delete(searchRuns).where(eq(searchRuns.mediaItemId, flagged.mediaItemId));
|
||||
}
|
||||
|
||||
if (seriesId) revalidatePath(`/admin/inventory/series/${seriesId}`);
|
||||
revalidatePath("/admin/inventory");
|
||||
}
|
||||
|
||||
/** Change your mind. Clears the flag, and with it the queue entry and the reaper's licence. */
|
||||
export async function cancelReplacement(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const fileId = optionalString(formData.get("fileId"));
|
||||
const seriesId = optionalString(formData.get("seriesId"));
|
||||
if (!fileId) return;
|
||||
|
||||
await db
|
||||
.update(storageFiles)
|
||||
.set({ replaceRequestedAt: null, replaceRequestedBy: null })
|
||||
.where(eq(storageFiles.id, fileId));
|
||||
|
||||
if (seriesId) revalidatePath(`/admin/inventory/series/${seriesId}`);
|
||||
revalidatePath("/admin/inventory");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { db } from "@/db/client";
|
||||
import { maintenanceJobs } from "@/db/schema";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatWhen(value: Date | null) {
|
||||
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (ms === null) return "—";
|
||||
if (ms < 1000) return ms + " ms";
|
||||
if (ms < 60_000) return (ms / 1000).toFixed(1) + " s";
|
||||
return Math.round(ms / 60_000) + " min";
|
||||
}
|
||||
|
||||
// "skipped" is deliberately neutral rather than a warning. Backup and archive
|
||||
// live on a host that is powered off most of the time, so a scan declining to
|
||||
// run is the system behaving correctly, not a fault.
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
running: "border-admin-accent text-admin-accent",
|
||||
succeeded: "border-admin-good text-admin-good",
|
||||
skipped: "border-admin-line text-admin-muted",
|
||||
failed: "border-admin-warn text-admin-warn",
|
||||
};
|
||||
|
||||
export default async function AdminJobsPage() {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(maintenanceJobs)
|
||||
.orderBy(desc(maintenanceJobs.startedAt))
|
||||
.limit(100);
|
||||
|
||||
// Latest outcome per job, so the health of the whole schedule reads at a glance.
|
||||
const latest = await db.execute<{
|
||||
job: string;
|
||||
status: string;
|
||||
started_at: Date;
|
||||
duration_ms: number | null;
|
||||
detail: string | null;
|
||||
runs: number;
|
||||
failures: number;
|
||||
}>(sql`
|
||||
select distinct on (job)
|
||||
job, status, started_at, duration_ms, detail,
|
||||
count(*) over (partition by job)::int runs,
|
||||
count(*) filter (where status = 'failed') over (partition by job)::int failures
|
||||
from maintenance_jobs
|
||||
order by job, started_at desc`);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Maintenance</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Jobs</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
Everything that touches the filesystem runs in the <code>ampelos-maintenance</code> container: scanning,
|
||||
probing, metadata, and the placement classifier. This dashboard only reads storage — write access lives
|
||||
in that container so nothing reachable from the network can modify media.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Current State</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{latest.rows.length ? latest.rows.map((row) => (
|
||||
<div key={row.job} className="grid gap-2 px-5 py-3 lg:grid-cols-[1fr_7rem_9rem_6rem_1fr] lg:items-center">
|
||||
<p className="font-mono text-sm">{row.job}</p>
|
||||
<span className={"justify-self-start rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[row.status] ?? "border-admin-line")}>
|
||||
{row.status}
|
||||
</span>
|
||||
<p className="text-xs text-admin-muted">{formatWhen(row.started_at)}</p>
|
||||
<p className="text-xs text-admin-muted">{formatDuration(row.duration_ms)}</p>
|
||||
<p className="truncate text-xs text-admin-muted" title={row.detail ?? ""}>
|
||||
{row.detail ?? (row.failures ? row.failures + " of " + row.runs + " runs failed" : "")}
|
||||
</p>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="p-5 text-sm text-admin-muted">
|
||||
No maintenance job has reported yet. Start the container with
|
||||
<code className="mx-1">docker compose -f maintenance/docker-compose.yml up -d</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Recent Runs</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{runs.map((run) => (
|
||||
<details key={run.id} className="px-5 py-3">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-3">
|
||||
<span className={"rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[run.status] ?? "border-admin-line")}>
|
||||
{run.status}
|
||||
</span>
|
||||
<span className="font-mono text-sm">{run.job}</span>
|
||||
<span className="text-xs text-admin-muted">{formatWhen(run.startedAt)}</span>
|
||||
<span className="text-xs text-admin-muted">{formatDuration(run.durationMs)}</span>
|
||||
<span className="text-xs text-admin-muted">{run.trigger}</span>
|
||||
{run.detail ? <span className="text-xs text-admin-warn">{run.detail}</span> : null}
|
||||
</summary>
|
||||
{run.output ? (
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-md border border-admin-line bg-admin-subpanel p-3 text-xs text-admin-muted">
|
||||
{run.output}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-admin-muted">No output captured.</p>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
{!runs.length ? <p className="p-5 text-sm text-admin-muted">Nothing recorded yet.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
// The manual search panel: what Sonarr's magnifying glass does.
|
||||
//
|
||||
// The design principle here is that a rejected release is information, not
|
||||
// noise. When the automatic pass reports "nothing found", the useful answer is
|
||||
// almost never "the internet is empty" -- it is "eleven results, and every one
|
||||
// of them was a telesync" or "the only copy is 40MB, which is a fake". So this
|
||||
// lists everything the fan-out saw, marks what the rules refused and why, and
|
||||
// still lets a person take it anyway. They can see the reason; they can
|
||||
// overrule it.
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
runManualSearch,
|
||||
grabCandidate,
|
||||
type SearchState,
|
||||
type GrabState,
|
||||
} from "./search-actions";
|
||||
import type { SearchCandidate } from "@/lib/indexer";
|
||||
|
||||
function formatBytes(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value) || value <= 0) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
||||
}
|
||||
|
||||
// The rejection reasons are machine strings from decide.mjs. They are readable
|
||||
// enough to keep as a tooltip, but the badge should say the thing in English.
|
||||
function rejectionLabel(reason: string | null) {
|
||||
if (!reason) return "Rejected";
|
||||
const [kind, detail] = reason.split(":");
|
||||
switch (kind) {
|
||||
case "blocklisted": return "Blocklisted";
|
||||
case "blocked-group": return `Blocked group ${detail ?? ""}`.trim();
|
||||
case "rejected": return detail === "cam" ? "Camcorder rip" : `Rejected (${detail})`;
|
||||
case "below-min-quality": return `Below minimum quality`;
|
||||
case "too-few-seeders": return `Only ${detail} seeders`;
|
||||
case "seeders-unknown-and-required": return "Swarm size unknown";
|
||||
case "too-small": return "Suspiciously small";
|
||||
case "too-large": return "Larger than wanted";
|
||||
case "season-pack-not-wanted": return "Season pack";
|
||||
case "no-download-link": return "No usable link";
|
||||
default: return reason;
|
||||
}
|
||||
}
|
||||
|
||||
function Candidate({
|
||||
candidate,
|
||||
searchId,
|
||||
onGrabbed,
|
||||
}: {
|
||||
candidate: SearchCandidate;
|
||||
searchId: string;
|
||||
onGrabbed: (state: GrabState) => void;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function grab() {
|
||||
if (!candidate.infoHash) return;
|
||||
startTransition(async () => {
|
||||
onGrabbed(await grabCandidate({ searchId, infoHash: candidate.infoHash! }));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"grid gap-2 px-3 py-2 text-xs lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]" + (candidate.accepted ? "" : " bg-admin-missing")}>
|
||||
<div className="min-w-0">
|
||||
{/* The full release name, unabbreviated and selectable. A group that
|
||||
ships broken files is identified by this string and no other. */}
|
||||
<p className="break-all font-mono text-admin-text">{candidate.title}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-admin-muted">
|
||||
<span>{candidate.indexerName}</span>
|
||||
{candidate.origin ? <span>via {candidate.origin}</span> : null}
|
||||
{candidate.indexerCount > 1 ? (
|
||||
<span title="Found on more than one indexer, which makes a fake less likely" className="text-admin-good">
|
||||
×{candidate.indexerCount} indexers
|
||||
</span>
|
||||
) : null}
|
||||
{candidate.hasAtmos ? <span className="text-admin-accent">Atmos</span> : null}
|
||||
{candidate.isSeasonPack ? <span>season pack</span> : null}
|
||||
{!candidate.accepted ? (
|
||||
<span title={candidate.rejection ?? ""} className="font-semibold text-admin-warn">
|
||||
{rejectionLabel(candidate.rejection)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-admin-muted">{candidate.quality ?? "unknown"}</span>
|
||||
<span className="text-admin-muted">{candidate.source ?? "—"}</span>
|
||||
<span className={candidate.seeders === null ? "text-admin-warn" : "text-admin-text"}>
|
||||
{candidate.seeders === null ? "?" : candidate.seeders}
|
||||
</span>
|
||||
<span className="text-admin-muted">{formatBytes(candidate.size)}</span>
|
||||
<span title={candidate.reasons.join("\n")} className="cursor-help font-semibold">
|
||||
{candidate.accepted ? candidate.score : "—"}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={grab}
|
||||
disabled={isPending || !candidate.infoHash}
|
||||
title={
|
||||
candidate.accepted
|
||||
? "Send to qBittorrent"
|
||||
: "Send to qBittorrent anyway, overruling: " + (candidate.rejection ?? "")
|
||||
}
|
||||
className={
|
||||
"admin-nav-button w-full px-2 py-1 text-xs font-medium " +
|
||||
(candidate.accepted ? "" : "text-admin-warn")
|
||||
}
|
||||
>
|
||||
{isPending ? "…" : candidate.accepted ? "Grab" : "Grab anyway"}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualSearch({
|
||||
mediaItemId,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
label,
|
||||
compact = false,
|
||||
}: {
|
||||
mediaItemId: string;
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState<SearchState | null>(null);
|
||||
const [notice, setNotice] = useState<GrabState | null>(null);
|
||||
const [showRejected, setShowRejected] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function search() {
|
||||
setNotice(null);
|
||||
startTransition(async () => {
|
||||
setState(await runManualSearch({ mediaItemId, seasonNumber, episodeNumber }));
|
||||
});
|
||||
}
|
||||
|
||||
const result = state?.ok ? state.result : null;
|
||||
const accepted = result?.candidates.filter((c) => c.accepted) ?? [];
|
||||
const rejected = result?.candidates.filter((c) => !c.accepted) ?? [];
|
||||
const shown = showRejected ? [...accepted, ...rejected] : accepted;
|
||||
|
||||
return (
|
||||
<div className={compact ? "" : "space-y-2"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); search(); }}
|
||||
disabled={isPending}
|
||||
className="admin-nav-button px-2 py-1 text-xs font-medium"
|
||||
title={"Search indexers for " + (label ?? "this title")}
|
||||
>
|
||||
{isPending ? "Searching…" : state ? "Search again" : "Search"}
|
||||
</button>
|
||||
|
||||
{state && !state.ok ? (
|
||||
<p className="mt-2 rounded-md border border-admin-warn px-3 py-2 text-xs text-admin-warn">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p className={"mt-2 text-xs " + (notice.ok ? "text-admin-good" : "text-admin-warn")}>
|
||||
{notice.ok ? notice.message : notice.error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<div className="mt-2 rounded-md border border-admin-line bg-admin-subpanel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
|
||||
<p className="text-xs text-admin-muted">
|
||||
<span className="font-semibold text-admin-text">{result.label}</span>
|
||||
{" — "}
|
||||
{result.candidates.length} results from {result.indexersQueried} indexers
|
||||
{" · "}{accepted.length} usable
|
||||
{" · "}{(result.durationMs / 1000).toFixed(1)}s
|
||||
</p>
|
||||
{rejected.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRejected((value) => !value)}
|
||||
className="admin-nav-button px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
{showRejected ? "Hide" : "Show"} {rejected.length} rejected
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Why an indexer contributed nothing. Without this, a thin result
|
||||
set reads as "nothing exists" when the real answer is that EZTV
|
||||
was skipped because this series has no IMDB id recorded. */}
|
||||
{result.skipped.length || result.errors.length ? (
|
||||
<p className="border-b border-admin-line px-3 py-2 text-xs text-admin-muted">
|
||||
{result.skipped.map((s) => `${s.indexer}: ${s.reason}`).join(" · ")}
|
||||
{result.skipped.length && result.errors.length ? " · " : ""}
|
||||
{result.errors.map((e) => `${e.indexer} failed: ${e.error}`).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="hidden border-b border-admin-line px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] text-admin-muted lg:grid lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]">
|
||||
<span>Release</span><span>Quality</span><span>Source</span>
|
||||
<span>Seed</span><span>Size</span><span>Score</span><span />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-admin-line">
|
||||
{shown.map((candidate, index) => (
|
||||
<Candidate
|
||||
key={(candidate.infoHash ?? "no-hash") + ":" + index}
|
||||
candidate={candidate}
|
||||
searchId={result.searchId}
|
||||
onGrabbed={setNotice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!shown.length ? (
|
||||
<p className="px-3 py-3 text-xs text-admin-muted">
|
||||
{rejected.length
|
||||
? `No usable release. All ${rejected.length} results were rejected — show them to see why.`
|
||||
: "No indexer returned anything for this."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { db } from "@/db/client";
|
||||
import { desiredStates, mediaItems, storageAvailability, storageTiers, users, watchingNowItems, watchlistItems } from "@/db/schema";
|
||||
import { count, desc, eq, isNull } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [
|
||||
[{ userCount }],
|
||||
[{ watchingNowCount }],
|
||||
[{ watchlistCount }],
|
||||
[{ desiredStateCount }],
|
||||
recentDemand,
|
||||
tiers,
|
||||
availabilityRows,
|
||||
] = await Promise.all([
|
||||
db.select({ userCount: count() }).from(users),
|
||||
db.select({ watchingNowCount: count() }).from(watchingNowItems).where(isNull(watchingNowItems.removedAt)),
|
||||
db.select({ watchlistCount: count() }).from(watchlistItems).where(isNull(watchlistItems.removedAt)),
|
||||
db.select({ desiredStateCount: count() }).from(desiredStates),
|
||||
db
|
||||
.select({
|
||||
id: watchingNowItems.id,
|
||||
title: mediaItems.title,
|
||||
mediaType: mediaItems.mediaType,
|
||||
displayName: users.displayName,
|
||||
addedAt: watchingNowItems.addedAt,
|
||||
})
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(users, eq(users.id, watchingNowItems.userId))
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(isNull(watchingNowItems.removedAt))
|
||||
.orderBy(desc(watchingNowItems.addedAt))
|
||||
.limit(5),
|
||||
db.select().from(storageTiers).orderBy(storageTiers.tier),
|
||||
db.select().from(storageAvailability).orderBy(desc(storageAvailability.detectedAt)).limit(20),
|
||||
]);
|
||||
|
||||
const latestAvailability = new Map<string, (typeof availabilityRows)[number]>();
|
||||
for (const row of availabilityRows) {
|
||||
if (!latestAvailability.has(row.tierId)) {
|
||||
latestAvailability.set(row.tierId, row);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ label: "Users", value: userCount, href: "/admin/users" },
|
||||
{ label: "Watching Now", value: watchingNowCount, href: "/admin/demand" },
|
||||
{ label: "Watchlist", value: watchlistCount, href: "/admin/demand" },
|
||||
{ label: "Desired states", value: desiredStateCount, href: "/admin/policy" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Control plane</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Admin Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Link key={stat.label} href={stat.href} className="admin-card block p-4">
|
||||
<p className="text-sm text-admin-muted">{stat.label}</p>
|
||||
<p className="mt-2 text-3xl font-bold">{stat.value}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<section className="admin-panel p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Recent Watching Now</h2>
|
||||
<Link href="/admin/demand" className="text-sm font-medium text-admin-accent hover:text-admin-text">View demand</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{recentDemand.length ? recentDemand.map((item) => (
|
||||
<div key={item.id} className="grid gap-2 py-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-admin-muted">{item.displayName} · {item.mediaType === "movie" ? "Movie" : "TV"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
|
||||
</div>
|
||||
)) : <p className="text-sm text-admin-muted">No active Watching Now items yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Storage Tiers</h2>
|
||||
<Link href="/admin/storage" className="text-sm font-medium text-admin-accent hover:text-admin-text">View storage</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{tiers.length ? tiers.map((tier) => {
|
||||
const latest = latestAvailability.get(tier.id);
|
||||
const online = tier.alwaysOnline || latest?.online;
|
||||
return (
|
||||
<div key={tier.id} className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">{tier.label}</p>
|
||||
<span className={online ? "text-sm font-medium text-admin-good" : "text-sm font-medium text-admin-muted"}>{online ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-admin-muted">{tier.basePath}</p>
|
||||
</div>
|
||||
);
|
||||
}) : <p className="text-sm text-admin-muted">No storage tiers configured yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { db } from "@/db/client";
|
||||
import { desiredStates, policyRuns } from "@/db/schema";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
|
||||
}
|
||||
|
||||
// Desired state is only half the picture; the work it implies comes from
|
||||
// comparing it to where files actually are.
|
||||
//
|
||||
// Backup is excluded because it mirrors live rather than being a placement of
|
||||
// its own. Unaired episodes are excluded from the work queue because they
|
||||
// cannot be acquired yet — they stay in desired_states so gaps stay visible.
|
||||
//
|
||||
// This is expressed as a CTE and a hash join on purpose. The obvious version, a
|
||||
// correlated subquery per row, measured 91 SECONDS for a single evaluation of
|
||||
// 4,000 rows; this covers the whole library in about 600ms.
|
||||
const PLACEMENT = sql`
|
||||
with actual as (
|
||||
select sf.media_item_id mid, se.season_number sn, e.episode_number en,
|
||||
array_agg(distinct t.tier) tiers
|
||||
from storage_files sf
|
||||
join storage_tiers t on t.id = sf.tier_id
|
||||
left join episodes e on e.id = sf.episode_id
|
||||
left join seasons se on se.id = e.season_id
|
||||
where sf.missing_at is null and t.tier <> 'backup'
|
||||
group by 1, 2, 3
|
||||
),
|
||||
joined as (
|
||||
select ds.id, mi.title, mi.media_type, ds.season_number sn, ds.episode_number en,
|
||||
ds.storage_tier, ds.reason_codes,
|
||||
case
|
||||
when a.tiers is null then 'acquire'
|
||||
when ds.storage_tier = 'live' and not ('live' = any(a.tiers)) then 'restore'
|
||||
when ds.storage_tier = 'archive' and ('live' = any(a.tiers)) then 'demote'
|
||||
else 'settled'
|
||||
end action
|
||||
from desired_states ds
|
||||
join media_items mi on mi.id = ds.media_item_id
|
||||
left join actual a
|
||||
on a.mid = ds.media_item_id
|
||||
and a.sn is not distinct from ds.season_number
|
||||
and a.en is not distinct from ds.episode_number
|
||||
where ds.wanted
|
||||
and ds.storage_tier is not null
|
||||
and not ('unaired' = any(ds.reason_codes))
|
||||
)`;
|
||||
|
||||
type ActionRow = { id: string; title: string; sn: number | null; en: number | null; action: string };
|
||||
|
||||
export default async function AdminPolicyPage() {
|
||||
const runs = await db.select().from(policyRuns).orderBy(desc(policyRuns.startedAt)).limit(10);
|
||||
|
||||
const counts = await db.execute<{ action: string; n: number }>(
|
||||
sql`${PLACEMENT} select action, count(*)::int n from joined group by 1`,
|
||||
);
|
||||
|
||||
// Ranked per action, not a flat LIMIT: a plain "order by action limit 240"
|
||||
// is swallowed whole by whichever action sorts first, leaving the other
|
||||
// columns rendering empty.
|
||||
const samples = await db.execute<ActionRow>(
|
||||
sql`${PLACEMENT},
|
||||
ranked as (
|
||||
select id, title, sn, en, action,
|
||||
row_number() over (partition by action order by title, sn, en) rank
|
||||
from joined where action <> 'settled'
|
||||
)
|
||||
select id, title, sn, en, action from ranked where rank <= 10`,
|
||||
);
|
||||
|
||||
const reasonTally = await db.execute<{ code: string; n: number }>(
|
||||
sql`select code, count(*)::int n
|
||||
from desired_states, unnest(reason_codes) code
|
||||
group by 1 order by 2 desc`,
|
||||
);
|
||||
|
||||
const [totals] = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)::int`,
|
||||
wanted: sql<number>`count(*) filter (where ${desiredStates.wanted})::int`,
|
||||
monitored: sql<number>`count(*) filter (where ${desiredStates.monitored})::int`,
|
||||
live: sql<number>`count(*) filter (where ${desiredStates.storageTier} = 'live')::int`,
|
||||
archive: sql<number>`count(*) filter (where ${desiredStates.storageTier} = 'archive')::int`,
|
||||
})
|
||||
.from(desiredStates);
|
||||
|
||||
const countFor = (action: string) => Number(counts.rows.find((row) => row.action === action)?.n ?? 0);
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: "restore", label: "Restore", blurb: "archive → live", tone: "text-admin-accent" },
|
||||
{ key: "demote", label: "Demote", blurb: "live → archive", tone: "text-admin-muted" },
|
||||
{ key: "acquire", label: "Acquire", blurb: "not held on any tier", tone: "text-admin-warn" },
|
||||
] as const;
|
||||
|
||||
function label(row: ActionRow) {
|
||||
if (row.sn === null) return row.title;
|
||||
return row.title + " S" + String(row.sn).padStart(2, "0") +
|
||||
(row.en !== null ? "E" + String(row.en).padStart(2, "0") : "");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Policy</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Desired State</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
Where every episode and movie <em>should</em> live, computed from the retention rules, compared to where it
|
||||
actually is. This is a plan, not an instruction — nothing here moves a file. The mover stays disarmed until
|
||||
Ampelos takes over from Sonarr and Radarr.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/policy/sizes" className="admin-nav-button px-3 py-2 text-sm font-medium">
|
||||
Release size limits
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
{ label: "Evaluated", value: totals?.total ?? 0 },
|
||||
{ label: "Wanted", value: totals?.wanted ?? 0 },
|
||||
{ label: "Monitored for upgrades", value: totals?.monitored ?? 0 },
|
||||
{ label: "Belongs on live", value: totals?.live ?? 0 },
|
||||
{ label: "Belongs on archive", value: totals?.archive ?? 0 },
|
||||
].map((tile) => (
|
||||
<div key={tile.label} className="admin-panel p-4">
|
||||
<p className="text-xs text-admin-muted">{tile.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{tile.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Placement Work Implied</h2>
|
||||
<p className="mt-1 text-sm text-admin-muted">
|
||||
{countFor("settled")} items are already where they belong.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 p-5 lg:grid-cols-3">
|
||||
{ACTIONS.map((action) => {
|
||||
const rows = samples.rows.filter((row) => row.action === action.key);
|
||||
const total = countFor(action.key);
|
||||
return (
|
||||
<div key={action.key} className="rounded-md border border-admin-line bg-admin-subpanel p-4">
|
||||
<p className={"text-sm font-semibold " + action.tone}>{action.label}</p>
|
||||
<p className="text-xs text-admin-muted">{action.blurb}</p>
|
||||
<p className="mt-2 text-3xl font-semibold">{total}</p>
|
||||
<ul className="mt-3 space-y-1 text-xs text-admin-muted">
|
||||
{rows.slice(0, 10).map((row) => (
|
||||
<li key={row.id} className="truncate">{label(row)}</li>
|
||||
))}
|
||||
{total > rows.slice(0, 10).length ? (
|
||||
<li>…and {total - rows.slice(0, 10).length} more</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Why</h2>
|
||||
<p className="mt-1 text-sm text-admin-muted">Reason codes across every computed decision.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{reasonTally.rows.map((row) => (
|
||||
<div key={row.code} className="flex items-center justify-between px-5 py-2 text-sm">
|
||||
<span className={row.code === "metadata_missing" ? "text-admin-warn" : ""}>{row.code}</span>
|
||||
<span className="text-admin-muted">{row.n}</span>
|
||||
</div>
|
||||
))}
|
||||
{!reasonTally.rows.length ? (
|
||||
<p className="p-5 text-sm text-admin-muted">No desired state computed yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="border-b border-admin-line px-5 py-4">
|
||||
<h2 className="font-serif text-xl font-semibold">Recent Policy Runs</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{runs.length ? runs.map((run) => (
|
||||
<div key={run.id} className="grid gap-3 px-5 py-4 md:grid-cols-[0.8fr_1fr_1fr_0.7fr_0.7fr] md:items-center">
|
||||
<p className="font-medium">{run.trigger}</p>
|
||||
<p className="text-sm text-admin-muted">Started {formatDate(run.startedAt)}</p>
|
||||
<p className="text-sm text-admin-muted">Completed {formatDate(run.completedAt)}</p>
|
||||
<p className="text-sm text-admin-muted">Evaluated {run.itemsEvaluated ?? 0}</p>
|
||||
<p className="text-sm text-admin-muted">Changed {run.itemsChanged ?? 0}</p>
|
||||
</div>
|
||||
)) : <p className="p-5 text-sm text-admin-muted">No policy runs recorded yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
// Server action for the release size rules.
|
||||
//
|
||||
// Re-checks the session like every other action here: Server Actions are
|
||||
// reachable by direct POST, not only through the form on the page, so the
|
||||
// layout's admin guard governs rendering and nothing more.
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { releaseSizeRules } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const QUALITIES = ["sd", "720p", "1080p", "4k"] as const;
|
||||
type Quality = (typeof QUALITIES)[number];
|
||||
|
||||
/**
|
||||
* Read one rate off the form.
|
||||
*
|
||||
* A blank or unparseable field is rejected rather than coerced. Number("") is
|
||||
* 0, and a silent 0 here is not a small mistake -- a floor of 0 accepts every
|
||||
* fake and a ceiling of 0 rejects everything ever offered.
|
||||
*/
|
||||
function rate(formData: FormData, name: string, { optional = false } = {}) {
|
||||
const raw = String(formData.get(name) ?? "").trim();
|
||||
if (raw === "") {
|
||||
if (optional) return null;
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive number`);
|
||||
}
|
||||
// numeric(6,1): four digits before the point, one after.
|
||||
if (value > 9999) throw new Error(`${name} is implausibly large`);
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export async function saveSizeRulesAction(formData: FormData) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
|
||||
|
||||
for (const quality of QUALITIES) {
|
||||
const min = rate(formData, `${quality}-min`);
|
||||
const max = rate(formData, `${quality}-max`);
|
||||
const atmosMax = rate(formData, `${quality}-atmos`, { optional: true });
|
||||
const timelessMax = rate(formData, `${quality}-timeless`, { optional: true });
|
||||
|
||||
// Checked per band rather than trusted: an inverted pair silently rejects
|
||||
// every release of that quality, and the symptom -- nothing is ever
|
||||
// grabbed -- looks nothing like the cause.
|
||||
if (min !== null && max !== null && min >= max) {
|
||||
throw new Error(`${quality}: the minimum must be below the maximum`);
|
||||
}
|
||||
if (atmosMax !== null && max !== null && atmosMax < max) {
|
||||
throw new Error(
|
||||
`${quality}: the Atmos allowance cannot be below the ordinary maximum — ` +
|
||||
"it is an allowance, not a second ceiling",
|
||||
);
|
||||
}
|
||||
|
||||
if (timelessMax !== null && max !== null && timelessMax < max) {
|
||||
throw new Error(
|
||||
`${quality}: the timeless allowance cannot be below the ordinary maximum`,
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(releaseSizeRules)
|
||||
.set({
|
||||
minMbPerMinute: String(min),
|
||||
maxMbPerMinute: String(max),
|
||||
atmosMaxMbPerMinute: atmosMax === null ? null : String(atmosMax),
|
||||
timelessMaxMbPerMinute: timelessMax === null ? null : String(timelessMax),
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where(eq(releaseSizeRules.quality, quality));
|
||||
}
|
||||
|
||||
revalidatePath("/admin/policy/sizes");
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { db } from "@/db/client";
|
||||
import { releaseSizeRules } from "@/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
import { saveSizeRulesAction } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The runtimes the preview is drawn at. Deliberately not round numbers only:
|
||||
// MB per minute is an abstract unit and nobody can judge it directly, but
|
||||
// everyone knows what a half-hour episode and a two-hour film should weigh.
|
||||
const PREVIEW_RUNTIMES = [
|
||||
{ minutes: 22, label: "22 min", note: "half-hour episode" },
|
||||
{ minutes: 45, label: "45 min", note: "drama episode" },
|
||||
{ minutes: 110, label: "110 min", note: "typical film" },
|
||||
{ minutes: 180, label: "180 min", note: "epic" },
|
||||
];
|
||||
|
||||
const QUALITY_ORDER = ["sd", "720p", "1080p", "4k"] as const;
|
||||
|
||||
type Rule = {
|
||||
quality: string;
|
||||
min: number;
|
||||
max: number;
|
||||
atmosMax: number | null;
|
||||
timelessMax: number | null;
|
||||
observed: { n: number; p05: number; p50: number; p95: number } | null;
|
||||
};
|
||||
|
||||
function gb(mbPerMinute: number, minutes: number) {
|
||||
return ((mbPerMinute * minutes) / 1024).toFixed(2);
|
||||
}
|
||||
|
||||
export default async function AdminSizeRulesPage() {
|
||||
const rows = await db.select().from(releaseSizeRules);
|
||||
|
||||
// What the library actually holds, alongside what the rules allow. A limit
|
||||
// set without the distribution in front of you is a guess, and the whole
|
||||
// point of these numbers is that they were measured rather than guessed.
|
||||
//
|
||||
// Probed files only, and only those with a real duration: filenames in this
|
||||
// library are wrong about quality on 47% of files, so an unprobed row cannot
|
||||
// say what a 1080p file weighs.
|
||||
const { rows: observed } = await db.execute<{
|
||||
quality: string;
|
||||
n: number;
|
||||
p05: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
}>(sql`
|
||||
select sf.quality::text as quality,
|
||||
count(*)::int as n,
|
||||
round(percentile_cont(0.05) within group (
|
||||
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p05,
|
||||
round(percentile_cont(0.50) within group (
|
||||
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p50,
|
||||
round(percentile_cont(0.95) within group (
|
||||
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p95
|
||||
from storage_files sf
|
||||
where sf.missing_at is null
|
||||
and sf.probed_at is not null
|
||||
and sf.duration_seconds > 300
|
||||
and sf.size_bytes > 0
|
||||
and sf.quality is not null
|
||||
group by 1`);
|
||||
|
||||
const observedByQuality = new Map(observed.map((row) => [row.quality, row]));
|
||||
|
||||
const rules: Rule[] = QUALITY_ORDER.flatMap((quality) => {
|
||||
const row = rows.find((entry) => entry.quality === quality);
|
||||
if (!row) return [];
|
||||
const stat = observedByQuality.get(quality);
|
||||
return [{
|
||||
quality,
|
||||
min: Number(row.minMbPerMinute),
|
||||
max: Number(row.maxMbPerMinute),
|
||||
atmosMax: row.atmosMaxMbPerMinute === null ? null : Number(row.atmosMaxMbPerMinute),
|
||||
timelessMax: row.timelessMaxMbPerMinute === null ? null : Number(row.timelessMaxMbPerMinute),
|
||||
observed: stat ? { n: stat.n, p05: stat.p05, p50: stat.p50, p95: stat.p95 } : null,
|
||||
}];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Policy</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Release size limits</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
How large a release may be, per minute of runtime. Per minute because runtime is
|
||||
the great leveller: a three-hour film and a half-hour episode are not the same
|
||||
file and never were. These apply to what gets <em>downloaded</em> — nothing here
|
||||
filters or removes anything already in the library.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/policy" className="admin-nav-button px-3 py-2 text-sm font-medium">
|
||||
Policy overview
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action={saveSizeRulesAction} className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[52rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
|
||||
<th className="py-2 pr-4">Quality</th>
|
||||
<th className="py-2 pr-4 text-right">Min MB/min</th>
|
||||
<th className="py-2 pr-4 text-right">Max MB/min</th>
|
||||
<th className="py-2 pr-4 text-right">Atmos max</th>
|
||||
<th className="py-2 pr-4 text-right">Timeless max</th>
|
||||
<th className="py-2 pr-4">What the library holds</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.quality} className="border-b border-admin-line/50">
|
||||
<td className="py-3 pr-4 font-medium uppercase">{rule.quality}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<input
|
||||
type="number" step="0.1" min="0.1" required
|
||||
name={`${rule.quality}-min`} defaultValue={rule.min}
|
||||
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<input
|
||||
type="number" step="0.1" min="0.1" required
|
||||
name={`${rule.quality}-max`} defaultValue={rule.max}
|
||||
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<input
|
||||
type="number" step="0.1" min="0.1"
|
||||
name={`${rule.quality}-atmos`}
|
||||
defaultValue={rule.atmosMax ?? ""}
|
||||
placeholder="—"
|
||||
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<input
|
||||
type="number" step="0.1" min="0.1"
|
||||
name={`${rule.quality}-timeless`}
|
||||
defaultValue={rule.timelessMax ?? ""}
|
||||
placeholder="—"
|
||||
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-admin-muted">
|
||||
{rule.observed ? (
|
||||
<span className="font-mono">
|
||||
n={rule.observed.n} · p05 {rule.observed.p05} · median {rule.observed.p50} · p95 {rule.observed.p95}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">nothing probed</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="max-w-3xl text-xs text-admin-muted">
|
||||
The Atmos column is an <em>allowance</em>, not a second ceiling: leave it blank and
|
||||
the ordinary maximum applies whatever the release calls itself. Only 4K carries one,
|
||||
because that is where it was measured to matter — on 4K files, probed TrueHD runs
|
||||
about 1.8× E-AC-3 and 4× AAC per minute. It is read from the release
|
||||
title, which is trustworthy in a way our own filenames are not: an encoder advertises
|
||||
“TrueHD 7.1 Atmos” because it sells the release.
|
||||
</p>
|
||||
|
||||
<p className="max-w-3xl text-xs text-admin-muted">
|
||||
The <strong>Timeless</strong> column is the only ceiling that admits a remux — the
|
||||
disc’s own streams repackaged without re-encoding, three to four times the size
|
||||
of a good WEB-DL. It applies to nothing except titles marked timeless, and that is
|
||||
the point: as a global ceiling it would stop being an allowance for the films worth
|
||||
keeping losslessly and simply become the size everything arrives at. It outranks the
|
||||
Atmos allowance, since a lossless copy carries its object-based track anyway. There
|
||||
is no 720p remux — the format is a re-encode by definition — so leaving those blank
|
||||
is correct.
|
||||
</p>
|
||||
|
||||
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
|
||||
Save limits
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-serif text-xl font-semibold">What these allow</h2>
|
||||
<p className="max-w-3xl text-sm text-admin-muted">
|
||||
The same limits in gigabytes, which is the unit anyone can actually judge. Saved
|
||||
values only — edit and save to see this change.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
|
||||
<th className="py-2 pr-4">Runtime</th>
|
||||
{rules.map((rule) => (
|
||||
<th key={rule.quality} className="py-2 pr-4 text-right uppercase">{rule.quality}</th>
|
||||
))}
|
||||
<th className="py-2 pr-4 text-right">4K + Atmos</th>
|
||||
<th className="py-2 pr-4 text-right">4K timeless</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PREVIEW_RUNTIMES.map((runtime) => {
|
||||
const fourK = rules.find((rule) => rule.quality === "4k");
|
||||
return (
|
||||
<tr key={runtime.minutes} className="border-b border-admin-line/50">
|
||||
<td className="py-3 pr-4">
|
||||
<div>{runtime.label}</div>
|
||||
<div className="text-xs text-admin-muted">{runtime.note}</div>
|
||||
</td>
|
||||
{rules.map((rule) => (
|
||||
<td key={rule.quality} className="py-3 pr-4 text-right font-mono text-xs">
|
||||
{gb(rule.min, runtime.minutes)}–{gb(rule.max, runtime.minutes)} GB
|
||||
</td>
|
||||
))}
|
||||
<td className="py-3 pr-4 text-right font-mono text-xs">
|
||||
{fourK?.atmosMax
|
||||
? `${gb(fourK.min, runtime.minutes)}–${gb(fourK.atmosMax, runtime.minutes)} GB`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-mono text-xs">
|
||||
{fourK?.timelessMax
|
||||
? `${gb(fourK.min, runtime.minutes)}–${gb(fourK.timelessMax, runtime.minutes)} GB`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
// Manual search, the Sonarr-style "show me what is out there" button.
|
||||
//
|
||||
// Both actions re-check the session. Server Actions are reachable by direct
|
||||
// POST, not only through the buttons on the page, so the layout's admin guard
|
||||
// governs rendering and nothing else -- and the second of these adds torrents.
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
grabRelease,
|
||||
searchReleases,
|
||||
IndexerUnavailableError,
|
||||
type SearchResult,
|
||||
} from "@/lib/indexer";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Errors are returned rather than thrown: an indexer being down, or the fetch
|
||||
// stack being stopped, is an ordinary condition that belongs in the panel next
|
||||
// to the results, not an error boundary that blanks the inventory page.
|
||||
export type SearchState =
|
||||
| { ok: true; result: SearchResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function runManualSearch(target: {
|
||||
mediaItemId: string;
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
}): Promise<SearchState> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
return { ok: true, result: await searchReleases(target) };
|
||||
} catch (error) {
|
||||
if (error instanceof IndexerUnavailableError) return { ok: false, error: error.message };
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Search failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export type GrabState = { ok: true; message: string } | { ok: false; error: string };
|
||||
|
||||
export async function grabCandidate(choice: {
|
||||
searchId: string;
|
||||
infoHash: string;
|
||||
}): Promise<GrabState> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
const result = await grabRelease(choice);
|
||||
// Sending it to qBittorrent changes what the Downloads page shows, and it
|
||||
// changes the inventory row's "in flight" state.
|
||||
revalidatePath("/admin/downloads");
|
||||
revalidatePath("/admin/inventory");
|
||||
revalidatePath("/admin/calendar");
|
||||
return result.grabbed
|
||||
? { ok: true, message: `Sent to qBittorrent: ${result.title}` }
|
||||
: { ok: false, error: result.reason ?? "Not grabbed" };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Grab failed" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { db } from "@/db/client";
|
||||
import { storageAvailability, storageFiles, storageTiers } from "@/db/schema";
|
||||
import { AGENT_TIMEOUT_SECONDS, formatSince, getAgentStatuses } from "@/lib/agents";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatBytes(value: number | null) {
|
||||
if (!value || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
|
||||
}
|
||||
|
||||
type AgentPath = { path: string; exists: boolean; mounted: boolean; readable: boolean; freeBytes: number };
|
||||
|
||||
function agentPaths(details: unknown): AgentPath[] {
|
||||
const paths = (details as { paths?: unknown } | null)?.paths;
|
||||
return Array.isArray(paths) ? (paths as AgentPath[]) : [];
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
|
||||
}
|
||||
|
||||
export default async function AdminStoragePage() {
|
||||
const agents = await getAgentStatuses();
|
||||
const agentByName = new Map(agents.map((agent) => [agent.agent, agent]));
|
||||
|
||||
const [tiers, availabilityRows, inventoryRows] = await Promise.all([
|
||||
db.select().from(storageTiers).orderBy(storageTiers.tier),
|
||||
db.select().from(storageAvailability).orderBy(desc(storageAvailability.detectedAt)).limit(100),
|
||||
db
|
||||
.select({
|
||||
tierId: storageFiles.tierId,
|
||||
fileCount: sql<string>`count(*)::text`,
|
||||
movieFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%')::text`,
|
||||
tvFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Television/%')::text`,
|
||||
totalSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}), 0))`,
|
||||
movieSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%'), 0))`,
|
||||
tvSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Television/%'), 0))`,
|
||||
})
|
||||
.from(storageFiles)
|
||||
.groupBy(storageFiles.tierId),
|
||||
]);
|
||||
|
||||
const availabilityByTier = new Map<string, typeof availabilityRows>();
|
||||
for (const row of availabilityRows) {
|
||||
availabilityByTier.set(row.tierId, [...(availabilityByTier.get(row.tierId) ?? []), row]);
|
||||
}
|
||||
const inventoryByTier = new Map(inventoryRows.map((row) => [row.tierId, row]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Storage</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Storage Tiers</h1>
|
||||
</div>
|
||||
<section className="admin-panel p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="font-serif text-xl font-semibold">Agents</h2>
|
||||
<p className="text-xs text-admin-muted">Considered offline after {AGENT_TIMEOUT_SECONDS}s of silence</p>
|
||||
</div>
|
||||
{agents.length ? (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const paths = agentPaths(agent.details);
|
||||
return (
|
||||
<div key={agent.agent} className="rounded-md border border-admin-line bg-admin-subpanel p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{agent.agent}</p>
|
||||
<p className="text-xs text-admin-muted">{agent.hostname ?? "unknown host"}</p>
|
||||
</div>
|
||||
<span className={agent.online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-warn"}>
|
||||
{agent.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-admin-muted">Last beat {formatSince(agent.secondsSinceSeen)}</p>
|
||||
{paths.length ? (
|
||||
<ul className="mt-3 space-y-1 text-xs">
|
||||
{paths.map((path) => (
|
||||
<li key={path.path} className="flex items-center justify-between gap-2">
|
||||
<span className="break-all text-admin-muted">{path.path}</span>
|
||||
<span className={path.readable ? "shrink-0 text-admin-good" : "shrink-0 text-admin-warn"}>
|
||||
{path.readable ? formatBytes(path.freeBytes) + " free" : "unreachable"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-admin-muted">
|
||||
No agents have reported yet. See <span className="font-mono">agent/README.md</span> to install the herald on silenus or edda.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{tiers.length ? tiers.map((tier) => {
|
||||
const events = availabilityByTier.get(tier.id) ?? [];
|
||||
const latest = events[0];
|
||||
// When a tier names an agent, that agent's heartbeat is authoritative:
|
||||
// storage on a powered-off host is not reachable no matter what the
|
||||
// last scan observed.
|
||||
const agent = tier.agentName ? agentByName.get(tier.agentName) : undefined;
|
||||
const online = tier.agentName
|
||||
? Boolean(agent?.online)
|
||||
: tier.alwaysOnline || latest?.online;
|
||||
const inventory = inventoryByTier.get(tier.id);
|
||||
return (
|
||||
<section key={tier.id} className="admin-panel p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-admin-muted">{tier.tier}</p>
|
||||
<h2 className="mt-2 font-serif text-2xl font-semibold">{tier.label}</h2>
|
||||
</div>
|
||||
<span className={online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-muted"}>{online ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
{tier.agentName ? (
|
||||
<p className="mt-2 text-xs text-admin-muted">
|
||||
Reachability from agent <span className="font-semibold">{tier.agentName}</span>
|
||||
{agent ? ` — last beat ${formatSince(agent.secondsSinceSeen)}` : " — never reported"}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 break-all text-sm text-admin-muted">{tier.basePath}</p>
|
||||
{tier.notes ? <p className="mt-3 text-sm text-admin-muted">{tier.notes}</p> : null}
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">Movie files</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.movieFileCount ?? "0"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">Movie size</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.movieSize ?? "0 bytes"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">TV files</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.tvFileCount ?? "0"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">TV size</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.tvSize ?? "0 bytes"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">All files</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.fileCount ?? "0"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
|
||||
<p className="text-admin-muted">All size</p>
|
||||
<p className="mt-1 text-xl font-semibold">{inventory?.totalSize ?? "0 bytes"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<h3 className="text-sm font-semibold">Recent availability</h3>
|
||||
{events.length ? events.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="rounded-md border border-admin-line bg-admin-subpanel p-3 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{event.online ? "Online" : "Offline"}</span>
|
||||
<span className="text-admin-muted">{formatDate(event.detectedAt)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-admin-muted">Resolved: {formatDate(event.resolvedAt)}</p>
|
||||
</div>
|
||||
)) : <p className="text-sm text-admin-muted">No availability observations yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}) : <section className="admin-panel p-5 text-sm text-admin-muted">No storage tiers configured yet.</section>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { db } from "@/db/client";
|
||||
import { users, watchingNowItems, watchlistItems } from "@/db/schema";
|
||||
import { count, desc, isNull } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(value: Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [userRows, watchingCounts, watchlistCounts] = await Promise.all([
|
||||
db.select().from(users).orderBy(desc(users.createdAt)),
|
||||
db
|
||||
.select({ userId: watchingNowItems.userId, activeCount: count() })
|
||||
.from(watchingNowItems)
|
||||
.where(isNull(watchingNowItems.removedAt))
|
||||
.groupBy(watchingNowItems.userId),
|
||||
db
|
||||
.select({ userId: watchlistItems.userId, activeCount: count() })
|
||||
.from(watchlistItems)
|
||||
.where(isNull(watchlistItems.removedAt))
|
||||
.groupBy(watchlistItems.userId),
|
||||
]);
|
||||
|
||||
const watchingByUser = new Map(watchingCounts.map((row) => [row.userId, row.activeCount]));
|
||||
const watchlistByUser = new Map(watchlistCounts.map((row) => [row.userId, row.activeCount]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">People</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Users</h1>
|
||||
</div>
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 border-b border-admin-line px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-admin-muted">
|
||||
<span>User</span>
|
||||
<span>Email</span>
|
||||
<span>Watch Now</span>
|
||||
<span>Watchlist</span>
|
||||
<span>Joined</span>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{userRows.length ? userRows.map((user) => (
|
||||
<div key={user.id} className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 px-4 py-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{user.displayName}</p>
|
||||
<p className="text-xs text-admin-muted">{user.isAdmin ? "Admin" : "User"}</p>
|
||||
</div>
|
||||
<p className="truncate text-admin-muted">{user.email}</p>
|
||||
<p>{watchingByUser.get(user.id) ?? 0}/{user.watchingNowTvSlots + user.watchingNowMovieSlots}</p>
|
||||
<p>{watchlistByUser.get(user.id) ?? 0}</p>
|
||||
<p className="text-admin-muted">{formatDate(user.createdAt)}</p>
|
||||
</div>
|
||||
)) : <p className="p-5 text-sm text-admin-muted">No users found.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { externalIds } from "@/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Server Actions are reachable by a direct POST, not only through the buttons
|
||||
* on the page, so the check belongs in here rather than in the layout that
|
||||
* happens to render them.
|
||||
*/
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
if (!session.user.isAdmin) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
function optionalString(value: FormDataEntryValue | null) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a judgement on a title's TMDB link.
|
||||
*
|
||||
* The three states are mutually exclusive, so each verdict clears the other:
|
||||
* confirming a link that had been rejected has to actually un-reject it, or
|
||||
* the title stays in the rejected queue forever and the reviewer's second look
|
||||
* counts for nothing.
|
||||
*
|
||||
* `mediaItemId` rather than the external_ids row id, because the reviewer is
|
||||
* answering a question about a TITLE -- is this the right film? -- and the row
|
||||
* carrying the answer is an implementation detail they never see.
|
||||
*/
|
||||
async function recordVerdict(formData: FormData, verdict: "confirm" | "reject" | "reset") {
|
||||
const session = await requireAdmin();
|
||||
const mediaItemId = optionalString(formData.get("mediaItemId"));
|
||||
if (!mediaItemId) return;
|
||||
|
||||
const now = new Date();
|
||||
const set =
|
||||
verdict === "confirm"
|
||||
? { verifiedAt: now, verifiedBy: session.user.id, rejectedAt: null, rejectedBy: null }
|
||||
: verdict === "reject"
|
||||
? { verifiedAt: null, verifiedBy: null, rejectedAt: now, rejectedBy: session.user.id }
|
||||
: { verifiedAt: null, verifiedBy: null, rejectedAt: null, rejectedBy: null };
|
||||
|
||||
await db
|
||||
.update(externalIds)
|
||||
.set(set)
|
||||
.where(and(eq(externalIds.mediaItemId, mediaItemId), eq(externalIds.source, "tmdb")));
|
||||
|
||||
revalidatePath("/admin/verify");
|
||||
revalidatePath("/admin/inventory");
|
||||
}
|
||||
|
||||
export async function confirmTmdbLink(formData: FormData) {
|
||||
await recordVerdict(formData, "confirm");
|
||||
}
|
||||
|
||||
export async function rejectTmdbLink(formData: FormData) {
|
||||
await recordVerdict(formData, "reject");
|
||||
}
|
||||
|
||||
/** Put a title back in the queue -- for a verdict entered by mistake. */
|
||||
export async function resetTmdbLink(formData: FormData) {
|
||||
await recordVerdict(formData, "reset");
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { db } from "@/db/client";
|
||||
import { externalIds, mediaItems } from "@/db/schema";
|
||||
import { and, asc, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { confirmTmdbLink, rejectTmdbLink, resetTmdbLink } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
const STATES = [
|
||||
{ value: "needs", label: "Needs review", blurb: "nobody has looked at these yet" },
|
||||
{ value: "rejected", label: "Rejected", blurb: "someone said the link is wrong; these need resolving" },
|
||||
{ value: "confirmed", label: "Confirmed", blurb: "a person checked the poster against the files" },
|
||||
] as const;
|
||||
|
||||
type ReviewState = (typeof STATES)[number]["value"];
|
||||
|
||||
const KINDS = [
|
||||
{ value: "all", label: "Everything" },
|
||||
{ value: "movie", label: "Films" },
|
||||
{ value: "tv_series", label: "Series" },
|
||||
] as const;
|
||||
|
||||
type Kind = (typeof KINDS)[number]["value"];
|
||||
|
||||
/**
|
||||
* Why the evidence could not settle this link.
|
||||
*
|
||||
* The same three facts verify-tmdb-links.mjs tests, expressed as filters, so a
|
||||
* reviewer can spend their attention where judgement is actually worth
|
||||
* something. "The name disagrees" is 105 real decisions; "TMDB publishes no
|
||||
* runtime" is three hundred titles where the third fact does not exist and
|
||||
* clicking through them means re-checking by eye what the machine already
|
||||
* checked better.
|
||||
*/
|
||||
const FAULTS = [
|
||||
{ value: "any", label: "Any fault" },
|
||||
{ value: "name", label: "Name disagrees" },
|
||||
{ value: "runtime", label: "Runtime disagrees" },
|
||||
{ value: "unmeasurable", label: "Nothing to measure" },
|
||||
] as const;
|
||||
|
||||
type Fault = (typeof FAULTS)[number]["value"];
|
||||
|
||||
function parseState(raw: string | undefined): ReviewState {
|
||||
return STATES.find((entry) => entry.value === raw)?.value ?? "needs";
|
||||
}
|
||||
|
||||
function parseKind(raw: string | undefined): Kind {
|
||||
return KINDS.find((entry) => entry.value === raw)?.value ?? "all";
|
||||
}
|
||||
|
||||
function parseFault(raw: string | undefined): Fault {
|
||||
return FAULTS.find((entry) => entry.value === raw)?.value ?? "any";
|
||||
}
|
||||
|
||||
function parsePage(raw: string | undefined) {
|
||||
const value = Number.parseInt(raw ?? "1", 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
|
||||
function href(params: { state: ReviewState; kind: Kind; fault: Fault; page?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.state !== "needs") query.set("state", params.state);
|
||||
if (params.kind !== "all") query.set("kind", params.kind);
|
||||
if (params.fault !== "any") query.set("fault", params.fault);
|
||||
if (params.page && params.page > 1) query.set("page", String(params.page));
|
||||
const search = query.toString();
|
||||
return search ? `/admin/verify?${search}` : "/admin/verify";
|
||||
}
|
||||
|
||||
/**
|
||||
* The condition each review state stands for.
|
||||
*
|
||||
* Unreviewed is the ABSENCE of both marks rather than a state of its own, so a
|
||||
* link that has never been looked at and one whose verdict was undone are the
|
||||
* same thing -- which is what a reviewer means by "back in the queue".
|
||||
*/
|
||||
function stateFilter(state: ReviewState) {
|
||||
if (state === "confirmed") return isNotNull(externalIds.verifiedAt);
|
||||
if (state === "rejected") return isNotNull(externalIds.rejectedAt);
|
||||
return and(isNull(externalIds.verifiedAt), isNull(externalIds.rejectedAt));
|
||||
}
|
||||
|
||||
export default async function AdminVerifyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ state?: string; kind?: string; fault?: string; page?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const state = parseState(params.state);
|
||||
const kind = parseKind(params.kind);
|
||||
const fault = parseFault(params.fault);
|
||||
const page = parsePage(params.page);
|
||||
|
||||
const kindFilter = kind === "all" ? undefined : eq(mediaItems.mediaType, kind);
|
||||
|
||||
// How far the files disagree with the runtime TMDB claims for this title.
|
||||
// Not a verdict -- a reviewer still decides -- but it points the eye at the
|
||||
// rows worth slowing down for, which is the difference between reviewing a
|
||||
// queue and scrolling past one.
|
||||
const runtimeGap = sql<number | null>`(
|
||||
case ${mediaItems.mediaType}
|
||||
when 'movie' then (
|
||||
select round(min(abs(sf.duration_seconds / 60.0 - m.runtime_minutes)))
|
||||
from storage_files sf, movies m
|
||||
where sf.media_item_id = ${mediaItems.id} and m.id = ${mediaItems.id}
|
||||
and sf.missing_at is null and sf.duration_seconds > 0 and m.runtime_minutes > 0)
|
||||
else (
|
||||
-- The median episode length has to be aggregated in its OWN subquery.
|
||||
-- Subtracting the series' runtime from an aggregate in the same select
|
||||
-- leaves that column ungrouped, which Postgres rejects at execution --
|
||||
-- long after anything static would have caught it.
|
||||
select round(abs((
|
||||
select percentile_cont(0.5) within group (order by sf.duration_seconds / 60.0)
|
||||
from storage_files sf
|
||||
where sf.media_item_id = ${mediaItems.id}
|
||||
and sf.missing_at is null and sf.duration_seconds > 0
|
||||
) - s.episode_runtime_minutes))
|
||||
from series s
|
||||
where s.id = ${mediaItems.id} and s.episode_runtime_minutes > 0)
|
||||
end)`;
|
||||
|
||||
// Both names reduced to letters and digits before comparing, because the
|
||||
// folder is where punctuation goes to die: "Mission- Impossible", "The Man
|
||||
// from U N C L E". Those are the same film written by a filesystem.
|
||||
const nameAgrees = sql<boolean>`(
|
||||
${externalIds.remoteTitle} is not null
|
||||
and regexp_replace(lower(${mediaItems.title}), '[^a-z0-9]', '', 'g')
|
||||
= regexp_replace(lower(${externalIds.remoteTitle}), '[^a-z0-9]', '', 'g'))`;
|
||||
|
||||
const unmeasurable = sql<boolean>`(${runtimeGap} is null)`;
|
||||
|
||||
const faultFilter =
|
||||
fault === "name"
|
||||
? sql`${externalIds.remoteTitle} is not null and not ${nameAgrees}`
|
||||
: fault === "runtime"
|
||||
? sql`${nameAgrees} and ${runtimeGap} is not null and ${runtimeGap} > 3`
|
||||
: fault === "unmeasurable"
|
||||
? sql`${unmeasurable}`
|
||||
: undefined;
|
||||
|
||||
const where = and(eq(externalIds.source, "tmdb"), stateFilter(state), kindFilter, faultFilter);
|
||||
|
||||
const [rows, [totals]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: mediaItems.id,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
kind: mediaItems.mediaType,
|
||||
posterPath: mediaItems.posterPath,
|
||||
tmdbId: externalIds.externalId,
|
||||
remoteTitle: externalIds.remoteTitle,
|
||||
nameAgrees,
|
||||
verifiedAt: externalIds.verifiedAt,
|
||||
rejectedAt: externalIds.rejectedAt,
|
||||
runtimeGap,
|
||||
fileCount: sql<number>`(select count(*)::int from storage_files sf
|
||||
where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null)`,
|
||||
// Series carry up to a thousand files; nobody reviews a link by reading
|
||||
// all of them. Four names from the largest files say what the folder
|
||||
// holds, which is the question the poster is being checked against.
|
||||
filenames: sql<string[]>`(select coalesce(array_agg(f.filename order by f.size_bytes desc), '{}')
|
||||
from (select sf.filename, sf.size_bytes from storage_files sf
|
||||
where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null
|
||||
order by sf.size_bytes desc limit 4) f)`,
|
||||
})
|
||||
.from(mediaItems)
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
.where(where)
|
||||
.orderBy(desc(sql`coalesce(${runtimeGap}, -1)`), asc(mediaItems.sortTitle), asc(mediaItems.title))
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE),
|
||||
db
|
||||
.select({
|
||||
matching: sql<number>`count(*)::int`,
|
||||
needs: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is null and ${externalIds.rejectedAt} is null)::int`,
|
||||
rejected: sql<number>`count(*) filter (where ${externalIds.rejectedAt} is not null)::int`,
|
||||
confirmed: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is not null)::int`,
|
||||
})
|
||||
.from(mediaItems)
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
// Scoped to the same fault, so the tallies on the state chips describe
|
||||
// the pile actually being worked rather than the whole collection.
|
||||
.where(and(eq(externalIds.source, "tmdb"), kindFilter, faultFilter)),
|
||||
]);
|
||||
|
||||
const inState =
|
||||
state === "needs" ? totals.needs : state === "rejected" ? totals.rejected : totals.confirmed;
|
||||
const pages = Math.max(1, Math.ceil(inState / PAGE_SIZE));
|
||||
const counts: Record<ReviewState, number> = {
|
||||
needs: totals.needs,
|
||||
rejected: totals.rejected,
|
||||
confirmed: totals.confirmed,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Metadata</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Verify Links</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
|
||||
Each title below was matched to TMDB by comparing a folder name against search results, which
|
||||
has been wrong in ways nothing downstream can detect. Check that the poster and title are the
|
||||
film the files hold. A tick records that a person confirmed it; a cross sets it aside to be
|
||||
resolved. Titles the runtime disagrees with come first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATES.map((entry) => (
|
||||
<Link
|
||||
key={entry.value}
|
||||
href={href({ state: entry.value, kind, fault })}
|
||||
title={entry.blurb}
|
||||
className={
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition " +
|
||||
(entry.value === state
|
||||
? "border-admin-accent text-admin-accent"
|
||||
: "border-admin-line text-admin-muted hover:text-admin-text")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
<span className="ml-2 tabular-nums opacity-70">{counts[entry.value]}</span>
|
||||
</Link>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
|
||||
{KINDS.map((entry) => (
|
||||
<Link
|
||||
key={entry.value}
|
||||
href={href({ state, kind: entry.value, fault })}
|
||||
className={
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition " +
|
||||
(entry.value === kind
|
||||
? "border-admin-accent text-admin-accent"
|
||||
: "border-admin-line text-admin-muted hover:text-admin-text")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
|
||||
{FAULTS.map((entry) => (
|
||||
<Link
|
||||
key={entry.value}
|
||||
href={href({ state, kind, fault: entry.value })}
|
||||
className={
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition " +
|
||||
(entry.value === fault
|
||||
? "border-admin-accent text-admin-accent"
|
||||
: "border-admin-line text-admin-muted hover:text-admin-text")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-admin-line px-4 py-8 text-center text-sm text-admin-muted">
|
||||
{state === "needs"
|
||||
? "Nothing left to review here."
|
||||
: state === "rejected"
|
||||
? "No rejected links."
|
||||
: "Nothing confirmed yet."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex gap-3 rounded-lg border border-admin-line p-3"
|
||||
>
|
||||
<div className="relative h-[138px] w-[92px] shrink-0 overflow-hidden rounded bg-admin-line/40">
|
||||
{row.posterPath ? (
|
||||
<Image
|
||||
src={`https://image.tmdb.org/t/p/w185${row.posterPath}`}
|
||||
alt=""
|
||||
fill
|
||||
sizes="92px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center text-[10px] text-admin-muted">
|
||||
no art
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate font-medium" title={row.title}>
|
||||
{row.title}
|
||||
</p>
|
||||
{/* TMDB's own name for the id, shown only when it differs.
|
||||
Repeating an identical name would add a line of noise to
|
||||
every card and bury the handful that actually clash. */}
|
||||
{row.remoteTitle && !row.nameAgrees ? (
|
||||
<p className="truncate text-xs font-medium text-admin-warn" title={row.remoteTitle}>
|
||||
tmdb: {row.remoteTitle}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-admin-muted">
|
||||
{row.year ?? "year unknown"} · {row.kind === "movie" ? "film" : "series"} ·{" "}
|
||||
<a
|
||||
href={`https://www.themoviedb.org/${row.kind === "movie" ? "movie" : "tv"}/${row.tmdbId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-admin-text"
|
||||
>
|
||||
tmdb:{row.tmdbId}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{row.runtimeGap != null && row.runtimeGap > 10 ? (
|
||||
<p className="mt-1 text-xs text-admin-warn">
|
||||
runtime off by {row.runtimeGap}m
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ul className="mt-2 min-w-0 flex-1 space-y-0.5 text-[11px] leading-snug text-admin-muted">
|
||||
{row.filenames.length === 0 ? (
|
||||
<li className="italic">holds no files</li>
|
||||
) : (
|
||||
row.filenames.map((name) => (
|
||||
<li key={name} className="truncate" title={name}>
|
||||
{name}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
{row.fileCount > row.filenames.length ? (
|
||||
<li className="opacity-70">and {row.fileCount - row.filenames.length} more</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{state === "needs" ? (
|
||||
<>
|
||||
<form action={confirmTmdbLink}>
|
||||
<input type="hidden" name="mediaItemId" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
title="This is the right title"
|
||||
className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-accent transition hover:border-admin-accent"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</form>
|
||||
<form action={rejectTmdbLink}>
|
||||
<input type="hidden" name="mediaItemId" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
title="This is not the right title — set aside to resolve"
|
||||
className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-warn transition hover:border-admin-warn"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form action={resetTmdbLink}>
|
||||
<input type="hidden" name="mediaItemId" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
title="Put this back in the review queue"
|
||||
className="rounded border border-admin-line px-3 py-1 text-xs text-admin-muted transition hover:text-admin-text"
|
||||
>
|
||||
{state === "confirmed" ? "Unconfirm" : "Back to queue"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pages > 1 ? (
|
||||
<div className="flex items-center justify-between text-xs text-admin-muted">
|
||||
<span>
|
||||
Page {Math.min(page, pages)} of {pages} · {inState} title{inState === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{page > 1 ? (
|
||||
<Link href={href({ state, kind, fault, page: page - 1 })} className="underline underline-offset-2">
|
||||
Previous
|
||||
</Link>
|
||||
) : null}
|
||||
{page < pages ? (
|
||||
<Link href={href({ state, kind, fault, page: page + 1 })} className="underline underline-offset-2">
|
||||
Next
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { auth, signOut } from "@/auth";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminNav } from "./admin-nav";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin", label: "Dashboard" },
|
||||
{ href: "/admin/users", label: "Users" },
|
||||
{ href: "/admin/demand", label: "Demand" },
|
||||
{ href: "/admin/policy", label: "Policy" },
|
||||
{ href: "/admin/inventory", label: "Inventory" },
|
||||
{ href: "/admin/verify", label: "Verify Links" },
|
||||
{ href: "/admin/calendar", label: "Calendar" },
|
||||
{ href: "/admin/downloads", label: "Downloads" },
|
||||
{ href: "/admin/corrupt", label: "Corrupt Files" },
|
||||
{ href: "/admin/storage", label: "Storage" },
|
||||
{ href: "/admin/jobs", label: "Jobs" },
|
||||
];
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
if (!session.user.isAdmin) redirect("/");
|
||||
|
||||
return (
|
||||
<div className="ampelos-admin flex min-h-screen text-admin-text">
|
||||
<nav className="ampelos-admin-sidebar flex w-60 shrink-0 flex-col px-4 py-6">
|
||||
<Link href="/" className="mb-7 block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Sticknife</p>
|
||||
<p className="mt-2 font-serif text-2xl font-semibold">Ampelos Admin</p>
|
||||
</Link>
|
||||
<AdminNav items={navItems} />
|
||||
<div className="mt-auto pt-6">
|
||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
|
||||
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-left text-sm font-medium">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="min-w-0 flex-1 overflow-x-auto px-6 py-8 lg:px-10">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use server";
|
||||
|
||||
// Linking a Plex account.
|
||||
//
|
||||
// Two steps, because Plex's sign-in is a round trip through their site: we ask
|
||||
// for a PIN, send the person to plex.tv to claim it, and pick the result up
|
||||
// when they come back. The PIN id is parked in an httpOnly cookie for the
|
||||
// duration -- it is a one-use handle that is worthless without the sign-in that
|
||||
// claims it, and it means the flow survives the user taking a minute over it.
|
||||
//
|
||||
// Linking is what grants library access AND what makes a watchlist readable.
|
||||
// Both follow from the same consent, which is why they happen together here
|
||||
// rather than being two things an admin has to remember to do.
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { plexAccounts } from "@/db/schema";
|
||||
import { appUrl } from "@/lib/app-url";
|
||||
import { createPin, authUrl, claimPin, shareLibraries } from "@/lib/plex";
|
||||
|
||||
const PIN_COOKIE = "ampelos_plex_pin";
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Sign in first");
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function startPlexLinkAction() {
|
||||
await requireUser();
|
||||
|
||||
const pin = await createPin();
|
||||
const jar = await cookies();
|
||||
jar.set(PIN_COOKIE, String(pin.id), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 15 * 60,
|
||||
});
|
||||
|
||||
// redirect() throws, so it must be the last thing here.
|
||||
redirect(authUrl(pin, appUrl("/account?linking=1")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the link, if the user has been to plex.tv and back.
|
||||
*
|
||||
* Returns a message rather than throwing on the ordinary failures -- an
|
||||
* abandoned sign-in and an expired PIN are both things a person does, not
|
||||
* errors.
|
||||
*/
|
||||
export async function completePlexLink(): Promise<string | null> {
|
||||
const user = await requireUser();
|
||||
const jar = await cookies();
|
||||
const pinId = jar.get(PIN_COOKIE)?.value;
|
||||
if (!pinId) return null;
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = await claimPin(Number(pinId));
|
||||
} catch (error) {
|
||||
jar.delete(PIN_COOKIE);
|
||||
return `Plex could not confirm the sign-in: ${(error as Error).message}`;
|
||||
}
|
||||
if (!identity) return "That sign-in was not completed. Try linking again.";
|
||||
|
||||
jar.delete(PIN_COOKIE);
|
||||
|
||||
// One Plex account per person, in both directions. Without this a second
|
||||
// person could link an account already in use and inherit its watchlist.
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(plexAccounts)
|
||||
.where(eq(plexAccounts.plexUserId, identity.plexUserId))
|
||||
.limit(1);
|
||||
if (existing.length && existing[0].userId !== user.id) {
|
||||
return "That Plex account is already linked to another Ampelos user.";
|
||||
}
|
||||
|
||||
let shared: { sectionTitles: string[]; sectionIds: string[]; alreadyShared: boolean } | null = null;
|
||||
let shareError: string | null = null;
|
||||
try {
|
||||
shared = await shareLibraries(identity);
|
||||
} catch (error) {
|
||||
// The link is still worth recording: it is what makes the watchlist
|
||||
// readable, and a share that failed can be retried without signing in
|
||||
// again.
|
||||
shareError = (error as Error).message;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(plexAccounts)
|
||||
.values({
|
||||
userId: user.id,
|
||||
plexUserId: identity.plexUserId,
|
||||
plexUuid: identity.plexUuid,
|
||||
plexUsername: identity.username,
|
||||
plexEmail: identity.email,
|
||||
librariesSharedAt: shared ? new Date() : null,
|
||||
sharedSectionIds: shared?.sectionIds ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: plexAccounts.userId,
|
||||
set: {
|
||||
plexUserId: identity.plexUserId,
|
||||
plexUuid: identity.plexUuid,
|
||||
plexUsername: identity.username,
|
||||
plexEmail: identity.email,
|
||||
librariesSharedAt: shared ? new Date() : null,
|
||||
sharedSectionIds: shared?.sectionIds ?? null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
|
||||
if (shareError) {
|
||||
return `Linked as ${identity.username}, but the libraries could not be shared: ${shareError}`;
|
||||
}
|
||||
if (shared?.alreadyShared) {
|
||||
return `Linked as ${identity.username}. You already had access to the libraries.`;
|
||||
}
|
||||
return `Linked as ${identity.username}. Shared: ${shared?.sectionTitles.join(", ")}.`;
|
||||
}
|
||||
|
||||
export async function unlinkPlexAction() {
|
||||
const user = await requireUser();
|
||||
// Only the link is removed. Library access is granted on Plex's side and is
|
||||
// not ours to quietly revoke from a button labelled "unlink"; whoever owns
|
||||
// the server can take it back there.
|
||||
await db.delete(plexAccounts).where(eq(plexAccounts.userId, user.id));
|
||||
revalidatePath("/account");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { plexAccounts } from "@/db/schema";
|
||||
|
||||
import { startPlexLinkAction, unlinkPlexAction, completePlexLink } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default async function AccountPage({ searchParams }: PageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-12">
|
||||
<p className="text-sm">Sign in to manage your account.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Plex sends the user back here when they are done. Completing on GET rather
|
||||
// than asking them to press another button: they have already consented, and
|
||||
// a second click to finish something they thought was finished is just a way
|
||||
// to lose people half way through.
|
||||
const resolved = (await searchParams) ?? {};
|
||||
const returning = resolved.linking === "1";
|
||||
const message = returning ? await completePlexLink() : null;
|
||||
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(plexAccounts)
|
||||
.where(eq(plexAccounts.userId, session.user.id))
|
||||
.limit(1);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl space-y-8 px-6 py-12">
|
||||
<header>
|
||||
<h1 className="font-serif text-3xl font-semibold">Your account</h1>
|
||||
<p className="mt-2 text-sm text-admin-muted">{session.user.email}</p>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
<p className="rounded border border-admin-accent px-4 py-3 text-sm">{message}</p>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-serif text-xl font-semibold">Plex</h2>
|
||||
|
||||
{link ? (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Linked as <strong>{link.plexUsername}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-admin-muted">
|
||||
{link.librariesSharedAt
|
||||
? "The Movies, TV Shows and Music libraries are shared with you. Check your email for the invitation if you have not accepted it yet."
|
||||
: "The libraries have not been shared yet — link again to retry."}
|
||||
</p>
|
||||
<p className="text-sm text-admin-muted">
|
||||
Anything you add to your Plex watchlist is treated as a request: if we already
|
||||
have it you will find it in the library, and if we do not, we will go and get it.
|
||||
</p>
|
||||
<form action={unlinkPlexAction}>
|
||||
<button type="submit" className="admin-nav-button px-3 py-2 text-sm font-medium">
|
||||
Unlink
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-admin-muted">
|
||||
Link your Plex account to get access to the Movies, TV Shows and Music libraries.
|
||||
Once linked, your Plex watchlist becomes your request list — add something there
|
||||
and it will be found for you.
|
||||
</p>
|
||||
<p className="text-xs text-admin-muted">
|
||||
You sign in at plex.tv, not here. Ampelos never sees your Plex password, and the
|
||||
sign-in token is discarded as soon as Plex confirms who you are.
|
||||
</p>
|
||||
<form action={startPlexLinkAction}>
|
||||
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
|
||||
Link my Plex account
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { classics, watchingNowItems } from "@/db/schema";
|
||||
import { and, inArray, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RequestBody = {
|
||||
action?: "add" | "remove";
|
||||
mediaItemIds?: string[];
|
||||
};
|
||||
|
||||
async function removeActiveWatchNow(mediaItemIds: string[]) {
|
||||
if (!mediaItemIds.length) return;
|
||||
|
||||
await db
|
||||
.update(watchingNowItems)
|
||||
.set({ removedAt: new Date() })
|
||||
.where(and(inArray(watchingNowItems.mediaItemId, mediaItemIds), isNull(watchingNowItems.removedAt)));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!session.user.isAdmin) return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = (await request.json()) as RequestBody;
|
||||
const mediaItemIds = Array.isArray(body.mediaItemIds)
|
||||
? body.mediaItemIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
|
||||
if (!body.action || !mediaItemIds.length) {
|
||||
return Response.json({ error: "Missing action or mediaItemIds" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === "add") {
|
||||
await Promise.all(
|
||||
mediaItemIds.map((mediaItemId) =>
|
||||
db
|
||||
.insert(classics)
|
||||
.values({ mediaItemId, note: "Bulk marked as Timeless Classic", addedBy: session.user.id })
|
||||
.onConflictDoNothing({ target: classics.mediaItemId }),
|
||||
),
|
||||
);
|
||||
await removeActiveWatchNow(mediaItemIds);
|
||||
} else {
|
||||
await db.delete(classics).where(inArray(classics.mediaItemId, mediaItemIds));
|
||||
}
|
||||
|
||||
revalidatePath("/admin/inventory");
|
||||
revalidatePath("/");
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { db } from "@/db/client";
|
||||
import { corruptFiles } from "@/db/schema";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Stage 2 and the execution half of stage 3.
|
||||
//
|
||||
// POST agent reports files it could not read
|
||||
// GET ?status=approved agent asks what a human has approved for deletion
|
||||
// PATCH agent confirms a deletion succeeded or failed
|
||||
//
|
||||
// Ampelos never deletes these itself: it mounts the archive read-only so a scan
|
||||
// can never damage the backup, and the reporting agent already has write access
|
||||
// to the tier it found the problem on.
|
||||
|
||||
function authorize(request: Request) {
|
||||
const expected = process.env.AMPELOS_AGENT_TOKEN;
|
||||
if (!expected) return { ok: false, status: 503, error: "AMPELOS_AGENT_TOKEN is not configured" };
|
||||
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const provided = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return { ok: false, status: 401, error: "unauthorized" };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = body as { agent?: unknown; files?: unknown } | null;
|
||||
const agent = typeof payload?.agent === "string" ? payload.agent.trim() : "";
|
||||
if (!agent) return Response.json({ error: "agent is required" }, { status: 400 });
|
||||
if (!Array.isArray(payload?.files)) return Response.json({ error: "files must be an array" }, { status: 400 });
|
||||
|
||||
let recorded = 0;
|
||||
for (const entry of payload.files as Record<string, unknown>[]) {
|
||||
const fullPath = typeof entry?.fullPath === "string" ? entry.fullPath.trim() : "";
|
||||
const reason = typeof entry?.reason === "string" ? entry.reason.slice(0, 1000) : "";
|
||||
if (!fullPath || !reason) continue;
|
||||
|
||||
const sizeBytes =
|
||||
typeof entry?.sizeBytes === "number" && Number.isFinite(entry.sizeBytes)
|
||||
? BigInt(Math.round(entry.sizeBytes))
|
||||
: null;
|
||||
const tier = typeof entry?.tier === "string" ? entry.tier : null;
|
||||
|
||||
await db
|
||||
.insert(corruptFiles)
|
||||
.values({ fullPath, agent, tier, reason, sizeBytes })
|
||||
.onConflictDoUpdate({
|
||||
target: corruptFiles.fullPath,
|
||||
// Refresh the evidence, but never resurrect something a human already
|
||||
// dismissed or that has been deleted.
|
||||
set: { reason, sizeBytes, agent, tier, lastDetectedAt: new Date() },
|
||||
where: eq(corruptFiles.status, "pending"),
|
||||
});
|
||||
recorded += 1;
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, recorded });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const agent = url.searchParams.get("agent");
|
||||
const status = url.searchParams.get("status") ?? "approved";
|
||||
|
||||
if (!["pending", "approved", "deleted", "dismissed", "failed"].includes(status)) {
|
||||
return Response.json({ error: "unknown status" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: corruptFiles.id,
|
||||
fullPath: corruptFiles.fullPath,
|
||||
reason: corruptFiles.reason,
|
||||
sizeBytes: corruptFiles.sizeBytes,
|
||||
tier: corruptFiles.tier,
|
||||
})
|
||||
.from(corruptFiles)
|
||||
.where(
|
||||
agent
|
||||
? and(eq(corruptFiles.status, status as "approved"), eq(corruptFiles.agent, agent))
|
||||
: eq(corruptFiles.status, status as "approved"),
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
files: rows.map((row) => ({ ...row, sizeBytes: row.sizeBytes === null ? null : Number(row.sizeBytes) })),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = body as { results?: unknown } | null;
|
||||
if (!Array.isArray(payload?.results)) return Response.json({ error: "results must be an array" }, { status: 400 });
|
||||
|
||||
let updated = 0;
|
||||
for (const entry of payload.results as Record<string, unknown>[]) {
|
||||
const id = typeof entry?.id === "string" ? entry.id : "";
|
||||
const deleted = entry?.deleted === true;
|
||||
const error = typeof entry?.error === "string" ? entry.error.slice(0, 1000) : null;
|
||||
if (!id) continue;
|
||||
|
||||
// Only an approved row may transition, so a stale agent cannot delete
|
||||
// something a human has since dismissed.
|
||||
const result = await db
|
||||
.update(corruptFiles)
|
||||
.set(
|
||||
deleted
|
||||
? { status: "deleted", deletedAt: new Date(), deleteError: null }
|
||||
: { status: "failed", deleteError: error ?? "agent reported failure" },
|
||||
)
|
||||
.where(and(eq(corruptFiles.id, id), inArray(corruptFiles.status, ["approved", "failed"])))
|
||||
.returning({ id: corruptFiles.id });
|
||||
|
||||
updated += result.length;
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, updated });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { db } from "@/db/client";
|
||||
import { agentHeartbeats } from "@/db/schema";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Machine-to-machine endpoint. Remote hosts (silenus, edda) post here on a
|
||||
// timer so Ampelos knows whether their storage is currently reachable;
|
||||
// see src/lib/agents.ts for how staleness is interpreted.
|
||||
|
||||
function tokenMatches(provided: string, expected: string) {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
// timingSafeEqual throws on length mismatch, so guard first.
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function authorize(request: Request) {
|
||||
const expected = process.env.AMPELOS_AGENT_TOKEN;
|
||||
if (!expected) return { ok: false, status: 503, error: "AMPELOS_AGENT_TOKEN is not configured" };
|
||||
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const provided = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
if (!provided || !tokenMatches(provided, expected)) {
|
||||
return { ok: false, status: 401, error: "unauthorized" };
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown> | null;
|
||||
const agent = typeof payload?.agent === "string" ? payload.agent.trim() : "";
|
||||
if (!agent) return Response.json({ error: "agent is required" }, { status: 400 });
|
||||
|
||||
const hostname = typeof payload?.hostname === "string" ? payload.hostname : null;
|
||||
const version = typeof payload?.version === "string" ? payload.version : null;
|
||||
const details = payload?.details ?? null;
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(agentHeartbeats)
|
||||
.values({ agent, hostname, version, details, firstSeenAt: now, lastSeenAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: agentHeartbeats.agent,
|
||||
// firstSeenAt is deliberately not touched — it records the first contact.
|
||||
set: { hostname, version, details, lastSeenAt: now },
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, agent, receivedAt: now.toISOString() });
|
||||
}
|
||||
|
||||
// Lets an agent verify connectivity and credentials without recording a beat.
|
||||
export async function GET(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { db } from "@/db/client";
|
||||
import { storageTiers } from "@/db/schema";
|
||||
import { getAgentStatuses, AGENT_TIMEOUT_SECONDS } from "@/lib/agents";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Read-only view of agent liveness, consumed by the local mount daemon
|
||||
// (agent/ampelos-mountd.sh) so it can decide whether a tier's storage host is
|
||||
// up. Deliberately read-only: the daemon holds the privilege, this endpoint
|
||||
// never performs a mount.
|
||||
|
||||
function authorize(request: Request) {
|
||||
const expected = process.env.AMPELOS_AGENT_TOKEN;
|
||||
if (!expected) return { ok: false, status: 503, error: "AMPELOS_AGENT_TOKEN is not configured" };
|
||||
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const provided = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return { ok: false, status: 401, error: "unauthorized" };
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = authorize(request);
|
||||
if (!auth.ok) return Response.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
const [agents, tiers] = await Promise.all([
|
||||
getAgentStatuses(),
|
||||
db.select().from(storageTiers),
|
||||
]);
|
||||
|
||||
const agentByName = new Map(agents.map((agent) => [agent.agent, agent]));
|
||||
|
||||
return Response.json({
|
||||
timeoutSeconds: AGENT_TIMEOUT_SECONDS,
|
||||
agents: agents.map((agent) => ({
|
||||
agent: agent.agent,
|
||||
online: agent.online,
|
||||
secondsSinceSeen: agent.secondsSinceSeen,
|
||||
hostname: agent.hostname,
|
||||
})),
|
||||
tiers: tiers.map((tier) => ({
|
||||
tier: tier.tier,
|
||||
basePath: tier.basePath,
|
||||
agentName: tier.agentName,
|
||||
alwaysOnline: tier.alwaysOnline,
|
||||
// null when the tier names no agent: availability is then unmanaged.
|
||||
agentOnline: tier.agentName ? Boolean(agentByName.get(tier.agentName)?.online) : null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getCatalogDetails, type CatalogKind } from "@/lib/catalog";
|
||||
|
||||
type Params = {
|
||||
kind: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
function isDetailKind(kind: string): kind is Exclude<CatalogKind, "music"> {
|
||||
return kind === "television" || kind === "movies";
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<Params> }) {
|
||||
const { kind, id } = await context.params;
|
||||
|
||||
if (!isDetailKind(kind)) {
|
||||
return Response.json({ error: "Unsupported catalog kind" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const details = await getCatalogDetails(kind, id);
|
||||
return Response.json(details);
|
||||
} catch (error) {
|
||||
console.error("Failed to load catalog details", error);
|
||||
return Response.json({ error: "Failed to load catalog details" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<h1 className="text-xl font-bold text-red-600">Authentication Error</h1>
|
||||
<p className="mt-2 font-mono text-sm text-gray-700">
|
||||
Check the server console for details.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
"use client";
|
||||
|
||||
import type { CatalogDetails, CatalogItem, CatalogKind } from "@/lib/catalog";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { addToWatchNow, moveWatchNowItem, removeFromWatchNow, removeFromWatchNowByTmdbId } from "./watch-now-actions";
|
||||
import { setPurged, setTimeless, type TitleActionResult, type TitleFlags } from "./title-actions";
|
||||
|
||||
// What the detail panel tracks about the open title. The three server-side
|
||||
// flags, plus whether it is in one of this user's Watch Now slots -- which is
|
||||
// state the panel owns rather than something a title action returns.
|
||||
type PanelFlags = TitleFlags & { watchNow: boolean };
|
||||
|
||||
type WatchNowItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
mediaType: "tv_series" | "movie";
|
||||
year: number | null;
|
||||
posterPath: string | null;
|
||||
slotNumber: number | null;
|
||||
};
|
||||
|
||||
type DragPayload =
|
||||
| { type: "catalog"; item: CatalogItem }
|
||||
| { type: "watch-now"; watchNowItemId: string };
|
||||
|
||||
type DetailState = {
|
||||
item: CatalogItem;
|
||||
details: CatalogDetails | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
flags: PanelFlags;
|
||||
};
|
||||
|
||||
type CatalogBoardProps = {
|
||||
kind: CatalogKind;
|
||||
visibleItems: CatalogItem[];
|
||||
collectionIds: string[];
|
||||
watchNowExternalIds: string[];
|
||||
purgedExternalIds: string[];
|
||||
watchNowItems: WatchNowItem[];
|
||||
slotCount: number;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
function mediaLabel(kind: CatalogItem["kind"]) {
|
||||
return kind === "movies" ? "Movie" : "TV";
|
||||
}
|
||||
|
||||
function slotTitle(kind: CatalogKind) {
|
||||
return kind === "movies" ? "movie" : "TV";
|
||||
}
|
||||
|
||||
function submitCatalogItem(item: CatalogItem, slotNumber?: number) {
|
||||
const formData = new FormData();
|
||||
formData.set("kind", item.kind);
|
||||
formData.set("tmdbId", item.id);
|
||||
formData.set("title", item.title);
|
||||
formData.set("year", item.year ?? "");
|
||||
formData.set("overview", item.overview ?? "");
|
||||
formData.set("posterPath", item.posterUrl ?? "");
|
||||
formData.set("releaseDate", item.releaseDate ?? "");
|
||||
|
||||
if (slotNumber) {
|
||||
formData.set("slotNumber", String(slotNumber));
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
function submitWatchNowItem(watchNowItemId: string, slotNumber: number) {
|
||||
const formData = new FormData();
|
||||
formData.set("watchNowItemId", watchNowItemId);
|
||||
formData.set("slotNumber", String(slotNumber));
|
||||
return formData;
|
||||
}
|
||||
|
||||
function dragData(event: React.DragEvent, payload: DragPayload) {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/json", JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readDragData(event: React.DragEvent): DragPayload | null {
|
||||
const raw = event.dataTransfer.getData("application/json");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as DragPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MediaPoster({ item, inCollection, inWatchNow, onOpenDetails, onRejectClassic }: { item: CatalogItem; inCollection: boolean; inWatchNow: boolean; onOpenDetails: (item: CatalogItem) => void; onRejectClassic: (title: string) => void }) {
|
||||
return (
|
||||
<article
|
||||
draggable
|
||||
onDragStart={(event) => dragData(event, { type: "catalog", item })}
|
||||
className={"ampelos-card group cursor-grab transition hover:-translate-y-0.5 active:cursor-grabbing " + (item.isClassic ? "shadow-[0_0_0_1px_rgba(204,177,95,0.55),0_0_32px_rgba(204,177,95,0.18)]" : "")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenDetails(item)}
|
||||
className="relative block aspect-[2/3] w-full cursor-grab bg-ampelos-ink/65 text-left transition focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ampelos-gold active:cursor-grabbing"
|
||||
aria-label={"Open details for " + item.title}
|
||||
>
|
||||
{item.posterUrl ? (
|
||||
<Image src={item.posterUrl} alt="" fill sizes="(min-width: 1280px) 16vw, (min-width: 1024px) 20vw, (min-width: 640px) 33vw, 50vw" className="object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm font-medium text-ampelos-muted">
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 transition group-hover:bg-black/10" />
|
||||
<div className="absolute left-2 top-2 flex gap-1">
|
||||
<span className="rounded bg-ampelos-ink/80 px-2 py-1 text-[11px] font-medium text-ampelos-parchment">
|
||||
{mediaLabel(item.kind)}
|
||||
</span>
|
||||
{inCollection && (
|
||||
<span className="rounded bg-ampelos-sage px-2 py-1 text-[11px] font-medium text-ampelos-ink">
|
||||
Dionysus
|
||||
</span>
|
||||
)}
|
||||
{item.isClassic && (
|
||||
<span className="rounded bg-ampelos-gold px-2 py-1 text-[11px] font-semibold text-ampelos-ink">
|
||||
Timeless
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="space-y-2 p-3">
|
||||
<div>
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug text-ampelos-parchment">{item.title}</h3>
|
||||
<p className="mt-1 text-xs text-ampelos-muted/75">
|
||||
{item.year ?? "Unknown year"}
|
||||
{item.rating ? " · " + item.rating.toFixed(1) : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className={"line-clamp-3 min-h-12 rounded text-xs leading-4 text-ampelos-muted " + (item.isClassic ? "border border-ampelos-gold/35 bg-ampelos-gold/10 p-2 shadow-[0_0_22px_rgba(204,177,95,0.18)]" : "")}>
|
||||
{item.overview ?? "No summary available yet."}
|
||||
</p>
|
||||
<form action={(formData) => {
|
||||
void addToWatchNow(formData).then((result) => {
|
||||
if (result && !result.ok) onRejectClassic(item.title);
|
||||
});
|
||||
}}>
|
||||
<input type="hidden" name="kind" value={item.kind} />
|
||||
<input type="hidden" name="tmdbId" value={item.id} />
|
||||
<input type="hidden" name="title" value={item.title} />
|
||||
<input type="hidden" name="year" value={item.year ?? ""} />
|
||||
<input type="hidden" name="overview" value={item.overview ?? ""} />
|
||||
<input type="hidden" name="posterPath" value={item.posterUrl ?? ""} />
|
||||
<input type="hidden" name="releaseDate" value={item.releaseDate ?? ""} />
|
||||
<button
|
||||
type={item.isClassic ? "button" : "submit"}
|
||||
onClick={item.isClassic ? () => onRejectClassic(item.title) : undefined}
|
||||
disabled={inWatchNow}
|
||||
className="ampelos-stone-button w-full px-3 py-2 text-sm disabled:cursor-default"
|
||||
>
|
||||
{item.isClassic ? "Always Available" : inWatchNow ? "In Watch Now" : "Add to Watch Now"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function titleFormData(item: CatalogItem, on: boolean) {
|
||||
const formData = submitCatalogItem(item);
|
||||
formData.set("on", on ? "1" : "0");
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* The buttons that say what should happen to a title.
|
||||
*
|
||||
* The first is for everybody and is about the person: Watch Now is the demand
|
||||
* signal this app is built around, and it is a toggle because the panel is
|
||||
* where someone looks to find out whether they already asked -- a button that
|
||||
* can only ever add makes them go and check the tray.
|
||||
*
|
||||
* The other two are admin-only and are about the library, so they are penned
|
||||
* off below a rule rather than mixed in. Neither deletes anything: purge
|
||||
* withdraws intent, and the worst it can do is stop a download.
|
||||
*/
|
||||
function TitleActions({
|
||||
item,
|
||||
flags,
|
||||
isAdmin,
|
||||
onFlags,
|
||||
onNotice,
|
||||
}: {
|
||||
item: CatalogItem;
|
||||
flags: PanelFlags;
|
||||
isAdmin: boolean;
|
||||
onFlags: (flags: Partial<PanelFlags>) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function run(action: (formData: FormData) => Promise<TitleActionResult>, on: boolean) {
|
||||
startTransition(() => {
|
||||
void action(titleFormData(item, on)).then((result) => {
|
||||
if (result.ok) {
|
||||
onFlags(result.flags);
|
||||
if (result.message) onNotice(result.message);
|
||||
} else {
|
||||
onNotice(result.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleWatchNow() {
|
||||
// A timeless title is always available, so a slot spent on one buys
|
||||
// nothing; the server refuses too, and saying so here saves the round trip.
|
||||
if (!flags.watchNow && flags.timeless) {
|
||||
onNotice(item.title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
const action = flags.watchNow
|
||||
? removeFromWatchNowByTmdbId(titleFormData(item, false))
|
||||
: addToWatchNow(submitCatalogItem(item));
|
||||
|
||||
void action.then((result) => {
|
||||
// A full tray is the ordinary reason an add fails, and the flag must
|
||||
// not flip on a request the server turned down.
|
||||
if (result && !result.ok) {
|
||||
onNotice(result.message);
|
||||
return;
|
||||
}
|
||||
onFlags({ watchNow: !flags.watchNow });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-ampelos-parchment">Watch Now</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={toggleWatchNow}
|
||||
className={
|
||||
"mt-2 block w-full px-3 py-2 text-center text-sm disabled:opacity-60 " +
|
||||
(flags.watchNow ? "ampelos-ghost-button font-medium" : "ampelos-stone-button")
|
||||
}
|
||||
>
|
||||
{flags.watchNow ? "Remove from Watch Now" : "Add to Watch Now"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs leading-5 text-ampelos-muted/80">
|
||||
{flags.watchNow
|
||||
? "In one of your slots: kept on the live tier at the best quality Ampelos can find."
|
||||
: "Takes one of your slots and puts this on the live tier at the best quality Ampelos can find."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="border-t border-ampelos-gold/15 pt-3">
|
||||
<h3 className="font-semibold text-ampelos-parchment">Admin</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => run(setTimeless, !flags.timeless)}
|
||||
className="ampelos-ghost-button mt-2 block w-full px-3 py-2 text-center text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{flags.timeless ? "Timeless — remove" : "Make timeless"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => run(setPurged, !flags.purged)}
|
||||
className="ampelos-ghost-button mt-2 block w-full px-3 py-2 text-center text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{flags.purged ? "Purged — allow again" : "Add to purge"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs leading-5 text-ampelos-muted/80">
|
||||
Timeless keeps a title live permanently and is the only thing allowed a remux.
|
||||
Purge stops Ampelos wanting it; it deletes nothing, and watching it still brings it back.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailOverlay({
|
||||
state,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onFlags,
|
||||
onNotice,
|
||||
}: {
|
||||
state: DetailState;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onFlags: (flags: Partial<PanelFlags>) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
const details = state.details;
|
||||
// The flag, not the fetched item: an admin who just switched it off should
|
||||
// see it off, and the item this overlay was opened with cannot know that.
|
||||
const isClassic = state.flags.timeless;
|
||||
const item = details ? { ...details, isClassic } : state.item;
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 overflow-y-auto bg-ampelos-ink/82 p-3 backdrop-blur-sm md:p-8" onClick={onClose}>
|
||||
<section className="ampelos-surface mx-auto min-h-[80vh] max-w-5xl overflow-hidden text-ampelos-parchment shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="relative min-h-64 bg-ampelos-ink text-ampelos-parchment md:min-h-80">
|
||||
{item.backdropUrl ? (
|
||||
<Image src={item.backdropUrl} alt="" fill sizes="100vw" className="object-cover opacity-55" priority />
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-ampelos-ink via-ampelos-ink/65 to-ampelos-ink/10" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full bg-ampelos-ink/65 text-xl leading-none text-ampelos-parchment hover:bg-ampelos-ink"
|
||||
aria-label="Close details"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-0 flex gap-4 p-4 md:p-6">
|
||||
<div className="relative hidden aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-md bg-ampelos-stone shadow-xl sm:block">
|
||||
{item.posterUrl ? <Image src={item.posterUrl} alt="" fill sizes="128px" className="object-cover" /> : null}
|
||||
</div>
|
||||
<div className="min-w-0 self-end">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-ampelos-gold">{item.kind === "movies" ? "Movie" : "Television"}</p>
|
||||
<h2 className="text-3xl font-semibold tracking-normal md:text-5xl">{item.title}</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium text-ampelos-muted">
|
||||
{item.year && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{item.year}</span>}
|
||||
{isClassic && <span className="rounded bg-ampelos-gold px-2 py-1 font-semibold text-ampelos-ink">Timeless Classic</span>}
|
||||
{details?.status && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{details.status}</span>}
|
||||
{details?.runtime && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{details.runtime}</span>}
|
||||
{item.rating && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{item.rating.toFixed(1)} rating</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 p-4 md:grid-cols-[1fr_18rem] md:p-6">
|
||||
<div className="space-y-6">
|
||||
{state.loading ? (
|
||||
<div className="rounded-md border border-ampelos-gold/20 bg-ampelos-ink/35 p-4 text-sm text-ampelos-muted">Loading details...</div>
|
||||
) : state.error ? (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">{state.error}</div>
|
||||
) : null}
|
||||
|
||||
{details?.tagline ? <p className="text-lg font-medium text-ampelos-muted">{details.tagline}</p> : null}
|
||||
<div className={isClassic ? "rounded-md border border-ampelos-gold/40 bg-ampelos-gold/10 p-4 shadow-[0_0_30px_rgba(204,177,95,0.18)]" : ""}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.24em] text-ampelos-gold">Overview</h3>
|
||||
{isClassic ? <p className="mt-2 text-sm font-medium text-ampelos-gold">Timeless Classic: this title stays in the main collection and does not need a Watch Now slot.</p> : null}
|
||||
<p className="mt-2 text-sm leading-6 text-ampelos-muted">{item.overview ?? "No description available yet."}</p>
|
||||
</div>
|
||||
|
||||
{details?.cast.length ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.24em] text-ampelos-gold">Cast</h3>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{details.cast.map((person) => (
|
||||
<div key={person.id} className="min-w-0 rounded-md border border-ampelos-gold/15 bg-ampelos-ink/35 p-2">
|
||||
<div className="relative mb-2 aspect-[2/3] overflow-hidden rounded bg-ampelos-stone">
|
||||
{person.profileUrl ? <Image src={person.profileUrl} alt="" fill sizes="120px" className="object-cover" /> : null}
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium">{person.name}</p>
|
||||
{person.role ? <p className="truncate text-xs text-ampelos-muted/70">{person.role}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4 rounded-md border border-ampelos-gold/15 bg-ampelos-ink/35 p-4 text-sm">
|
||||
<TitleActions item={item} flags={state.flags} isAdmin={isAdmin} onFlags={onFlags} onNotice={onNotice} />
|
||||
|
||||
{details?.genres.length ? (
|
||||
<div>
|
||||
<h3 className="font-semibold text-ampelos-parchment">Genres</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{details.genres.map((genre) => <span key={genre} className="rounded bg-ampelos-parchment/10 px-2 py-1 text-xs text-ampelos-muted">{genre}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{details?.creators.length ? (
|
||||
<div>
|
||||
<h3 className="font-semibold text-ampelos-parchment">Created by</h3>
|
||||
<p className="mt-1 text-ampelos-muted">{details.creators.map((person) => person.name).join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{details?.networks.length ? (
|
||||
<div>
|
||||
<h3 className="font-semibold text-ampelos-parchment">Network</h3>
|
||||
<p className="mt-1 text-ampelos-muted">{details.networks.join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{details?.seasonCount || details?.episodeCount ? (
|
||||
<div>
|
||||
<h3 className="font-semibold text-ampelos-parchment">Episodes</h3>
|
||||
<p className="mt-1 text-ampelos-muted">
|
||||
{details.seasonCount ? details.seasonCount + " seasons" : ""}
|
||||
{details.seasonCount && details.episodeCount ? " · " : ""}
|
||||
{details.episodeCount ? details.episodeCount + " episodes" : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{details?.homepage ? (
|
||||
<a href={details.homepage} target="_blank" rel="noreferrer" className="ampelos-stone-button block px-3 py-2 text-center text-sm">
|
||||
Official site
|
||||
</a>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MusicPlaceholder() {
|
||||
return (
|
||||
<section className="ampelos-panel border-dashed p-8 text-center">
|
||||
<h2 className="text-lg font-semibold text-ampelos-parchment">Music is warming up backstage</h2>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-ampelos-muted">
|
||||
The tab is here so the shape of Ampelos is honest early. Once music support is designed, this will use the same search, demand, and availability model as TV and movies.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSlots(items: WatchNowItem[], slotCount: number) {
|
||||
const slots: Array<WatchNowItem | null> = Array.from({ length: slotCount }, () => null);
|
||||
const unplaced: WatchNowItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.slotNumber && item.slotNumber >= 1 && item.slotNumber <= slotCount && !slots[item.slotNumber - 1]) {
|
||||
slots[item.slotNumber - 1] = item;
|
||||
} else {
|
||||
unplaced.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of unplaced) {
|
||||
const emptyIndex = slots.findIndex((slot) => slot === null);
|
||||
if (emptyIndex === -1) {
|
||||
break;
|
||||
}
|
||||
slots[emptyIndex] = item;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function WatchNowTray({ kind, items, slotCount, onRejectClassic }: { kind: CatalogKind; items: WatchNowItem[]; slotCount: number; onRejectClassic: (title: string) => void }) {
|
||||
const [, startTransition] = useTransition();
|
||||
const [dragOverSlot, setDragOverSlot] = useState<number | null>(null);
|
||||
const [isClosedDropTarget, setIsClosedDropTarget] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const slots = useMemo(() => buildSlots(items, slotCount), [items, slotCount]);
|
||||
const noun = slotTitle(kind);
|
||||
|
||||
function onDrop(event: React.DragEvent, slotNumber: number) {
|
||||
event.preventDefault();
|
||||
setDragOverSlot(null);
|
||||
const payload = readDragData(event);
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "catalog") {
|
||||
if (payload.item.isClassic) {
|
||||
onRejectClassic(payload.item.title);
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
void addToWatchNow(submitCatalogItem(payload.item, slotNumber)).then((result) => {
|
||||
if (result && !result.ok) onRejectClassic(payload.item.title);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => moveWatchNowItem(submitWatchNowItem(payload.watchNowItemId, slotNumber)));
|
||||
}
|
||||
|
||||
function onClosedDrop(event: React.DragEvent) {
|
||||
event.preventDefault();
|
||||
setIsClosedDropTarget(false);
|
||||
const payload = readDragData(event);
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "catalog") {
|
||||
if (payload.item.isClassic) {
|
||||
onRejectClassic(payload.item.title);
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
void addToWatchNow(submitCatalogItem(payload.item)).then((result) => {
|
||||
if (result && !result.ok) onRejectClassic(payload.item.title);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOpenSlot = slots.findIndex((slot) => slot === null) + 1;
|
||||
if (firstOpenSlot > 0) {
|
||||
startTransition(() => moveWatchNowItem(submitWatchNowItem(payload.watchNowItemId, firstOpenSlot)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
onDragEnter={() => setIsClosedDropTarget(true)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsClosedDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setIsClosedDropTarget(false)}
|
||||
onDrop={onClosedDrop}
|
||||
className={
|
||||
"fixed bottom-3 right-3 z-30 grid h-14 w-14 place-items-center rounded-full border text-ampelos-parchment shadow-2xl transition hover:-translate-y-0.5 md:bottom-6 md:right-6 " +
|
||||
(isClosedDropTarget
|
||||
? "border-ampelos-gold bg-ampelos-gold-strong shadow-[0_0_0_5px_rgba(152,170,88,0.24)]"
|
||||
: "border-ampelos-gold/30 bg-ampelos-ink hover:border-ampelos-gold hover:bg-ampelos-stone")
|
||||
}
|
||||
aria-label="Open Watch Now tray"
|
||||
>
|
||||
<span className="relative h-6 w-10" aria-hidden="true">
|
||||
<span className="absolute left-0 top-2 h-3.5 w-4 rounded-full bg-ampelos-gold shadow-[24px_0_0_#98aa58]" />
|
||||
<span className="absolute left-4 top-3.5 h-0.5 w-5 bg-ampelos-gold" />
|
||||
<span className="absolute left-0 top-0.5 h-1 w-10 rounded-full bg-ampelos-parchment/90" />
|
||||
<span className="absolute left-1 top-2.5 h-1 w-2 rounded-full bg-ampelos-ink/60 shadow-[24px_0_0_rgba(7,16,23,0.6)]" />
|
||||
</span>
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-ampelos-gold px-1.5 py-0.5 text-[10px] font-bold text-ampelos-ink">
|
||||
{items.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<aside className="ampelos-surface fixed inset-x-3 bottom-3 z-30 p-3 text-ampelos-parchment shadow-2xl backdrop-blur md:inset-x-auto md:bottom-6 md:right-6 md:w-[28rem]">
|
||||
<div className="flex max-h-[16rem] flex-col gap-3 overflow-hidden md:max-h-[72vh]" onDragEnd={() => setDragOverSlot(null)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-ampelos-gold">Watch Now</p>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{items.length ? "Your active " + noun + " slots" : "Your " + noun + " slots are empty"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<p className="text-xs text-ampelos-muted">{items.length}/{slotCount}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded text-lg leading-none text-ampelos-muted hover:bg-ampelos-parchment/10 hover:text-ampelos-parchment"
|
||||
aria-label="Collapse Watch Now tray"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[9.5rem] grid-cols-2 gap-2 overflow-y-auto pr-1 md:max-h-[calc(72vh-4.5rem)]">
|
||||
{slots.map((item, index) => {
|
||||
const slotNumber = index + 1;
|
||||
return (
|
||||
<div
|
||||
key={slotNumber}
|
||||
onDragEnter={() => setDragOverSlot(slotNumber)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOverSlot(slotNumber);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setDragOverSlot((current) => current === slotNumber ? null : current);
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => onDrop(event, slotNumber)}
|
||||
className={
|
||||
"min-h-20 rounded-md border border-dashed p-2 transition " +
|
||||
(dragOverSlot === slotNumber
|
||||
? "border-ampelos-gold bg-ampelos-gold/20 shadow-[0_0_0_2px_rgba(152,170,88,0.28)]"
|
||||
: "border-ampelos-gold/20 bg-ampelos-ink/35 hover:border-ampelos-gold/75 hover:bg-ampelos-gold/10")
|
||||
}
|
||||
>
|
||||
{item ? (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(event) => dragData(event, { type: "watch-now", watchNowItemId: item.id })}
|
||||
className="flex h-full cursor-grab items-center gap-2 rounded bg-ampelos-parchment/10 p-2 active:cursor-grabbing"
|
||||
>
|
||||
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded bg-ampelos-stone">
|
||||
{item.posterPath ? (
|
||||
<Image src={item.posterPath} alt="" fill sizes="40px" className="object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-xs font-semibold text-ampelos-muted">{item.title.slice(0, 1)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
</div>
|
||||
<form action={removeFromWatchNow}>
|
||||
<input type="hidden" name="watchNowItemId" value={item.id} />
|
||||
<button type="submit" className="grid h-7 w-7 place-items-center rounded text-lg leading-none text-ampelos-muted hover:bg-ampelos-parchment/10 hover:text-ampelos-parchment" aria-label={"Remove " + item.title + " from Watch Now"}>
|
||||
×
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-16 items-center justify-center rounded text-center text-xs font-medium text-ampelos-muted/70">
|
||||
Drop {noun} here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin }: CatalogBoardProps) {
|
||||
const collectionSet = useMemo(() => new Set(collectionIds), [collectionIds]);
|
||||
const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]);
|
||||
const purgedSet = useMemo(() => new Set(purgedExternalIds), [purgedExternalIds]);
|
||||
const [detailState, setDetailState] = useState<DetailState | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
function rejectClassic(title: string) {
|
||||
setNotice(title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot.");
|
||||
}
|
||||
|
||||
// Applied to whatever is open now, and only if it is still the same title --
|
||||
// a slow action must not stamp its result onto a panel the person has since
|
||||
// moved on from.
|
||||
function applyFlags(itemId: string, flags: Partial<PanelFlags>) {
|
||||
setDetailState((current) =>
|
||||
current && current.item.id === itemId
|
||||
? { ...current, flags: { ...current.flags, ...flags } }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
|
||||
async function openDetails(item: CatalogItem) {
|
||||
const flags: PanelFlags = {
|
||||
timeless: Boolean(item.isClassic),
|
||||
purged: purgedSet.has(item.id),
|
||||
watchNow: watchNowSet.has(item.id),
|
||||
};
|
||||
setDetailState({ item, details: null, loading: true, error: null, flags });
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/catalog/" + item.kind + "/" + item.id);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load details for " + item.title);
|
||||
}
|
||||
|
||||
const details = (await response.json()) as CatalogDetails;
|
||||
setDetailState((current) => {
|
||||
if (!current || current.item.id !== item.id) return current;
|
||||
return {
|
||||
...current,
|
||||
details,
|
||||
loading: false,
|
||||
error: null,
|
||||
// The detail fetch can know a title is timeless when the board's list
|
||||
// did not, so it may only ever turn the flag ON -- reading it back
|
||||
// wholesale would undo an admin who just switched it off while this
|
||||
// request was in the air.
|
||||
flags: { ...current.flags, timeless: current.flags.timeless || Boolean(details.isClassic) },
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
setDetailState((current) => current && current.item.id === item.id
|
||||
? {
|
||||
...current,
|
||||
details: null,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "Unable to load details.",
|
||||
}
|
||||
: current);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{kind === "music" ? (
|
||||
<div className="mt-5"><MusicPlaceholder /></div>
|
||||
) : visibleItems.length ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleItems.map((item) => (
|
||||
<MediaPoster key={item.kind + "-" + item.id} item={item} inCollection={collectionSet.has(item.id)} inWatchNow={watchNowSet.has(item.id)} onOpenDetails={openDetails} onRejectClassic={rejectClassic} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<section className="ampelos-panel mt-5 p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">No matching titles yet</h2>
|
||||
<p className="mt-2 text-sm text-ampelos-muted">
|
||||
Try clearing the Dionysus collection filter or searching for another title.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className="fixed left-3 right-3 top-3 z-50 mx-auto max-w-xl rounded-md border border-ampelos-gold/60 bg-ampelos-ink px-4 py-3 text-sm font-medium text-ampelos-parchment shadow-2xl md:left-auto md:right-6 md:top-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p>{notice}</p>
|
||||
<button type="button" onClick={() => setNotice(null)} className="text-lg leading-none text-ampelos-muted hover:text-ampelos-parchment" aria-label="Dismiss message">×</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailState && (
|
||||
<DetailOverlay
|
||||
state={detailState}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setDetailState(null)}
|
||||
onFlags={(flags) => applyFlags(detailState.item.id, flags)}
|
||||
onNotice={setNotice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{kind !== "music" && <WatchNowTray kind={kind} items={watchNowItems} slotCount={slotCount} onRejectClassic={rejectClassic} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { CatalogKind } from "@/lib/catalog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
type CatalogControlsProps = {
|
||||
kind: CatalogKind;
|
||||
query: string;
|
||||
collectionOnly: boolean;
|
||||
};
|
||||
|
||||
function buildHref(kind: CatalogKind, query: string, collectionOnly: boolean) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("tab", kind);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
if (trimmedQuery) {
|
||||
params.set("q", trimmedQuery);
|
||||
}
|
||||
|
||||
if (collectionOnly) {
|
||||
params.set("collection", "1");
|
||||
}
|
||||
|
||||
return "/?" + params.toString();
|
||||
}
|
||||
|
||||
export function CatalogControls({ kind, query, collectionOnly }: CatalogControlsProps) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [searchText, setSearchText] = useState(query);
|
||||
const [onlyCollection, setOnlyCollection] = useState(collectionOnly);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
const href = buildHref(kind, searchText, onlyCollection);
|
||||
startTransition(() => router.replace(href, { scroll: false }));
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [kind, onlyCollection, router, searchText, startTransition]);
|
||||
|
||||
return (
|
||||
<div className="ampelos-panel mt-5 grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<label className="min-w-0">
|
||||
<span className="sr-only">Search catalog</span>
|
||||
<input
|
||||
name="q"
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
placeholder={kind === "movies" ? "Search movies" : kind === "television" ? "Search television" : "Search music"}
|
||||
className="ampelos-input h-11 w-full rounded-md px-3 text-sm outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="ampelos-ghost-button flex h-11 items-center gap-2 px-3 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
checked={onlyCollection}
|
||||
onChange={(event) => setOnlyCollection(event.target.checked)}
|
||||
className="h-4 w-4 accent-ampelos-gold"
|
||||
/>
|
||||
Dionysus collection
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #141a14;
|
||||
--foreground: #e8ecd8;
|
||||
|
||||
/* Core earth & vine palette */
|
||||
--ampelos-ink: #0e1410;
|
||||
--ampelos-earth: #182018;
|
||||
--ampelos-clay: #384030;
|
||||
--ampelos-mortar: #5e6e52;
|
||||
--ampelos-stone: #88988a;
|
||||
--ampelos-stone-soft: #a8b8a8;
|
||||
--ampelos-parchment: #e8ecd8;
|
||||
--ampelos-muted: #a8b890;
|
||||
--ampelos-gold: #98aa58;
|
||||
--ampelos-gold-strong: #687038;
|
||||
|
||||
/* Grape & vine accents */
|
||||
--ampelos-wine: #382848;
|
||||
--ampelos-wine-soft: #5a4878;
|
||||
--ampelos-grape: #7080a8;
|
||||
--ampelos-vine: #3a5028;
|
||||
--ampelos-sage: #5e7840;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-ampelos-ink: var(--ampelos-ink);
|
||||
--color-ampelos-earth: var(--ampelos-earth);
|
||||
--color-ampelos-clay: var(--ampelos-clay);
|
||||
--color-ampelos-mortar: var(--ampelos-mortar);
|
||||
--color-ampelos-stone: var(--ampelos-stone);
|
||||
--color-ampelos-stone-soft: var(--ampelos-stone-soft);
|
||||
--color-ampelos-parchment: var(--ampelos-parchment);
|
||||
--color-ampelos-muted: var(--ampelos-muted);
|
||||
--color-ampelos-gold: var(--ampelos-gold);
|
||||
--color-ampelos-gold-strong: var(--ampelos-gold-strong);
|
||||
--color-ampelos-wine: var(--ampelos-wine);
|
||||
--color-ampelos-wine-soft: var(--ampelos-wine-soft);
|
||||
--color-ampelos-grape: var(--ampelos-grape);
|
||||
--color-ampelos-vine: var(--ampelos-vine);
|
||||
--color-ampelos-sage: var(--ampelos-sage);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ampelos-ink);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #141a14;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 22% 0%, rgba(90, 120, 55, 0.18), transparent 34rem),
|
||||
radial-gradient(circle at 80% 82%, rgba(80, 60, 110, 0.18), transparent 28rem),
|
||||
radial-gradient(circle at 6% 74%, rgba(55, 80, 40, 0.14), transparent 22rem),
|
||||
linear-gradient(180deg, #1a2218 0%, #141a14 52%, #0e1410 100%);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Subtle vine-row texture */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: 0.30;
|
||||
background-image:
|
||||
linear-gradient(162deg, rgba(160, 185, 120, 0.07) 0 1px, transparent 1px 80px),
|
||||
linear-gradient(68deg, rgba(14, 20, 16, 0.22) 0 1px, transparent 1px 60px),
|
||||
radial-gradient(ellipse at 18% 40%, rgba(90, 120, 55, 0.07), transparent 22rem),
|
||||
radial-gradient(circle at 76% 68%, rgba(80, 60, 110, 0.06), transparent 18rem);
|
||||
background-size: 160px 160px, 112px 112px, auto, auto;
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
|
||||
.ampelos-app {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 100vh;
|
||||
background-color: #161c16;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 18% 0%, rgba(90, 120, 55, 0.20), transparent 32rem),
|
||||
radial-gradient(circle at 88% 6%, rgba(80, 60, 110, 0.18), transparent 24rem),
|
||||
radial-gradient(circle at 54% 84%, rgba(55, 80, 40, 0.16), transparent 28rem),
|
||||
linear-gradient(180deg, rgba(30, 40, 28, 0.95), rgba(20, 26, 18, 0.98));
|
||||
}
|
||||
|
||||
.ampelos-login {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: var(--ampelos-ink);
|
||||
}
|
||||
|
||||
.ampelos-login__background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(90deg, rgba(14, 20, 16, 0.94) 0%, rgba(14, 20, 16, 0.78) 36%, rgba(14, 20, 16, 0.28) 68%, rgba(14, 20, 16, 0.62) 100%),
|
||||
radial-gradient(circle at 30% 30%, rgba(90, 120, 55, 0.18), transparent 22rem),
|
||||
radial-gradient(circle at 72% 22%, rgba(80, 60, 110, 0.22), transparent 24rem),
|
||||
radial-gradient(circle at 60% 76%, rgba(55, 80, 40, 0.14), transparent 22rem),
|
||||
url("/vineyard.webp");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
filter: saturate(0.92) contrast(1.06) brightness(0.90);
|
||||
}
|
||||
|
||||
.ampelos-login__background::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(108deg, rgba(160, 185, 120, 0.05) 0 1px, transparent 1px 60px),
|
||||
radial-gradient(circle at 22% 22%, rgba(90, 120, 55, 0.10), transparent 18rem),
|
||||
radial-gradient(circle at 74% 66%, rgba(80, 60, 110, 0.10), transparent 22rem);
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
|
||||
/* Surfaces: aged stone slab / terracotta */
|
||||
.ampelos-topbar,
|
||||
.ampelos-panel,
|
||||
.ampelos-card,
|
||||
.ampelos-surface {
|
||||
border: 1px solid rgba(140, 160, 110, 0.20);
|
||||
background-color: #1e2a1e;
|
||||
background-image:
|
||||
radial-gradient(circle at 16% 0%, rgba(90, 120, 55, 0.15), transparent 18rem),
|
||||
radial-gradient(circle at 88% 88%, rgba(80, 60, 110, 0.16), transparent 16rem),
|
||||
repeating-linear-gradient(
|
||||
108deg,
|
||||
transparent,
|
||||
transparent 14px,
|
||||
rgba(0, 0, 0, 0.020) 14px,
|
||||
rgba(0, 0, 0, 0.020) 15px
|
||||
),
|
||||
linear-gradient(180deg, rgba(38, 50, 34, 0.94), rgba(22, 30, 20, 0.98));
|
||||
background-blend-mode: normal, normal, soft-light, normal;
|
||||
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.52), inset 0 1px 0 rgba(160, 185, 120, 0.11);
|
||||
}
|
||||
|
||||
.ampelos-topbar {
|
||||
border-width: 0 0 1px;
|
||||
background-color: #192019;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 0%, rgba(90, 120, 55, 0.14), transparent 26rem),
|
||||
radial-gradient(circle at 90% 0%, rgba(80, 60, 110, 0.12), transparent 20rem),
|
||||
linear-gradient(90deg, rgba(28, 38, 26, 0.97), rgba(22, 32, 20, 0.95));
|
||||
}
|
||||
|
||||
.ampelos-panel,
|
||||
.ampelos-card,
|
||||
.ampelos-surface {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ampelos-card {
|
||||
overflow: hidden;
|
||||
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.ampelos-card:hover {
|
||||
border-color: rgba(140, 165, 100, 0.38);
|
||||
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(160, 185, 120, 0.13);
|
||||
}
|
||||
|
||||
.ampelos-input {
|
||||
border: 1px solid rgba(140, 160, 100, 0.32);
|
||||
background: rgba(14, 20, 16, 0.68);
|
||||
color: var(--ampelos-parchment);
|
||||
}
|
||||
|
||||
.ampelos-input::placeholder {
|
||||
color: rgba(168, 184, 144, 0.60);
|
||||
}
|
||||
|
||||
.ampelos-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(152, 170, 88, 0.68);
|
||||
box-shadow: 0 0 0 3px rgba(152, 170, 88, 0.16);
|
||||
}
|
||||
|
||||
/* Primary button: beveled stone slab */
|
||||
.ampelos-stone-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
|
||||
background-image:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#b4c4a8 0%,
|
||||
#94a888 46%,
|
||||
#7a9070 100%
|
||||
);
|
||||
|
||||
/* Embossed bevel: bright top-left, dark bottom-right */
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.58);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.24);
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.48);
|
||||
|
||||
color: #141c12;
|
||||
font-weight: 700;
|
||||
|
||||
box-shadow:
|
||||
0 2px 5px rgba(0, 0, 0, 0.52),
|
||||
0 6px 20px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.62),
|
||||
inset 0 -3px 6px rgba(0, 0, 0, 0.16);
|
||||
|
||||
transition: filter 100ms ease, box-shadow 100ms ease, transform 80ms ease;
|
||||
}
|
||||
|
||||
.ampelos-stone-button:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
box-shadow:
|
||||
0 3px 7px rgba(0, 0, 0, 0.56),
|
||||
0 8px 24px rgba(0, 0, 0, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.68),
|
||||
inset 0 -3px 6px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.ampelos-stone-button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.90);
|
||||
/* reverse bevel: pushed down into the ground */
|
||||
border-top: 2px solid rgba(0, 0, 0, 0.32);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.18);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.20);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
0 0 2px rgba(0, 0, 0, 0.28),
|
||||
inset 0 2px 6px rgba(0, 0, 0, 0.30),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* Disabled/already-added: sunken into the ground, dimmed */
|
||||
.ampelos-stone-button:disabled {
|
||||
cursor: default;
|
||||
background-image:
|
||||
linear-gradient(180deg, #788870 0%, #647860 100%);
|
||||
border-top: 2px solid rgba(0, 0, 0, 0.24);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
inset 0 2px 5px rgba(0, 0, 0, 0.30),
|
||||
0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
color: rgba(20, 28, 18, 0.48);
|
||||
}
|
||||
|
||||
/* Ghost button: mortared stone edge */
|
||||
.ampelos-ghost-button {
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(140, 165, 100, 0.28);
|
||||
background: rgba(14, 20, 16, 0.42);
|
||||
color: var(--ampelos-muted);
|
||||
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.ampelos-ghost-button:hover {
|
||||
border-color: rgba(152, 170, 88, 0.55);
|
||||
background: rgba(152, 170, 88, 0.10);
|
||||
color: var(--ampelos-parchment);
|
||||
}
|
||||
|
||||
|
||||
/* Admin palette: readable operational UI. Kept as plain CSS so Tailwind never drops it. */
|
||||
.ampelos-admin {
|
||||
min-height: 100vh;
|
||||
background: #0d1518;
|
||||
color: #f4f8f1;
|
||||
}
|
||||
|
||||
.ampelos-admin-sidebar {
|
||||
background: #111c1f;
|
||||
border-right: 1px solid rgba(151, 185, 191, 0.34);
|
||||
color: #f4f8f1;
|
||||
box-shadow: 8px 0 28px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.admin-panel,
|
||||
.admin-card {
|
||||
border: 1px solid rgba(151, 185, 191, 0.30);
|
||||
border-radius: 6px;
|
||||
background: #172629;
|
||||
color: #f4f8f1;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
transition: border-color 140ms ease, background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.admin-card:hover {
|
||||
border-color: rgba(159, 197, 209, 0.58);
|
||||
background: #1d3034;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.admin-nav-button {
|
||||
display: block;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(151, 185, 191, 0.30);
|
||||
background: #172629;
|
||||
color: #f4f8f1;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.admin-nav-button:hover {
|
||||
border-color: rgba(159, 197, 209, 0.68);
|
||||
background: #20363a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* The selected item in a group: the sidebar's current page, inventory's
|
||||
Movies/Television and tier pickers, the calendar's scope.
|
||||
|
||||
Filled rather than tinted. A row of outlined buttons where the active one
|
||||
merely has a slightly brighter outline does not read as "this one is on" at
|
||||
a glance -- which is the entire job of the control. Inverting the fill makes
|
||||
the state legible from across the room and survives being glanced at.
|
||||
|
||||
Driven off aria-current, which these controls already set, so the selected
|
||||
look and the state announced to a screen reader cannot drift apart. */
|
||||
.admin-nav-button[aria-current]:not([aria-current="false"]) {
|
||||
border-color: #9fc5d1;
|
||||
background: #9fc5d1;
|
||||
color: #0d1b1e;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 2px 10px rgba(159, 197, 209, 0.20);
|
||||
}
|
||||
|
||||
.admin-nav-button[aria-current]:not([aria-current="false"]):hover {
|
||||
border-color: #d8f3fa;
|
||||
background: #d8f3fa;
|
||||
color: #0d1b1e;
|
||||
}
|
||||
|
||||
/* Nested spans inherit the admin text colour by default, which would keep the
|
||||
pale foreground against the now-pale fill. */
|
||||
.admin-nav-button[aria-current]:not([aria-current="false"]) :is(span, p) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.text-admin-text,
|
||||
.ampelos-admin :is(h1, h2, h3, .font-medium, .font-semibold, .font-bold) {
|
||||
color: #f4f8f1;
|
||||
}
|
||||
|
||||
.text-admin-muted {
|
||||
color: #c7d6cf;
|
||||
}
|
||||
|
||||
.text-admin-accent,
|
||||
a.text-admin-accent {
|
||||
color: #9fc5d1;
|
||||
}
|
||||
|
||||
a.text-admin-accent:hover {
|
||||
color: #d8f3fa;
|
||||
}
|
||||
|
||||
.text-admin-good {
|
||||
color: #b7d69a;
|
||||
}
|
||||
|
||||
.border-admin-line {
|
||||
border-color: rgba(151, 185, 191, 0.26);
|
||||
}
|
||||
|
||||
.divide-admin-line > :not(:last-child) {
|
||||
border-color: rgba(151, 185, 191, 0.22);
|
||||
}
|
||||
|
||||
.bg-admin-subpanel {
|
||||
background: #102023;
|
||||
}
|
||||
|
||||
.text-admin-warn {
|
||||
color: #e0c27a;
|
||||
}
|
||||
|
||||
/* Border variants matching the text tokens above. border-admin-accent was
|
||||
already used by the inventory table but had no definition. */
|
||||
.border-admin-accent {
|
||||
border-color: rgba(159, 197, 209, 0.62);
|
||||
}
|
||||
|
||||
.border-admin-good {
|
||||
border-color: rgba(183, 214, 154, 0.58);
|
||||
}
|
||||
|
||||
.border-admin-warn {
|
||||
border-color: rgba(224, 194, 122, 0.58);
|
||||
}
|
||||
|
||||
/* Episode rows for which no file exists on any tier. */
|
||||
.bg-admin-missing {
|
||||
background: rgba(126, 168, 116, 0.16);
|
||||
}
|
||||
|
||||
.ampelos-admin :is(p, span, div, a, button) {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ampelos",
|
||||
description: "The Sticknife vineyard for Dionysus entertainment.",
|
||||
icons: {
|
||||
icon: "/grapes.png",
|
||||
shortcut: "/grapes.png",
|
||||
apple: "/grapes.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body suppressHydrationWarning className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { signIn } from "@/auth";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="ampelos-login min-h-screen overflow-hidden text-ampelos-parchment">
|
||||
<div className="ampelos-login__background" aria-hidden="true" />
|
||||
<section className="relative z-10 flex min-h-screen items-center px-6 py-12 md:px-12">
|
||||
<div className="max-w-2xl">
|
||||
{/* Intrinsic size is the source's own 320px square. The old 108 was
|
||||
below the rendered box on a retina screen, so the seal was being
|
||||
stretched rather than drawn. */}
|
||||
<Image
|
||||
src="/ampelos.png"
|
||||
alt="Ampelos"
|
||||
width={320}
|
||||
height={320}
|
||||
priority
|
||||
className="mb-8 h-28 w-28 rounded-full border border-ampelos-gold/35 bg-ampelos-ink/45 object-cover p-1 shadow-[0_18px_55px_rgba(0,0,0,0.45)] md:h-36 md:w-36"
|
||||
/>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.42em] text-ampelos-gold">STICKNIFE</p>
|
||||
<h1 className="mt-4 max-w-xl font-serif text-5xl font-semibold leading-none text-ampelos-parchment md:text-7xl">
|
||||
Ampelos gathers the fruit of Dionysus.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-base leading-7 text-ampelos-muted md:text-lg">
|
||||
A vineyard of entertainment, pressed from the Dionysus collection and poured into your Watch Now tray.
|
||||
</p>
|
||||
<form
|
||||
className="mt-9"
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="ampelos-stone-button px-6 py-3 text-sm">
|
||||
Log in with Sticknife
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { adminOverrides, externalIds, mediaItems, users, watchingNowItems } from "@/db/schema";
|
||||
import { getCatalogItems, type CatalogKind } from "@/lib/catalog";
|
||||
import { and, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogBoard } from "./catalog-board";
|
||||
import { CatalogControls } from "./catalog-controls";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type WatchNowItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
mediaType: "tv_series" | "movie";
|
||||
year: number | null;
|
||||
posterPath: string | null;
|
||||
slotNumber: number | null;
|
||||
};
|
||||
|
||||
const tabs: Array<{ key: CatalogKind; label: string }> = [
|
||||
{ key: "television", label: "Television" },
|
||||
{ key: "movies", label: "Movies" },
|
||||
{ key: "music", label: "Music" },
|
||||
];
|
||||
|
||||
function singleParam(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function activeKind(value: string | string[] | undefined): CatalogKind {
|
||||
const tab = singleParam(value);
|
||||
|
||||
if (tab === "movies" || tab === "music") {
|
||||
return tab;
|
||||
}
|
||||
|
||||
return "television";
|
||||
}
|
||||
|
||||
function mediaTypeForKind(kind: CatalogKind) {
|
||||
return kind === "movies" ? "movie" : "tv_series";
|
||||
}
|
||||
|
||||
function hrefFor(next: Record<string, string | null>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? "/?" + query : "/";
|
||||
}
|
||||
|
||||
async function getCollectionIds(kind: CatalogKind, ids: string[]) {
|
||||
if (kind === "music" || ids.length === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(externalIds)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, externalIds.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(externalIds.source, "tmdb"),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
inArray(externalIds.externalId, ids),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
async function getSlotCount(userId: string, kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ tv: users.watchingNowTvSlots, movies: users.watchingNowMovieSlots })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return kind === "movies" ? row?.movies ?? 10 : row?.tv ?? 5;
|
||||
}
|
||||
|
||||
async function getWatchNow(userId: string, kind: CatalogKind): Promise<WatchNowItem[]> {
|
||||
if (kind === "music") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: watchingNowItems.id,
|
||||
title: mediaItems.title,
|
||||
mediaType: mediaItems.mediaType,
|
||||
year: mediaItems.year,
|
||||
posterPath: mediaItems.posterPath,
|
||||
slotNumber: watchingNowItems.slotNumber,
|
||||
})
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Purged titles, for the admin buttons. An expired override is not in force, so
|
||||
// it must not show as purged -- the classifier applies the same test.
|
||||
async function getPurgedExternalIds(kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(adminOverrides)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, adminOverrides.mediaItemId))
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
.where(
|
||||
and(
|
||||
eq(adminOverrides.overrideType, "purge"),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
eq(externalIds.source, "tmdb"),
|
||||
or(isNull(adminOverrides.expiresAt), gt(adminOverrides.expiresAt, new Date())),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
async function getWatchNowExternalIds(userId: string, kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
eq(externalIds.source, "tmdb"),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
export default async function Home({ searchParams }: PageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const resolvedSearchParams = (await searchParams) ?? {};
|
||||
const kind = activeKind(resolvedSearchParams.tab);
|
||||
const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
|
||||
const collectionOnly = singleParam(resolvedSearchParams.collection) === "1";
|
||||
// The same flag the admin layout and the server actions check, rather than
|
||||
// re-deriving it from the group list: a button that appears under one rule and
|
||||
// an action that refuses under another is the worst version of this.
|
||||
const isAdmin = Boolean(session.user.isAdmin);
|
||||
|
||||
const [catalogItems, watchNowItems, watchNowExternalIds, purgedExternalIds, slotCount] = await Promise.all([
|
||||
getCatalogItems(kind, query),
|
||||
getWatchNow(session.user.id, kind),
|
||||
getWatchNowExternalIds(session.user.id, kind),
|
||||
isAdmin ? getPurgedExternalIds(kind) : Promise.resolve(new Set<string>()),
|
||||
getSlotCount(session.user.id, kind),
|
||||
]);
|
||||
|
||||
const collectionIds = await getCollectionIds(kind, catalogItems.map((item) => item.id));
|
||||
const visibleItems = collectionOnly
|
||||
? catalogItems.filter((item) => collectionIds.has(item.id))
|
||||
: catalogItems;
|
||||
|
||||
return (
|
||||
<main className="ampelos-app pb-44 text-ampelos-parchment">
|
||||
<header className="ampelos-topbar">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-5 md:flex-row md:items-center md:justify-between md:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src="/ampelos.png"
|
||||
alt="Ampelos"
|
||||
width={64}
|
||||
height={64}
|
||||
priority
|
||||
className="h-14 w-14 shrink-0 rounded-full border border-ampelos-gold/35 bg-ampelos-ink/45 object-cover p-0.5 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-ampelos-gold">Ampelos</p>
|
||||
<h1 className="font-serif text-3xl font-semibold tracking-normal text-ampelos-parchment">Tend the vine. Set Dionysus's table.</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
|
||||
<button type="submit" className="ampelos-ghost-button px-3 py-2 font-medium">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 py-6 md:px-6">
|
||||
<nav className="flex gap-2 overflow-x-auto" aria-label="Media type">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.key === kind;
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={hrefFor({ tab: tab.key, q: query || null, collection: collectionOnly ? "1" : null })}
|
||||
className={
|
||||
active
|
||||
? "ampelos-stone-button px-4 py-2 text-sm"
|
||||
: "ampelos-ghost-button px-4 py-2 text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<CatalogControls key={kind} kind={kind} query={query} collectionOnly={collectionOnly} />
|
||||
|
||||
<div className="mt-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold text-ampelos-parchment">
|
||||
{query ? "Results for “" + query + "”" : kind === "movies" ? "Popular movies" : kind === "television" ? "Popular television" : "Music"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-ampelos-muted">
|
||||
{collectionOnly
|
||||
? "Showing titles Ampelos already recognizes as part of the local collection."
|
||||
: "Popular picks are sourced from TMDB while search is open-ended."}
|
||||
</p>
|
||||
</div>
|
||||
{query && (
|
||||
<Link href={hrefFor({ tab: kind, q: null, collection: collectionOnly ? "1" : null })} className="text-sm font-medium text-ampelos-gold hover:text-ampelos-parchment">
|
||||
Clear search
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CatalogBoard
|
||||
kind={kind}
|
||||
visibleItems={visibleItems}
|
||||
collectionIds={[...collectionIds]}
|
||||
watchNowExternalIds={[...watchNowExternalIds]}
|
||||
purgedExternalIds={[...purgedExternalIds]}
|
||||
watchNowItems={watchNowItems}
|
||||
slotCount={slotCount}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use server";
|
||||
|
||||
// The buttons on a title's detail panel.
|
||||
//
|
||||
// The two admin verbs. Both are statements about the LIBRARY, which is what
|
||||
// separates them from Watch Now -- that one is a statement about the person
|
||||
// making it, lives in watch-now-actions, and is the button anybody sees.
|
||||
//
|
||||
// timeless "This one is kept, at whatever it costs" -- and it is the only
|
||||
// flag that admits a remux.
|
||||
// purge "Stop wanting this." Deliberately loses to Watching Now, so
|
||||
// someone can still pull a purged title back by watching it, and
|
||||
// it returns to purged afterwards.
|
||||
//
|
||||
// Nothing here deletes a file. Purge withdraws intent, which is what makes it
|
||||
// safe to put behind a button: the worst it can do is stop a download.
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { adminOverrides, classics } from "@/db/schema";
|
||||
import { ensureMediaItem, findMediaItemByTmdbId, type CatalogRef, type MediaType } from "@/lib/media-item";
|
||||
|
||||
export type TitleFlags = {
|
||||
timeless: boolean;
|
||||
purged: boolean;
|
||||
};
|
||||
|
||||
export type TitleActionResult =
|
||||
| { ok: true; flags: Partial<TitleFlags>; message?: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function mediaTypeFromKind(kind: string | null): MediaType | null {
|
||||
if (kind === "movies") return "movie";
|
||||
if (kind === "television") return "tv_series";
|
||||
return null;
|
||||
}
|
||||
|
||||
function text(value: FormDataEntryValue | null) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isOn(value: FormDataEntryValue | null) {
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
|
||||
/** The catalog entry a form is talking about, or null if it is malformed. */
|
||||
function catalogRef(formData: FormData): CatalogRef | null {
|
||||
const mediaType = mediaTypeFromKind(text(formData.get("kind")));
|
||||
const tmdbId = text(formData.get("tmdbId"));
|
||||
const title = text(formData.get("title"));
|
||||
if (!mediaType || !tmdbId || !title) return null;
|
||||
|
||||
const parsedYear = Number.parseInt(text(formData.get("year")) ?? "", 10);
|
||||
const releaseDate = text(formData.get("releaseDate"));
|
||||
|
||||
return {
|
||||
mediaType,
|
||||
tmdbId,
|
||||
title,
|
||||
year: Number.isFinite(parsedYear) ? parsedYear : null,
|
||||
overview: text(formData.get("overview")),
|
||||
posterPath: text(formData.get("posterPath")),
|
||||
releaseDate: releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(releaseDate) ? releaseDate : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/** Mark a title as one to keep forever, or stop. Admins only. */
|
||||
export async function setTimeless(formData: FormData): Promise<TitleActionResult> {
|
||||
const user = await requireUser();
|
||||
if (!user) return { ok: false, message: "Sign in first." };
|
||||
if (!user.isAdmin) return { ok: false, message: "Only admins can change this." };
|
||||
|
||||
const ref = catalogRef(formData);
|
||||
if (!ref) return { ok: false, message: "This title could not be identified." };
|
||||
const on = isOn(formData.get("on"));
|
||||
|
||||
if (!on) {
|
||||
const mediaItemId = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId);
|
||||
if (mediaItemId) {
|
||||
await db.delete(classics).where(eq(classics.mediaItemId, mediaItemId));
|
||||
}
|
||||
revalidatePath("/");
|
||||
return { ok: true, flags: { timeless: false } };
|
||||
}
|
||||
|
||||
const mediaItemId = await ensureMediaItem(ref);
|
||||
await db
|
||||
.insert(classics)
|
||||
.values({ mediaItemId, addedBy: user.id })
|
||||
.onConflictDoNothing({ target: classics.mediaItemId });
|
||||
|
||||
revalidatePath("/");
|
||||
return {
|
||||
ok: true,
|
||||
flags: { timeless: true },
|
||||
message: ref.title + " is now timeless: kept live, and the only tier allowed a remux.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop wanting a title, or let it be wanted again. Admins only. */
|
||||
export async function setPurged(formData: FormData): Promise<TitleActionResult> {
|
||||
const user = await requireUser();
|
||||
if (!user) return { ok: false, message: "Sign in first." };
|
||||
if (!user.isAdmin) return { ok: false, message: "Only admins can change this." };
|
||||
|
||||
const ref = catalogRef(formData);
|
||||
if (!ref) return { ok: false, message: "This title could not be identified." };
|
||||
const on = isOn(formData.get("on"));
|
||||
|
||||
// Purge withdraws intent, so there has to be intent to withdraw. Creating a
|
||||
// media item in order to mark it unwanted would leave a row that exists for
|
||||
// no reason but to say nobody wants it.
|
||||
const mediaItemId = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId);
|
||||
if (!mediaItemId) {
|
||||
return { ok: false, message: ref.title + " is not in the collection, so there is nothing to purge." };
|
||||
}
|
||||
|
||||
// Delete first either way: the table allows several overrides per item, and a
|
||||
// second purge row would be a duplicate nobody could tell apart from the
|
||||
// first when it came time to lift it.
|
||||
await db
|
||||
.delete(adminOverrides)
|
||||
.where(and(eq(adminOverrides.mediaItemId, mediaItemId), eq(adminOverrides.overrideType, "purge")));
|
||||
|
||||
if (on) {
|
||||
await db.insert(adminOverrides).values({
|
||||
mediaItemId,
|
||||
overrideType: "purge",
|
||||
reason: "purged from the title panel",
|
||||
createdBy: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return {
|
||||
ok: true,
|
||||
flags: { purged: on },
|
||||
message: on
|
||||
? ref.title + " is purged: no longer wanted, though watching it still brings it back."
|
||||
: ref.title + " is no longer purged.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { classics, externalIds, mediaItems, users, watchingNowItems } from "@/db/schema";
|
||||
import { ensureMediaItem, findMediaItemByTmdbId } from "@/lib/media-item";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type MediaType = "tv_series" | "movie";
|
||||
|
||||
type WatchKind = "television" | "movies";
|
||||
|
||||
type WatchNowResult = { ok: true } | { ok: false; message: string };
|
||||
|
||||
function validDate(value: FormDataEntryValue | null) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: FormDataEntryValue | null) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function mediaTypeFromKind(kind: string | null): MediaType | null {
|
||||
if (kind === "movies") {
|
||||
return "movie";
|
||||
}
|
||||
|
||||
if (kind === "television") {
|
||||
return "tv_series";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function kindFromMediaType(mediaType: MediaType): WatchKind {
|
||||
return mediaType === "movie" ? "movies" : "television";
|
||||
}
|
||||
|
||||
function parseSlot(value: FormDataEntryValue | null) {
|
||||
const slot = typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
|
||||
return Number.isInteger(slot) && slot > 0 ? slot : null;
|
||||
}
|
||||
|
||||
async function matchingClassic(mediaType: MediaType, tmdbId: string, title: string, year: number | null) {
|
||||
const rows = await db
|
||||
.select({
|
||||
mediaItemId: mediaItems.id,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
externalId: externalIds.externalId,
|
||||
})
|
||||
.from(classics)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, classics.mediaItemId))
|
||||
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), eq(externalIds.source, "tmdb")))
|
||||
.where(eq(mediaItems.mediaType, mediaType));
|
||||
|
||||
const normalizedTitle = title.toLocaleLowerCase("en-US");
|
||||
return rows.find((row) =>
|
||||
row.externalId === tmdbId ||
|
||||
(row.title.toLocaleLowerCase("en-US") === normalizedTitle && (row.year ?? null) === year),
|
||||
);
|
||||
}
|
||||
|
||||
async function mediaItemIsClassic(mediaItemId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: classics.id })
|
||||
.from(classics)
|
||||
.where(eq(classics.mediaItemId, mediaItemId))
|
||||
.limit(1);
|
||||
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async function getQuota(userId: string, kind: WatchKind) {
|
||||
const [row] = await db
|
||||
.select({ tv: users.watchingNowTvSlots, movies: users.watchingNowMovieSlots })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return kind === "movies" ? row?.movies ?? 10 : row?.tv ?? 5;
|
||||
}
|
||||
|
||||
async function activeSlots(userId: string, mediaType: MediaType) {
|
||||
return db
|
||||
.select({ id: watchingNowItems.id, slotNumber: watchingNowItems.slotNumber })
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaType),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function firstOpenSlot(userId: string, mediaType: MediaType, quota: number) {
|
||||
const occupied = new Set(
|
||||
(await activeSlots(userId, mediaType))
|
||||
.map((row) => row.slotNumber)
|
||||
.filter((slot): slot is number => typeof slot === "number"),
|
||||
);
|
||||
|
||||
for (let slot = 1; slot <= quota; slot += 1) {
|
||||
if (!occupied.has(slot)) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function clearSlot(userId: string, mediaType: MediaType, slotNumber: number, keepId?: string) {
|
||||
const rows = await db
|
||||
.select({ id: watchingNowItems.id })
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaType),
|
||||
eq(watchingNowItems.slotNumber, slotNumber),
|
||||
keepId ? ne(watchingNowItems.id, keepId) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
db.update(watchingNowItems).set({ removedAt: new Date() }).where(eq(watchingNowItems.id, row.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function addToWatchNow(formData: FormData): Promise<WatchNowResult | undefined> {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const kind = optionalString(formData.get("kind"));
|
||||
const mediaType = mediaTypeFromKind(kind);
|
||||
const tmdbId = optionalString(formData.get("tmdbId"));
|
||||
const title = optionalString(formData.get("title"));
|
||||
|
||||
if (!mediaType || !tmdbId || !title) {
|
||||
return { ok: false, message: "This title could not be added to Watch Now." };
|
||||
}
|
||||
|
||||
const yearValue = optionalString(formData.get("year"));
|
||||
const parsedYear = yearValue ? Number.parseInt(yearValue, 10) : Number.NaN;
|
||||
const year = Number.isFinite(parsedYear) ? parsedYear : null;
|
||||
const classic = await matchingClassic(mediaType, tmdbId, title, year);
|
||||
|
||||
if (classic) {
|
||||
return { ok: false, message: title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot." };
|
||||
}
|
||||
|
||||
const watchKind = kindFromMediaType(mediaType);
|
||||
const quota = await getQuota(session.user.id, watchKind);
|
||||
const requestedSlot = parseSlot(formData.get("slotNumber"));
|
||||
const targetSlot = requestedSlot && requestedSlot <= quota
|
||||
? requestedSlot
|
||||
: await firstOpenSlot(session.user.id, mediaType, quota);
|
||||
|
||||
if (!targetSlot) {
|
||||
revalidatePath("/");
|
||||
return { ok: false, message: "No Watch Now slots are open." };
|
||||
}
|
||||
const overview = optionalString(formData.get("overview"));
|
||||
const posterPath = optionalString(formData.get("posterPath"));
|
||||
const releaseDate = validDate(formData.get("releaseDate"));
|
||||
|
||||
const existingId = await findMediaItemByTmdbId(mediaType, tmdbId);
|
||||
|
||||
if (existingId && await mediaItemIsClassic(existingId)) {
|
||||
return { ok: false, message: title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot." };
|
||||
}
|
||||
|
||||
const mediaItemId = existingId ?? await ensureMediaItem({
|
||||
mediaType,
|
||||
tmdbId,
|
||||
title,
|
||||
year: Number.isFinite(year) ? year : null,
|
||||
overview,
|
||||
posterPath,
|
||||
releaseDate,
|
||||
});
|
||||
|
||||
const [active] = await db
|
||||
.select({ id: watchingNowItems.id })
|
||||
.from(watchingNowItems)
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, session.user.id),
|
||||
eq(watchingNowItems.mediaItemId, mediaItemId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
await clearSlot(session.user.id, mediaType, targetSlot, active?.id);
|
||||
|
||||
if (active) {
|
||||
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, active.id));
|
||||
} else {
|
||||
await db.insert(watchingNowItems).values({
|
||||
userId: session.user.id,
|
||||
mediaItemId,
|
||||
scope: "show",
|
||||
slotNumber: targetSlot,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a title from Watch Now knowing only what the board knows.
|
||||
*
|
||||
* The catalog deals in TMDB ids -- most of what it shows has no row here at
|
||||
* all -- so the detail panel cannot name the watching_now row it wants gone.
|
||||
* Looking it up from the id is what lets one button both add and remove.
|
||||
*/
|
||||
export async function removeFromWatchNowByTmdbId(formData: FormData): Promise<WatchNowResult> {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const mediaType = mediaTypeFromKind(optionalString(formData.get("kind")));
|
||||
const tmdbId = optionalString(formData.get("tmdbId"));
|
||||
if (!mediaType || !tmdbId) {
|
||||
return { ok: false, message: "This title could not be identified." };
|
||||
}
|
||||
|
||||
const mediaItemId = await findMediaItemByTmdbId(mediaType, tmdbId);
|
||||
// Nothing here means nothing to remove, which is the state the caller wanted.
|
||||
if (mediaItemId) {
|
||||
await db
|
||||
.update(watchingNowItems)
|
||||
.set({ removedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, session.user.id),
|
||||
eq(watchingNowItems.mediaItemId, mediaItemId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeFromWatchNow(formData: FormData) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const itemId = optionalString(formData.get("watchNowItemId"));
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(watchingNowItems)
|
||||
.set({ removedAt: new Date() })
|
||||
.where(and(eq(watchingNowItems.id, itemId), eq(watchingNowItems.userId, session.user.id)));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function moveWatchNowItem(formData: FormData) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const itemId = optionalString(formData.get("watchNowItemId"));
|
||||
const targetSlot = parseSlot(formData.get("slotNumber"));
|
||||
|
||||
if (!itemId || !targetSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [item] = await db
|
||||
.select({ mediaType: mediaItems.mediaType })
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.id, itemId),
|
||||
eq(watchingNowItems.userId, session.user.id),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaType = item.mediaType;
|
||||
const quota = await getQuota(session.user.id, kindFromMediaType(mediaType));
|
||||
|
||||
if (targetSlot > quota) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clearSlot(session.user.id, mediaType, targetSlot, itemId);
|
||||
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, itemId));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Authentik from "next-auth/providers/authentik";
|
||||
import { findOrCreateUser } from "@/lib/users";
|
||||
|
||||
type AuthentikProfile = {
|
||||
groups?: unknown;
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
};
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true,
|
||||
providers: [
|
||||
Authentik({
|
||||
issuer: process.env.AUTHENTIK_ISSUER,
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, profile }) {
|
||||
if (profile) {
|
||||
const authentikProfile = profile as AuthentikProfile;
|
||||
const groups = Array.isArray(authentikProfile.groups)
|
||||
? authentikProfile.groups.filter((group): group is string => typeof group === "string")
|
||||
: [];
|
||||
const isAdmin = groups.includes("sticknife_admins");
|
||||
|
||||
// Create or update the local user record on each sign-in.
|
||||
const localUserId = await findOrCreateUser({
|
||||
externalId: token.sub!,
|
||||
provider: "authentik",
|
||||
name: typeof authentikProfile.name === "string" ? authentikProfile.name : token.sub!,
|
||||
email: typeof authentikProfile.email === "string" ? authentikProfile.email : "",
|
||||
isAdmin,
|
||||
});
|
||||
|
||||
token.localUserId = localUserId;
|
||||
token.groups = groups;
|
||||
token.isAdmin = isAdmin;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.localUserId as string;
|
||||
session.user.groups = token.groups as string[];
|
||||
session.user.isAdmin = token.isAdmin as boolean;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/auth-error",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!, { schema });
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TYPE "public"."storage_tier" AS ENUM('live', 'backup', 'archive');--> statement-breakpoint
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"is_admin" boolean DEFAULT false NOT NULL,
|
||||
"watching_now_tv_slots" integer DEFAULT 5 NOT NULL,
|
||||
"watching_now_movie_slots" integer DEFAULT 10 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "storage_availability" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tier_id" uuid NOT NULL,
|
||||
"online" boolean NOT NULL,
|
||||
"detected_at" timestamp DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "storage_tiers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tier" "storage_tier" NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"base_path" text NOT NULL,
|
||||
"always_online" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
CONSTRAINT "storage_tiers_tier_unique" UNIQUE("tier")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "storage_availability" ADD CONSTRAINT "storage_availability_tier_id_storage_tiers_id_fk" FOREIGN KEY ("tier_id") REFERENCES "public"."storage_tiers"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,146 @@
|
||||
CREATE TYPE "public"."external_id_source" AS ENUM('tvdb', 'tmdb', 'imdb', 'plex');--> statement-breakpoint
|
||||
CREATE TYPE "public"."media_type" AS ENUM('tv_series', 'movie');--> statement-breakpoint
|
||||
CREATE TYPE "public"."season_status" AS ENUM('upcoming', 'airing', 'complete');--> statement-breakpoint
|
||||
CREATE TYPE "public"."series_status" AS ENUM('upcoming', 'continuing', 'ended');--> statement-breakpoint
|
||||
CREATE TYPE "public"."watching_now_scope" AS ENUM('show', 'season');--> statement-breakpoint
|
||||
CREATE TYPE "public"."watchlist_source" AS ENUM('plex', 'manual');--> statement-breakpoint
|
||||
CREATE TYPE "public"."override_type" AS ENUM('force_live', 'force_archive', 'force_quality', 'force_monitor', 'force_ignore', 'temporary_promotion');--> statement-breakpoint
|
||||
CREATE TYPE "public"."policy_trigger" AS ENUM('scheduled', 'manual', 'demand_change', 'metadata_refresh');--> statement-breakpoint
|
||||
CREATE TYPE "public"."quality" AS ENUM('sd', '720p', '1080p', '4k');--> statement-breakpoint
|
||||
CREATE TABLE "episodes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"season_id" uuid NOT NULL,
|
||||
"episode_number" integer NOT NULL,
|
||||
"title" text,
|
||||
"overview" text,
|
||||
"air_date" date
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "external_ids" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"source" "external_id_source" NOT NULL,
|
||||
"external_id" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "media_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_type" "media_type" NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"sort_title" text,
|
||||
"overview" text,
|
||||
"year" integer,
|
||||
"poster_path" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "movies" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"release_date" date,
|
||||
"runtime_minutes" integer,
|
||||
"status" text,
|
||||
"metadata_refreshed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "seasons" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"series_id" uuid NOT NULL,
|
||||
"season_number" integer NOT NULL,
|
||||
"title" text,
|
||||
"episode_count" integer,
|
||||
"air_date" date,
|
||||
"status" "season_status" DEFAULT 'upcoming' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "series" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"status" "series_status" DEFAULT 'upcoming' NOT NULL,
|
||||
"network" text,
|
||||
"first_air_date" date,
|
||||
"last_air_date" date,
|
||||
"is_currently_relevant" text,
|
||||
"metadata_refreshed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "classics" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"note" text,
|
||||
"added_by" uuid,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "classics_media_item_id_unique" UNIQUE("media_item_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "watching_now_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"scope" "watching_now_scope" DEFAULT 'show' NOT NULL,
|
||||
"season_number" integer,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
"removed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "watchlist_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"source" "watchlist_source" DEFAULT 'plex' NOT NULL,
|
||||
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||
"removed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "admin_overrides" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"season_number" integer,
|
||||
"episode_number" integer,
|
||||
"override_type" "override_type" NOT NULL,
|
||||
"value" jsonb,
|
||||
"reason" text,
|
||||
"expires_at" timestamp,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "desired_states" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"policy_run_id" uuid,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"season_number" integer,
|
||||
"episode_number" integer,
|
||||
"wanted" boolean DEFAULT false NOT NULL,
|
||||
"storage_tier" "storage_tier",
|
||||
"min_quality" "quality",
|
||||
"preferred_quality" "quality",
|
||||
"monitored" boolean DEFAULT false NOT NULL,
|
||||
"reason_codes" text[] DEFAULT '{}' NOT NULL,
|
||||
"computed_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "policy_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trigger" "policy_trigger" NOT NULL,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp,
|
||||
"items_evaluated" integer,
|
||||
"items_changed" integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "external_ids" ADD CONSTRAINT "external_ids_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "movies" ADD CONSTRAINT "movies_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "seasons" ADD CONSTRAINT "seasons_series_id_series_id_fk" FOREIGN KEY ("series_id") REFERENCES "public"."series"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "series" ADD CONSTRAINT "series_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "classics" ADD CONSTRAINT "classics_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "classics" ADD CONSTRAINT "classics_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "watching_now_items" ADD CONSTRAINT "watching_now_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "watching_now_items" ADD CONSTRAINT "watching_now_items_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "watchlist_items" ADD CONSTRAINT "watchlist_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "watchlist_items" ADD CONSTRAINT "watchlist_items_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "admin_overrides" ADD CONSTRAINT "admin_overrides_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "admin_overrides" ADD CONSTRAINT "admin_overrides_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "desired_states" ADD CONSTRAINT "desired_states_policy_run_id_policy_runs_id_fk" FOREIGN KEY ("policy_run_id") REFERENCES "public"."policy_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "desired_states" ADD CONSTRAINT "desired_states_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "external_ids_source_external_id_idx" ON "external_ids" USING btree ("source","external_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "watching_now_items" ADD COLUMN "slot_number" integer;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "storage_files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tier_id" uuid NOT NULL,
|
||||
"media_item_id" uuid,
|
||||
"relative_path" text NOT NULL,
|
||||
"full_path" text NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"extension" text NOT NULL,
|
||||
"size_bytes" bigint NOT NULL,
|
||||
"modified_at" timestamp NOT NULL,
|
||||
"observed_at" timestamp DEFAULT now() NOT NULL,
|
||||
"missing_at" timestamp,
|
||||
"inferred_title" text,
|
||||
"inferred_year" text,
|
||||
"quality" text,
|
||||
"codec" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_tier_id_storage_tiers_id_fk" FOREIGN KEY ("tier_id") REFERENCES "public"."storage_tiers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "storage_files_tier_relative_path_idx" ON "storage_files" USING btree ("tier_id","relative_path");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "storage_files" ADD COLUMN "episode_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "storage_files" ADD COLUMN "width" integer;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "height" integer;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "video_codec" text;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "audio_codec" text;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "audio_channels" integer;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "duration_seconds" integer;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "bitrate" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "probed_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "probe_error" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "agent_heartbeats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"agent" text NOT NULL,
|
||||
"hostname" text,
|
||||
"version" text,
|
||||
"first_seen_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp DEFAULT now() NOT NULL,
|
||||
"details" jsonb,
|
||||
CONSTRAINT "agent_heartbeats_agent_unique" UNIQUE("agent")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "series" ALTER COLUMN "is_currently_relevant" SET DATA TYPE boolean USING "is_currently_relevant"::boolean;--> statement-breakpoint
|
||||
ALTER TABLE "storage_tiers" ADD COLUMN "agent_name" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE UNIQUE INDEX "episodes_season_id_episode_number_idx" ON "episodes" USING btree ("season_id","episode_number");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "seasons_series_id_season_number_idx" ON "seasons" USING btree ("series_id","season_number");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "storage_files" ADD COLUMN "edition" text;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TYPE "public"."corrupt_file_status" AS ENUM('pending', 'approved', 'deleted', 'dismissed', 'failed');--> statement-breakpoint
|
||||
CREATE TABLE "corrupt_files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"full_path" text NOT NULL,
|
||||
"agent" text NOT NULL,
|
||||
"tier" text,
|
||||
"reason" text NOT NULL,
|
||||
"size_bytes" bigint,
|
||||
"status" "corrupt_file_status" DEFAULT 'pending' NOT NULL,
|
||||
"first_detected_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_detected_at" timestamp DEFAULT now() NOT NULL,
|
||||
"reviewed_by" uuid,
|
||||
"reviewed_at" timestamp,
|
||||
"deleted_at" timestamp,
|
||||
"delete_error" text,
|
||||
CONSTRAINT "corrupt_files_full_path_unique" UNIQUE("full_path")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "corrupt_files" ADD CONSTRAINT "corrupt_files_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TYPE "public"."maintenance_job_status" AS ENUM('running', 'succeeded', 'failed', 'skipped');--> statement-breakpoint
|
||||
CREATE TYPE "public"."maintenance_trigger" AS ENUM('scheduled', 'manual');--> statement-breakpoint
|
||||
CREATE TABLE "maintenance_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job" text NOT NULL,
|
||||
"trigger" "maintenance_trigger" DEFAULT 'scheduled' NOT NULL,
|
||||
"status" "maintenance_job_status" DEFAULT 'running' NOT NULL,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp,
|
||||
"duration_ms" integer,
|
||||
"exit_code" integer,
|
||||
"detail" text,
|
||||
"output" text,
|
||||
"summary" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "maintenance_jobs_job_started_idx" ON "maintenance_jobs" USING btree ("job","started_at");
|
||||
@@ -0,0 +1,70 @@
|
||||
CREATE TYPE "public"."grab_source" AS ENUM('auto', 'manual');--> statement-breakpoint
|
||||
CREATE TYPE "public"."grab_status" AS ENUM('queued', 'downloading', 'completed', 'importing', 'imported', 'failed', 'orphaned');--> statement-breakpoint
|
||||
CREATE TABLE "grabs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_item_id" uuid NOT NULL,
|
||||
"season_number" integer,
|
||||
"episode_number" integer,
|
||||
"info_hash" text NOT NULL,
|
||||
"release_title" text NOT NULL,
|
||||
"size_bytes" bigint,
|
||||
"magnet_url" text,
|
||||
"download_url" text,
|
||||
"indexer_name" text NOT NULL,
|
||||
"origin" text,
|
||||
"seeders" integer,
|
||||
"quality" "quality",
|
||||
"score" integer,
|
||||
"score_reasons" jsonb DEFAULT '[]'::jsonb,
|
||||
"source" "grab_source" NOT NULL,
|
||||
"requested_by" uuid,
|
||||
"status" "grab_status" DEFAULT 'queued' NOT NULL,
|
||||
"status_detail" text,
|
||||
"progress" integer DEFAULT 0,
|
||||
"content_path" text,
|
||||
"imported_path" text,
|
||||
"imported_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "release_blocklist" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"info_hash" text NOT NULL,
|
||||
"release_title" text,
|
||||
"media_item_id" uuid,
|
||||
"reason" text,
|
||||
"permanent" boolean DEFAULT false NOT NULL,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "search_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"media_item_id" uuid,
|
||||
"season_number" integer,
|
||||
"episode_number" integer,
|
||||
"source" "grab_source" NOT NULL,
|
||||
"query" text,
|
||||
"indexers_queried" integer DEFAULT 0 NOT NULL,
|
||||
"results_found" integer DEFAULT 0 NOT NULL,
|
||||
"results_accepted" integer DEFAULT 0 NOT NULL,
|
||||
"skipped" jsonb DEFAULT '[]'::jsonb,
|
||||
"errors" jsonb DEFAULT '[]'::jsonb,
|
||||
"grab_id" uuid,
|
||||
"decision" text,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"duration_ms" integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "grabs" ADD CONSTRAINT "grabs_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grabs" ADD CONSTRAINT "grabs_requested_by_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "release_blocklist" ADD CONSTRAINT "release_blocklist_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "release_blocklist" ADD CONSTRAINT "release_blocklist_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "search_runs" ADD CONSTRAINT "search_runs_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "search_runs" ADD CONSTRAINT "search_runs_grab_id_grabs_id_fk" FOREIGN KEY ("grab_id") REFERENCES "public"."grabs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "grabs_info_hash_idx" ON "grabs" USING btree ("info_hash");--> statement-breakpoint
|
||||
CREATE INDEX "grabs_status_idx" ON "grabs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "grabs_media_item_idx" ON "grabs" USING btree ("media_item_id","season_number","episode_number");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "release_blocklist_info_hash_idx" ON "release_blocklist" USING btree ("info_hash");--> statement-breakpoint
|
||||
CREATE INDEX "search_runs_media_item_idx" ON "search_runs" USING btree ("media_item_id","started_at");
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "grab_files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"grab_id" uuid NOT NULL,
|
||||
"storage_file_id" uuid,
|
||||
"imported_path" text NOT NULL,
|
||||
"season_number" integer,
|
||||
"episode_number" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "grabs" ADD COLUMN "seed_path" text;--> statement-breakpoint
|
||||
ALTER TABLE "grabs" ADD COLUMN "seed_released_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "grabs" ADD COLUMN "seed_release_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "grab_files" ADD CONSTRAINT "grab_files_grab_id_grabs_id_fk" FOREIGN KEY ("grab_id") REFERENCES "public"."grabs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grab_files" ADD CONSTRAINT "grab_files_storage_file_id_storage_files_id_fk" FOREIGN KEY ("storage_file_id") REFERENCES "public"."storage_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "grab_files_grab_path_idx" ON "grab_files" USING btree ("grab_id","imported_path");--> statement-breakpoint
|
||||
CREATE INDEX "grab_files_storage_file_idx" ON "grab_files" USING btree ("storage_file_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "release_blocklist" ALTER COLUMN "info_hash" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "release_blocklist" ADD COLUMN "release_group" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "grabs" ADD COLUMN "progress_changed_at" timestamp;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."override_type" ADD VALUE 'purge';
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "release_size_rules" (
|
||||
"quality" "quality" PRIMARY KEY NOT NULL,
|
||||
"min_mb_per_minute" numeric(6, 1) NOT NULL,
|
||||
"max_mb_per_minute" numeric(6, 1) NOT NULL,
|
||||
"atmos_max_mb_per_minute" numeric(6, 1),
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Seeded from percentiles over every probed file in this library that has a
|
||||
-- real duration, so the table starts out describing the collection it governs:
|
||||
--
|
||||
-- quality n p05 p50 p90 p95 p99 max
|
||||
-- sd 3604 6.1 15.2 16.0 17.0 18.5 45.7
|
||||
-- 720p 9958 3.4 7.7 18.0 19.1 25.5 55.3
|
||||
-- 1080p 9184 8.3 19.6 48.7 61.9 104.8 237.9
|
||||
-- 4k 449 30.3 92.2 154.4 184.1 233.4 264.5
|
||||
--
|
||||
-- 1080p is capped at 60 rather than the 40 the film numbers alone suggest:
|
||||
-- television runs hotter than film at 1080p (median 24.1 against 15.9), and 40
|
||||
-- would have refused 1,602 files already held, 1,569 of them television.
|
||||
INSERT INTO "release_size_rules"
|
||||
("quality", "min_mb_per_minute", "max_mb_per_minute", "atmos_max_mb_per_minute")
|
||||
VALUES
|
||||
('sd', 2, 20, NULL),
|
||||
('720p', 2, 20, NULL),
|
||||
('1080p', 4, 60, NULL),
|
||||
('4k', 16, 160, 240)
|
||||
ON CONFLICT ("quality") DO NOTHING;
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE "release_size_rules" ADD COLUMN "timeless_max_mb_per_minute" numeric(6, 1);--> statement-breakpoint
|
||||
-- Sized to admit a genuine remux and nothing looser. A remux is the disc's own
|
||||
-- streams, so its size is a property of the disc rather than a choice: a 1080p
|
||||
-- one runs 20-30GB for a two-hour film (170-250 MB/min) and a 4K one 50-80GB
|
||||
-- (420-670 MB/min).
|
||||
--
|
||||
-- Only 1080p and 4K carry one. There is no such thing as a 720p remux -- the
|
||||
-- format is a re-encode by definition, so an allowance there would license
|
||||
-- bloat with nothing lossless to show for it.
|
||||
UPDATE "release_size_rules" SET "timeless_max_mb_per_minute" = 250 WHERE "quality" = '1080p';
|
||||
--> statement-breakpoint
|
||||
UPDATE "release_size_rules" SET "timeless_max_mb_per_minute" = 650 WHERE "quality" = '4k';
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "plex_accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"plex_user_id" text NOT NULL,
|
||||
"plex_uuid" text,
|
||||
"plex_username" text NOT NULL,
|
||||
"plex_email" text,
|
||||
"libraries_shared_at" timestamp,
|
||||
"shared_section_ids" text[],
|
||||
"linked_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plex_accounts_user_id_unique" UNIQUE("user_id"),
|
||||
CONSTRAINT "plex_accounts_plex_user_id_unique" UNIQUE("plex_user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plex_accounts" ADD CONSTRAINT "plex_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "external_ids" ADD COLUMN "verified_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "external_ids" ADD COLUMN "verified_by" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "series" ADD COLUMN "episode_runtime_minutes" integer;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "external_ids" ADD COLUMN "rejected_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "external_ids" ADD COLUMN "rejected_by" uuid;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "storage_files_media_item_idx" ON "storage_files" USING btree ("media_item_id");--> statement-breakpoint
|
||||
CREATE INDEX "external_ids_media_item_idx" ON "external_ids" USING btree ("media_item_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "external_ids" ADD COLUMN "remote_title" text;--> statement-breakpoint
|
||||
ALTER TABLE "external_ids" ADD COLUMN "remote_checked_at" timestamp;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "storage_files" ADD COLUMN "replace_requested_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "storage_files" ADD COLUMN "replace_requested_by" uuid;
|
||||
@@ -0,0 +1,282 @@
|
||||
{
|
||||
"id": "d8b57dfe-223d-4124-8c4a-d1f05c6d62c1",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.user_identities": {
|
||||
"name": "user_identities",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_identities_user_id_users_id_fk": {
|
||||
"name": "user_identities_user_id_users_id_fk",
|
||||
"tableFrom": "user_identities",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_admin": {
|
||||
"name": "is_admin",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"watching_now_tv_slots": {
|
||||
"name": "watching_now_tv_slots",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 5
|
||||
},
|
||||
"watching_now_movie_slots": {
|
||||
"name": "watching_now_movie_slots",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.storage_availability": {
|
||||
"name": "storage_availability",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"tier_id": {
|
||||
"name": "tier_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"online": {
|
||||
"name": "online",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"detected_at": {
|
||||
"name": "detected_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"storage_availability_tier_id_storage_tiers_id_fk": {
|
||||
"name": "storage_availability_tier_id_storage_tiers_id_fk",
|
||||
"tableFrom": "storage_availability",
|
||||
"tableTo": "storage_tiers",
|
||||
"columnsFrom": [
|
||||
"tier_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.storage_tiers": {
|
||||
"name": "storage_tiers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"tier": {
|
||||
"name": "tier",
|
||||
"type": "storage_tier",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"base_path": {
|
||||
"name": "base_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"always_online": {
|
||||
"name": "always_online",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"storage_tiers_tier_unique": {
|
||||
"name": "storage_tiers_tier_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"tier"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.storage_tier": {
|
||||
"name": "storage_tier",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"live",
|
||||
"backup",
|
||||
"archive"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||