Admin
Settings
-
Connect TMDB for metadata auto-fill and Radarr for library import.
+
+ {/* Trakt device-code modal */}
+ {traktModal && (
+
+
setTraktModal(null)} />
+
+
Connect Trakt
+
Visit the URL below, sign in to Trakt, and enter the code:
+
{traktModal.verification_url}
+
+
Enter this code
+
{traktModal.user_code}
+
+
Waiting for approval… this window will close automatically.
+
+
+ )}
+
);
}
diff --git a/frontend/src/pages/ShowDetail.jsx b/frontend/src/pages/ShowDetail.jsx
new file mode 100644
index 0000000..6871b2a
--- /dev/null
+++ b/frontend/src/pages/ShowDetail.jsx
@@ -0,0 +1,80 @@
+import { useEffect, useState, useMemo } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import api from "../lib/api";
+import { Play, ArrowLeft } from "lucide-react";
+
+export default function ShowDetail() {
+ const { id } = useParams();
+ const nav = useNavigate();
+ const [show, setShow] = useState(null);
+ const [episodes, setEpisodes] = useState([]);
+ const [season, setSeason] = useState(1);
+
+ useEffect(() => {
+ (async () => {
+ const [s, e] = await Promise.all([api.get(`/shows/${id}`), api.get(`/shows/${id}/episodes`)]);
+ setShow(s.data); setEpisodes(e.data);
+ const firstS = e.data[0]?.season_number || 1;
+ setSeason(firstS);
+ })();
+ }, [id]);
+
+ const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
+ const inSeason = useMemo(() => episodes.filter((e) => e.season_number === season), [episodes, season]);
+
+ if (!show) return
Loading…
;
+
+ return (
+
+
+

+
+
+
+
Series
+
{show.title}
+
+ {show.year}·{show.rating}·
+ {show.season_count} seasons · {show.episode_count} episodes
+
+
{show.description}
+
+
+
+
+
+ Seasons
+ {seasons.map((n) => (
+
+ ))}
+
+
+
+ {inSeason.map((ep) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Shows.jsx b/frontend/src/pages/Shows.jsx
new file mode 100644
index 0000000..431fc73
--- /dev/null
+++ b/frontend/src/pages/Shows.jsx
@@ -0,0 +1,84 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import api from "../lib/api";
+import { Tv } from "lucide-react";
+
+export default function Shows() {
+ const [shows, setShows] = useState([]);
+ const [featured, setFeatured] = useState(null);
+ const [continueEps, setContinueEps] = useState([]);
+ const nav = useNavigate();
+
+ useEffect(() => {
+ (async () => {
+ const [s, f, c] = await Promise.all([
+ api.get("/shows"),
+ api.get("/shows/featured").catch(() => ({ data: null })),
+ api.get("/progress/episodes/continue").catch(() => ({ data: [] })),
+ ]);
+ setShows(s.data); setFeatured(f.data); setContinueEps(c.data);
+ })();
+ }, []);
+
+ if (shows.length === 0) {
+ return (
+
+
+
+
No TV shows yet
+
Configure Sonarr in Settings and import your library.
+
+ Sonarr Import
+
+
+
+ );
+ }
+
+ return (
+
+
+
Series
+
TV Shows
+
+ {continueEps.length > 0 && (
+
+
Continue Watching
+
+ {continueEps.map(({ episode, show, progress }) => {
+ const pct = progress.duration_seconds ? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100) : 0;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+ {shows.map((s) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/SonarrImport.jsx b/frontend/src/pages/SonarrImport.jsx
new file mode 100644
index 0000000..e023a7e
--- /dev/null
+++ b/frontend/src/pages/SonarrImport.jsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import api from "../lib/api";
+import { toast } from "sonner";
+import { Download, RefreshCcw, Check } from "lucide-react";
+
+export default function SonarrImport() {
+ const [series, setSeries] = useState([]);
+ const [selected, setSelected] = useState(new Set());
+ const [loading, setLoading] = useState(false);
+ const [importing, setImporting] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try { const { data } = await api.get("/sonarr/series"); setSeries(data); }
+ catch (err) { toast.error(err.response?.data?.detail || "Could not load Sonarr series"); }
+ finally { setLoading(false); }
+ };
+ useEffect(() => { load(); }, []);
+
+ const toggle = (id) => {
+ const s = new Set(selected);
+ s.has(id) ? s.delete(id) : s.add(id);
+ setSelected(s);
+ };
+
+ const importSelected = async () => {
+ if (selected.size === 0) return;
+ setImporting(true);
+ try {
+ const { data } = await api.post("/sonarr/import", { sonarr_ids: Array.from(selected) });
+ toast.success(`Imported ${data.shows_imported} show(s), ${data.episodes_imported} episode(s)`);
+ setSelected(new Set()); load();
+ } catch (err) { toast.error(err.response?.data?.detail || "Import failed"); }
+ finally { setImporting(false); }
+ };
+
+ const withEpisodes = series.filter((s) => s.episode_file_count > 0);
+
+ return (
+
+
+
Sonarr
+
Import TV Series
+
+ {series.length === 0 && !loading ? "No series found — configure Sonarr in Settings first." : `${withEpisodes.length} series with episode files available.`}
+
+
+
+
+
+
+
+
+ {withEpisodes.map((m) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/install.sh b/install.sh
index e62c0fd..3616444 100755
--- a/install.sh
+++ b/install.sh
@@ -19,7 +19,7 @@
set -euo pipefail
# ---------- Defaults (override via env) ----------
-KINO_REPO="${KINO_REPO:-https://github.com/CHANGE_ME/kino-media-server.git}"
+KINO_REPO="${KINO_REPO:-https://github.com/myronblair/kino-app.git}"
KINO_BRANCH="${KINO_BRANCH:-main}"
KINO_DIR="${KINO_DIR:-/opt/kino}"
KINO_HTTP_PORT="${KINO_HTTP_PORT:-8080}"
@@ -44,7 +44,7 @@ die() { printf "%s %s\n" "$(c_red '✗')" "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run as root (or via sudo). Try: curl -fsSL ... | sudo bash"
if [ "$KINO_REPO" = "https://github.com/CHANGE_ME/kino-media-server.git" ]; then
- die "KINO_REPO placeholder — edit install.sh and set your GitHub URL, or pass KINO_REPO=... env var."
+ die "KINO_REPO placeholder — pass KINO_REPO=... env var to install.sh"
fi
# ---------- OS detection & package manager ----------
diff --git a/memory/PRD.md b/memory/PRD.md
index c513ffe..6885b33 100644
--- a/memory/PRD.md
+++ b/memory/PRD.md
@@ -60,6 +60,13 @@
- **Crash recovery**: stuck `running` jobs are reset to `failed` on backend restart
- Cancel of a pending job clears `hls_path` to avoid stale references
+### Phase 5 (2026-07-23)
+- **Sonarr / TV Shows** — Show → Episode data model, Sonarr library import (multi-select), season/episode picker UI, dedicated `/shows` browse page
+- **Episode player** with auto-next-episode CTA in last 20s, auto-advance on video end, profile-scoped progress + continue-watching
+- **Trakt.tv sync** — OAuth device-code flow to connect account, auto-scrobble on every movie AND episode progress update (start/pause/stop based on % watched)
+- **OpenSubtitles auto-search** — one-click subtitle search by TMDB ID in the upload flow, auto-converts SRT to WEBVTT, attaches directly to movie
+- **Bulk TMDB enrichment** — sweeps every existing movie with missing metadata (cast/director/description/poster) and backfills from TMDB in one click
+
## Backlog (P1)
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
- Subtitle search via OpenSubtitles API