Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s

AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/
python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie
einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt-
Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft
jetzt ganz ohne .env-Pflichtwerte.

Serien-Flow (Etappe-12-Kern, ARM-Wunde #395):
- Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN
  (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im
  Serien-Ordner, bei Staffel 2 nicht ueberschrieben.
- Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan
  ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten
  (GET /metadata/tv/{id}/season/{n}; tv-season-details-API).
  Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei
  uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei
  eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests.

Weitere Punkte:
- Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip
  (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) —
  URL/Key + Test-Knopf in Einstellungen -> Ripping.
- Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses
  + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt',
  Vollautomatik ueberspringt Duplikate.
- 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten
  (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses
  Setting; pro Rip im Dialog uebersteuerbar. Mit Tests.
- OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id,
  de-DE; find-by-id-API).
- Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export
  (GET /jobs/export, Semikolon+BOM fuer deutsches Excel).
- Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl.
  Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und
  /metadata/confirm (schrieb nie gelesenen Cache-Key).
- Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen.
- Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE
  veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports
  jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 14:22:19 +02:00
parent e7a7d5fdb4
commit 8eb5653848
28 changed files with 934 additions and 892 deletions
+22 -5
View File
@@ -3,7 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
import RipTargetModal from './RipTargetModal'
import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
interface DiscInfo {
@@ -17,6 +17,11 @@ interface DiscInfo {
type?: string
source?: string
}
bereits_gerippt?: {
job_id: string
title?: string
finished_at?: string
}
}
interface Device {
@@ -66,13 +71,15 @@ export default function DeviceDiscovery() {
}
}
const startRip = async (device: Device, targetDir?: string) => {
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
setActionBusy(true)
setActionFeedback(null)
try {
const response = await api.post('/jobs', {
device_path: device.path,
...(targetDir ? { target_dir: targetDir } : {}),
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
@@ -203,6 +210,15 @@ export default function DeviceDiscovery() {
{device.disc!.metadata.overview}
</p>
)}
{device.disc!.bereits_gerippt && (
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
<AlertCircle size={13} className="flex-shrink-0" />
Diese Disc wurde bereits gerippt
{device.disc!.bereits_gerippt.finished_at
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
: ''} nochmal rippen ist trotzdem möglich.
</p>
)}
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => setModalDevice(device)}
@@ -341,11 +357,12 @@ export default function DeviceDiscovery() {
Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal
isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
discTitle={modalDevice?.disc?.title}
onClose={() => setModalDevice(null)}
onSave={(target) => {
onSave={(target, optionen) => {
if (modalDevice) {
startRip(modalDevice, target.path)
startRip(modalDevice, target.path, optionen)
}
}}
/>
+68 -4
View File
@@ -11,11 +11,18 @@ interface TargetConfig {
isActive: boolean
}
export interface RipOptionen {
series?: string
season?: number
mainFeatureOnly?: boolean
}
interface RipTargetModalProps {
isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
discTitle?: string // Vorschlag für den Serien-Namen
onClose: () => void
onSave: (target: TargetConfig) => void
onSave: (target: TargetConfig, optionen: RipOptionen) => void
}
interface BrowseDir {
@@ -25,8 +32,11 @@ interface BrowseDir {
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
export default function RipTargetModal({ isOpen, initialType, onClose, onSave }: RipTargetModalProps) {
export default function RipTargetModal({ isOpen, initialType, discTitle, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [serienName, setSerienName] = useState('')
const [staffel, setStaffel] = useState(1)
const [nurHauptfilm, setNurHauptfilm] = useState(false)
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
@@ -44,8 +54,11 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
if (!isOpen) return
setSelectedType(initialType || 'movies')
setCustomPath('')
setSerienName(discTitle || '')
setStaffel(1)
api.get('/settings').then(r => {
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
@@ -70,11 +83,16 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
}
const handleSave = () => {
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
if (selectedType === 'series' && serienName.trim()) {
optionen.series = serienName.trim()
optionen.season = Math.max(1, staffel || 1)
}
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true })
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
} else if (target) {
onSave(target)
onSave(target, optionen)
}
onClose()
}
@@ -120,6 +138,52 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
))}
</div>
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
{selectedType === 'series' && (
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<div className="grid grid-cols-[1fr_90px] gap-3">
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
<input
value={serienName}
onChange={e => setSerienName(e.target.value)}
placeholder="z. B. Neon Genesis Evangelion"
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
<input
type="number"
min={1}
value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
</div>
</div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch
zu S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01" benannt (nur bei eindeutiger Zuordnung).
</p>
</div>
)}
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
{selectedType === 'movies' && (
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-indigo-600"
/>
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
</label>
)}
{/* Ordner-Browser */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>