mirror of
https://github.com/myronblair/kino-app
synced 2026-07-30 06:23:40 -05:00
b74d8a2652
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.
184 lines
10 KiB
React
184 lines
10 KiB
React
import { useEffect, useState } from "react";
|
|
import api from "../lib/api";
|
|
import { toast } from "sonner";
|
|
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() {
|
|
const nav = useNavigate();
|
|
const [movies, setMovies] = useState([]);
|
|
const [shows, setShows] = useState([]);
|
|
const [transcoding, setTranscoding] = useState({});
|
|
|
|
const load = async () => {
|
|
const [{ data: m }, { data: s }] = await Promise.all([
|
|
api.get("/movies"),
|
|
api.get("/shows").catch(() => ({ data: [] })),
|
|
]);
|
|
setMovies(m); setShows(s);
|
|
};
|
|
useEffect(() => { load(); }, []);
|
|
|
|
// Poll for transcoding status
|
|
useEffect(() => {
|
|
const inProgress = movies.some((m) => m.hls_status === "running" || m.hls_status === "pending");
|
|
if (!inProgress) return;
|
|
const t = setInterval(load, 3000);
|
|
return () => clearInterval(t);
|
|
}, [movies]);
|
|
|
|
const remove = async (id) => {
|
|
if (!window.confirm("Remove this movie permanently?")) return;
|
|
await api.delete(`/movies/${id}`);
|
|
toast.success("Removed");
|
|
load();
|
|
};
|
|
|
|
const toggleFeatured = async (m) => {
|
|
await api.patch(`/movies/${m.id}`, { featured: !m.featured });
|
|
load();
|
|
};
|
|
|
|
const startTranscode = async (m, quality = "quick") => {
|
|
setTranscoding({ ...transcoding, [m.id]: true });
|
|
try {
|
|
await api.post(`/movies/${m.id}/transcode`, { quality });
|
|
toast.success(`${quality === "abr" ? "ABR" : "Quick"} transcode started`);
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.detail || "Could not start transcode");
|
|
} finally {
|
|
setTranscoding({ ...transcoding, [m.id]: false });
|
|
}
|
|
};
|
|
|
|
const hlsLabel = (m) => {
|
|
if (m.hls_status === "done") {
|
|
return m.hls_path === "master.m3u8" ? "ABR" : "Quick";
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const hlsStatusBadge = (m) => {
|
|
if (!m.hls_status) return null;
|
|
const colors = {
|
|
pending: "text-[#fcd34d]",
|
|
running: "text-[#fcd34d]",
|
|
done: "text-[#86efac]",
|
|
failed: "text-[#fca5a5]",
|
|
};
|
|
const variant = m.hls_status === "done" ? hlsLabel(m) : "";
|
|
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}{variant ? ` · ${variant}` : ""}</span>;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-page">
|
|
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library management</span>
|
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Admin</h1>
|
|
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
|
|
|
|
<div className="mt-10 flex flex-wrap gap-3">
|
|
<Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link">
|
|
<Film size={14} strokeWidth={1.5} /> Upload Movie
|
|
</Link>
|
|
<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/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
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mt-12 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">Title</span>
|
|
<span className="col-span-1">Year</span>
|
|
<span className="col-span-1">Rating</span>
|
|
<span className="col-span-2">Source</span>
|
|
<span className="col-span-2">HLS</span>
|
|
<span className="col-span-2 text-right">Actions</span>
|
|
</div>
|
|
{movies.map((m) => (
|
|
<div key={m.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={`admin-movie-row-${m.id}`}>
|
|
<div className="col-span-4 flex items-center gap-3 min-w-0">
|
|
<img src={m.poster_url} alt="" className="w-10 h-14 object-cover" />
|
|
<span className="text-white truncate">{m.title}</span>
|
|
</div>
|
|
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.year}</span>
|
|
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.rating}</span>
|
|
<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.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"
|
|
data-testid={`transcode-abr-${m.id}`}>ABR</button>
|
|
</div>
|
|
)}
|
|
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}>
|
|
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
|
|
</button>
|
|
<button onClick={() => remove(m.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors" aria-label="Delete" data-testid={`delete-${m.id}`}>
|
|
<Trash2 size={16} strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{shows.length > 0 && (
|
|
<div className="mt-12">
|
|
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Series</h2>
|
|
<div className="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-6">Title</span>
|
|
<span className="col-span-2">Year</span>
|
|
<span className="col-span-2">Episodes</span>
|
|
<span className="col-span-2 text-right">Actions</span>
|
|
</div>
|
|
{shows.map((s) => (
|
|
<div key={s.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={`admin-show-row-${s.id}`}>
|
|
<div className="col-span-6 flex items-center gap-3 min-w-0">
|
|
<img src={s.poster_url} alt="" className="w-10 h-14 object-cover" />
|
|
<span className="text-white truncate">{s.title}</span>
|
|
</div>
|
|
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.year}</span>
|
|
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.season_count}s · {s.episode_count}e</span>
|
|
<div className="col-span-2 flex justify-end">
|
|
<a href={`/admin/shows/${s.id}/episodes`} className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 transition-colors" data-testid={`manage-episodes-${s.id}`}>
|
|
Manage Episodes
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|