Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s

Volle Titel-Auswahl vor dem Rip (Etappe-12-Rest):
- worker.tasks.scan_tracks: makemkvcon info -> Titel-Liste (Dauer/Groesse/
  Kapitel, apdefs-Attr 8/9/11) in die settings-Tabelle 'tracks:<device>';
  API: POST /devices/{n}/scan-tracks + GET /devices/{n}/tracks (Polling).
- Rip-Dialog: 'Disc scannen' -> Tabelle mit Checkboxen, Vorauswahl ab
  5 min, Groessen-Summe; gewaehlte Titel als meta.titles in den Job.
- rip_titel_auswahl: ein makemkvcon-Aufruf je Titel (mkv kann nur einen
  Titel oder all), Fortschritt anteilig. Parser-Tests dabei.

Nativer Windows-Worker OHNE Docker (Commander-Wunsch):
- Die Rippy-Instanz versorgt sich selbst: GET /worker-setup/windows
  liefert install.ps1, /worker-setup/paket den Worker-Code als Zip
  (api-Dockerfile kopiert docker/worker/*.py als worker_dist/ ins Image).
- install.ps1: Python-Check, venv + requirements, HandBrakeCLI 1.9.2 vom
  offiziellen GitHub-Release (URL per HEAD verifiziert), start-worker.bat
  mit celery -Q transcode --pool=solo (Celery-Doku: Windows nur solo),
  optional -Autostart via schtasks onlogon.
- tasks.py laedt jetzt auch ohne fcntl (Import-Guard) — rip_disc ist auf
  solchen Workern hart verriegelt (Klartext-Fehler statt Crash).
- Einstellungen -> Worker: Varianten-Umschalter Linux(Docker)/Windows(nativ)
  mit passenden Copy-Paste-Befehlen.

Anleitungs-Tab: Rippy erklaert sich selbst (Disc-Weg, Serien, NAS,
Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 15:18:08 +02:00
parent 325d95af6f
commit 3a4eb25392
15 changed files with 738 additions and 44 deletions
@@ -359,6 +359,7 @@ export default function DeviceDiscovery() {
isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
discTitle={modalDevice?.disc?.title}
deviceId={modalDevice?.id}
onClose={() => setModalDevice(null)}
onSave={(target, optionen) => {
if (modalDevice) {
+134 -3
View File
@@ -15,16 +15,31 @@ export interface RipOptionen {
series?: string
season?: number
mainFeatureOnly?: boolean
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle)
}
interface TitelInfo {
nr: number
dauer_s: number
groesse_bytes: number
kapitel: number
}
interface RipTargetModalProps {
isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
discTitle?: string // Vorschlag für den Serien-Namen
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan
onClose: () => void
onSave: (target: TargetConfig, optionen: RipOptionen) => void
}
function dauerText(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`
}
interface BrowseDir {
name: string
path: string
@@ -32,11 +47,48 @@ 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, discTitle, onClose, onSave }: RipTargetModalProps) {
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, 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 [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
const [scanFehler, setScanFehler] = useState('')
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const scanStarten = async () => {
if (!deviceId) return
setScanStatus('running')
setScanFehler('')
try {
await api.post(`/devices/${deviceId}/scan-tracks`)
} catch (e: any) {
setScanStatus('error')
setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden')
}
}
// Polling, solange der Titel-Scan läuft (Info-Lauf dauert 20120 s)
useEffect(() => {
if (scanStatus !== 'running' || !deviceId) return
const interval = setInterval(async () => {
try {
const r = await api.get(`/devices/${deviceId}/tracks`)
if (r.data.status === 'done') {
const tracks: TitelInfo[] = r.data.tracks || []
setTitelListe(tracks)
// Vorauswahl: alles ab 5 Minuten (Trailer/Menü-Schnipsel bleiben weg)
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
setScanStatus('done')
} else if (r.data.status === 'error') {
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
setScanStatus('error')
}
} catch { /* API kurz weg — weiter pollen */ }
}, 3000)
return () => clearInterval(interval)
}, [scanStatus, deviceId])
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 },
@@ -56,6 +108,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
setCustomPath('')
setSerienName(discTitle || '')
setStaffel(1)
setScanStatus('idle')
setScanFehler('')
setTitelListe([])
setGewaehlt(new Set())
api.get('/settings').then(r => {
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
@@ -88,6 +144,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
optionen.series = serienName.trim()
optionen.season = Math.max(1, staffel || 1)
}
// Explizite Titel-Auswahl schlägt alles andere
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
} else if (scanStatus === 'done' && gewaehlt.size > 0) {
// Alle gewählt = normales „all" — keine Einzelaufrufe nötig
}
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
@@ -171,8 +233,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
</div>
)}
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
{selectedType === 'movies' && (
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
eine explizite Titel-Auswahl unten schlägt diese Option */}
{selectedType === 'movies' && scanStatus !== 'done' && (
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
<input
type="checkbox"
@@ -184,6 +247,74 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
</label>
)}
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */}
{deviceId && selectedType !== 'music' && (
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center justify-between px-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}>
<div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && (
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm.
</p>
)}
</div>
{scanStatus === 'running' ? (
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-indigo-500 border-t-transparent" />
Disc wird gelesen (20120 s)…
</span>
) : (
<button
onClick={scanStarten}
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</button>
)}
</div>
{scanStatus === 'error' && (
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p>
)}
{scanStatus === 'done' && (
<>
<div className="max-h-44 overflow-y-auto">
{titelListe.map(t => (
<label
key={t.nr}
className={`flex items-center gap-2.5 px-3 py-1.5 text-sm cursor-pointer ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700/50' : 'text-slate-700 hover:bg-slate-50'}`}
>
<input
type="checkbox"
checked={gewaehlt.has(t.nr)}
onChange={e => {
const neu = new Set(gewaehlt)
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
setGewaehlt(neu)
}}
className="w-4 h-4 accent-indigo-600 flex-shrink-0"
/>
<span className="font-mono text-xs w-14 flex-shrink-0">Titel {t.nr}</span>
<span className="flex-shrink-0">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && (
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{t.kapitel} Kap.</span>
)}
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span>
</label>
))}
</div>
<p className={`text-xs px-3 py-2 border-t ${theme === 'dark' ? 'border-slate-700 text-slate-500' : 'border-slate-200 text-slate-400'}`}>
{gewaehlt.size} von {titelListe.length} Titeln gewählt
{' · '}~{([...gewaehlt].reduce((s, nr) => s + (titelListe.find(t => t.nr === nr)?.groesse_bytes || 0), 0) / 1024 ** 3).toFixed(1)} GB
{' · '}Vorauswahl: alles ab 5 Minuten
</p>
</>
)}
</div>
)}
{/* 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'}`}>
+34 -16
View File
@@ -40,6 +40,7 @@ function relativeZeit(iso?: string): string {
export default function WorkerVerwaltung() {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
const { theme } = useDarkMode()
const { toast } = useToast()
@@ -59,11 +60,17 @@ export default function WorkerVerwaltung() {
}, [])
const rippyHost = window.location.hostname
const installBefehl = [
`git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
const installBefehl = variante === 'linux'
? [
`git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
: [
`iwr http://${rippyHost}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
` -RippyHost ${rippyHost} -WorkerName mein-pc`,
].join('\n')
const kopieren = () => {
navigator.clipboard.writeText(installBefehl).then(() => {
@@ -131,15 +138,28 @@ export default function WorkerVerwaltung() {
)}
</div>
{/* Neue Maschine anbinden */}
{/* Neue Maschine anbinden — zwei Varianten */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
<div className="flex gap-2 mb-3">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<button
key={wert}
onClick={() => setVariante(wert)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
variante === wert
? theme === 'dark' ? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500' : 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
}`}
>
{label}
</button>
))}
</div>
<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 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.
{variante === 'linux'
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch von dieser Rippy-Instanz bzw. dem offiziellen HandBrake-Release und erzeugt eine start-worker.bat. Autostart bei Anmeldung: Parameter -Autostart anhängen.'}
</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}
@@ -152,10 +172,8 @@ export default function WorkerVerwaltung() {
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
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.
Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier,
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
</p>
</div>
</div>