Compare commits

..

36 Commits

Author SHA1 Message Date
Myron Blair 671074764e Add iTunes and AcoustID as additional free music enrichment sources
- itunes.py: zero-auth fallback for album artwork when MusicBrainz/Cover Art
  Archive has no match — kicks in automatically during music enrichment,
  toggleable in Settings (on by default, no key required).
- acoustid.py: audio fingerprint identification (Chromaprint fpcalc + the
  AcoustID API) for tracks with missing/unknown artist or album — nothing
  to text-search on, so this identifies from the actual audio content
  instead. Requires a free user-supplied API key from acoustid.org/api-key,
  off by default. New /music/identify endpoint + admin trigger button.
- Dockerfile: added libchromaprint-tools for fpcalc.
- Settings page: new Music Metadata & Artwork section with both toggles.
2026-07-27 21:03:59 -05:00
Myron Blair 628a729afd Speed up ABR transcoding: cap variants at 2, drop x264 preset to superfast
Host CPU is an old Xeon E5530 (2010-era, no hardware encode available at all)
already fairly loaded, so raw compute is the real bottleneck. Two changes:
- Cap ABR renditions to the 2 highest applicable instead of up to 4 — roughly
  halves total encode work per job while still giving a real high/low
  adaptive-bitrate pair.
- veryfast -> superfast x264 preset, a meaningful further speed win at the
  cost of somewhat larger files and slightly softer quality per bitrate.
Combined, remaining variants also get more threads each since fewer variants
now share the same TRANSCODE_THREADS budget.
2026-07-27 20:41:30 -05:00
Myron Blair 4397617cf1 Add mobile navigation menu
Nav links (Library/Series/Shelf/Music/Wishlist/Admin) were hidden below the
md breakpoint with no mobile alternative — there was no way to navigate the
app at all on a phone besides the logo link and search/settings icons.
Adds a hamburger toggle and slide-down menu with the same links plus
Upload/Settings/Sign Out, closing automatically on navigation.
2026-07-27 19:24:19 -05:00
Myron Blair 119dd20743 Use branded StreamHoard placeholder for missing movie/show artwork
AlbumArt was music-only until now; movies and series grids/detail views
still showed a broken-image icon when poster_url/backdrop_url was empty.
Reused the same component (movies library grid, series grid + resume
strip, show detail backdrop, movie detail modal backdrop) instead of
duplicating the placeholder logic.
2026-07-27 18:28:37 -05:00
Myron Blair eed071dd8f Fix Now Processing panel never showing once the queue backlog exceeds ~500 jobs
The main queue list sorts by created_at desc and caps at 500 rows, but the FIFO
worker processes oldest-first — with a large backlog the actually-running job
was routinely outside that window and never reached the frontend at all.
Fetch it via a dedicated /transcode/queue/running endpoint instead.
2026-07-27 17:57:50 -05:00
Myron Blair 8fa1291e0f Fix progress flags being inserted before nice's own -n flag, breaking every job
-progress pipe:1 -nostats belong to ffmpeg, not nice — they were landing right
after cmd[0] (nice) instead of after the ffmpeg token, which made nice choke
on an unrecognized option and fail before ffmpeg ever started.
2026-07-27 17:47:25 -05:00
Myron Blair 71e731124a Add live transcode progress with a dedicated now-processing panel
- ffmpeg progress streamed via -progress pipe:1, persisted on the job as a percent
- retry/retry-all now mark the old row as superseded instead of deleting it,
  preserving the failed/cancelled audit trail
- frontend shows the running job in its own panel above the list with a live
  progress bar instead of burying it as just another row
2026-07-27 17:38:53 -05:00
Myron Blair 6438407f5b Fix retry-all-failed silently no-oping when content already has a queued job
Also lift the artificial 500-row cap on the retry-failed query.
2026-07-27 17:25:52 -05:00
Myron Blair e54803abb0 Add app version number, shown in Navbar next to the logo
New backend/VERSION file (starts at 1.0.0) served via GET /api/version,
displayed as a small "v1.0.0" next to the StreamHoard wordmark. Going
forward I'll bump this on meaningful changes -- patch for fixes, minor
for new features, major for big/breaking redesigns.
2026-07-27 00:01:04 -05:00
Myron Blair 9742ebf50c Add copyright notice 2026-07-26 23:53:15 -05:00
Myron Blair b74d8a2652 Add fast audio-only transcode mode; fixes no-sound from AC3/DTS/EAC3
Root cause of "movies have no volume control / can't hear anything":
0 of 200 movies had finished transcoding, so every movie streamed its
raw source file — and BDRip/AVI sources very commonly carry AC3/DTS/EAC3
audio, which no browser can decode natively. Browsers respond by hiding
the volume control entirely when they can't find a playable audio track,
which is exactly the symptom reported.

New "audio" transcode quality: video is stream-copied untouched, only
the audio track is re-encoded to AAC — dramatically faster than a full
ABR pass since no video re-encoding happens. Added:
- transcode_audio_fix() in transcode.py
- "audio" as a valid quality value everywhere quick/abr were validated
- POST /transcode/queue/fix-audio-all: cancels every still-pending job
  and replaces it with the fast audio fix for the same content (running
  jobs are left alone), so a library-wide backlog of slow ABR jobs can
  be swapped for something that actually restores sound soon
- Per-movie "Audio" button in Admin, "Fix Audio (Fast, All)" button on
  the Transcode Queue page

Note: this doesn't help HEVC/x265 sources, whose video (not just audio)
isn't natively browser-playable either — those still need a full ABR
re-encode to H.264.
2026-07-26 23:31:23 -05:00
Myron Blair cf8c7890be Add confirm+stop to Library Cleanup scan; branded album art placeholder
- Library Cleanup scan is now a real background job (POST .../start,
  GET .../status, POST .../stop) instead of one long synchronous request,
  so a Stop button can actually abandon it instead of just waiting.
  Frontend confirms before starting (explains it can take a minute+)
  and polls status while running.
- New shared AlbumArt component: shows real art when available, otherwise
  a branded "Streamhoard" placeholder instead of a bare icon in an empty
  box. Used in the Music grid, album detail header, and the player bar —
  swaps to real art automatically once enrichment/a manual edit sets it.
2026-07-26 23:15:07 -05:00
Myron Blair 43340c0f49 Remove redundant nav entries; parallelize cleanup file checks
- Navbar: drop the Users link (already reachable from Admin's quick-links)
- Admin quick-links: drop Settings (already reachable from the navbar
  for every user)
- library_cleanup.py: parallelize the missing-file stat checks across a
  small thread pool instead of one file at a time — sequential NAS round
  trips were taking minutes for a library of any real size, even after
  moving them off the event loop.
2026-07-26 23:05:53 -05:00
Myron Blair ef5c13d587 Fix Library Cleanup scan freezing the entire server
scan() was doing hundreds of blocking Path.is_file()/stat() calls directly
in an async function -- movie/episode/track files often live on the
NFS/CIFS-mounted NAS, so each stat call has real network latency. Since
the backend runs a single-threaded asyncio event loop, this froze every
other request (confirmed live: a plain GET /api/movies hung for 60s+ while
a cleanup scan was in flight) for as long as the scan took.

Moved every filesystem-touching check (missing files, orphaned HLS dirs)
into a plain sync function run via asyncio.to_thread, so the scan can no
longer block anything else.
2026-07-26 22:35:37 -05:00
Myron Blair 5fc37e46e4 Fix 8 code-review findings across transcode queue, requests, cleanup, music
- Episode transcode (single + bulk) now accepts storage_type=scan, matching
  stream_episode and the movie-transcode endpoint — Library-Scan-imported
  TV episodes can finally be transcoded, not just streamed.
- retry_job now forwards content_type, fixing retry for failed/cancelled
  episode jobs (was silently treating them as movies and failing again).
- cancel_job now updates the correct collection (movies vs episodes) based
  on content_type instead of always writing to db.movies.
- approve_request atomically claims the request (pending -> approving)
  before contacting Radarr/Sonarr, so a double-click or two concurrent
  admins can't both add the same content; releases the claim back to
  pending on any failure so a fixable error can be retried.
- Music enrichment's update_many now re-checks album_art_url is still
  empty, so it no longer clobbers art an admin manually set via PATCH.
- Library Cleanup's dangling-refs scan now also checks episode_progress
  for rows referencing a deleted episode (previously only watchlist/
  progress against movie_id were checked).
- Settings now surfaces a failed bulk-TMDB-enrich run as an error instead
  of a false "Enrichment done" success summary.
- Music player's isPlaying now syncs from the audio element's native
  play/pause events instead of only being set inside togglePlay/track-
  change, so it can't drift from actual playback state (buffering stalls,
  OS media-key pauses, etc).
2026-07-26 22:24:09 -05:00
Myron Blair 5ac78654d4 Code review fix: Library Cleanup left orphaned rows on episode delete
Fixing a missing-file or duplicate-import episode only deleted the
episode itself, leaving its episode_progress/subtitle rows behind for
the next scan to catch as separate orphaned_subtitles/dangling findings.
Consolidated movie/episode/track deletion (with all their dependent rows)
into one _delete_content() helper, used consistently by missing_files,
orphaned_episodes, and duplicate_paths.
2026-07-26 22:11:05 -05:00
Myron Blair 9af505b45c Add live progress status to bulk TMDB movie enrichment
Was a single synchronous request with no visibility into progress —
large libraries gave no indication whether it was working or just slow.
Now runs as a background job (matching the existing music-enrich pattern)
with GET /api/tmdb/enrich-all/status polled every 2s; Settings shows
"Enriching... N/total" while running and a summary once done.
2026-07-26 22:01:12 -05:00
Myron Blair a313008440 Fix ABR thread cap actually stacking per variant instead of capping total
Setting -threads:v:i to the full TRANSCODE_THREADS on every variant let
each of the (up to 4) ABR renditions ask for the full budget independently
-- confirmed live on CT104: ffmpeg was still pulling ~7 of 8 cores despite
the "cap". Now divides the budget across however many variants are in
this job (6 threads / 4 variants = ~1-2 each), so the total stays near
the intended cap regardless of source resolution.
2026-07-26 21:26:35 -05:00
Myron Blair 4e72f568f3 Speed up transcoding without overloading the VM, add bulk retry
- nice level 19 -> 10 (was the absolute lowest CPU priority) and cap
  ffmpeg to 6 threads on this 8-core host, leaving headroom for Mongo,
  the *arr stack, and the API itself instead of an unbounded encode.
- Add POST /transcode/queue/retry-failed to re-queue every failed/
  cancelled job at once; label the per-job Retry button with text
  (was icon-only) and clarify in the page copy that failed/cancelled
  jobs never delete the movie — retry just re-queues the same file.
2026-07-26 21:21:22 -05:00
Myron Blair c174ac60b1 Add Library Cleanup admin page — missing files, orphaned records, stuck jobs, duplicates
New read-only scan (GET /api/library/cleanup/scan) across movies, episodes,
tracks, subtitles, HLS output, transcode queue, and watchlist/progress rows:
- Missing files: DB record exists but the file is gone from disk
- Orphaned episodes: show_id no longer exists
- Orphaned HLS output: transcoded folders left behind by deleted content
- Stuck transcode jobs: "running" 2+ hours with no completion (backend-restart artifact)
- Duplicate imports: same file imported into the library more than once
- Orphaned subtitles / dangling watchlist+progress rows

Nothing is deleted until an admin clicks Fix (per-item or Fix All per
category) via POST /api/library/cleanup/fix.
2026-07-26 21:13:16 -05:00
Myron Blair 3c47894628 Fix Library Scan tab race condition mixing results across kinds
Switching tabs quickly (e.g. Movies -> Music) had no guard against an
in-flight response from the previous tab resolving late and overwriting
the new tab's results — showing movie/TV rips under the Music tab. Now
tracks a request sequence number and ignores stale responses, and clears
results immediately on tab switch so nothing renders under the wrong label.
2026-07-26 21:07:40 -05:00
Myron Blair 3d2b7a4122 Wire Requests approval into Radarr/Sonarr with live download status
Admin can now Approve a pending request as Movie or TV, which looks up
the title via Radarr/Sonarr's own lookup API, adds the best match with
search-on-add enabled, and tracks the resulting radarr_id/sonarr_id on
the request. A live status line (searching/queued/downloading/downloaded,
polled every 15s) shows next to each approved request.
2026-07-26 20:55:22 -05:00
Myron Blair 1891fd0b7c Add Select All / Deselect All to Library Scan
Lets admins select every not-yet-imported item in the current tab in one
click instead of clicking each row individually.
2026-07-26 20:47:19 -05:00
Myron Blair 64d8945460 Fix wrong Content-Type for m4a/flac/ogg/wav/aac track streams
mimetypes on the backend image doesn't recognize these extensions, so
every non-mp3 track streamed as audio/mpeg — wrong codec hint, breaks
playback in strict browsers. Add an explicit fallback map.
2026-07-26 20:40:59 -05:00
Myron Blair cf31a14ae1 Speed up music scan by deferring tag reads to import time
Reading embedded ID3/MP4 tags for every file during the scan itself was
taking minutes even for a small (146-track) library, since each mutagen
open is a slow round trip over the CIFS-mounted NAS share. Scan now uses
fast filename/path parsing only; tag reads happen once, only for the
tracks actually selected for import.
2026-07-26 20:38:09 -05:00
Myron Blair 514cbad4ab Add filesystem Library Scan (movies/tv/music) and a full Music library
- New admin Library Scan page walks the NAS mounts directly for movies,
  TV, and music not already in Radarr/Sonarr/local storage, and imports
  them with storage_type=scan (fully integrated: streamable, transcodable,
  listed in Admin) — Radarr/Sonarr import flows are untouched.
- New Music section (nav tab, album-grid browse, per-album track list,
  persistent bottom player with queue/seek) backed by a Track model and
  range-request audio streaming endpoint.
- MusicBrainz + Cover Art Archive integration (free, no API key) to fill
  in album art for scanned tracks, paced to their 1 req/sec rate limit.
- Mounted the NAS music share into CT104 (new mp2 LXC mountpoint,
  CIFS read-only) alongside the existing video mount.
- Optional Bluetooth/output-device picker on the player bar via the Audio
  Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge
  only, since browsers can't pair new BT devices directly.
2026-07-26 20:25:41 -05:00
Myron Blair f7626ced2e Merge watch profiles into user accounts — one profile per login, no picker
- Removed the "Who's Watching" sub-profile picker entirely: ProfileProvider now
  auto-resolves the single profile tied to the account immediately after login
- Backend: removed POST/DELETE /profiles (no more creating/deleting extra
  profiles — lifecycle is entirely driven by user create/delete, already wired
  in the admin Users CRUD); kept GET (internal) and PATCH (edit your own
  profile's avatar/kids-mode/rating cap)
- Deleted ProfileSelect page and the now-unused ProtectedRoute component
- Navbar: removed the "switch profile" action, account avatar is now just a
  static display
- api.js: dropped the now-dead X-Profile-Id header logic (backend already
  falls back to the account's single profile when the header is absent)
2026-07-26 19:40:12 -05:00
Myron Blair 5b191da471 Add user management (CRUD + admin toggle), make Settings available to all users
- New /admin/users page: create/delete users, toggle admin role, guarded against
  self-demotion/self-deletion
- Backend: GET/POST/PATCH/DELETE /api/admin/users, admin-only
- Settings moved from /admin/settings to /settings (all logged-in users); the
  password-change section shows for everyone, integration/API-key sections only
  render (and only fetch) for admins, since those endpoints stay admin-gated
- Navbar: Settings link now visible to all users; added Users nav link for admins
2026-07-26 19:21:50 -05:00
Myron Blair 0f6f03509a Move service status bar to global chrome, fixed just below navbar (admin-only) 2026-07-26 19:13:45 -05:00
Myron Blair 4d77a36f99 Add admin-only service status panel with restart, wired to Docker directly 2026-07-26 17:53:19 -05:00
Myron Blair 19374b694e Add self-service admin password change (requires current password) 2026-07-23 22:48:32 -05:00
Myron Blair 5b881a902d Auto-enqueue ABR transcode on Sonarr import, matching Radarr import behavior 2026-07-23 22:45:40 -05:00
Myron Blair 003512c01f Add *arr stack + qBittorrent (VPN'd via gluetun) + Portainer to compose 2026-07-23 22:20:26 -05:00
Myron Blair e27bb37f7b Rebrand StreamHoard, strip Emergent.sh platform artifacts
- Remove .emergent/ state dir, tracked .gitconfig, @emergentbase/visual-edits
  dep + craco wiring, emergent.sh script/meta tags, PostHog telemetry snippet
- Rename UI/docs branding Kino -> StreamHoard (README, DEPLOY.md, Navbar,
  Login, Register, AdminShowEpisodes, FastAPI title, health check response)
- Rename docker-compose container/network names to streamhoard-*
- Default admin email domain -> admin@streamhoard.local
2026-07-23 20:20:29 -05:00
Myron Blair d4dffdb6fb Pin mongo to 4.4 - host CPU lacks AVX required by mongo:7+ 2026-07-23 19:55:32 -05:00
Myron Blair 3e30f1491c Remove emergentintegrations dep - not on PyPI, unused in code 2026-07-23 19:50:31 -05:00
54 changed files with 3070 additions and 573 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1",
"job_id": "08d9a7a1-2a0c-4502-9939-fc5d643c04c4",
"created_at": "2026-07-23T22:57:32.873830+00:00Z"
}
-3
View File
@@ -1,3 +0,0 @@
[user]
email = github@emergent.sh
name = emergent-agent-e1
-1
View File
@@ -61,7 +61,6 @@ logs/
# --- Test reports / agent runtime ---
test_reports/
.emergent/
tests/__pycache__/
# --- Misc ---
+11 -11
View File
@@ -1,8 +1,8 @@
# Kino — Deployment on a Proxmox LAMP host
# StreamHoard — Deployment on a Proxmox LAMP host
Kino is FastAPI + MongoDB + React. It runs happily alongside your existing
StreamHoard is FastAPI + MongoDB + React. It runs happily alongside your existing
LAMP stack (Apache/MySQL/PHP) because we run everything in Docker containers
on their own network. Apache keeps port 80/443, MySQL is untouched, Kino
on their own network. Apache keeps port 80/443, MySQL is untouched, StreamHoard
uses whatever port you pick.
---
@@ -54,7 +54,7 @@ Log in with the admin credentials from `.env`.
## Running behind your existing Apache (optional, recommended)
If you already have Apache on port 80/443 with virtual hosts, add a Kino vhost
If you already have Apache on port 80/443 with virtual hosts, add a StreamHoard vhost
that reverse-proxies to the container. This gives you `https://kino.example.com`
instead of `http://ip:8080`.
@@ -130,7 +130,7 @@ To reset everything: `docker compose down -v` (deletes the mongo-data volume).
## Coexistence with your LAMP stack
| Kino uses | LAMP uses | Conflict? |
| StreamHoard uses | LAMP uses | Conflict? |
| ------------------------ | ----------------------- | --------- |
| Port `KINO_HTTP_PORT` | Apache 80/443 | No — different ports |
| MongoDB (internal only) | MySQL | No — different DBs, Mongo not exposed to host |
@@ -144,16 +144,16 @@ same box.
## Radarr integration
If Radarr is running on the same Proxmox host (native or container), point Kino
If Radarr is running on the same Proxmox host (native or container), point StreamHoard
at it via **Admin → Settings → Radarr**:
- **URL**: `http://<host-ip>:7878` (use the host IP, not `localhost` — containers
can't reach the host's `localhost` unless you add `host.docker.internal`)
- **API key**: from Radarr → Settings → General → Security
**Important**: For Kino to actually play the imported movies, the path Radarr
**Important**: For StreamHoard to actually play the imported movies, the path Radarr
reports (e.g. `/movies/Inception (2010)/Inception.mkv`) must be **readable from
inside the Kino backend container**. Mount Radarr's media path into Kino:
inside the StreamHoard backend container**. Mount Radarr's media path into StreamHoard:
```yaml
# docker-compose.yml — extend the backend volumes
@@ -163,7 +163,7 @@ backend:
- /mnt/radarr-movies:/mnt/radarr-movies:ro # <— add this
```
Radarr's file paths must be identical inside both Radarr and Kino containers
Radarr's file paths must be identical inside both Radarr and StreamHoard containers
(easiest: match the paths exactly).
---
@@ -172,14 +172,14 @@ Radarr's file paths must be identical inside both Radarr and Kino containers
```bash
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
# {"app":"Kino","status":"ok"}
# {"app":"StreamHoard","status":"ok"}
```
---
## Firewalling
Kino has no built-in rate limiting or IP allowlist. Since you confirmed
StreamHoard has no built-in rate limiting or IP allowlist. Since you confirmed
**internal network only**, keep it that way:
```bash
+1
View File
@@ -0,0 +1 @@
Property of TomTom Enterprises.
+4 -4
View File
@@ -1,4 +1,4 @@
# Kino — Personal Media Server (Netflix Clone)
# StreamHoard — Personal Media Server (Netflix Clone)
A self-hosted Netflix-style streaming app for movies you legally own.
Built with FastAPI + React + MongoDB.
@@ -41,7 +41,7 @@ See **[DEPLOY.md](./DEPLOY.md)** for LAMP-coexistence, Apache reverse proxy, Rad
*(Legal alternative to auto-downloading — auto-download of copyrighted material is not implemented.)*
## Default admin
- Email: `admin@kino.local`
- Email: `admin@streamhoard.local`
- Password: `kino-admin-2026`
Change `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `backend/.env` before first start
@@ -118,7 +118,7 @@ Movie media files should live on your Proxmox storage, not Git.
# Login
curl -X POST $URL/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@kino.local","password":"kino-admin-2026"}'
-d '{"email":"admin@streamhoard.local","password":"kino-admin-2026"}'
# List movies
curl $URL/api/movies
@@ -130,7 +130,7 @@ curl $URL/api/stream/<movie_id>?auth=<token>
## Why no auto-download?
Auto-downloading copyrighted movies from the internet is illegal in most
jurisdictions (DMCA, EU copyright directive, etc.). Kino is built as a
jurisdictions (DMCA, EU copyright directive, etc.). StreamHoard is built as a
**personal media server** for content you legally own or that is in the
public domain (Internet Archive, Blender Open Movies, etc.).
+3 -1
View File
@@ -1,10 +1,12 @@
# Kino backend — FastAPI + ffmpeg
FROM python:3.11-slim
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit)
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit);
# libchromaprint-tools provides `fpcalc`, used for AcoustID audio fingerprinting.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
libchromaprint-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+1
View File
@@ -0,0 +1 @@
1.0.0
+83
View File
@@ -0,0 +1,83 @@
"""AcoustID audio fingerprint identification — free, but requires:
1. A free API key from https://acoustid.org/api-key (one signup, no cost)
2. The `fpcalc` binary (Chromaprint) to compute a fingerprint from the actual audio file
Unlike musicbrainz.py/itunes.py (which match on artist+album text), this identifies a track
from its audio content — useful for files with missing/wrong tags where there's nothing
usable to text-search on.
"""
import asyncio
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional, Dict
import requests
logger = logging.getLogger("kino.acoustid")
ACOUSTID_BASE = "https://api.acoustid.org/v2/lookup"
def fingerprint_file(path: Path) -> Optional[Dict]:
"""Runs fpcalc on the file; returns {"duration": float, "fingerprint": str} or None."""
try:
proc = subprocess.run(
["fpcalc", "-json", str(path)],
capture_output=True, timeout=60, text=True,
)
if proc.returncode != 0:
return None
data = json.loads(proc.stdout)
if not data.get("fingerprint"):
return None
return {"duration": data.get("duration"), "fingerprint": data["fingerprint"]}
except FileNotFoundError:
logger.warning("fpcalc not installed — AcoustID identification unavailable")
return None
except Exception:
return None
def lookup_recording(api_key: str, duration: float, fingerprint: str) -> Optional[Dict]:
"""Identifies a recording from its fingerprint. Returns best-match title/artist/album."""
try:
r = requests.get(ACOUSTID_BASE, params={
"client": api_key,
"duration": int(duration or 0),
"fingerprint": fingerprint,
"meta": "recordings+releasegroups",
}, timeout=15)
r.raise_for_status()
data = r.json() or {}
except Exception:
return None
if data.get("status") != "ok":
return None
results = sorted(data.get("results") or [], key=lambda x: x.get("score", 0), reverse=True)
for res in results:
recordings = res.get("recordings") or []
if not recordings:
continue
rec = recordings[0]
releasegroups = rec.get("releasegroups") or []
artist = (rec.get("artists") or [{}])[0].get("name")
title = rec.get("title")
if not (artist and title):
continue
return {
"title": title,
"artist": artist,
"album": releasegroups[0].get("title") if releasegroups else None,
"mbid": rec.get("id"),
}
return None
async def identify_track(api_key: str, path: Path) -> Optional[Dict]:
"""Fingerprint + lookup a single file. Runs the blocking pieces off the event loop."""
fp = await asyncio.to_thread(fingerprint_file, path)
if not fp:
return None
return await asyncio.to_thread(lookup_recording, api_key, fp["duration"], fp["fingerprint"])
+32
View File
@@ -0,0 +1,32 @@
"""iTunes Search API client — free, no API key, no auth, no rate-limit registration.
Used as a fallback when MusicBrainz + Cover Art Archive has no match/art for a release.
"""
import requests
from typing import Optional, Dict
ITUNES_BASE = "https://itunes.apple.com/search"
def lookup_album(artist: str, album: str) -> Optional[Dict]:
"""Best-effort match for an artist+album against the iTunes Store catalog."""
try:
r = requests.get(ITUNES_BASE, params={
"term": f"{artist} {album}", "media": "music", "entity": "album", "limit": 1,
}, timeout=15)
r.raise_for_status()
results = (r.json() or {}).get("results") or []
except Exception:
return None
if not results:
return None
res = results[0]
art = res.get("artworkUrl100")
if art:
# iTunes serves a small default; the same CDN path accepts arbitrary sizes.
art = art.replace("100x100bb", "1200x1200bb")
return {
"title": res.get("collectionName") or album,
"artist": res.get("artistName") or artist,
"year": (res.get("releaseDate") or "")[:4] or None,
"art_url": art,
}
+209
View File
@@ -0,0 +1,209 @@
"""Library integrity scan — missing files, orphaned records/disk usage, stuck jobs, duplicates.
Read-only by design: scan() only reports findings; server.py's /library/cleanup/fix endpoint
performs the actual deletion/reset the admin picked, one category at a time.
"""
import shutil
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List
import asyncio
# Movie/episode/track files often live on an NFS/CIFS-mounted NAS, where every is_file() stat
# is a real network round trip. Checking hundreds of them one at a time (even off the event
# loop) can take minutes; a bounded worker pool checks them concurrently instead.
_STAT_WORKERS = 24
def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path:
if storage_type in ("radarr", "sonarr", "scan"):
return Path(storage_path)
return videos_dir / storage_path
def _scan_filesystem(movies: List[Dict], episodes: List[Dict], tracks: List[Dict],
videos_dir: Path, hls_dir: Path, live_ids: set) -> Dict[str, List[Dict]]:
"""Every filesystem stat call in the whole scan lives here, run off the event loop via
asyncio.to_thread, with the individual stat calls themselves parallelized across a small
worker pool — sequential one-at-a-time NAS round trips were taking minutes for a library
of any real size."""
out: Dict[str, List[Dict]] = {"missing_files": [], "orphaned_hls": []}
candidates = [] # (finding_dict, path_to_check)
for m in movies:
if m.get("storage_type") not in ("local", "radarr", "scan") or not m.get("storage_path"):
continue
candidates.append((
{"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]},
_file_path(m["storage_type"], m["storage_path"], videos_dir),
))
for e in episodes:
if e.get("_orphaned"):
continue # already reported as orphaned_episodes; don't also report a missing file
if e.get("storage_type") not in ("local", "sonarr", "scan") or not e.get("storage_path"):
continue
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
candidates.append((
{"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]},
_file_path(e["storage_type"], e["storage_path"], videos_dir),
))
for t in tracks:
candidates.append((
{"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')}{t.get('title','Untitled')}", "detail": t["storage_path"]},
Path(t["storage_path"]),
))
with ThreadPoolExecutor(max_workers=_STAT_WORKERS) as pool:
exists = pool.map(lambda pair: pair[1].is_file(), candidates)
for (finding, _path), is_present in zip(candidates, exists):
if not is_present:
out["missing_files"].append(finding)
if hls_dir.is_dir():
for d in hls_dir.iterdir():
if d.is_dir() and d.name not in live_ids:
size = sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
out["orphaned_hls"].append({"id": d.name, "content_type": "hls_dir", "label": d.name, "detail": f"{round(size / 1_000_000)} MB reclaimable"})
return out
async def scan(db, videos_dir: Path, hls_dir: Path) -> Dict[str, List[Dict]]:
findings: Dict[str, List[Dict]] = {
"missing_files": [], "orphaned_episodes": [], "orphaned_hls": [],
"stuck_jobs": [], "duplicate_paths": [], "orphaned_subtitles": [], "dangling_refs": [],
}
movies = await db.movies.find({}, {"_id": 0, "id": 1, "title": 1, "storage_type": 1, "storage_path": 1}).to_list(10000)
episodes = await db.episodes.find({}, {"_id": 0, "id": 1, "title": 1, "show_id": 1, "season_number": 1, "episode_number": 1, "storage_type": 1, "storage_path": 1}).to_list(20000)
tracks = await db.tracks.find({}, {"_id": 0, "id": 1, "title": 1, "artist": 1, "storage_path": 1}).to_list(20000)
show_ids = set(await db.shows.distinct("id"))
for e in episodes:
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
if e.get("show_id") not in show_ids:
findings["orphaned_episodes"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": f"show_id {e.get('show_id')} not found"})
e["_orphaned"] = True
live_ids = {m["id"] for m in movies} | {e["id"] for e in episodes}
fs_findings = await asyncio.to_thread(_scan_filesystem, movies, episodes, tracks, videos_dir, hls_dir, live_ids)
findings["missing_files"] = fs_findings["missing_files"]
findings["orphaned_hls"] = fs_findings["orphaned_hls"]
# --- Stuck transcode jobs (running with no progress for 2+ hours — likely dead from a restart) ---
now = datetime.now(timezone.utc)
running = await db.transcode_queue.find({"status": "running"}, {"_id": 0}).to_list(500)
for j in running:
started = j.get("started_at")
if not started:
continue
try:
age_hours = (now - datetime.fromisoformat(started)).total_seconds() / 3600
except ValueError:
continue
if age_hours > 2:
findings["stuck_jobs"].append({"id": j["id"], "content_type": "job", "label": j.get("movie_title") or j["movie_id"], "detail": f"running {round(age_hours, 1)}h with no completion"})
# --- Duplicate storage_path within the same collection (same file imported twice) ---
for coll_name, coll, label_fn in (
("movies", db.movies, lambda d: d.get("title", "Untitled")),
("episodes", db.episodes, lambda d: d.get("title") or d.get("id")),
("tracks", db.tracks, lambda d: d.get("title", "Untitled")),
):
docs = await coll.find({"storage_path": {"$ne": None}}, {"_id": 0}).sort("created_at", 1).to_list(20000)
seen = {}
for d in docs:
key = d.get("storage_path")
if not key:
continue
if key in seen:
findings["duplicate_paths"].append({"id": d["id"], "content_type": coll_name[:-1], "label": label_fn(d), "detail": f"duplicate of {seen[key]}"})
else:
seen[key] = d["id"]
# --- Orphaned subtitles (reference a movie/episode that no longer exists) ---
movie_ids = {m["id"] for m in movies}
episode_ids = {e["id"] for e in episodes}
subs = await db.subtitles.find({}, {"_id": 0}).to_list(5000)
for s in subs:
mid, eid = s.get("movie_id") or "", s.get("episode_id") or ""
if mid and mid not in movie_ids:
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"movie_id {mid} not found"})
elif eid and eid not in episode_ids:
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"episode_id {eid} not found"})
# --- Dangling watchlist/progress rows (reference a deleted movie) ---
for coll_name, coll in (("watchlist", db.watchlist), ("progress", db.progress)):
rows = await coll.find({}, {"_id": 0}).to_list(20000)
for r in rows:
if r.get("movie_id") and r["movie_id"] not in movie_ids:
findings["dangling_refs"].append({"id": r["id"] if "id" in r else f"{r['profile_id']}:{r['movie_id']}", "content_type": coll_name, "label": r["movie_id"], "detail": f"references deleted movie {r['movie_id']}", "profile_id": r.get("profile_id"), "movie_id": r.get("movie_id")})
# --- Dangling episode_progress rows (reference a deleted episode) ---
ep_rows = await db.episode_progress.find({}, {"_id": 0}).to_list(20000)
for r in ep_rows:
if r.get("episode_id") and r["episode_id"] not in episode_ids:
findings["dangling_refs"].append({"id": f"{r['profile_id']}:{r['episode_id']}", "content_type": "episode_progress", "label": r["episode_id"], "detail": f"references deleted episode {r['episode_id']}", "profile_id": r.get("profile_id"), "episode_id": r.get("episode_id")})
return findings
async def _delete_content(db, content_type: str, item_id: str) -> None:
"""Deletes a movie/episode/track and every row that references it, so a Fix never
leaves orphans behind for the next scan to catch on a second pass."""
if content_type == "movie":
await db.movies.delete_one({"id": item_id})
await db.watchlist.delete_many({"movie_id": item_id})
await db.progress.delete_many({"movie_id": item_id})
await db.subtitles.delete_many({"movie_id": item_id})
elif content_type == "episode":
await db.episodes.delete_one({"id": item_id})
await db.episode_progress.delete_many({"episode_id": item_id})
await db.subtitles.delete_many({"episode_id": item_id})
elif content_type == "track":
await db.tracks.delete_one({"id": item_id})
else:
raise ValueError(f"Unknown content_type: {content_type}")
async def fix_one(db, videos_dir: Path, hls_dir: Path, category: str, item: Dict) -> None:
content_type = item.get("content_type")
item_id = item.get("id")
if category == "missing_files":
await _delete_content(db, content_type, item_id)
elif category == "orphaned_episodes":
await _delete_content(db, "episode", item_id)
elif category == "orphaned_hls":
shutil.rmtree(hls_dir / item_id, ignore_errors=True)
elif category == "stuck_jobs":
await db.transcode_queue.update_one({"id": item_id}, {"$set": {
"status": "failed", "error": "Reset by library cleanup (stuck with no progress)",
"finished_at": datetime.now(timezone.utc).isoformat(),
}})
elif category == "duplicate_paths":
await _delete_content(db, content_type, item_id)
elif category == "orphaned_subtitles":
sub = await db.subtitles.find_one({"id": item_id}, {"_id": 0})
if sub and sub.get("storage_path"):
try:
(Path(str(videos_dir.parent / "subtitles")) / sub["storage_path"]).unlink(missing_ok=True)
except OSError:
pass
await db.subtitles.delete_one({"id": item_id})
elif category == "dangling_refs":
if content_type == "episode_progress":
await db.episode_progress.delete_one({"profile_id": item.get("profile_id"), "episode_id": item.get("episode_id")})
else:
coll = {"watchlist": db.watchlist, "progress": db.progress}[content_type]
await coll.delete_one({"profile_id": item.get("profile_id"), "movie_id": item.get("movie_id")})
else:
raise ValueError(f"Unknown cleanup category: {category}")
+171
View File
@@ -0,0 +1,171 @@
"""Filesystem scanner for content already sitting on the NAS mounts —
covers movies/tv placed outside Radarr/Sonarr, and the music library
(nothing else in StreamHoard touches music). Read-only: never writes files,
just walks paths and reports what it finds. Callers (server.py) cross-check
results against the DB to decide what's new vs. already imported.
"""
import re
from pathlib import Path
from typing import Dict, List, Optional
from mutagen import File as MutagenFile
MOVIE_EXTS = {".mp4", ".mkv", ".avi", ".m4v", ".mov"}
AUDIO_EXTS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".ogg"}
MOVIES_DIR = Path("/mnt/nas/video/movies")
TV_DIR = Path("/mnt/nas/video/tv")
MUSIC_DIR = Path("/mnt/nas/music/Music")
_YEAR_RE = re.compile(r"[\[\(]\s*(\d{4})\s*[\]\)]")
_SEASON_DIR_RE = re.compile(r"season\s*(\d+)", re.IGNORECASE)
_EPISODE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,3})")
_TRACK_NUM_RE = re.compile(r"^(\d{1,3})[\s._-]+(.*)$")
def _clean_title(name: str) -> str:
name = _YEAR_RE.sub("", name)
return re.sub(r"\s+", " ", name).strip(" -_.")
def _parse_year(name: str) -> Optional[int]:
m = _YEAR_RE.search(name)
return int(m.group(1)) if m else None
def _largest_video_file(files: List[Path]) -> Optional[Path]:
vids = [f for f in files if f.suffix.lower() in MOVIE_EXTS]
if not vids:
return None
return max(vids, key=lambda f: f.stat().st_size)
def scan_movies() -> List[Dict]:
"""Each movies/ entry is either a standalone video file or a folder containing one."""
if not MOVIES_DIR.is_dir():
return []
out = []
for entry in sorted(MOVIES_DIR.iterdir()):
if entry.name.startswith("@") or entry.name.startswith("#"):
continue # Synology internal dirs (@eaDir, #recycle)
if entry.is_file():
if entry.suffix.lower() not in MOVIE_EXTS:
continue
title_raw = entry.stem
file_path = entry
elif entry.is_dir():
try:
files = [f for f in entry.rglob("*") if f.is_file()]
except OSError:
continue
file_path = _largest_video_file(files)
if not file_path:
continue
title_raw = entry.name
else:
continue
out.append({
"title": _clean_title(title_raw) or title_raw,
"year": _parse_year(title_raw),
"storage_path": str(file_path),
"size_bytes": file_path.stat().st_size,
})
return out
def scan_tv() -> List[Dict]:
"""tv/<Show>/Season N/<episode files>. Groups by show, one entry per episode."""
if not TV_DIR.is_dir():
return []
out = []
for show_dir in sorted(TV_DIR.iterdir()):
if not show_dir.is_dir() or show_dir.name.startswith("@") or show_dir.name.startswith("#"):
continue
show_title = _clean_title(show_dir.name) or show_dir.name
for season_dir in sorted(show_dir.iterdir()):
if not season_dir.is_dir():
continue
m = _SEASON_DIR_RE.search(season_dir.name)
season_number = int(m.group(1)) if m else 1
for f in sorted(season_dir.iterdir()):
if not f.is_file() or f.suffix.lower() not in MOVIE_EXTS:
continue
em = _EPISODE_RE.search(f.stem)
if em:
season_number = int(em.group(1))
episode_number = int(em.group(2))
title = _clean_title(f.stem[em.end():]) or f"Episode {episode_number}"
else:
episode_number = None
title = _clean_title(f.stem)
out.append({
"show_title": show_title,
"season_number": season_number,
"episode_number": episode_number,
"title": title,
"storage_path": str(f),
"size_bytes": f.stat().st_size,
})
return out
def _read_tags(path: Path) -> Dict:
try:
tags = MutagenFile(path, easy=True)
except Exception:
tags = None
duration = 0.0
artist = album = title = track_number = None
if tags is not None:
if getattr(tags, "info", None) is not None:
duration = getattr(tags.info, "length", 0.0) or 0.0
artist = (tags.get("artist") or [None])[0]
album = (tags.get("album") or [None])[0]
title = (tags.get("title") or [None])[0]
tn = (tags.get("tracknumber") or [None])[0]
if tn:
try:
track_number = int(str(tn).split("/")[0])
except ValueError:
track_number = None
return {"duration": duration, "artist": artist, "album": album, "title": title, "track_number": track_number}
def scan_music() -> List[Dict]:
"""Music/<Artist>/<Album>/<NN Title.ext>. Filename/path-derived only — no file reads, so this
stays fast even over a network share with a large library. Embedded tags (better titles, real
duration) are read later, only for the tracks actually selected for import — see tags_for_file().
"""
if not MUSIC_DIR.is_dir():
return []
out = []
for artist_dir in sorted(MUSIC_DIR.iterdir()):
if not artist_dir.is_dir() or artist_dir.name.startswith("@") or artist_dir.name.startswith("#"):
continue
for album_dir in sorted(artist_dir.iterdir()):
if not album_dir.is_dir():
continue
for f in sorted(album_dir.iterdir()):
if not f.is_file() or f.suffix.lower() not in AUDIO_EXTS:
continue
track_number = None
title = f.stem
fm = _TRACK_NUM_RE.match(f.stem)
if fm:
track_number = int(fm.group(1))
title = fm.group(2)
out.append({
"artist": artist_dir.name,
"album": album_dir.name,
"title": _clean_title(title) or title,
"track_number": track_number,
"duration_seconds": 0,
"storage_path": str(f),
"size_bytes": f.stat().st_size,
})
return out
def tags_for_file(path_str: str) -> Dict:
"""Embedded-tag lookup for a single file — used at import time, not during scan."""
return _read_tags(Path(path_str))
+61 -9
View File
@@ -20,6 +20,11 @@ class UserLogin(BaseModel):
password: str
class PasswordChange(BaseModel):
current_password: str
new_password: str
class UserPublic(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
@@ -29,15 +34,21 @@ class UserPublic(BaseModel):
created_at: str
# ---------- Profiles ("Who's Watching") ----------
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
class ProfileCreate(BaseModel):
class AdminUserCreate(BaseModel):
email: str
password: str
name: str
avatar_color: str = "#D9381E"
is_kids: bool = False
max_rating: str = "NR" # one of RATING_ORDER
is_admin: bool = False
class AdminUserUpdate(BaseModel):
name: Optional[str] = None
is_admin: Optional[bool] = None
password: Optional[str] = None
# ---------- Profile (1:1 with account) ----------
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
class ProfileUpdate(BaseModel):
@@ -108,6 +119,29 @@ class Movie(MovieBase):
created_at: str = Field(default_factory=_now_iso)
# ---------- Music ----------
class TrackUpdate(BaseModel):
title: Optional[str] = None
artist: Optional[str] = None
album: Optional[str] = None
track_number: Optional[int] = None
album_art_url: Optional[str] = None
class Track(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
title: str
artist: str = "Unknown Artist"
album: str = "Unknown Album"
album_art_url: str = ""
track_number: Optional[int] = None
duration_seconds: float = 0
storage_type: str = "scan" # scan is currently the only source for tracks
storage_path: str
created_at: str = Field(default_factory=_now_iso)
# ---------- Subtitles ----------
class Subtitle(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -157,6 +191,10 @@ class RequestUpdate(BaseModel):
status: str
class RequestApprove(BaseModel):
content_type: str # "movie" | "tv"
class MovieRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@@ -165,7 +203,12 @@ class MovieRequest(BaseModel):
title: str
year: Optional[int] = None
notes: str = ""
status: str = "pending"
status: str = "pending" # pending | approved | fulfilled | rejected
content_type: Optional[str] = None # movie | tv, set once approved
radarr_id: Optional[int] = None
sonarr_id: Optional[int] = None
matched_title: Optional[str] = None
matched_year: Optional[int] = None
created_at: str = Field(default_factory=_now_iso)
@@ -182,6 +225,9 @@ class AppSettings(BaseModel):
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
music_itunes_fallback: bool = True
music_acoustid_enabled: bool = False
acoustid_api_key: str = ""
class AppSettingsPublic(BaseModel):
@@ -200,6 +246,10 @@ class AppSettingsPublic(BaseModel):
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
music_itunes_fallback: bool = True
music_acoustid_enabled: bool = False
acoustid_api_key: str = ""
acoustid_configured: bool = False
# ---------- Shows / Episodes (Sonarr) ----------
@@ -323,6 +373,8 @@ class TranscodeJob(BaseModel):
status: str = "pending"
error: str = ""
triggered_by: str = "manual"
progress: float = 0.0
superseded_by: Optional[str] = None
created_at: str = Field(default_factory=_now_iso)
started_at: Optional[str] = None
finished_at: Optional[str] = None
+42
View File
@@ -0,0 +1,42 @@
"""MusicBrainz + Cover Art Archive clients — both free, no API key.
MusicBrainz rate limit is 1 req/sec for unauthenticated clients; callers must pace requests.
"""
import requests
from typing import Optional, Dict
_HEADERS = {"User-Agent": "StreamHoard/1.0 (self-hosted media library)"}
MB_BASE = "https://musicbrainz.org/ws/2"
COVER_ART_BASE = "https://coverartarchive.org"
def lookup_release(artist: str, album: str) -> Optional[Dict]:
"""Best-effort match for an artist+album against MusicBrainz's release index."""
query = f'release:"{album}" AND artist:"{artist}"'
try:
r = requests.get(f"{MB_BASE}/release/", params={"query": query, "fmt": "json", "limit": 1},
headers=_HEADERS, timeout=15)
r.raise_for_status()
releases = (r.json() or {}).get("releases") or []
except Exception:
return None
if not releases:
return None
rel = releases[0]
return {
"mbid": rel.get("id"),
"title": rel.get("title") or album,
"artist": (rel.get("artist-credit") or [{}])[0].get("name") or artist,
"year": (rel.get("date") or "")[:4] or None,
}
def cover_art_url(mbid: str) -> Optional[str]:
"""Returns the front cover URL if the Cover Art Archive has one for this release, else None."""
url = f"{COVER_ART_BASE}/release/{mbid}/front"
try:
r = requests.head(url, headers=_HEADERS, timeout=10, allow_redirects=True)
if r.status_code == 200:
return r.url
except Exception:
pass
return None
+56
View File
@@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
return out
def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
"""Radarr's own title lookup (proxies TMDB) — used to resolve a free-text request to a real movie."""
base_url = base_url.rstrip("/")
r = requests.get(f"{base_url}/api/v3/movie/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
r.raise_for_status()
out = []
for m in r.json() or []:
poster = ""
for img in m.get("images", []) or []:
if img.get("coverType") == "poster":
poster = img.get("remoteUrl") or img.get("url") or ""
break
out.append({
"tmdb_id": m.get("tmdbId"), "title": m.get("title") or "",
"year": m.get("year"), "overview": m.get("overview") or "", "poster_url": poster,
})
return out
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill."""
base_url = base_url.rstrip("/")
h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
if not profiles or not roots:
raise RuntimeError("Radarr has no quality profile or root folder configured")
payload = {
"title": title, "tmdbId": tmdb_id, "year": year or 0,
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
"monitored": True, "addOptions": {"searchForMovie": True},
}
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
r.raise_for_status()
added = r.json()
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
def movie_status(base_url: str, api_key: str, radarr_id: int) -> Dict:
base_url = base_url.rstrip("/")
h = _h(api_key)
m = requests.get(f"{base_url}/api/v3/movie/{radarr_id}", headers=h, timeout=15).json()
if m.get("hasFile"):
return {"state": "downloaded"}
q = requests.get(f"{base_url}/api/v3/queue", params={"movieIds": radarr_id}, headers=h, timeout=15).json()
items = q.get("records") if isinstance(q, dict) else q
if items:
it = items[0]
size = it.get("size") or 0
sizeleft = it.get("sizeleft") or 0
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
return {"state": "searching"}
def test_connection(base_url: str, api_key: str) -> bool:
base_url = base_url.rstrip("/")
try:
+2 -1
View File
@@ -24,4 +24,5 @@ numpy>=1.26.0
python-multipart>=0.0.9
jq>=1.6.0
typer>=0.9.0
emergentintegrations==0.1.0
docker>=7.1.0
mutagen>=1.47.0
+681 -80
View File
@@ -5,7 +5,7 @@ import mimetypes
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from typing import List, Optional
from typing import List, Optional, Dict
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks
from fastapi.responses import StreamingResponse, FileResponse, Response
@@ -15,26 +15,34 @@ from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient
from models import (
UserCreate, UserLogin, UserPublic, TokenResponse,
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange,
AdminUserCreate, AdminUserUpdate,
Profile, ProfileUpdate, RATING_ORDER,
Movie, MovieCreate, MovieUpdate,
Subtitle,
WatchlistItem, ProgressUpsert,
RequestCreate, RequestUpdate, MovieRequest,
RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle,
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
Track, TrackUpdate,
)
from auth import hash_password, verify_password, create_token, decode_token
from seed import SAMPLE_MOVIES
import tmdb as tmdb_client
import radarr as radarr_client
import sonarr as sonarr_client
import services as services_client
import library_scan
import library_cleanup
import musicbrainz as musicbrainz_client
import itunes as itunes_client
import acoustid as acoustid_client
import trakt as trakt_client
import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, srt_to_vtt
from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt
ROOT_DIR = Path(__file__).parent
@@ -57,10 +65,17 @@ for d in (VIDEOS_DIR, POSTERS_DIR, SUBS_DIR, HLS_DIR):
CHUNK_SIZE = 1024 * 1024
app = FastAPI(title="Kino")
app = FastAPI(title="StreamHoard")
api = APIRouter(prefix="/api")
bearer = HTTPBearer(auto_error=False)
APP_VERSION = (ROOT_DIR / "VERSION").read_text().strip() if (ROOT_DIR / "VERSION").is_file() else "0.0.0"
@api.get("/version")
async def get_version():
return {"version": APP_VERSION}
# ---------- Auth deps ----------
async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> dict:
@@ -114,7 +129,7 @@ async def on_startup():
await db.profiles.create_index("user_id")
await db.subtitles.create_index("movie_id")
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
admin_email = os.environ.get("ADMIN_EMAIL", "admin@streamhoard.local")
if not await db.users.find_one({"email": admin_email}):
admin_user = {
"id": str(uuid.uuid4()),
@@ -235,25 +250,88 @@ async def me(user: dict = Depends(get_current_user)):
return _user_public(user)
# ============ PROFILES ============
@api.get("/profiles", response_model=List[Profile])
async def list_profiles(user: dict = Depends(get_current_user)):
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20)
@api.post("/auth/change-password")
async def change_password(payload: PasswordChange, user: dict = Depends(get_current_user)):
full_user = await db.users.find_one({"id": user["id"]})
if not full_user or not verify_password(payload.current_password, full_user["password_hash"]):
raise HTTPException(status_code=401, detail="Current password is incorrect")
if len(payload.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
await db.users.update_one(
{"id": user["id"]}, {"$set": {"password_hash": hash_password(payload.new_password)}}
)
return {"ok": True}
# ============ USERS (admin) ============
@api.get("/admin/users", response_model=List[UserPublic])
async def admin_list_users(user: dict = Depends(require_admin)):
docs = await db.users.find({}, {"_id": 0, "password_hash": 0}).sort("created_at", 1).to_list(500)
return docs
@api.post("/profiles", response_model=Profile)
async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)):
count = await db.profiles.count_documents({"user_id": user["id"]})
if count >= 5:
raise HTTPException(status_code=400, detail="Max 5 profiles per account")
prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump()
await db.profiles.insert_one(prof)
return _strip(prof)
@api.post("/admin/users", response_model=UserPublic)
async def admin_create_user(payload: AdminUserCreate, user: dict = Depends(require_admin)):
if await db.users.find_one({"email": payload.email.lower()}):
raise HTTPException(status_code=400, detail="Email already registered")
new_user = {
"id": str(uuid.uuid4()),
"email": payload.email.lower(),
"name": payload.name,
"password_hash": hash_password(payload.password),
"is_admin": payload.is_admin,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.users.insert_one(new_user)
await db.profiles.insert_one(Profile(user_id=new_user["id"], name=new_user["name"]).model_dump())
return _user_public(new_user)
@api.patch("/admin/users/{user_id}", response_model=UserPublic)
async def admin_update_user(user_id: str, payload: AdminUserUpdate, user: dict = Depends(require_admin)):
target = await db.users.find_one({"id": user_id})
if not target:
raise HTTPException(status_code=404, detail="User not found")
updates = {}
if payload.name is not None:
updates["name"] = payload.name
if payload.is_admin is not None:
if user_id == user["id"] and not payload.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin access")
updates["is_admin"] = payload.is_admin
if payload.password:
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
updates["password_hash"] = hash_password(payload.password)
if updates:
await db.users.update_one({"id": user_id}, {"$set": updates})
target = await db.users.find_one({"id": user_id})
return _user_public(target)
@api.delete("/admin/users/{user_id}")
async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)):
if user_id == user["id"]:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
target = await db.users.find_one({"id": user_id})
if not target:
raise HTTPException(status_code=404, detail="User not found")
await db.users.delete_one({"id": user_id})
await db.profiles.delete_many({"user_id": user_id})
return {"ok": True}
# ============ PROFILE (1:1 with account — no separate sub-profile management) ============
@api.get("/profiles", response_model=List[Profile])
async def list_profiles(user: dict = Depends(get_current_user)):
"""Always exactly one profile per account; get_active_profile auto-creates it if missing."""
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(1)
return docs
@api.patch("/profiles/{pid}", response_model=Profile)
async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)):
"""Edit your own single profile (avatar color, kids mode, rating cap) — not a multi-profile switcher."""
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
@@ -267,18 +345,6 @@ async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(
return res
@api.delete("/profiles/{pid}")
async def delete_profile(pid: str, user: dict = Depends(get_current_user)):
if await db.profiles.count_documents({"user_id": user["id"]}) <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the only profile")
res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]})
if res.deleted_count == 0:
raise HTTPException(status_code=404, detail="Profile not found")
await db.watchlist.delete_many({"profile_id": pid})
await db.progress.delete_many({"profile_id": pid})
return {"ok": True}
# ============ MOVIES ============
def _movie_filter_for_profile(profile: dict) -> dict:
cap = profile.get("max_rating", "NR")
@@ -436,11 +502,11 @@ async def stream_movie(
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
if movie.get("storage_type") == "radarr":
file_path = Path(movie["storage_path"]) # absolute path on Radarr-mounted storage
if movie.get("storage_type") in ("radarr", "scan"):
file_path = Path(movie["storage_path"]) # absolute path on NAS-mounted storage
else:
file_path = VIDEOS_DIR / movie["storage_path"]
if not file_path.is_file():
@@ -502,7 +568,7 @@ async def _process_job(job: dict):
{"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
if doc.get("storage_type") not in ("local", "radarr", "sonarr") or not doc.get("storage_path"):
if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
await db.transcode_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
@@ -510,7 +576,7 @@ async def _process_job(job: dict):
return
source = (
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr")
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr", "scan")
else VIDEOS_DIR / doc["storage_path"]
)
out_dir = HLS_DIR / job["movie_id"]
@@ -519,15 +585,19 @@ async def _process_job(job: dict):
last_error = {"msg": ""}
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None, progress: Optional[float] = None):
update = {"hls_status": status}
if status == "done" and entry: update["hls_path"] = entry
await coll.update_one({"id": job["movie_id"]}, {"$set": update})
if error: last_error["msg"] = error
if progress is not None:
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"progress": progress}})
try:
if job["quality"] == "abr":
await transcode_abr(source, out_dir, cb)
elif job["quality"] == "audio":
await transcode_audio_fix(source, out_dir, cb)
else:
await transcode_quick(source, out_dir, cb)
except Exception as e:
@@ -591,14 +661,14 @@ async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str =
@api.post("/movies/{movie_id}/transcode")
async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
"""Body: {"quality": "quick"|"abr"} — default "quick". Adds to background queue."""
"""Body: {"quality": "quick"|"abr"|"audio"} — default "quick". Adds to background queue."""
quality = (payload or {}).get("quality", "quick")
if quality not in ("quick", "abr"):
raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'")
if quality not in ("quick", "abr", "audio"):
raise HTTPException(status_code=400, detail="quality must be 'quick', 'abr', or 'audio'")
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr/scanned movies can be transcoded")
if movie.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
@@ -615,10 +685,18 @@ async def list_queue(status: Optional[str] = None, user: dict = Depends(require_
return rows
@api.get("/transcode/queue/running", response_model=Optional[TranscodeJob])
async def get_running_job(user: dict = Depends(require_admin)):
"""The currently-running job, independent of the paginated/sorted list above — with a
large backlog the FIFO worker often processes a job much older than the newest 500 rows,
which would otherwise never surface in the main list's created_at-desc window."""
return await db.transcode_queue.find_one({"status": "running"}, {"_id": 0})
@api.get("/transcode/queue/stats")
async def queue_stats(user: dict = Depends(require_admin)):
pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0}
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0, "superseded": 0}
async for row in db.transcode_queue.aggregate(pipeline):
out[row["_id"]] = row["n"]
s = await get_settings()
@@ -635,8 +713,9 @@ async def cancel_job(job_id: str, user: dict = Depends(require_admin)):
raise HTTPException(status_code=400, detail="Cannot cancel a running job")
await db.transcode_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
if job["status"] == "pending":
# Reset movie hls_status AND clear stale hls_path so the row no longer shows as transcoded
await db.movies.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
# Reset hls_status AND clear stale hls_path so the row no longer shows as transcoded
coll = db.movies if job.get("content_type", "movie") == "movie" else db.episodes
await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
return {"ok": True}
@@ -646,10 +725,50 @@ async def retry_job(job_id: str, user: dict = Depends(require_admin)):
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"))
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job["id"] != job["id"]:
# Keep the original row for the audit trail — mark it superseded rather than deleting,
# so cancelled/failed history never silently disappears from the list.
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
return {"ok": True, "job_id": new_job["id"]}
@api.post("/transcode/queue/retry-failed")
async def retry_all_failed(user: dict = Depends(require_admin)):
jobs = await db.transcode_queue.find({"status": {"$in": ["failed", "cancelled"]}}, {"_id": 0}).to_list(None)
retried = 0
for job in jobs:
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job["id"] != job["id"]:
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
retried += 1
return {"ok": True, "retried": retried}
@api.post("/transcode/queue/fix-audio-all")
async def fix_audio_all(user: dict = Depends(require_admin)):
"""Replaces every still-pending job (not yet started) with a fast audio-only fix job for
the same content — for libraries where the source audio is AC3/DTS/EAC3 (unplayable in any
browser), this gets working sound out much sooner than waiting on the full ABR backlog to
catch up, since only the audio track gets re-encoded rather than the whole video ladder.
Jobs already running are left alone; queue a full ABR pass later for real multi-bitrate
quality once there's no urgency."""
pending_jobs = await db.transcode_queue.find({"status": "pending"}, {"_id": 0}).to_list(5000)
cancelled = 0
for j in pending_jobs:
await db.transcode_queue.update_one({"id": j["id"]}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
cancelled += 1
queued = 0
for coll, content_type in ((db.movies, "movie"), (db.episodes, "episode")):
async for doc in coll.find({"hls_status": {"$ne": "done"}}, {"_id": 0, "id": 1, "storage_type": 1, "storage_path": 1}):
if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
continue
await _enqueue_transcode(doc["id"], "audio", triggered_by="auto", content_type=content_type)
queued += 1
return {"ok": True, "cancelled": cancelled, "queued": queued}
@api.post("/transcode/queue/clear")
async def clear_finished(user: dict = Depends(require_admin)):
res = await db.transcode_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled"]}})
@@ -863,6 +982,85 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
return res
@api.post("/requests/{request_id}/approve", response_model=MovieRequest)
async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)):
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate
search enabled, and marks the request approved. Content actually lands via the normal
Radarr/Sonarr download pipeline — this only kicks that off."""
if payload.content_type not in ("movie", "tv"):
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
# Atomically claim the request so two concurrent approve calls (double-click, two admins)
# can't both pass a check-then-act race and each add the content to Radarr/Sonarr.
req = await db.requests.find_one_and_update(
{"id": request_id, "status": "pending"}, {"$set": {"status": "approving"}},
projection={"_id": 0}, return_document=False,
)
if not req:
existing = await db.requests.find_one({"id": request_id}, {"_id": 0, "status": 1})
if not existing: raise HTTPException(status_code=404, detail="Request not found")
raise HTTPException(status_code=409, detail=f"Request is already {existing['status']}")
s = await get_settings()
try:
if payload.content_type == "movie":
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Radarr")
try:
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
else:
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Sonarr")
try:
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
except HTTPException:
# Release the claim so a fixable failure (e.g. Radarr temporarily down) can be retried,
# instead of leaving the request stuck in "approving" forever.
await db.requests.update_one({"id": request_id}, {"$set": {"status": "pending"}})
raise
res = await db.requests.find_one_and_update(
{"id": request_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
)
return res
@api.get("/requests/{request_id}/status")
async def request_download_status(request_id: str, user: dict = Depends(require_admin)):
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
if not req: raise HTTPException(status_code=404, detail="Request not found")
s = await get_settings()
try:
if req.get("radarr_id") and s.get("radarr_url") and s.get("radarr_api_key"):
return await asyncio.to_thread(radarr_client.movie_status, s["radarr_url"], s["radarr_api_key"], req["radarr_id"])
if req.get("sonarr_id") and s.get("sonarr_url") and s.get("sonarr_api_key"):
return await asyncio.to_thread(sonarr_client.series_status, s["sonarr_url"], s["sonarr_api_key"], req["sonarr_id"])
except Exception as e:
return {"state": "unknown", "error": str(e)}
return {"state": req.get("status", "pending")}
# ============ SETTINGS (admin) ============
@api.get("/settings", response_model=AppSettingsPublic)
async def read_settings(user: dict = Depends(require_admin)):
@@ -883,6 +1081,10 @@ async def read_settings(user: dict = Depends(require_admin)):
trakt_client_secret=s.get("trakt_client_secret", ""),
auto_transcode=s.get("auto_transcode", "off"),
queue_paused=bool(s.get("queue_paused", False)),
music_itunes_fallback=bool(s.get("music_itunes_fallback", True)),
music_acoustid_enabled=bool(s.get("music_acoustid_enabled", False)),
acoustid_api_key=s.get("acoustid_api_key", ""),
acoustid_configured=bool(s.get("acoustid_api_key")),
)
@@ -907,6 +1109,10 @@ async def update_settings(payload: AppSettings, user: dict = Depends(require_adm
trakt_client_secret=doc.get("trakt_client_secret", ""),
auto_transcode=doc.get("auto_transcode", "off"),
queue_paused=bool(doc.get("queue_paused", False)),
music_itunes_fallback=bool(doc.get("music_itunes_fallback", True)),
music_acoustid_enabled=bool(doc.get("music_acoustid_enabled", False)),
acoustid_api_key=doc.get("acoustid_api_key", ""),
acoustid_configured=bool(doc.get("acoustid_api_key")),
)
@@ -1075,12 +1281,12 @@ async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_ad
async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
quality = payload.get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
if quality not in ("quick", "abr", "audio"): raise HTTPException(status_code=400, detail="Invalid quality")
queued = 0
for eid in ids:
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
if not ep or ep.get("hls_status") in ("running", "pending"): continue
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"): continue
await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode")
queued += 1
return {"ok": True, "queued": queued}
@@ -1109,11 +1315,11 @@ async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)):
@api.post("/episodes/{episode_id}/transcode")
async def transcode_episode(episode_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
quality = (payload or {}).get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
if quality not in ("quick", "abr", "audio"): raise HTTPException(status_code=400, detail="Invalid quality")
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded")
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr/scanned episodes can be transcoded")
if ep.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
@@ -1131,9 +1337,9 @@ async def stream_episode(
decode_token(token)
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Episode has no local file")
file_path = Path(ep["storage_path"]) if ep["storage_type"] == "sonarr" else VIDEOS_DIR / ep["storage_path"]
file_path = Path(ep["storage_path"]) if ep["storage_type"] in ("sonarr", "scan") else VIDEOS_DIR / ep["storage_path"]
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing")
file_size = file_path.stat().st_size
content_type = mimetypes.guess_type(str(file_path))[0] or "video/mp4"
@@ -1237,6 +1443,7 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
all_series = await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"])
by_id = {x["sonarr_id"]: x for x in all_series}
created_shows = 0; created_eps = 0
auto = s.get("auto_transcode", "off")
for sid in sonarr_ids:
m = by_id.get(int(sid))
if not m: continue
@@ -1269,6 +1476,8 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
sonarr_episode_id=ep["sonarr_episode_id"],
).model_dump()
await db.episodes.insert_one(edoc); created_eps += 1
if auto in ("quick", "abr"):
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode")
# Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers)
show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1})
if show_doc and show_doc.get("tmdb_id"):
@@ -1283,6 +1492,357 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
return {"ok": True, "shows_imported": created_shows, "episodes_imported": created_eps}
# ============ LIBRARY SCAN (filesystem — separate from Radarr/Sonarr) ============
@api.get("/scan/movies")
async def scan_movies(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_movies)
imported_paths = set(await db.movies.distinct("storage_path", {"storage_type": {"$in": ["radarr", "scan", "local"]}}))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.get("/scan/tv")
async def scan_tv(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_tv)
imported_paths = set(await db.episodes.distinct("storage_path", {"storage_type": {"$in": ["sonarr", "scan", "local"]}}))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.get("/scan/music")
async def scan_music(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_music)
imported_paths = set(await db.tracks.distinct("storage_path"))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.post("/scan/import")
async def scan_import(payload: dict, user: dict = Depends(require_admin)):
"""Body: {kind: "movies"|"tv"|"music", items: [<scan result dicts as returned by GET /scan/*>]}"""
kind = payload.get("kind")
items = payload.get("items") or []
if kind not in ("movies", "tv", "music"):
raise HTTPException(status_code=400, detail="kind must be movies, tv, or music")
if not items:
raise HTTPException(status_code=400, detail="No items provided")
s = await get_settings()
auto = s.get("auto_transcode", "off")
created = 0
if kind == "movies":
for it in items:
if await db.movies.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
movie = Movie(
title=it.get("title") or "Untitled", year=it.get("year") or 2024,
storage_type="scan", storage_path=it["storage_path"],
).model_dump()
await db.movies.insert_one(movie)
if auto in ("quick", "abr"):
await _enqueue_transcode(movie["id"], auto, triggered_by="auto")
created += 1
elif kind == "tv":
shows_by_title: Dict[str, str] = {}
for it in items:
if await db.episodes.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
show_title = it.get("show_title") or "Untitled"
show_id = shows_by_title.get(show_title)
if not show_id:
existing = await db.shows.find_one({"title": show_title}, {"_id": 0})
if existing:
show_id = existing["id"]
else:
show_doc = Show(title=show_title, year=2024, rating="NR").model_dump()
await db.shows.insert_one(show_doc)
show_id = show_doc["id"]
shows_by_title[show_title] = show_id
edoc = Episode(
show_id=show_id, season_number=it.get("season_number") or 1,
episode_number=it.get("episode_number") or 0,
title=it.get("title") or "", storage_type="scan", storage_path=it["storage_path"],
).model_dump()
await db.episodes.insert_one(edoc)
if auto in ("quick", "abr"):
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode")
created += 1
for show_id in set(shows_by_title.values()):
seasons = await db.episodes.distinct("season_number", {"show_id": show_id})
ecount = await db.episodes.count_documents({"show_id": show_id})
await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}})
elif kind == "music":
for it in items:
if await db.tracks.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
tags = await asyncio.to_thread(library_scan.tags_for_file, it["storage_path"])
track = Track(
title=tags.get("title") or it.get("title") or "Untitled",
artist=tags.get("artist") or it.get("artist") or "Unknown Artist",
album=tags.get("album") or it.get("album") or "Unknown Album",
track_number=tags.get("track_number") or it.get("track_number"),
duration_seconds=round(tags.get("duration") or 0, 1), storage_path=it["storage_path"],
).model_dump()
await db.tracks.insert_one(track)
created += 1
return {"ok": True, "imported": created}
# ============ LIBRARY CLEANUP (admin) ============
_cleanup_scan_status = {"running": False, "findings": None, "error": "", "scan_id": 0}
async def _run_cleanup_scan(scan_id: int):
try:
findings = await library_cleanup.scan(db, VIDEOS_DIR, HLS_DIR)
if _cleanup_scan_status["scan_id"] == scan_id:
_cleanup_scan_status["findings"] = findings
except Exception as e:
if _cleanup_scan_status["scan_id"] == scan_id:
_cleanup_scan_status["error"] = str(e)
finally:
if _cleanup_scan_status["scan_id"] == scan_id:
_cleanup_scan_status["running"] = False
@api.post("/library/cleanup/scan/start")
async def library_cleanup_scan_start(user: dict = Depends(require_admin)):
if _cleanup_scan_status["running"]:
raise HTTPException(status_code=409, detail="Scan already running")
_cleanup_scan_status["scan_id"] += 1
_cleanup_scan_status.update(running=True, findings=None, error="")
asyncio.create_task(_run_cleanup_scan(_cleanup_scan_status["scan_id"]))
return {"ok": True}
@api.get("/library/cleanup/scan/status")
async def library_cleanup_scan_status(user: dict = Depends(require_admin)):
return _cleanup_scan_status
@api.post("/library/cleanup/scan/stop")
async def library_cleanup_scan_stop(user: dict = Depends(require_admin)):
# The already-dispatched file checks finish naturally in the background (there's no clean
# way to interrupt a ThreadPoolExecutor mid-flight), but bumping scan_id means whenever they
# do finish, _run_cleanup_scan sees a stale id and discards the result instead of surfacing it.
_cleanup_scan_status["scan_id"] += 1
_cleanup_scan_status["running"] = False
return {"ok": True}
@api.post("/library/cleanup/fix")
async def library_cleanup_fix(payload: dict, user: dict = Depends(require_admin)):
"""Body: {category: str, items: [<finding dicts as returned by GET /library/cleanup/scan>]}"""
category = payload.get("category")
items = payload.get("items") or []
if not items:
raise HTTPException(status_code=400, detail="No items provided")
fixed = 0
for item in items:
try:
await library_cleanup.fix_one(db, VIDEOS_DIR, HLS_DIR, category, item)
fixed += 1
except Exception as e:
logger.warning(f"Library cleanup fix failed for {category}/{item.get('id')}: {e}")
return {"ok": True, "fixed": fixed}
# ============ MUSIC ============
@api.get("/tracks", response_model=List[Track])
async def list_tracks(user: dict = Depends(get_current_user)):
docs = await db.tracks.find({}, {"_id": 0}).sort([("artist", 1), ("album", 1), ("track_number", 1)]).to_list(10000)
return docs
@api.patch("/tracks/{track_id}", response_model=Track)
async def update_track(track_id: str, payload: TrackUpdate, user: dict = Depends(require_admin)):
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
res = await db.tracks.find_one_and_update(
{"id": track_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
)
if not res:
raise HTTPException(status_code=404, detail="Track not found")
return res
@api.delete("/tracks/{track_id}")
async def delete_track(track_id: str, user: dict = Depends(require_admin)):
res = await db.tracks.delete_one({"id": track_id})
if res.deleted_count == 0:
raise HTTPException(status_code=404, detail="Track not found")
return {"ok": True}
_music_enrich_status = {"running": False, "total": 0, "done": 0, "updated": 0, "error": ""}
async def _run_music_enrich():
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
try:
settings = await get_settings()
itunes_fallback = settings.get("music_itunes_fallback", True)
pairs = await db.tracks.aggregate([
{"$match": {"album_art_url": {"$in": [None, ""]}}},
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
]).to_list(5000)
_music_enrich_status["total"] = len(pairs)
for p in pairs:
artist = p["_id"]["artist"]; album = p["_id"]["album"]
try:
art_url = None
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
if release and release.get("mbid"):
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
if not art_url and itunes_fallback:
# MusicBrainz has no release, or CAA has no art for it — iTunes needs no
# key/rate-limit pacing, so it's a free second attempt before giving up.
itunes_match = await asyncio.to_thread(itunes_client.lookup_album, artist, album)
if itunes_match and itunes_match.get("art_url"):
art_url = itunes_match["art_url"]
if art_url:
res = await db.tracks.update_many(
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
{"$set": {"album_art_url": art_url}},
)
_music_enrich_status["updated"] += res.modified_count
except Exception as e:
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
_music_enrich_status["done"] += 1
await asyncio.sleep(1.1) # MusicBrainz rate limit: 1 req/sec, unauthenticated
except Exception as e:
_music_enrich_status["error"] = str(e)
finally:
_music_enrich_status["running"] = False
_acoustid_status = {"running": False, "total": 0, "done": 0, "identified": 0, "error": ""}
async def _run_acoustid_identify():
"""Fingerprints tracks with missing/placeholder artist or album and identifies them via
AcoustID — for files with no usable tags to text-search on. Filling in artist/album lets
the normal MusicBrainz/iTunes art enrichment pick them up afterward."""
_acoustid_status.update(running=True, total=0, done=0, identified=0, error="")
try:
settings = await get_settings()
api_key = settings.get("acoustid_api_key", "")
if not api_key:
_acoustid_status["error"] = "No AcoustID API key configured"
return
tracks = await db.tracks.find(
{"$or": [{"artist": "Unknown Artist"}, {"album": "Unknown Album"}]},
{"_id": 0},
).to_list(5000)
_acoustid_status["total"] = len(tracks)
for t in tracks:
try:
path = Path(t["storage_path"])
match = await acoustid_client.identify_track(api_key, path)
if match:
update = {}
if match.get("title") and t.get("artist") == "Unknown Artist":
update["title"] = match["title"]
if match.get("artist"):
update["artist"] = match["artist"]
if match.get("album"):
update["album"] = match["album"]
if update:
await db.tracks.update_one({"id": t["id"]}, {"$set": update})
_acoustid_status["identified"] += 1
except Exception as e:
logger.warning(f"AcoustID identify failed for {t.get('storage_path')}: {e}")
_acoustid_status["done"] += 1
await asyncio.sleep(0.35) # AcoustID's documented rate limit is 3 req/sec
except Exception as e:
_acoustid_status["error"] = str(e)
finally:
_acoustid_status["running"] = False
@api.post("/music/identify")
async def start_acoustid_identify(user: dict = Depends(require_admin)):
if _acoustid_status["running"]:
raise HTTPException(status_code=409, detail="Identification already running")
asyncio.create_task(_run_acoustid_identify())
return {"ok": True}
@api.get("/music/identify/status")
async def acoustid_identify_status(user: dict = Depends(require_admin)):
return _acoustid_status
@api.post("/music/enrich")
async def start_music_enrich(user: dict = Depends(require_admin)):
if _music_enrich_status["running"]:
raise HTTPException(status_code=409, detail="Enrichment already running")
asyncio.create_task(_run_music_enrich())
return {"ok": True}
@api.get("/music/enrich/status")
async def music_enrich_status(user: dict = Depends(require_admin)):
return _music_enrich_status
@api.get("/stream/track/{track_id}")
async def stream_track(
track_id: str, request: Request,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
decode_token(token)
track = await db.tracks.find_one({"id": track_id}, {"_id": 0})
if not track: raise HTTPException(status_code=404, detail="Track not found")
file_path = Path(track["storage_path"])
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing on disk")
file_size = file_path.stat().st_size
_audio_mime_fallback = {".m4a": "audio/mp4", ".flac": "audio/flac", ".ogg": "audio/ogg", ".wav": "audio/wav", ".aac": "audio/aac"}
content_type = mimetypes.guess_type(str(file_path))[0] or _audio_mime_fallback.get(file_path.suffix.lower(), "audio/mpeg")
range_header = request.headers.get("range") or request.headers.get("Range")
if range_header and range_header.startswith("bytes="):
try:
start_str, end_str = range_header.replace("bytes=", "").split("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
raise HTTPException(status_code=416, detail="Invalid range")
if start >= file_size:
raise HTTPException(status_code=416, detail="Range out of bounds")
end = min(end, file_size - 1)
length = end - start + 1
def iter_file():
with file_path.open("rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk: break
remaining -= len(chunk)
yield chunk
return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
"Content-Type": content_type,
})
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
# ============ TRAKT ============
async def _trakt_get_token(user_id: str) -> Optional[dict]:
return await db.trakt_tokens.find_one({"user_id": user_id}, {"_id": 0})
@@ -1414,37 +1974,64 @@ async def opensubs_download(payload: dict, user: dict = Depends(require_admin)):
# ============ BULK TMDB ENRICHMENT ============
_movie_enrich_status = {"running": False, "total": 0, "done": 0, "enriched": 0, "skipped": 0, "failed": 0, "error": ""}
async def _run_movie_enrich(key: str):
_movie_enrich_status.update(running=True, total=0, done=0, enriched=0, skipped=0, failed=0, error="")
try:
movies = await db.movies.find({}, {"_id": 0}).to_list(20000)
_movie_enrich_status["total"] = len(movies)
for m in movies:
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
if not needs:
_movie_enrich_status["skipped"] += 1
_movie_enrich_status["done"] += 1
continue
try:
if m.get("tmdb_id"):
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
else:
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
if not results:
_movie_enrich_status["failed"] += 1
_movie_enrich_status["done"] += 1
continue
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
updates = {}
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
if data.get(field) and not m.get(field): updates[field] = data[field]
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
if updates:
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
_movie_enrich_status["enriched"] += 1
except Exception as e:
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
_movie_enrich_status["failed"] += 1
_movie_enrich_status["done"] += 1
except Exception as e:
_movie_enrich_status["error"] = str(e)
finally:
_movie_enrich_status["running"] = False
@api.post("/tmdb/enrich-all")
async def tmdb_enrich_all(user: dict = Depends(require_admin)):
"""Sweep movies with missing metadata and fill from TMDB (by title match)."""
"""Kicks off a background sweep of movies with missing metadata, filled from TMDB (by title match)."""
if _movie_enrich_status["running"]:
raise HTTPException(status_code=409, detail="Enrichment already running")
s = await get_settings()
key = s.get("tmdb_api_key", "")
if not key: raise HTTPException(status_code=400, detail="TMDB not configured")
enriched = 0; skipped = 0; failed = 0
async for m in db.movies.find({}, {"_id": 0}):
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
if not needs:
skipped += 1; continue
try:
if m.get("tmdb_id"):
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
else:
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
if not results: failed += 1; continue
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
updates = {}
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
if data.get(field) and not m.get(field): updates[field] = data[field]
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
if updates:
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
enriched += 1
except Exception as e:
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
failed += 1
return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed}
asyncio.create_task(_run_movie_enrich(key))
return {"ok": True}
@api.get("/tmdb/enrich-all/status")
async def tmdb_enrich_all_status(user: dict = Depends(require_admin)):
return _movie_enrich_status
async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int],
@@ -1479,10 +2066,24 @@ async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Opti
logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}")
# ============ SERVICES (admin) ============
@api.get("/services")
async def services_status(user: dict = Depends(require_admin)):
return await asyncio.to_thread(services_client.list_service_status)
@api.post("/services/{key}/restart")
async def services_restart(key: str, user: dict = Depends(require_admin)):
ok = await asyncio.to_thread(services_client.restart_service, key)
if not ok:
raise HTTPException(status_code=404, detail="Unknown service")
return {"ok": True}
# ============ HEALTH ============
@api.get("/")
async def root():
return {"app": "Kino", "status": "ok"}
return {"app": "StreamHoard", "status": "ok"}
app.include_router(api)
+46
View File
@@ -0,0 +1,46 @@
"""Docker service status/restart for the internal StreamHoard stack."""
import docker
SERVICES = [
{"key": "gluetun", "label": "VPN (gluetun)", "container": "streamhoard-gluetun"},
{"key": "sonarr", "label": "Sonarr", "container": "streamhoard-sonarr"},
{"key": "radarr", "label": "Radarr", "container": "streamhoard-radarr"},
{"key": "prowlarr", "label": "Prowlarr", "container": "streamhoard-prowlarr"},
{"key": "qbittorrent", "label": "qBittorrent", "container": "streamhoard-qbittorrent"},
{"key": "mongo", "label": "Database", "container": "streamhoard-mongo"},
{"key": "portainer", "label": "Portainer", "container": "streamhoard-portainer"},
]
_client = None
def _get_client():
global _client
if _client is None:
_client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
return _client
def list_service_status():
client = _get_client()
out = []
for svc in SERVICES:
try:
c = client.containers.get(svc["container"])
running = c.status == "running"
except docker.errors.NotFound:
running = False
except Exception:
running = False
out.append({"key": svc["key"], "label": svc["label"], "running": running})
return out
def restart_service(key: str) -> bool:
svc = next((s for s in SERVICES if s["key"] == key), None)
if not svc:
return False
client = _get_client()
c = client.containers.get(svc["container"])
c.restart(timeout=15)
return True
+53
View File
@@ -14,6 +14,59 @@ def _poster(images):
return ""
def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
"""Sonarr's own title lookup (proxies TVDB) — used to resolve a free-text request to a real series."""
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
r.raise_for_status()
out = []
for s in r.json() or []:
out.append({
"tvdb_id": s.get("tvdbId"), "tmdb_id": s.get("tmdbId"), "title": s.get("title") or "",
"year": s.get("year"), "overview": s.get("overview") or "", "poster_url": _poster(s.get("images")),
})
return out
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled."""
base_url = base_url.rstrip("/")
h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
if not profiles or not roots:
raise RuntimeError("Sonarr has no quality profile or root folder configured")
payload = {
"title": title, "tvdbId": tvdb_id, "year": year or 0,
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
"seasonFolder": True, "monitored": True,
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
}
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
r.raise_for_status()
added = r.json()
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
def series_status(base_url: str, api_key: str, sonarr_id: int) -> Dict:
base_url = base_url.rstrip("/")
h = _h(api_key)
s = requests.get(f"{base_url}/api/v3/series/{sonarr_id}", headers=h, timeout=15).json()
stats = s.get("statistics") or {}
if stats.get("episodeFileCount", 0) > 0 and stats.get("episodeFileCount") >= stats.get("episodeCount", 1):
return {"state": "downloaded"}
q = requests.get(f"{base_url}/api/v3/queue", params={"seriesId": sonarr_id, "includeSeries": False}, headers=h, timeout=15).json()
items = q.get("records") if isinstance(q, dict) else q
if items:
it = items[0]
size = it.get("size") or 0
sizeleft = it.get("sizeleft") or 0
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
if stats.get("episodeFileCount", 0) > 0:
return {"state": "partially downloaded"}
return {"state": "searching"}
def test_connection(base_url: str, api_key: str) -> bool:
try:
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
+6 -6
View File
@@ -1,5 +1,5 @@
"""
Backend pytest suite for Kino Personal Media Server.
Backend pytest suite for StreamHoard Personal Media Server.
Tests auth, movies, watchlist, progress, requests, and stream auth.
"""
import os
@@ -8,8 +8,8 @@ import uuid
import pytest
import requests
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
ADMIN_EMAIL = "admin@kino.local"
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "http://localhost:8001").rstrip("/")
ADMIN_EMAIL = "admin@streamhoard.local"
ADMIN_PASSWORD = "kino-admin-2026"
@@ -32,7 +32,7 @@ def admin_token(api):
@pytest.fixture(scope="session")
def member_token(api):
email = f"TEST_user_{uuid.uuid4().hex[:8]}@kino.local"
email = f"TEST_user_{uuid.uuid4().hex[:8]}@streamhoard.local"
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Test User"})
assert r.status_code == 200, f"register failed: {r.text}"
data = r.json()
@@ -73,7 +73,7 @@ class TestAuth:
assert r.status_code == 401
def test_register_and_me(self, api):
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@kino.local"
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@streamhoard.local"
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
assert r.status_code == 200
d = r.json()
@@ -86,7 +86,7 @@ class TestAuth:
assert r2.json()["email"] == email.lower()
def test_register_duplicate(self, api):
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@kino.local"
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@streamhoard.local"
api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
assert r.status_code == 400
+3 -3
View File
@@ -9,7 +9,7 @@ import pytest
import requests
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "").rstrip("/")
ADMIN_EMAIL = "admin@kino.local"
ADMIN_EMAIL = "admin@streamhoard.local"
ADMIN_PASSWORD = "kino-admin-2026"
@@ -36,7 +36,7 @@ def admin_headers(admin_token):
@pytest.fixture(scope="module")
def member(api):
"""Create a fresh member for profile-scoped tests."""
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@kino.local"
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@streamhoard.local"
r = api.post(f"{BASE_URL}/api/auth/register",
json={"email": email, "password": "pass1234", "name": "P2 User"})
assert r.status_code == 200
@@ -101,7 +101,7 @@ class TestProfiles:
def test_cannot_delete_last_profile(self, api):
# fresh user with single profile
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@kino.local"
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@streamhoard.local"
d = api.post(f"{BASE_URL}/api/auth/register",
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
h = {"Authorization": f"Bearer {d['access_token']}"}
+2 -2
View File
@@ -17,7 +17,7 @@ import pytest
import requests
BASE_URL = os.environ["REACT_APP_BACKEND_URL"].rstrip("/")
ADMIN_EMAIL = "admin@kino.local"
ADMIN_EMAIL = "admin@streamhoard.local"
ADMIN_PASSWORD = "kino-admin-2026"
QTEST_VIDEO = "/tmp/qtest.mp4"
@@ -40,7 +40,7 @@ def admin_token(api):
@pytest.fixture(scope="session")
def member_token(api):
email = f"TEST_q_{uuid.uuid4().hex[:8]}@kino.local"
email = f"TEST_q_{uuid.uuid4().hex[:8]}@streamhoard.local"
r = api.post(f"{BASE_URL}/api/auth/register",
json={"email": email, "password": "pass1234", "name": "QTest"},
headers={"Content-Type": "application/json"})
+105 -11
View File
@@ -13,6 +13,11 @@ from typing import Optional, List, Tuple, Callable, Awaitable
logger = logging.getLogger("kino.transcode")
# Moderate priority (was 19, the absolute lowest) and a thread cap so an ABR encode can't
# starve the rest of the stack (Mongo, *arr apps, the API itself) on this 8-core host.
TRANSCODE_NICE = 10
TRANSCODE_THREADS = 6
async def probe_video(source: Path) -> Tuple[int, int]:
"""Return (width, height) using ffprobe; (0, 0) on failure."""
@@ -32,6 +37,23 @@ async def probe_video(source: Path) -> Tuple[int, int]:
return (0, 0)
async def probe_duration(source: Path) -> float:
"""Return source duration in seconds using ffprobe; 0 on failure."""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "json", str(source),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return 0.0
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
return float((data.get("format") or {}).get("duration") or 0.0)
except Exception:
return 0.0
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
ALL_VARIANTS = [
(1080, "5000k", "5500k", "7500k", "192k"),
@@ -42,13 +64,38 @@ ALL_VARIANTS = [
def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]:
"""Pick variants ≤ source height. Always include at least one (smallest)."""
"""Pick up to 2 variants ≤ source height (the two highest applicable renditions —
ALL_VARIANTS is ordered highest-to-lowest, so slicing keeps the best pair). Capped at 2
instead of the full ladder to roughly halve ABR encode time on this host's aging CPU, while
still giving a real high/low adaptive-bitrate pair for most connections."""
if src_height <= 0:
return [ALL_VARIANTS[2]] # safe default 480p
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
if not chosen:
chosen = [ALL_VARIANTS[-1]]
return chosen
return chosen[:2]
async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
"""Video stream-copied untouched, audio re-encoded to AAC — fixes browsers' inability to
play AC3/DTS/EAC3 (common on BDRips) without paying for a full video re-encode. Same
H.264-source assumption as transcode_quick; HEVC/x265 sources still need the full ABR path
since the video itself isn't browser-playable either in that case."""
if not source.is_file():
await on_status("failed", error=f"Source missing: {source}")
return
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"nice", "-n", str(TRANSCODE_NICE),
"ffmpeg", "-y", "-i", str(source),
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ac", "2",
"-bsf:v", "h264_mp4toannexb",
"-f", "hls", "-hls_time", "6",
"-hls_list_size", "0", "-hls_playlist_type", "vod",
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"),
]
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
@@ -58,7 +105,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
return
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"nice", "-n", "19",
"nice", "-n", str(TRANSCODE_NICE),
"ffmpeg", "-y", "-i", str(source),
"-c:v", "copy", "-c:a", "copy",
"-bsf:v", "h264_mp4toannexb",
@@ -67,7 +114,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"),
]
await _run(cmd, on_status, out_dir, "playlist.m3u8")
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
@@ -88,13 +135,19 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
filter_complex = ";".join(fc_parts)
cmd: List[str] = ["nice", "-n", "19", "ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex]
# Per-variant thread count, not the full cap each — x264 threads are set per encoder
# instance, so giving every one of the (up to 2) variants the full TRANSCODE_THREADS
# budget lets them all run near-full-tilt simultaneously and still saturate the host.
per_variant_threads = max(1, TRANSCODE_THREADS // n)
cmd: List[str] = ["nice", "-n", str(TRANSCODE_NICE), "ffmpeg", "-y", "-threads", str(per_variant_threads), "-i", str(source), "-filter_complex", filter_complex]
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
cmd += [
"-map", f"[v{i}out]",
f"-c:v:{i}", "libx264",
f"-preset:v:{i}", "veryfast",
f"-preset:v:{i}", "superfast",
f"-threads:v:{i}", str(per_variant_threads),
f"-profile:v:{i}", "main",
f"-pix_fmt:v:{i}", "yuv420p",
f"-b:v:{i}", vb,
@@ -125,23 +178,64 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
for i in range(n):
(out_dir / f"v{i}").mkdir(exist_ok=True)
await _run(cmd, on_status, out_dir, "master.m3u8")
await _run(cmd, on_status, out_dir, "master.m3u8", source)
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None:
await on_status("running")
def _parse_out_time_seconds(line: str) -> Optional[float]:
"""Parse an `out_time=HH:MM:SS.microseconds` line from ffmpeg's -progress output."""
_, _, value = line.partition("=")
value = value.strip()
if not value or value == "N/A":
return None
try:
h, m, s = value.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
except ValueError:
return None
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str, source: Optional[Path] = None) -> None:
duration = await probe_duration(source) if source else 0.0
await on_status("running", progress=0.0)
# Machine-readable progress on stdout, separate from ffmpeg's normal stderr logging.
# Insert right after the "ffmpeg" token, not cmd[0] — cmd is prefixed with `nice -n N`,
# and these flags belong to ffmpeg, not to nice.
ffmpeg_idx = cmd.index("ffmpeg")
cmd = cmd[:ffmpeg_idx + 1] + ["-progress", "pipe:1", "-nostats"] + cmd[ffmpeg_idx + 1:]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
stderr_chunks: List[bytes] = []
async def _drain_stderr():
async for line in proc.stderr:
stderr_chunks.append(line)
stderr_task = asyncio.create_task(_drain_stderr())
last_reported = -1.0
async for raw in proc.stdout:
line = raw.decode("utf-8", errors="ignore").strip()
if line.startswith("out_time=") and duration > 0:
seconds = _parse_out_time_seconds(line)
if seconds is not None:
pct = round(min(99.0, seconds / duration * 100), 1)
if pct != last_reported:
last_reported = pct
await on_status("running", progress=pct)
await proc.wait()
await stderr_task
stderr = b"".join(stderr_chunks)
if proc.returncode != 0:
err = stderr.decode("utf-8", errors="ignore")[-500:]
logger.error(f"ffmpeg failed: {err}")
await on_status("failed", error=err[:200])
shutil.rmtree(out_dir, ignore_errors=True)
return
await on_status("done", entry=entry_filename)
await on_status("done", entry=entry_filename, progress=100.0)
except FileNotFoundError:
await on_status("failed", error="ffmpeg not installed")
except Exception as e:
+121 -14
View File
@@ -1,9 +1,9 @@
# Kino — Docker Compose deployment
# StreamHoard — Docker Compose deployment
#
# Runs alongside existing LAMP stacks without conflicts:
# - Apache keeps port 80/443
# - MySQL is untouched (Kino uses its own MongoDB in a container)
# - Kino exposes a single configurable port (default 8080)
# - MySQL is untouched (StreamHoard uses its own MongoDB in a container)
# - StreamHoard exposes a single configurable port (default 8080)
#
# Usage:
# cp .env.compose.example .env
@@ -13,20 +13,20 @@
services:
mongo:
image: mongo:7
container_name: kino-mongo
image: mongo:4.4
container_name: streamhoard-mongo
restart: unless-stopped
volumes:
- mongo-data:/data/db
networks:
- kino
# No ports exposed to host — only reachable inside kino network
- streamhoard
# No ports exposed to host — only reachable inside streamhoard network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: kino-backend
container_name: streamhoard-backend
restart: unless-stopped
depends_on:
- mongo
@@ -37,35 +37,142 @@ services:
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
JWT_ALG: HS256
JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-720}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@kino.local}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@streamhoard.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADMIN_NAME: ${ADMIN_NAME:-Admin}
MEDIA_ROOT: /media
volumes:
# Host media directory (movies, HLS, subtitles) — point at your NAS/ZFS mount
- ${MEDIA_ROOT_HOST:-./media}:/media
# Radarr/Sonarr report absolute file paths under this same path — must match exactly
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro
# NAS music library — used by the admin Library Scan (movies/tv/music), read-only
- ${MEDIA_NAS_MUSIC_ROOT:-/mnt/nas/music}:/mnt/nas/music:ro
# Docker socket — lets the admin Services panel check/restart sibling containers
- /var/run/docker.sock:/var/run/docker.sock
networks:
- kino
- streamhoard
# No direct host port — frontend container reverse-proxies /api → backend:8001
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: kino-frontend
container_name: streamhoard-frontend
restart: unless-stopped
depends_on:
- backend
ports:
# Only Kino's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
# Only StreamHoard's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
- "${KINO_HTTP_PORT:-8080}:80"
networks:
- kino
- streamhoard
# ============ *arr stack + qBittorrent, all routed through gluetun (NordVPN) ============
gluetun:
image: qmcgaw/gluetun
container_name: streamhoard-gluetun
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
VPN_SERVICE_PROVIDER: custom
VPN_TYPE: wireguard
WIREGUARD_ENDPOINT_IP: ${VPN_ENDPOINT_IP:?set VPN_ENDPOINT_IP in .env}
WIREGUARD_ENDPOINT_PORT: ${VPN_ENDPOINT_PORT:-51820}
WIREGUARD_PUBLIC_KEY: ${VPN_PUBLIC_KEY:?set VPN_PUBLIC_KEY in .env}
WIREGUARD_PRIVATE_KEY: ${VPN_PRIVATE_KEY:?set VPN_PRIVATE_KEY in .env}
WIREGUARD_ADDRESSES: ${VPN_ADDRESSES:-10.5.0.2/16}
FIREWALL_OUTBOUND_SUBNETS: 10.48.200.0/24
ports:
- "8082:8080" # qBittorrent WebUI
- "8989:8989" # Sonarr
- "7878:7878" # Radarr
- "9696:9696" # Prowlarr
networks:
- streamhoard
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: streamhoard-qbittorrent
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
- gluetun
environment:
PUID: 999
PGID: 988
TZ: ${TZ:-America/Chicago}
WEBUI_PORT: 8080
volumes:
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/qbittorrent/qBittorrent:/config/qBittorrent
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: streamhoard-radarr
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
- gluetun
environment:
PUID: 999
PGID: 988
TZ: ${TZ:-America/Chicago}
volumes:
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/radarr:/config
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: streamhoard-sonarr
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
- gluetun
environment:
PUID: 999
PGID: 988
TZ: ${TZ:-America/Chicago}
volumes:
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/sonarr:/config
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: streamhoard-prowlarr
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
- gluetun
environment:
PUID: 999
PGID: 988
TZ: ${TZ:-America/Chicago}
volumes:
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/prowlarr:/config
# ============ Docker management GUI ============
portainer:
image: portainer/portainer-ce:latest
container_name: streamhoard-portainer
restart: unless-stopped
ports:
- "9443:9443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer-data:/data
networks:
- streamhoard
networks:
kino:
streamhoard:
driver: bridge
volumes:
mongo-data:
driver: local
portainer-data:
driver: local
-20
View File
@@ -2,10 +2,6 @@
const path = require("path");
require("dotenv").config();
// Check if we're in development/preview mode (not production build)
// Craco sets NODE_ENV=development for start, NODE_ENV=production for build
const isDevServer = process.env.NODE_ENV !== "production";
// Environment variable overrides
const config = {
enableHealthCheck: process.env.ENABLE_HEALTH_CHECK === "true",
@@ -81,20 +77,4 @@ webpackConfig.devServer = (devServerConfig) => {
return devServerConfig;
};
// Wrap with visual edits (automatically adds babel plugin, dev server, and overlay in dev mode)
if (isDevServer) {
try {
const { withVisualEdits } = require("@emergentbase/visual-edits/craco");
webpackConfig = withVisualEdits(webpackConfig);
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND' && err.message.includes('@emergentbase/visual-edits/craco')) {
console.warn(
"[visual-edits] @emergentbase/visual-edits not installed — visual editing disabled."
);
} else {
throw err;
}
}
}
module.exports = webpackConfig;
-1
View File
@@ -76,7 +76,6 @@
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@craco/craco": "^7.1.0",
"@emergentbase/visual-edits": "https://assets.emergent.sh/npm/emergentbase-visual-edits-1.0.8.tgz",
"@eslint/js": "9.23.0",
"autoprefixer": "^10.4.20",
"eslint": "9.23.0",
+2 -74
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="A product of emergent.sh" />
<meta name="description" content="StreamHoard - self-hosted personal media server" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet" />
@@ -21,9 +21,8 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Emergent | Fullstack App</title>
<title>StreamHoard</title>
<script>window.addEventListener("error",function(e){if(e.error instanceof DOMException&&e.error.name==="DataCloneError"&&e.message&&e.message.includes("PerformanceServerTiming")){e.stopImmediatePropagation();e.preventDefault()}},true);</script>
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@@ -38,76 +37,5 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push(
[e].concat(
Array.prototype.slice.call(
arguments,
0,
),
),
);
});
}
((p = t.createElement("script")).type =
"text/javascript"),
(p.crossOrigin = "anonymous"),
(p.async = !0),
(p.src =
s.api_host.replace(
".i.posthog.com",
"-assets.i.posthog.com",
) + "/static/array.js"),
(r =
t.getElementsByTagName(
"script",
)[0]).parentNode.insertBefore(p, r);
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
u.people = u.people || [],
u.toString = function (t) {
var e = "posthog";
return (
"posthog" !== a && (e += "." + a),
t || (e += " (stub)"),
e
);
},
u.people.toString = function () {
return u.toString(1) + ".people (stub)";
},
o =
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
" ",
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
session_recording: {
recordCrossOriginIframes: true,
capturePerformance: false,
},
});
</script>
</body>
</html>
+46 -34
View File
@@ -2,9 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./lib/auth";
import { ProfileProvider, useProfile } from "./lib/profile";
import ProtectedRoute from "./components/ProtectedRoute";
import { MusicPlayerProvider } from "./lib/musicPlayer";
import Navbar from "./components/Navbar";
import GrainOverlay from "./components/GrainOverlay";
import ServiceStatus from "./components/ServiceStatus";
import MusicPlayerBar from "./components/MusicPlayerBar";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Browse from "./pages/Browse";
@@ -16,23 +18,26 @@ import Admin from "./pages/Admin";
import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import ProfileSelect from "./pages/ProfileSelect";
import TranscodeQueue from "./pages/TranscodeQueue";
import Shows from "./pages/Shows";
import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport";
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
import Users from "./pages/Users";
import Music from "./pages/Music";
import AlbumDetail from "./pages/AlbumDetail";
import LibraryScan from "./pages/LibraryScan";
import LibraryCleanup from "./pages/LibraryCleanup";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
const { active, loading: profLoading } = useProfile();
const loc = useLocation();
if (authLoading || profLoading) {
if (authLoading || profLoading || (user && !active)) {
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
}
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
if (!active) return <Navigate to="/profile" replace />;
return children;
};
@@ -48,11 +53,13 @@ const Shell = ({ children }) => {
const { user } = useAuth();
const { active } = useProfile();
const loc = useLocation();
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
return (
<>
{!hideChrome && user && active && <Navbar />}
{!hideChrome && user?.is_admin && active && <ServiceStatus />}
{children}
{!hideChrome && user && active && <MusicPlayerBar />}
<GrainOverlay />
</>
);
@@ -60,10 +67,9 @@ const Shell = ({ children }) => {
const RootRedirect = () => {
const { user, loading } = useAuth();
const { active, loading: pl } = useProfile();
const { loading: pl } = useProfile();
if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!active) return <Navigate to="/profile" replace />;
return <Navigate to="/browse" replace />;
};
@@ -73,33 +79,39 @@ function App() {
<BrowserRouter>
<AuthProvider>
<ProfileProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
<MusicPlayerProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
<Route path="/music" element={<ProfileGate><Music /></ProfileGate>} />
<Route path="/music/album/:artist/:album" element={<ProfileGate><AlbumDetail /></ProfileGate>} />
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></AdminGate>} />
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
</MusicPlayerProvider>
</ProfileProvider>
</AuthProvider>
</BrowserRouter>
+20
View File
@@ -0,0 +1,20 @@
// Shows real album art when a URL is available; otherwise a branded placeholder instead of an
// empty box. Once musicbrainz enrichment (or a manual edit) sets album_art_url, callers just
// pass the new url and this swaps over automatically no separate "loaded" state to manage.
export default function AlbumArt({ url, size = "lg", className = "", loading }) {
if (url) {
return <img src={url} alt="" loading={loading} className={`w-full h-full object-cover ${className}`} />;
}
const small = size === "sm";
return (
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br from-[#161616] to-[#0a0a0a] ${className}`}>
{small ? (
<span className="font-display font-black text-[#3a3a3a] text-lg select-none">S<span className="text-[#D9381E]">.</span></span>
) : (
<span className="font-display font-black tracking-tighter text-[#3a3a3a] text-sm sm:text-base select-none text-center px-2">
Stream<span className="text-[#D9381E]">hoard</span>
</span>
)}
</div>
);
}
+2 -7
View File
@@ -1,4 +1,5 @@
import { Play } from "lucide-react";
import AlbumArt from "./AlbumArt";
export const MovieCard = ({ movie, onClick, progress }) => {
const pct = progress?.duration_seconds
@@ -10,13 +11,7 @@ export const MovieCard = ({ movie, onClick, progress }) => {
className="group relative shrink-0 w-[180px] md:w-[220px] aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-all duration-300 hover:scale-105 hover:z-10 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`movie-card-${movie.id}`}
>
<img
src={movie.poster_url || movie.backdrop_url}
alt={movie.title}
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
/>
<AlbumArt url={movie.poster_url || movie.backdrop_url} loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent opacity-90 md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-2 md:translate-y-0 md:opacity-0 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300">
<h3 className="font-display text-lg font-bold tracking-tight text-white leading-none line-clamp-2">
+2 -5
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import api from "../lib/api";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import AlbumArt from "./AlbumArt";
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
const nav = useNavigate();
@@ -64,11 +65,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
</button>
<div className="relative h-[280px] md:h-[440px]">
<img
src={movie.backdrop_url || movie.poster_url}
alt={movie.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<AlbumArt url={movie.backdrop_url || movie.poster_url} className="absolute inset-0" />
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-10">
<h2 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white" data-testid="modal-movie-title">
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { Play, Pause, SkipBack, SkipForward, Speaker } from "lucide-react";
import { useMusicPlayer } from "../lib/musicPlayer";
import AlbumArt from "./AlbumArt";
const fmt = (s) => {
if (!s || !isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m}:${sec}`;
};
export default function MusicPlayerBar() {
const {
current, isPlaying, progress, togglePlay, next, prev, seek, hasNext, hasPrev,
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
} = useMusicPlayer();
const [showOutputs, setShowOutputs] = useState(false);
useEffect(() => {
if (showOutputs) refreshOutputDevices();
}, [showOutputs, refreshOutputDevices]);
if (!current) return null;
const pct = progress.duration ? (progress.position / progress.duration) * 100 : 0;
const onSeek = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
seek(ratio * (progress.duration || 0));
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-t border-[#222] px-4 md:px-8 py-3" data-testid="music-player-bar">
<div className="max-w-[1500px] mx-auto flex items-center gap-4">
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] overflow-hidden">
<AlbumArt url={current.album_art_url} size="sm" />
</div>
<div className="min-w-0 w-40 md:w-56">
<p className="text-white text-sm truncate">{current.title}</p>
<p className="text-[#8A8A8A] text-xs truncate">{current.artist}</p>
</div>
<button onClick={prev} disabled={!hasPrev} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-prev">
<SkipBack size={16} />
</button>
<button onClick={togglePlay} className="w-8 h-8 flex items-center justify-center bg-[#D9381E] hover:bg-[#ED4B32] text-white" data-testid="music-play-pause">
{isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
</button>
<button onClick={next} disabled={!hasNext} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-next">
<SkipForward size={16} />
</button>
<span className="text-[10px] text-[#8A8A8A] w-9 text-right shrink-0">{fmt(progress.position)}</span>
<div className="flex-1 h-1 bg-white/10 cursor-pointer hidden sm:block" onClick={onSeek} data-testid="music-seek">
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-[#8A8A8A] w-9 shrink-0">{fmt(progress.duration)}</span>
{sinkSupported && (
<div className="relative shrink-0">
<button onClick={() => setShowOutputs((v) => !v)} className="text-[#8A8A8A] hover:text-white" title="Output device" data-testid="music-output-toggle">
<Speaker size={16} />
</button>
{showOutputs && (
<div className="absolute bottom-full right-0 mb-2 w-56 bg-[#0F0F0F] border border-[#222] py-1" data-testid="music-output-list">
{outputDevices.length === 0 && (
<p className="px-3 py-2 text-xs text-[#8A8A8A]">No output devices found</p>
)}
{outputDevices.map((d) => (
<button key={d.deviceId} onClick={() => { selectOutput(d.deviceId); setShowOutputs(false); }}
className={`w-full text-left px-3 py-2 text-xs truncate ${sinkId === d.deviceId ? "text-[#D9381E]" : "text-white hover:bg-white/5"}`}
data-testid={`music-output-${d.deviceId}`}>
{d.label}
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+65 -33
View File
@@ -1,14 +1,18 @@
import { Link, NavLink, useNavigate } from "react-router-dom";
import { Link, NavLink, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { useProfile } from "../lib/profile";
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
import { Search, LogOut, Upload, Settings as SettingsIcon, Menu, X } from "lucide-react";
import { useState, useEffect } from "react";
import api from "../lib/api";
export const Navbar = () => {
const { user, logout } = useAuth();
const { active, clearActive } = useProfile();
const { active } = useProfile();
const nav = useNavigate();
const location = useLocation();
const [scrolled, setScrolled] = useState(false);
const [version, setVersion] = useState("");
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
@@ -16,27 +20,34 @@ export const Navbar = () => {
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
api.get("/version").then(({ data }) => setVersion(data.version)).catch(() => {});
}, []);
// Close the mobile menu on navigation so it never lingers open over the next page.
useEffect(() => { setMobileOpen(false); }, [location.pathname]);
const linkClass = ({ isActive }) =>
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
const switchProfile = () => {
clearActive();
nav("/profile");
};
const mobileLinkClass = ({ isActive }) =>
`block py-3 text-base tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A]"}`;
return (
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled || mobileOpen ? "glass border-b" : ""}`} data-testid="main-navbar">
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
<div className="flex items-center gap-10">
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
<span className="font-display text-2xl font-black tracking-tighter text-white">Kino</span>
<span className="font-display text-2xl font-black tracking-tighter text-white">StreamHoard</span>
<span className="text-[#D9381E] text-2xl leading-none">.</span>
{version && <span className="text-[10px] text-[#8A8A8A] self-start mt-1" data-testid="nav-version">v{version}</span>}
</Link>
{user && (
<nav className="hidden md:flex items-center gap-7">
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
<NavLink to="/music" className={linkClass} data-testid="nav-music">Music</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
</nav>
@@ -49,39 +60,60 @@ export const Navbar = () => {
<Search size={18} strokeWidth={1.5} />
</button>
{user.is_admin && (
<>
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button>
</>
)}
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
{active && (
<>
<div className="hidden sm:flex flex-col items-end leading-tight">
<span className="text-xs text-white">{active.name}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
</div>
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
{active.name?.[0]?.toUpperCase() || "U"}
</div>
</>
)}
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
)}
<button onClick={() => nav("/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button>
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
{active && (
<div className="flex items-center gap-2" data-testid="account-avatar">
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
{active.name?.[0]?.toUpperCase() || "U"}
</div>
</div>
)}
<button onClick={() => { logout(); nav("/login"); }} className="hidden md:block text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
<LogOut size={16} strokeWidth={1.5} />
</button>
</div>
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden text-white -mr-2 p-2"
data-testid="nav-mobile-toggle"
aria-label={mobileOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileOpen}
>
{mobileOpen ? <X size={22} strokeWidth={1.5} /> : <Menu size={22} strokeWidth={1.5} />}
</button>
</div>
) : (
<Link to="/login" className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300" data-testid="nav-sign-in">Sign in</Link>
)}
</div>
{user && mobileOpen && (
<nav className="md:hidden glass border-t border-[#222] px-6 pb-6" data-testid="nav-mobile-menu">
<NavLink to="/browse" className={mobileLinkClass} data-testid="nav-mobile-browse">Library</NavLink>
<NavLink to="/shows" className={mobileLinkClass} data-testid="nav-mobile-shows">Series</NavLink>
<NavLink to="/my-list" className={mobileLinkClass} data-testid="nav-mobile-my-list">Shelf</NavLink>
<NavLink to="/music" className={mobileLinkClass} data-testid="nav-mobile-music">Music</NavLink>
<NavLink to="/requests" className={mobileLinkClass} data-testid="nav-mobile-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={mobileLinkClass} data-testid="nav-mobile-admin">Admin</NavLink>}
<div className="mt-2 pt-3 border-t border-[#222] flex flex-col gap-1">
{user.is_admin && (
<NavLink to="/admin/upload" className={mobileLinkClass} data-testid="nav-mobile-upload">Upload</NavLink>
)}
<NavLink to="/settings" className={mobileLinkClass} data-testid="nav-mobile-settings">Settings</NavLink>
<button onClick={() => { logout(); nav("/login"); }} className="text-left py-3 text-base tracking-wide text-[#8A8A8A]" data-testid="nav-mobile-logout">
Sign Out
</button>
</div>
</nav>
)}
</header>
);
};
@@ -1,18 +0,0 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
export const ProtectedRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
Loading
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
return children;
};
export default ProtectedRoute;
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState, useCallback } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RotateCw } from "lucide-react";
export default function ServiceStatus() {
const [services, setServices] = useState([]);
const [restarting, setRestarting] = useState(null);
const load = useCallback(async () => {
try {
const { data } = await api.get("/services");
setServices(data);
} catch {
setServices([]);
}
}, []);
useEffect(() => {
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, [load]);
const restart = async (key, label) => {
setRestarting(key);
try {
await api.post(`/services/${key}/restart`);
toast.success(`${label} restarting…`);
setTimeout(load, 4000);
} catch (err) {
toast.error(err.response?.data?.detail || `Could not restart ${label}`);
} finally {
setRestarting(null);
}
};
if (!services.length) return null;
return (
<div
className="fixed top-16 left-0 right-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#222] px-6 md:px-12 py-2"
data-testid="service-status"
>
<div className="flex flex-wrap gap-2">
{services.map((s) => (
<div
key={s.key}
className="flex items-center gap-2 bg-[#0F0F0F] border border-[#222] px-3 py-1.5 text-xs"
data-testid={`service-${s.key}`}
>
<span
className={`inline-block w-2 h-2 rounded-full ${s.running ? "bg-[#86efac]" : "bg-[#fca5a5]"}`}
/>
<span className="text-[#8A8A8A]">{s.label}</span>
{!s.running && (
<button
onClick={() => restart(s.key, s.label)}
disabled={restarting === s.key}
className="ml-1 flex items-center gap-1 text-[#D9381E] hover:text-[#ED4B32] disabled:opacity-50"
data-testid={`service-restart-${s.key}`}
>
<RotateCw size={12} className={restarting === s.key ? "animate-spin" : ""} />
Restart
</button>
)}
</div>
))}
</div>
</div>
);
}
+7 -7
View File
@@ -8,12 +8,6 @@ const instance = axios.create({ baseURL: API });
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("kino_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
// Active profile id (set by ProfileProvider via localStorage)
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
if (userId) {
const pid = localStorage.getItem(`kino_profile_${userId}`);
if (pid) config.headers["X-Profile-Id"] = pid;
}
return config;
});
@@ -25,7 +19,7 @@ export const getStreamUrl = (movie) => {
if (movie.hls_status === "done" && movie.hls_path) {
return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`;
}
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
if (movie.storage_type === "local" || movie.storage_type === "radarr" || movie.storage_type === "scan") {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
}
return movie.video_url;
@@ -37,3 +31,9 @@ export const getSubtitleUrl = (sub) => {
const token = localStorage.getItem("kino_token") || "";
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
};
export const getTrackStreamUrl = (track) => {
if (!track) return "";
const token = localStorage.getItem("kino_token") || "";
return `${API}/stream/track/${track.id}?auth=${encodeURIComponent(token)}`;
};
+99
View File
@@ -0,0 +1,99 @@
import { createContext, useContext, useRef, useState, useCallback, useEffect } from "react";
import { getTrackStreamUrl } from "./api";
const MusicPlayerCtx = createContext(null);
export const MusicPlayerProvider = ({ children }) => {
const audioRef = useRef(new Audio());
const [queue, setQueue] = useState([]);
const [index, setIndex] = useState(-1);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState({ position: 0, duration: 0 });
const [outputDevices, setOutputDevices] = useState([]);
const [sinkId, setSinkId] = useState("default");
const sinkSupported = typeof audioRef.current.setSinkId === "function";
const current = index >= 0 ? queue[index] : null;
const refreshOutputDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outs = devices.filter((d) => d.kind === "audiooutput");
setOutputDevices(outs.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Output ${i + 1}` })));
} catch { /* enumeration unsupported/denied — picker just won't populate */ }
}, []);
const selectOutput = useCallback(async (deviceId) => {
if (!sinkSupported) return;
try {
await audioRef.current.setSinkId(deviceId);
setSinkId(deviceId);
} catch { /* device may have disappeared (e.g. BT speaker turned off) — keep current output */ }
}, [sinkSupported]);
const playAt = useCallback((newQueue, startIndex) => {
setQueue(newQueue);
setIndex(startIndex);
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!current) { audio.pause(); return; }
audio.src = getTrackStreamUrl(current);
audio.play().catch(() => {}); // isPlaying reflects reality via the native play/pause listeners below
}, [current]);
// isPlaying always mirrors the audio element's own state, so it stays correct through anything
// that changes playback outside togglePlay a stall, an OS media-key pause, autoplay being blocked.
useEffect(() => {
const audio = audioRef.current;
const onTime = () => setProgress({ position: audio.currentTime, duration: audio.duration || 0 });
const onEnded = () => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => {
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
};
}, [index, queue]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!current) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}, [current]);
const next = useCallback(() => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
}, [index, queue]);
const prev = useCallback(() => {
if (index > 0) setIndex(index - 1);
}, [index]);
const seek = useCallback((seconds) => {
audioRef.current.currentTime = seconds;
}, []);
return (
<MusicPlayerCtx.Provider value={{
current, isPlaying, progress, playAt, togglePlay, next, prev, seek,
hasNext: index < queue.length - 1, hasPrev: index > 0,
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
}}>
{children}
</MusicPlayerCtx.Provider>
);
};
export const useMusicPlayer = () => useContext(MusicPlayerCtx);
+3 -19
View File
@@ -6,19 +6,15 @@ const ProfileCtx = createContext(null);
export const ProfileProvider = ({ children }) => {
const { user } = useAuth();
const [profiles, setProfiles] = useState([]);
const [active, setActive] = useState(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
if (!user) { setProfiles([]); setActive(null); return; }
if (!user) { setActive(null); return; }
setLoading(true);
try {
const { data } = await api.get("/profiles");
setProfiles(data);
const stored = localStorage.getItem(`kino_profile_${user.id}`);
const found = data.find((p) => p.id === stored);
setActive(found || null);
setActive(data[0] || null);
} finally {
setLoading(false);
}
@@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => {
useEffect(() => { refresh(); }, [refresh]);
const switchTo = (p) => {
if (!user) return;
localStorage.setItem(`kino_profile_${user.id}`, p.id);
setActive(p);
};
const clearActive = () => {
if (!user) return;
localStorage.removeItem(`kino_profile_${user.id}`);
setActive(null);
};
return (
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
<ProfileCtx.Provider value={{ active, loading, refresh }}>
{children}
</ProfileCtx.Provider>
);
+14 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
@@ -85,11 +85,17 @@ export default function Admin() {
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link>
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Users
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests
@@ -116,12 +122,16 @@ export default function Admin() {
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2">
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && (
{(m.storage_type === "local" || m.storage_type === "radarr" || m.storage_type === "scan") && m.hls_status !== "running" && m.hls_status !== "pending" && (
<div className="flex gap-1">
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
title="Quick transcode (stream-copy, single bitrate)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-quick-${m.id}`}>Quick</button>
<button onClick={() => startTranscode(m, "audio")} disabled={transcoding[m.id]}
title="Fix audio only (video untouched, audio re-encoded to AAC) — fast, fixes no-sound from AC3/DTS/EAC3"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-audio-${m.id}`}>Audio</button>
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
+1 -1
View File
@@ -61,7 +61,7 @@ export default function AdminShowEpisodes() {
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
msg = `${data.modified} unhidden`;
} else if (action === "delete") {
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but Kino entries and HLS output are removed.`)) return;
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but StreamHoard entries and HLS output are removed.`)) return;
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
msg = `${data.deleted} deleted`;
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Play, Pause, ArrowLeft, Music2 } from "lucide-react";
import { useMusicPlayer } from "../lib/musicPlayer";
import AlbumArt from "../components/AlbumArt";
export default function AlbumDetail() {
const { artist, album } = useParams();
const nav = useNavigate();
const [tracks, setTracks] = useState([]);
const { current, isPlaying, togglePlay, playAt } = useMusicPlayer();
const artistName = decodeURIComponent(artist);
const albumName = decodeURIComponent(album);
useEffect(() => {
api.get("/tracks").then(({ data }) => setTracks(data));
}, []);
const albumTracks = useMemo(
() => tracks.filter((t) => t.artist === artistName && t.album === albumName)
.sort((a, b) => (a.track_number || 0) - (b.track_number || 0)),
[tracks, artistName, albumName]
);
const artUrl = albumTracks.find((t) => t.album_art_url)?.album_art_url;
const playTrack = (t) => {
const idx = albumTracks.findIndex((x) => x.id === t.id);
if (current?.id === t.id) togglePlay();
else playAt(albumTracks, idx);
};
if (albumTracks.length === 0) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading</div>;
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid="album-detail-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<button onClick={() => nav(-1)} className="flex items-center gap-2 text-white/80 hover:text-white mb-8" data-testid="album-back">
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button>
<div className="flex items-center gap-6 mb-10">
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] overflow-hidden">
<AlbumArt url={artUrl} />
</div>
<div>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Album</span>
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2">{albumName}</h1>
<p className="text-[#8A8A8A] mt-2">{artistName} · {albumTracks.length} tracks</p>
</div>
</div>
<div className="space-y-2">
{albumTracks.map((t) => {
const playing = current?.id === t.id;
return (
<button key={t.id} onClick={() => playTrack(t)}
className={`w-full flex items-center gap-4 p-3 border transition-colors text-left group ${playing ? "border-[#D9381E] bg-[#0F0F0F]" : "border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F]"}`}
data-testid={`track-${t.id}`}>
<div className="w-9 h-9 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
{playing && isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
</div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] w-6">{t.track_number || "-"}</span>
<h3 className="font-display text-base text-white truncate flex-1">{t.title}</h3>
{t.duration_seconds > 0 && <span className="text-xs text-[#8A8A8A]">{Math.floor(t.duration_seconds / 60)}:{Math.floor(t.duration_seconds % 60).toString().padStart(2, "0")}</span>}
</button>
);
})}
</div>
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, Trash2, Wrench, ShieldCheck, Square } from "lucide-react";
const CATEGORIES = [
{ key: "missing_files", label: "Missing Files", desc: "Database record exists but the file is gone from disk.", action: "Remove record" },
{ key: "orphaned_episodes", label: "Orphaned Episodes", desc: "Episode references a show that no longer exists.", action: "Delete episode" },
{ key: "orphaned_hls", label: "Orphaned HLS Output", desc: "Transcoded HLS folders left behind by deleted movies/episodes.", action: "Delete folder" },
{ key: "stuck_jobs", label: "Stuck Transcode Jobs", desc: "Jobs stuck \"running\" for 2+ hours, usually from a backend restart mid-job.", action: "Mark failed" },
{ key: "duplicate_paths", label: "Duplicate Imports", desc: "The same file imported into the library more than once.", action: "Delete duplicate" },
{ key: "orphaned_subtitles", label: "Orphaned Subtitles", desc: "Subtitle references a movie/episode that no longer exists.", action: "Delete subtitle" },
{ key: "dangling_refs", label: "Dangling Watchlist/Progress", desc: "Watchlist or progress rows pointing at a deleted movie.", action: "Delete row" },
];
export default function LibraryCleanup() {
const [findings, setFindings] = useState(null);
const [running, setRunning] = useState(false);
const [fixing, setFixing] = useState({});
const pollRef = useRef(null);
const checkStatus = async () => {
const { data } = await api.get("/library/cleanup/scan/status");
setRunning(data.running);
if (data.error) toast.error(`Scan failed: ${data.error}`);
if (data.findings) setFindings(data.findings);
return data.running;
};
const startPolling = () => {
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
const stillRunning = await checkStatus();
if (!stillRunning) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, 2000);
};
useEffect(() => {
checkStatus().then((isRunning) => { if (isRunning) startPolling(); });
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const scan = async () => {
if (!window.confirm(
"This checks every movie, episode, and track's file against the NAS, which can take a while on a large library (roughly a minute or more) since each check is a network round trip. It runs in the background and won't block the rest of the app — you can navigate away or stop it anytime. Continue?"
)) return;
try {
await api.post("/library/cleanup/scan/start");
setRunning(true);
setFindings(null);
startPolling();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start scan");
}
};
const stopScan = async () => {
try {
await api.post("/library/cleanup/scan/stop");
setRunning(false);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
toast.success("Scan stopped");
} catch (err) {
toast.error(err.response?.data?.detail || "Could not stop scan");
}
};
const fix = async (category, items) => {
if (items.length === 0) return;
setFixing((p) => ({ ...p, [category]: true }));
try {
const { data } = await api.post("/library/cleanup/fix", { category, items });
toast.success(`Fixed ${data.fixed} item(s)`);
scan();
} catch (err) {
toast.error(err.response?.data?.detail || "Fix failed");
} finally {
setFixing((p) => ({ ...p, [category]: false }));
}
};
const totalIssues = findings ? Object.values(findings).reduce((n, arr) => n + arr.length, 0) : 0;
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-cleanup-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Maintenance</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Cleanup</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Checks for missing files, orphaned records, stuck jobs, and duplicate imports across movies, shows, and music. Nothing is deleted until you click Fix.
</p>
<div className="mt-8 flex items-center gap-4">
{running ? (
<button onClick={stopScan} className="flex items-center gap-2 border border-[#D9381E] text-[#D9381E] hover:bg-[#D9381E]/10 px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="cleanup-stop">
<Square size={12} strokeWidth={1.5} fill="currentColor" /> Stop Scan
</button>
) : (
<button onClick={scan} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="cleanup-scan">
<RefreshCcw size={14} strokeWidth={1.5} /> {findings ? "Rescan" : "Scan Library"}
</button>
)}
{running && <span className="text-xs text-[#fcd34d] animate-pulse" data-testid="cleanup-running">Scanning this can take a minute</span>}
{!running && findings && (
<span className="flex items-center gap-2 text-sm" data-testid="cleanup-summary">
{totalIssues === 0 ? (
<><ShieldCheck size={16} className="text-[#86efac]" /> <span className="text-[#86efac]">No issues found</span></>
) : (
<span className="text-[#fcd34d]">{totalIssues} issue(s) found</span>
)}
</span>
)}
</div>
<div className="mt-10 space-y-8">
{CATEGORIES.map((cat) => {
const items = findings?.[cat.key] || [];
if (findings && items.length === 0) return null;
return (
<div key={cat.key} className="border border-[#222]" data-testid={`cleanup-section-${cat.key}`}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[#222] bg-[#0F0F0F]">
<div>
<h2 className="text-white text-lg font-display">{cat.label} <span className="text-[#8A8A8A] text-sm">({items.length})</span></h2>
<p className="text-xs text-[#8A8A8A] mt-1">{cat.desc}</p>
</div>
<button onClick={() => fix(cat.key, items)} disabled={fixing[cat.key] || items.length === 0}
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-4 py-2 text-xs uppercase tracking-[0.2em] shrink-0"
data-testid={`cleanup-fix-all-${cat.key}`}>
<Wrench size={13} strokeWidth={1.5} /> {cat.action} All
</button>
</div>
<div>
{items.map((it) => (
<div key={`${it.content_type}-${it.id}`} className="flex items-center justify-between gap-4 px-5 py-3 border-b border-[#222] last:border-b-0" data-testid={`cleanup-item-${it.id}`}>
<div className="min-w-0">
<p className="text-white text-sm truncate">{it.label}</p>
<p className="text-xs text-[#666] truncate">{it.detail}</p>
</div>
<button onClick={() => fix(cat.key, [it])} disabled={fixing[cat.key]}
className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-3 py-2 shrink-0 disabled:opacity-50"
data-testid={`cleanup-fix-${it.id}`}>
<Trash2 size={12} strokeWidth={1.5} /> {cat.action}
</button>
</div>
))}
</div>
</div>
);
})}
{!running && findings && totalIssues === 0 && (
<div className="text-center py-16 text-[#8A8A8A]">
<ShieldCheck size={40} strokeWidth={1.5} className="mx-auto mb-4 text-[#86efac]" />
Library is clean no missing files, orphaned records, or stuck jobs.
</div>
)}
{!running && !findings && (
<div className="text-center py-16 text-[#8A8A8A]">
Click "Scan Library" above to check for issues.
</div>
)}
</div>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, Download, Check, Sparkles, CheckSquare, Square } from "lucide-react";
const KINDS = [
{ key: "movies", label: "Movies" },
{ key: "tv", label: "TV Shows" },
{ key: "music", label: "Music" },
];
export default function LibraryScan() {
const [kind, setKind] = useState("movies");
const [results, setResults] = useState([]);
const [selected, setSelected] = useState(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [enrich, setEnrich] = useState(null);
const requestSeq = useRef(0);
const load = async (k = kind) => {
const seq = ++requestSeq.current;
setLoading(true);
setSelected(new Set());
try {
const { data } = await api.get(`/scan/${k}`);
if (seq !== requestSeq.current) return; // a newer tab switch/rescan superseded this response
setResults(data);
} catch (err) {
if (seq !== requestSeq.current) return;
toast.error(err.response?.data?.detail || "Scan failed");
} finally {
if (seq === requestSeq.current) setLoading(false);
}
};
useEffect(() => { setResults([]); load(kind); }, [kind]);
useEffect(() => {
if (!enrich?.running) return;
const t = setInterval(async () => {
const { data } = await api.get("/music/enrich/status");
setEnrich(data);
if (!data.running) clearInterval(t);
}, 2000);
return () => clearInterval(t);
}, [enrich?.running]);
const toggle = (path) => {
const s = new Set(selected);
s.has(path) ? s.delete(path) : s.add(path);
setSelected(s);
};
const importable = results.filter((r) => !r.already_imported);
const allSelected = importable.length > 0 && importable.every((r) => selected.has(r.storage_path));
const toggleSelectAll = () => {
setSelected(allSelected ? new Set() : new Set(importable.map((r) => r.storage_path)));
};
const importSelected = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const items = results.filter((r) => selected.has(r.storage_path));
const { data } = await api.post("/scan/import", { kind, items });
toast.success(`Imported ${data.imported} item(s)`);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Import failed");
} finally {
setImporting(false);
}
};
const startEnrich = async () => {
try {
await api.post("/music/enrich");
const { data } = await api.get("/music/enrich/status");
setEnrich(data);
toast.success("Fetching metadata & covers from MusicBrainz…");
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start enrichment");
}
};
const label = (r) => {
if (kind === "movies") return `${r.title}${r.year ? ` (${r.year})` : ""}`;
if (kind === "tv") return `${r.show_title} · S${r.season_number}E${r.episode_number ?? "?"} · ${r.title}`;
return `${r.artist}${r.album}${r.title}`;
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-scan-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Filesystem</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Scan</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Scans the NAS mounts directly for movies, TV, and music that aren't already in the library separate from Radarr/Sonarr, which are untouched.
</p>
<div className="mt-8 flex gap-3 flex-wrap">
{KINDS.map((k) => (
<button key={k.key} onClick={() => setKind(k.key)}
className={`text-sm px-4 py-2 border transition-colors ${kind === k.key ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
data-testid={`scan-tab-${k.key}`}>
{k.label}
</button>
))}
</div>
<div className="mt-6 flex gap-3 flex-wrap items-center">
<button onClick={() => load()} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Rescan
</button>
<button onClick={toggleSelectAll} disabled={importable.length === 0} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-select-all">
{allSelected ? <CheckSquare size={14} strokeWidth={1.5} /> : <Square size={14} strokeWidth={1.5} />} {allSelected ? "Deselect All" : "Select All"}
</button>
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-import-btn">
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
</button>
{kind === "music" && (
<button onClick={startEnrich} disabled={enrich?.running} className="flex items-center gap-2 border border-white/10 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-enrich-btn">
<Sparkles size={14} strokeWidth={1.5} /> {enrich?.running ? `Fetching covers… ${enrich.done}/${enrich.total}` : "Fetch metadata & covers"}
</button>
)}
</div>
<p className="text-[#8A8A8A] mt-6 text-sm">
{loading ? "Scanning…" : `${importable.length} new of ${results.length} found on disk.`}
</p>
<div className="mt-6 border border-[#222]">
{results.map((r) => (
<button key={r.storage_path} onClick={() => !r.already_imported && toggle(r.storage_path)}
disabled={r.already_imported}
className={`w-full flex items-center gap-4 px-5 py-3 border-b border-[#222] last:border-b-0 text-left transition-colors ${
r.already_imported ? "opacity-40 cursor-not-allowed" : selected.has(r.storage_path) ? "bg-[#D9381E]/10" : "hover:bg-[#0F0F0F]"
}`}
data-testid={`scan-row-${r.storage_path}`}>
<div className={`w-5 h-5 shrink-0 border flex items-center justify-center ${selected.has(r.storage_path) ? "bg-[#D9381E] border-[#D9381E]" : "border-[#222]"}`}>
{selected.has(r.storage_path) && <Check size={12} strokeWidth={2} color="white" />}
</div>
<span className="text-white text-sm truncate flex-1">{label(r)}</span>
{r.already_imported && <span className="text-[10px] uppercase tracking-[0.2em] text-[#86efac] shrink-0">Imported</span>}
</button>
))}
{results.length === 0 && !loading && (
<div className="px-5 py-8 text-center text-[#8A8A8A] text-sm">Nothing found on disk for this category.</div>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -48,7 +48,7 @@ export default function Login() {
<div className="flex items-center justify-center px-6 md:px-12 py-12">
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="login-form">
<Link to="/" className="block mb-12" data-testid="login-logo">
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
<span className="text-[#D9381E] text-3xl">.</span>
</Link>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
@@ -93,7 +93,7 @@ export default function Login() {
<div className="mt-8 p-4 border border-[#222] text-xs text-[#8A8A8A]" data-testid="login-demo-credentials">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] block mb-2">Demo Admin</span>
admin@kino.local / kino-admin-2026
admin@streamhoard.local / kino-admin-2026
</div>
</form>
</div>
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Music2 } from "lucide-react";
import AlbumArt from "../components/AlbumArt";
export default function Music() {
const [tracks, setTracks] = useState([]);
const nav = useNavigate();
useEffect(() => {
api.get("/tracks").then(({ data }) => setTracks(data));
}, []);
const albums = useMemo(() => {
const map = new Map();
for (const t of tracks) {
const key = `${t.artist}|||${t.album}`;
if (!map.has(key)) map.set(key, { artist: t.artist, album: t.album, album_art_url: t.album_art_url, count: 0 });
map.get(key).count += 1;
}
return Array.from(map.values()).sort((a, b) => a.artist.localeCompare(b.artist) || a.album.localeCompare(b.album));
}, [tracks]);
if (tracks.length === 0) {
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-empty-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
<Music2 size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No music yet</h1>
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Run a Library Scan in Admin to import tracks from the NAS.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Music</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Music</h1>
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{albums.map((a) => (
<button key={`${a.artist}|||${a.album}`}
onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)}
className="group relative aspect-square overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`album-card-${a.artist}-${a.album}`}>
<AlbumArt url={a.album_art_url} />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<p className="text-white text-sm truncate">{a.album}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</p>
</div>
</button>
))}
</div>
</div>
</div>
);
}
-157
View File
@@ -1,157 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { useProfile } from "../lib/profile";
import { useAuth } from "../lib/auth";
import { Plus, Pencil, Check, X, Trash2 } from "lucide-react";
import { toast } from "sonner";
const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"];
export default function ProfileSelect() {
const { user, logout } = useAuth();
const { profiles, refresh, switchTo } = useProfile();
const nav = useNavigate();
const [editing, setEditing] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
useEffect(() => { refresh(); }, [refresh]);
const choose = (p) => {
switchTo(p);
nav("/browse", { replace: true });
};
const submitNew = async (e) => {
e.preventDefault();
if (!draft.name.trim()) return;
try {
await api.post("/profiles", draft);
toast.success("Profile created");
setCreating(false);
setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
refresh();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not create");
}
};
const remove = async (id) => {
if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return;
try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) {
toast.error(err.response?.data?.detail || "Could not delete");
}
};
return (
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
Choose a viewer
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
{profiles.map((p) => (
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
<button
onClick={() => editing ? null : choose(p)}
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
style={{ backgroundColor: p.avatar_color }}
data-testid={`select-profile-${p.id}`}
>
{p.name?.[0]?.toUpperCase() || "?"}
</button>
<span className="font-display text-lg text-white">{p.name}</span>
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{p.is_kids && <span>Kids</span>}
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
</div>
{editing && (
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
)}
</div>
))}
{profiles.length < 5 && (
<button
onClick={() => setCreating(true)}
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="add-profile-button"
>
<Plus size={32} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
</button>
)}
</div>
<div className="mt-16 flex gap-4">
<button onClick={() => setEditing(!editing)}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="manage-profiles-button">
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
{editing ? "Done" : "Manage Profiles"}
</button>
<button onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="profile-logout-button">
Sign out
</button>
</div>
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
<label className="block mt-6">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="new-profile-name" />
</label>
<div className="mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
<div className="mt-2 flex gap-2">
{COLORS.map((c) => (
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
data-testid={`color-${c.replace('#', '')}`} />
))}
</div>
</div>
<label className="flex items-center gap-3 mt-5 cursor-pointer">
<input type="checkbox" checked={draft.is_kids}
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
className="accent-[#D9381E]" data-testid="new-profile-kids" />
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
data-testid="new-profile-rating">
<option value="G">G</option>
<option value="PG">PG</option>
<option value="PG-13">PG-13</option>
<option value="R">R</option>
<option value="NR">No restriction</option>
</select>
</label>
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
data-testid="new-profile-submit">Create profile</button>
</form>
</div>
)}
</div>
);
}
+2 -2
View File
@@ -20,7 +20,7 @@ export default function Register() {
setSubmitting(true);
try {
await register(email, password, name);
toast.success("Welcome to Kino");
toast.success("Welcome to StreamHoard");
nav("/browse", { replace: true });
} catch (err) {
toast.error(err.response?.data?.detail || "Could not register");
@@ -49,7 +49,7 @@ export default function Register() {
<div className="flex items-center justify-center px-6 md:px-12 py-12">
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
<Link to="/" className="block mb-12">
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
<span className="text-[#D9381E] text-3xl">.</span>
</Link>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
+71 -6
View File
@@ -3,6 +3,43 @@ import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
const STATUS_LABEL = {
searching: "Searching…",
queued: "Queued",
downloading: "Downloading",
"partially downloaded": "Partially downloaded",
downloaded: "Downloaded",
completed: "Downloaded",
delay: "Waiting",
unknown: "",
};
function LiveStatus({ requestId }) {
const [status, setStatus] = useState(null);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const { data } = await api.get(`/requests/${requestId}/status`);
if (!cancelled) setStatus(data);
} catch { /* transient — next poll will retry */ }
};
poll();
const t = setInterval(poll, 15000);
return () => { cancelled = true; clearInterval(t); };
}, [requestId]);
if (!status) return null;
const label = STATUS_LABEL[status.state] || status.state;
if (!label) return null;
return (
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""}
</span>
);
}
export default function Requests() {
const { user } = useAuth();
const [mine, setMine] = useState([]);
@@ -10,6 +47,7 @@ export default function Requests() {
const [title, setTitle] = useState("");
const [year, setYear] = useState("");
const [notes, setNotes] = useState("");
const [approving, setApproving] = useState({});
const load = async () => {
const { data } = await api.get("/requests/mine");
@@ -21,6 +59,19 @@ export default function Requests() {
};
useEffect(() => { load(); }, [user?.is_admin]);
const approve = async (id, contentType) => {
setApproving((p) => ({ ...p, [id]: true }));
try {
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
toast.success(`Searching for "${data.matched_title || data.title}"`);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not approve request");
} finally {
setApproving((p) => ({ ...p, [id]: false }));
}
};
const submit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
@@ -107,7 +158,8 @@ export default function Requests() {
</div>
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
r.status === "fulfilled" ? "text-[#86efac]" :
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
r.status === "rejected" ? "text-[#fca5a5]" :
r.status === "approved" ? "text-[#93c5fd]" : "text-[#fcd34d]"
}`}>
{r.status}
</span>
@@ -125,13 +177,26 @@ export default function Requests() {
) : (
<div className="border border-[#222]">
{all.map((r) => (
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
<div>
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
<div key={r.id} className="flex items-center justify-between gap-4 px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
<div className="min-w-0">
<p className="text-white truncate">
{r.matched_title || r.title} {(r.matched_year || r.year) ? <span className="text-[#8A8A8A]">({r.matched_year || r.year})</span> : null}
</p>
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}</p>
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
{r.status === "approved" && <div className="mt-1"><LiveStatus requestId={r.id} /></div>}
</div>
<div className="flex gap-2">
<div className="flex gap-2 shrink-0">
{r.status === "pending" && (
<>
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
</>
)}
<button onClick={() => updateStatus(r.id, "fulfilled")}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
+203 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, createContext, useContext } from "react";
import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
@@ -40,19 +41,28 @@ const Field = ({ label, k, type = "text", placeholder, mask }) => {
};
export default function Settings() {
const { user } = useAuth();
const isAdmin = !!user?.is_admin;
const [s, setS] = useState({
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
sonarr_url: "", sonarr_api_key: "",
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
auto_transcode: "off", queue_paused: false,
music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "",
});
const [info, setInfo] = useState({});
const [saving, setSaving] = useState(false);
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
const [traktModal, setTraktModal] = useState(null);
const [show, setShow] = useState({});
const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" });
const [pwShow, setPwShow] = useState(false);
const [pwSaving, setPwSaving] = useState(false);
const [enrich, setEnrich] = useState(null);
const [identify, setIdentify] = useState(null);
const load = async () => {
if (!isAdmin) return;
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
setS({
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
@@ -60,10 +70,13 @@ export default function Settings() {
opensubs_api_key: data.opensubs_api_key || "",
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
auto_transcode: data.auto_transcode || "off", queue_paused: !!data.queue_paused,
music_itunes_fallback: data.music_itunes_fallback !== false,
music_acoustid_enabled: !!data.music_acoustid_enabled,
acoustid_api_key: data.acoustid_api_key || "",
});
setInfo(data); setTraktStatus(ts);
};
useEffect(() => { load(); }, []);
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const save = async (e) => {
e.preventDefault(); setSaving(true);
@@ -97,19 +110,130 @@ export default function Settings() {
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
};
const changePassword = async (e) => {
e.preventDefault();
if (pw.new_password.length < 8) { toast.error("New password must be at least 8 characters"); return; }
if (pw.new_password !== pw.confirm) { toast.error("New passwords don't match"); return; }
setPwSaving(true);
try {
await api.post("/auth/change-password", { current_password: pw.current_password, new_password: pw.new_password });
toast.success("Password changed");
setPw({ current_password: "", new_password: "", confirm: "" });
} catch (err) { toast.error(err.response?.data?.detail || "Could not change password"); }
finally { setPwSaving(false); }
};
const enrichAll = async () => {
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
try {
await api.post("/tmdb/enrich-all");
const { data } = await api.get("/tmdb/enrich-all/status");
setEnrich(data);
toast.success("Enrichment started");
} catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
};
useEffect(() => {
if (!isAdmin) return;
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
api.get("/music/identify/status").then(({ data }) => setIdentify(data)).catch(() => {});
}, [isAdmin]);
useEffect(() => {
if (!enrich?.running) return;
const t = setInterval(async () => {
const { data } = await api.get("/tmdb/enrich-all/status");
setEnrich(data);
if (!data.running) {
clearInterval(t);
if (data.error) {
toast.error(`Enrichment failed: ${data.error}`);
} else {
toast.success(`Enrichment done — ${data.enriched} enriched, ${data.skipped} already complete, ${data.failed} not found`);
}
}
}, 2000);
return () => clearInterval(t);
}, [enrich?.running]);
const identifyAll = async () => {
if (!window.confirm("Fingerprint every track with an unknown artist or album and identify it via AcoustID? This may take a while.")) return;
try {
await api.post("/music/identify");
const { data } = await api.get("/music/identify/status");
setIdentify(data);
toast.success("Identification started");
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
};
useEffect(() => {
if (!identify?.running) return;
const t = setInterval(async () => {
const { data } = await api.get("/music/identify/status");
setIdentify(data);
if (!data.running) {
clearInterval(t);
if (data.error) {
toast.error(`Identification failed: ${data.error}`);
} else {
toast.success(`Identification done — ${data.identified} of ${data.total} tracks identified`);
}
}
}, 2000);
return () => clearInterval(t);
}, [identify?.running]);
return (
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{isAdmin ? "Admin" : "Account"}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
<form onSubmit={changePassword} className="mt-12 space-y-4 pb-10 border-b border-[#222]" data-testid="change-password-form">
<h2 className="font-display text-2xl font-bold text-white">Password</h2>
<p className="text-sm text-[#8A8A8A]">Requires your current password. Changing it locks out anyone who only had the old one.</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Current password</span>
<div className="relative mt-2">
<input
type={pwShow ? "text" : "password"}
value={pw.current_password}
onChange={(e) => setPw({ ...pw, current_password: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="current-password"
/>
<button type="button" onClick={() => setPwShow(!pwShow)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{pwShow ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">New password (min 8 characters)</span>
<input
type={pwShow ? "text" : "password"}
value={pw.new_password}
onChange={(e) => setPw({ ...pw, new_password: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="new-password"
/>
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Confirm new password</span>
<input
type={pwShow ? "text" : "password"}
value={pw.confirm}
onChange={(e) => setPw({ ...pw, confirm: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="confirm-password"
/>
</label>
<button type="submit" disabled={pwSaving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="change-password-button">
{pwSaving ? "Changing…" : "Change Password"}
</button>
</form>
{isAdmin && (
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
{/* TMDB */}
<section>
@@ -119,9 +243,21 @@ export default function Settings() {
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
<Field label="API key (v3)" k="tmdb_api_key" mask />
{info.tmdb_configured && (
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
Bulk enrich all movies
</button>
<div className="mt-4 flex items-center gap-4 flex-wrap">
<button type="button" onClick={enrichAll} disabled={enrich?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="enrich-all-button">
{enrich?.running ? `Enriching… ${enrich.done}/${enrich.total}` : "Bulk enrich all movies →"}
</button>
{enrich && !enrich.running && enrich.error && (
<span className="text-xs text-[#fca5a5]" data-testid="enrich-all-error">
Last run failed: {enrich.error}
</span>
)}
{enrich && !enrich.running && !enrich.error && enrich.total > 0 && (
<span className="text-xs text-[#8A8A8A]" data-testid="enrich-all-summary">
Last run: {enrich.enriched} enriched · {enrich.skipped} already complete · {enrich.failed} not found on TMDB
</span>
)}
</div>
)}
</section>
@@ -204,10 +340,70 @@ export default function Settings() {
</label>
</section>
{/* Music Metadata & Artwork */}
<section>
<h2 className="font-display text-2xl font-bold text-white mb-3">Music Metadata &amp; Artwork</h2>
<p className="text-sm text-[#8A8A8A] mb-4">
Album art/tags come from MusicBrainz + Cover Art Archive first (free, no key). These add free fallbacks for what that misses.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={s.music_itunes_fallback}
onChange={(e) => setS({ ...s, music_itunes_fallback: e.target.checked })}
className="w-4 h-4 accent-[#D9381E]"
data-testid="settings-itunes-fallback"
/>
<span className="text-sm text-white">Fall back to iTunes Search for artwork MusicBrainz doesn't have</span>
</label>
<p className="text-xs text-[#8A8A8A] mt-1 ml-7">No API key needed always free, no signup.</p>
<div className="mt-6 pt-6 border-t border-[#222]">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-white">AcoustID audio fingerprint identification</span>
<Badge on={info.acoustid_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Identifies tracks with missing/unknown artist or album by fingerprinting the actual audio file useful for badly-tagged files with nothing to text-search on. Free key at{" "}
<a className="text-[#D9381E]" href="https://acoustid.org/api-key" target="_blank" rel="noreferrer">acoustid.org/api-key</a>.
</p>
<label className="flex items-center gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={s.music_acoustid_enabled}
onChange={(e) => setS({ ...s, music_acoustid_enabled: e.target.checked })}
className="w-4 h-4 accent-[#D9381E]"
data-testid="settings-acoustid-enabled"
/>
<span className="text-sm text-white">Enable AcoustID identification</span>
</label>
<Field label="API key" k="acoustid_api_key" mask />
{info.acoustid_configured && s.music_acoustid_enabled && (
<div className="mt-4 flex items-center gap-4 flex-wrap">
<button type="button" onClick={identifyAll} disabled={identify?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="identify-all-button">
{identify?.running ? `Identifying… ${identify.done}/${identify.total}` : "Identify unknown tracks →"}
</button>
{identify && !identify.running && identify.error && (
<span className="text-xs text-[#fca5a5]" data-testid="identify-all-error">
Last run failed: {identify.error}
</span>
)}
{identify && !identify.running && !identify.error && identify.total > 0 && (
<span className="text-xs text-[#8A8A8A]" data-testid="identify-all-summary">
Last run: {identify.identified} of {identify.total} identified
</span>
)}
</div>
)}
</div>
</section>
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
)}
</div>
{/* Trakt device-code modal */}
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Play, ArrowLeft } from "lucide-react";
import AlbumArt from "../components/AlbumArt";
export default function ShowDetail() {
const { id } = useParams();
@@ -27,7 +28,7 @@ export default function ShowDetail() {
return (
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
<img src={show.backdrop_url || show.poster_url} alt="" className="absolute inset-0 w-full h-full object-cover" />
<AlbumArt url={show.backdrop_url || show.poster_url} className="absolute inset-0" />
<div className="absolute inset-0 hero-fade" />
<button onClick={() => nav(-1)} className="absolute top-24 left-6 md:left-12 flex items-center gap-2 text-white/80 hover:text-white bg-black/40 hover:bg-black/60 px-4 py-2 backdrop-blur transition-colors" data-testid="show-back">
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Tv } from "lucide-react";
import AlbumArt from "../components/AlbumArt";
export default function Shows() {
const [shows, setShows] = useState([]);
@@ -51,7 +52,7 @@ export default function Shows() {
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
className="relative shrink-0 w-[260px] group focus:outline-none" data-testid={`continue-ep-${episode.id}`}>
<div className="aspect-video overflow-hidden bg-[#0F0F0F] transition-transform duration-300 group-hover:scale-105">
<img src={show.backdrop_url || show.poster_url} alt="" className="w-full h-full object-cover" />
<AlbumArt url={show.backdrop_url || show.poster_url} />
</div>
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
@@ -70,7 +71,7 @@ export default function Shows() {
<button key={s.id} onClick={() => nav(`/show/${s.id}`)}
className="group shrink-0 aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`show-card-${s.id}`}>
<img src={s.poster_url || s.backdrop_url} alt={s.title} loading="lazy" className="w-full h-full object-cover" />
<AlbumArt url={s.poster_url || s.backdrop_url} loading="lazy" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<p className="text-white text-sm truncate">{s.title}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{s.season_count} seasons · {s.episode_count} eps</p>
+75 -19
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2, Volume2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
@@ -9,11 +9,13 @@ const STATUS_COLORS = {
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
superseded: "text-[#8A8A8A]",
};
export default function TranscodeQueue() {
const [jobs, setJobs] = useState([]);
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false, auto_transcode: "off" });
const [runningJob, setRunningJob] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
@@ -24,15 +26,25 @@ export default function TranscodeQueue() {
setStats(s.data);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// The running job can be far outside the main list's created_at-desc/500-row window once
// there's a large backlog (the FIFO worker processes oldest-first), so it's fetched separately
// rather than derived from `jobs`.
const loadRunning = async () => {
try { const { data } = await api.get("/transcode/queue/running"); setRunningJob(data); }
catch { /* ignore transient poll failures */ }
};
useEffect(() => { load(); loadRunning(); }, []);
// Poll while jobs are active
// Poll while jobs are active faster while something is actually running so the
// progress bar feels live, slower while only pending jobs are waiting.
useEffect(() => {
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
if (!active) return;
const t = setInterval(load, 4000);
const pending = jobs.some((j) => j.status === "pending");
if (!runningJob && !pending) return;
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
return () => clearInterval(t);
}, [jobs]);
}, [jobs, runningJob]);
const listedJobs = jobs.filter((j) => j.status !== "running");
const cancel = async (id) => {
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
@@ -44,6 +56,14 @@ export default function TranscodeQueue() {
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const retryAllFailed = async () => {
try {
const { data } = await api.post("/transcode/queue/retry-failed");
toast.success(`Re-queued ${data.retried} job(s)`);
load();
} catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const togglePause = async () => {
try {
const { data } = await api.post("/transcode/queue/pause", { paused: !stats.paused });
@@ -52,6 +72,17 @@ export default function TranscodeQueue() {
} catch { toast.error("Could not toggle pause"); }
};
const fixAudioAll = async () => {
if (!window.confirm(
"Cancels every job still pending (not yet started) and replaces it with a fast audio-only fix — video is left untouched, only the audio track is re-encoded to AAC. This is much faster than the full ABR queue and fixes \"no sound\" caused by AC3/DTS/EAC3 audio (common on BDRips), which browsers can't play natively. Continue?"
)) return;
try {
const { data } = await api.post("/transcode/queue/fix-audio-all");
toast.success(`Queued audio fix for ${data.queued} item(s), replacing ${data.cancelled} pending job(s)`);
load();
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
};
const clearFinished = async () => {
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
@@ -64,9 +95,9 @@ export default function TranscodeQueue() {
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
One job runs at a time at low CPU priority. Auto-transcode is currently:{" "}
One job runs at a time, moderate CPU priority, capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie retry re-queues the same file.
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
@@ -86,12 +117,42 @@ export default function TranscodeQueue() {
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
{stats.paused ? "Resume" : "Pause"}
</button>
<button onClick={retryAllFailed} disabled={!stats.failed && !stats.cancelled} className="flex items-center gap-2 border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="queue-retry-all">
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
</button>
<button onClick={fixAudioAll} className="flex items-center gap-2 border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-fix-audio-all">
<Volume2 size={14} strokeWidth={1.5} /> Fix Audio (Fast, All)
</button>
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>
</div>
<div className="mt-10 border border-[#222]">
{runningJob && (
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="now-processing">
<div className="flex items-center justify-between text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
<span className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
Now Processing
</span>
<span>{(runningJob.progress || 0).toFixed(1)}%</span>
</div>
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
{runningJob.quality} · {runningJob.triggered_by}
</div>
<div className="mt-3 h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
<div
className="h-full bg-[#D9381E] transition-[width] duration-500 ease-linear rounded-full"
style={{ width: `${Math.max(2, runningJob.progress || 0)}%` }}
/>
</div>
</div>
)}
<div className="mt-6 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-5">Movie</span>
<span className="col-span-1">Quality</span>
@@ -100,9 +161,9 @@ export default function TranscodeQueue() {
<span className="col-span-2">Created</span>
<span className="col-span-1 text-right">Actions</span>
</div>
{jobs.length === 0 ? (
{listedJobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div>
) : jobs.map((j) => (
) : listedJobs.map((j) => (
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`queue-row-${j.id}`}>
<div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
@@ -113,19 +174,14 @@ export default function TranscodeQueue() {
</span>
<span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</span>
<div className="col-span-1 flex justify-end gap-2">
{j.status === "running" && (
<button disabled className="text-[#444] cursor-not-allowed" title="Cannot cancel a running job" aria-label="Running">
<X size={14} strokeWidth={1.5} />
</button>
)}
{j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} />
</button>
)}
{(j.status === "failed" || j.status === "cancelled") && (
<button onClick={() => retry(j.id)} className="text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`} aria-label="Retry">
<RotateCcw size={14} strokeWidth={1.5} />
<button onClick={() => retry(j.id)} className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`}>
<RotateCcw size={13} strokeWidth={1.5} /> Retry
</button>
)}
</div>
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
import { Trash2, UserPlus, Eye, EyeOff } from "lucide-react";
export default function Users() {
const { user: me } = useAuth();
const [users, setUsers] = useState([]);
const [showNew, setShowNew] = useState(false);
const [form, setForm] = useState({ email: "", password: "", name: "", is_admin: false });
const [showPw, setShowPw] = useState(false);
const [saving, setSaving] = useState(false);
const load = async () => {
const { data } = await api.get("/admin/users");
setUsers(data);
};
useEffect(() => { load(); }, []);
const createUser = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.post("/admin/users", form);
toast.success("User created");
setForm({ email: "", password: "", name: "", is_admin: false });
setShowNew(false);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not create user");
} finally {
setSaving(false);
}
};
const toggleAdmin = async (u) => {
try {
await api.patch(`/admin/users/${u.id}`, { is_admin: !u.is_admin });
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not update user");
}
};
const removeUser = async (u) => {
if (!window.confirm(`Delete user ${u.email}? This also removes their profiles/watchlist.`)) return;
try {
await api.delete(`/admin/users/${u.id}`);
toast.success("User deleted");
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not delete user");
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="users-page">
<div className="px-6 md:px-12 max-w-4xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Users</h1>
<button
onClick={() => setShowNew(!showNew)}
className="mt-8 flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]"
data-testid="new-user-button"
>
<UserPlus size={14} strokeWidth={1.5} /> {showNew ? "Cancel" : "New User"}
</button>
{showNew && (
<form onSubmit={createUser} className="mt-6 border border-[#222] p-6 space-y-4" data-testid="new-user-form">
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
</label>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password (min 8 chars)</span>
<div className="relative mt-2">
<input type={showPw ? "text" : "password"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12" />
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showPw ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" checked={form.is_admin} onChange={(e) => setForm({ ...form, is_admin: e.target.checked })} />
<span className="text-sm text-[#8A8A8A]">Admin access</span>
</label>
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]">
{saving ? "Creating…" : "Create User"}
</button>
</form>
)}
<div className="mt-10 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-4">Name</span>
<span className="col-span-4">Email</span>
<span className="col-span-2">Role</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{users.map((u) => (
<div key={u.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`user-row-${u.id}`}>
<span className="col-span-4 text-white truncate">{u.name}</span>
<span className="col-span-4 text-[#8A8A8A] text-sm truncate">{u.email}</span>
<div className="col-span-2">
<button
onClick={() => toggleAdmin(u)}
disabled={u.id === me?.id && u.is_admin}
title={u.id === me?.id && u.is_admin ? "Can't remove your own admin access" : "Toggle role"}
className={`text-[10px] uppercase tracking-[0.2em] px-2 py-1 border disabled:opacity-40 disabled:cursor-not-allowed ${u.is_admin ? "border-[#D9381E] text-[#D9381E]" : "border-[#222] text-[#8A8A8A] hover:border-white hover:text-white"}`}
data-testid={`toggle-admin-${u.id}`}
>
{u.is_admin ? "Admin" : "User"}
</button>
</div>
<div className="col-span-2 flex justify-end">
<button
onClick={() => removeUser(u)}
disabled={u.id === me?.id}
title={u.id === me?.id ? "Can't delete your own account" : "Delete"}
className="text-[#8A8A8A] hover:text-[#fca5a5] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
data-testid={`delete-user-${u.id}`}
>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Kino — one-line installer
# StreamHoard — one-line installer
#
# Usage (interactive):
# curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh | sudo bash
@@ -25,7 +25,7 @@ KINO_DIR="${KINO_DIR:-/opt/kino}"
KINO_HTTP_PORT="${KINO_HTTP_PORT:-8080}"
MEDIA_ROOT_HOST="${MEDIA_ROOT_HOST:-${KINO_DIR}/media}"
DB_NAME="${DB_NAME:-kino}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@kino.local}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@streamhoard.local}"
ADMIN_NAME="${ADMIN_NAME:-Admin}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
JWT_SECRET="${JWT_SECRET:-}"
@@ -172,7 +172,7 @@ fi
# ---------- Done ----------
cat <<EOF
$(c_grn '━━━ Kino is up ━━━')
$(c_grn '━━━ StreamHoard is up ━━━')
URL : $(c_cyn "$URL")
Admin : $(c_cyn "$ADMIN_EMAIL")