Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Ampel / ampel (push) Successful in 29s
Ampel / ampel (push) Successful in 29s
Zwei bewiesene Bug-Fixes:
- SMB-Mount 'Unable to apply new capability set': mount.cifs hebt
CAP_DAC_READ_SEARCH an, die in Dockers Default-Caps fehlt (auf der VM
reproduziert: Bounding-Set a82425fb ohne Bit 2; mit der Capability
verschwindet der Fehler). Fix: cap_add DAC_READ_SEARCH fuer den
api-Container.
- TMDB fiel still aus: Client konnte nur v4-Bearer-Tokens, der uebliche
32-Hex-v3-Key bekam 401 und die Suche lieferte nur OMDb. Jetzt beide
Key-Arten (ist_v4_token + api_key-Query-Param lt.
developer.themoviedb.org, mit Tests) — damit kommen auch die deutschen
Texte an (language=de-DE war ueberall schon gesetzt). Neu:
GET /metadata/status + 'Verbindung pruefen' in Einstellungen -> APIs
(Live-Check am Cache vorbei).
Features aus dem Commander-Feedback:
- 4K UHD als eigener Disc-Typ: classify >= 55 GiB (BD-66/BD-100; BD-50
bleibt bluray), beide detection.py + Tests, eigene Badge-Farbe in
Dashboard/Laufwerken, Prescan-Label '4K UHD'. UHD-Rip-Fehler 'Failed to
open disc' (Code 11) bekommt Klartext: LibreDrive-Firmware noetig.
- Vollautomatik (Setting autoRipStart): Disc erkannt -> Rip startet ohne
Popup in den Schnellwahl-Ordner (Serie->Serien, sonst Filme, CD->Musik).
- Job-Verwaltung: 'Neu komprimieren' nur noch mit can_retry (Rohdaten
liegen wirklich da), DELETE /jobs/{id} + 'Erledigte aufraeumen'
(Dateien bleiben immer), 'Alle herunterladen' im Job-Detail
(gestaffelte Einzel-Downloads statt Server-Zip von 40-GB-Dateien).
- Worker zuordenbar: WORKER_NAME-Env als stabiler Anzeigename (fixt auch
die Offline-Leichen nach Rebuilds), info.hostname/ip gemeldet,
Online-Abgleich ueber hostname, DELETE /workers/{name} + Papierkorb im
UI, Quelle-Anzeige an der Disc-Karte ('Quelle: TMDB - 99 % sicher').
- Ripping-Tab nach Medium gegliedert (Video/Audio-CD/Allgemein) +
Untertitel-Klartext (--all-audio/--all-subtitles bleiben komplett),
CD -> Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung beschriftet
und standardmaessig eingeklappt.
- Docs: SAVEPOINT v3.3, ROADMAP Etappe 14 + Ideen (nativer
Windows-Worker, deutsche OMDb-Texte via TMDB-Find), README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,14 @@ interface DiscInfo {
|
||||
poster_path?: string
|
||||
overview?: string
|
||||
type?: string
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
type: 'cd' | 'dvd' | 'bluray' | 'unknown'
|
||||
type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||
path: string
|
||||
status: 'empty' | 'ready' | 'ripping'
|
||||
serial?: string
|
||||
@@ -112,10 +113,18 @@ export default function DeviceDiscovery() {
|
||||
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
|
||||
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
|
||||
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
|
||||
case 'uhd': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-700'
|
||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
const typLabel = (type: string) =>
|
||||
({ cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD' } as Record<string, string>)[type] || type.toUpperCase()
|
||||
|
||||
const QUELLEN_LABEL: Record<string, string> = {
|
||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
@@ -185,7 +194,8 @@ export default function DeviceDiscovery() {
|
||||
{device.disc!.disc_type?.toUpperCase()}
|
||||
</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Übereinstimmung {Math.round((device.disc!.confidence || 0) * 100)} %
|
||||
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
||||
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
||||
</span>
|
||||
</div>
|
||||
{device.disc!.metadata?.overview && (
|
||||
@@ -266,7 +276,7 @@ export default function DeviceDiscovery() {
|
||||
'Rippt'}
|
||||
</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Typ: {device.type.toUpperCase()}
|
||||
Typ: {typLabel(device.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +303,7 @@ export default function DeviceDiscovery() {
|
||||
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
|
||||
{device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'}
|
||||
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -331,6 +341,7 @@ export default function DeviceDiscovery() {
|
||||
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
||||
<RipTargetModal
|
||||
isOpen={modalDevice !== null}
|
||||
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
|
||||
onClose={() => setModalDevice(null)}
|
||||
onSave={(target) => {
|
||||
if (modalDevice) {
|
||||
|
||||
@@ -177,9 +177,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
||||
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
|
||||
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm font-medium mb-2 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
||||
<Download size={15} /> Dateien herunterladen
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
||||
<Download size={15} /> Dateien herunterladen
|
||||
</p>
|
||||
{dateien.length > 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Gestaffelte Einzel-Downloads statt Server-seitigem
|
||||
// 40-GB-Zip — der Browser fragt ggf. einmal nach
|
||||
// Erlaubnis für mehrere Downloads.
|
||||
dateien.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
|
||||
a.download = f.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}, i * 400)
|
||||
})
|
||||
}}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Alle herunterladen ({dateien.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dateien.map(f => (
|
||||
<a
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TargetConfig {
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||
onClose: () => void
|
||||
onSave: (target: TargetConfig) => void
|
||||
}
|
||||
@@ -24,7 +25,7 @@ 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, onClose, onSave }: RipTargetModalProps) {
|
||||
export default function RipTargetModal({ isOpen, initialType, onClose, onSave }: RipTargetModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
@@ -41,6 +42,8 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
||||
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setSelectedType(initialType || 'movies')
|
||||
setCustomPath('')
|
||||
api.get('/settings').then(r => {
|
||||
const s = r.data || {}
|
||||
const basis = s.outputDir || '/app/media'
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function StorageMounts() {
|
||||
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||
const [browserOffen, setBrowserOffen] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -188,9 +189,30 @@ export default function StorageMounts() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lokale Ordner durchsuchen + anlegen */}
|
||||
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht
|
||||
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet
|
||||
und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
|
||||
<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'}`}>
|
||||
<button
|
||||
onClick={() => setBrowserOffen(!browserOffen)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`}
|
||||
>
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
Ordner-Verwaltung (optional)
|
||||
</h3>
|
||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder
|
||||
„doku", die danach im „Rippen starten"-Dialog wählbar sind. Fürs normale
|
||||
Rippen brauchst du das hier nicht.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
|
||||
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
||||
</span>
|
||||
</button>
|
||||
{browserOffen && (<>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<button
|
||||
onClick={() => browseParent && browsen(browseParent)}
|
||||
disabled={!browseParent}
|
||||
@@ -249,6 +271,7 @@ export default function StorageMounts() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Neues Netzwerk-Ziel */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -14,6 +14,12 @@ interface WorkerInfo {
|
||||
encoders: string[]
|
||||
last_seen?: string
|
||||
online?: boolean
|
||||
info?: {
|
||||
hostname?: string
|
||||
ip?: string
|
||||
makemkv?: string
|
||||
handbrake?: string
|
||||
}
|
||||
}
|
||||
|
||||
const ENCODER_LABELS: Record<string, string> = {
|
||||
@@ -55,7 +61,8 @@ export default function WorkerVerwaltung() {
|
||||
const rippyHost = window.location.hostname
|
||||
const installBefehl = [
|
||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||
`RIPPY_HOST=${rippyHost} docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
].join('\n')
|
||||
|
||||
const kopieren = () => {
|
||||
@@ -88,18 +95,35 @@ export default function WorkerVerwaltung() {
|
||||
/>
|
||||
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-mono truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
|
||||
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{w.encoders.map(e => (
|
||||
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{w.encoders.map(e => (
|
||||
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!w.online && (
|
||||
<button
|
||||
onClick={() => {
|
||||
api.delete(`/workers/${encodeURIComponent(w.name)}`)
|
||||
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
||||
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
||||
}}
|
||||
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
|
||||
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -112,9 +136,10 @@ export default function WorkerVerwaltung() {
|
||||
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen
|
||||
Befehl ausführen — der Worker verbindet sich, taucht oben in der Liste auf und
|
||||
übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt
|
||||
und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
||||
Befehl ausführen — WORKER_NAME frei wählen, unter dem Namen taucht die Maschine
|
||||
oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden?
|
||||
Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen
|
||||
Rippy-Host im Netz.
|
||||
</p>
|
||||
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
|
||||
{installBefehl}
|
||||
@@ -127,7 +152,10 @@ export default function WorkerVerwaltung() {
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant.
|
||||
Ehrliche Antwort zu „muss das Docker sein?": aktuell ja — der Worker braucht
|
||||
HandBrake + Celery, und Docker ist der einzige Weg, das auf jeder Maschine
|
||||
identisch und ohne Installations-Roulette auszuliefern. Ein nativer
|
||||
Windows-Dienst (Installer, ohne Docker) steht als Ausbaustufe in der ROADMAP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user