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
+5 -2
View File
@@ -1,15 +1,16 @@
import { useState, useEffect } from 'react'
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-react'
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react'
import { api } from './lib/api'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard'
import AnleitungPage from './pages/Anleitung'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
import FirstRunWizard from './components/FirstRunWizard'
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
type Page = 'dashboard' | 'settings' | 'logs'
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
function App() {
const [page, setPage] = useState<Page>('dashboard')
@@ -28,6 +29,7 @@ function App() {
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
]
@@ -86,6 +88,7 @@ function App() {
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
<div className="max-w-7xl mx-auto p-6">
{page === 'dashboard' && <Dashboard />}
{page === 'anleitung' && <AnleitungPage />}
{page === 'logs' && <LogsPage />}
{page === 'settings' && <SettingsPage />}
</div>
@@ -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>
+162
View File
@@ -0,0 +1,162 @@
import { ReactNode } from 'react'
import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext'
// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
const { theme } = useDarkMode()
return (
<section className={`rounded-xl border p-6 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<h2 className={`text-lg font-semibold mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Icon size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
{titel}
</h2>
<div className={`space-y-2 text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{children}
</div>
</section>
)
}
export default function AnleitungPage() {
const { theme } = useDarkMode()
const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
return (
<div className="space-y-6 max-w-4xl transition-colors duration-300">
<div className="flex flex-col gap-2">
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Anleitung</h1>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
So funktioniert Rippy vom Einlegen bis zum fertigen Film in deinem Media-Server.
</p>
</div>
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
<p>
<span className={fett}>1. Disc einlegen.</span> Rippy erkennt sie automatisch (Titel, Poster,
Beschreibung) und zeigt sie oben im Dashboard. Stimmt der Titel nicht Nicht korrekt?"
klicken und den richtigen wählen — Rippy merkt sich das für diese Disc.
</p>
<p>
<span className={fett}>2. „Rippen starten"</span> und das Ziel wählen: 🎥 Filme, 📺 Serien,
🎵 Musik oder ein eigener Ordner (auch NAS-Freigaben). Optional: Nur Hauptfilm" (längster
Titel) oder per <span className={fett}>„Disc scannen"</span> einzelne Titel an-/abwählen.
</p>
<p>
<span className={fett}>3. Warten.</span> Rippy sichert verlustfrei (MakeMKV) und komprimiert
danach auf Arbeitsgröße (HandBrake) alle Tonspuren und Untertitel bleiben erhalten.
Fertige Jobs haben einen Download-Knopf; Klick auf den Titel zeigt alle Details.
</p>
<p>
<span className={fett}>Vollautomatik:</span> Unter Einstellungen Ripping einschaltbar
dann startet der Rip ohne Nachfrage, sobald eine Disc erkannt wurde. Bereits gerippte
Discs (Fingerabdruck) werden übersprungen.
</p>
</Abschnitt>
<Abschnitt icon={Tv} titel="Serien rippen">
<p>
Im Rippen starten"-Dialog <span className={fett}>„Serien"</span> wählen, Serienname und
Staffel angeben. Die Ablage wird automatisch <code>Serie/Season 02/</code>, und nach dem
Rip ordnet Rippy die Episoden über einen Laufzeit-Abgleich mit TMDB zu und benennt sie
(Serie S02E03.mkv"). Das passiert nur bei <span className={fett}>eindeutiger</span> Zuordnung —
im Zweifel bleiben die Original-Namen und das Log sagt warum.
</p>
<p>
Eine komplette Staffel auf einer Disc klappt auch, wenn alle Episoden gleich lang sind
(Anime-Boxen). Bei Teil-Discs mit lauter gleich langen Episoden ist eine automatische
Zuordnung prinzipiell nicht möglich — dann von Hand umbenennen.
</p>
</Abschnitt>
<Abschnitt icon={HardDrive} titel="NAS &amp; Speicherziele">
<p>
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
auflisten", Freigabe wählen, Einhängen &amp; speichern". Danach taucht das Ziel im
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
</p>
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
Einstellungen Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
jedem Rip und bricht sonst mit Klartext ab.
</p>
</Abschnitt>
<Abschnitt icon={Tv} titel="Jellyfin, Emby, Kodi, Plex">
<p>
Einstellungen Ripping Media-Server: Rippy benennt fertige Rips als Titel (Jahr)" und
legt für Jellyfin/Emby/Kodi zusätzlich <code>movie.nfo</code> + <code>poster.jpg</code> dazu —
dein Server erkennt alles ohne Nacharbeit (Plex nutzt nur die Benennung).
</p>
<p>
<span className={fett}>Null-Klick-Kette:</span> Bei Jellyfin/Emby zusätzlich Server-URL und
API-Key eintragen (Administration → API-Schlüssel) — dann stößt Rippy nach jedem fertigen
Rip sofort einen Bibliotheks-Scan an. Disc rein → Film erscheint im Server.
</p>
</Abschnitt>
<Abschnitt icon={Bell} titel="Benachrichtigungen">
<p>
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen (Discord-Webhook, ntfy-Thema
für Handy-Push, Slack oder eigenes JSON), „Test senden", speichern. Rippy meldet sich, wenn
ein Job fertig ist, fehlschlägt oder abgebrochen wird. Die Schritt-für-Schritt-Anleitung je
Dienst steht direkt auf der Einstellungs-Seite.
</p>
</Abschnitt>
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker zeigt für
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ,
ohne Docker nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
</p>
</Abschnitt>
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System">
<p>
Blu-ray-Ripping braucht den MakeMKV-Beta-Key, der etwa <span className={fett}>monatlich
wechselt</span> (MakeMKV-Forum, Thread MakeMKV is free while in beta"). Neuen Key unter
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
</p>
</Abschnitt>
<Abschnitt icon={Download} titel="Fertige Dateien herunterladen">
<p>
Bei fertigen Jobs: „Download" in der Aktion-Spalte (oder Klick auf den Titel) Dateiliste
mit Alle herunterladen". Die Job-Liste lässt sich per Papierkorb bzw. „Erledigte aufräumen"
leeren <span className={fett}>die gerippten Dateien bleiben dabei immer liegen</span>.
CSV" exportiert die komplette Historie für Excel.
</p>
</Abschnitt>
<Abschnitt icon={HelpCircle} titel="Häufige Fragen">
<p>
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
Fingerabdruck. Nochmal rippen geht trotzdem der Hinweis verhindert nur Versehen.
</p>
<p>
<span className={fett}>4K-UHD schlägt fehl mit volume key is unknown"?</span> Das Laufwerk
liest die Disc (LibreDrive), aber MakeMKV kennt den Schlüssel dieser (zu neuen) Pressung
noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
der nächsten Updates ist die Disc rippbar.
</p>
<p>
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
erscheint nur, wenn die Rohdaten noch daliegen bei einem Job, der nie gerippt hat, gäbe
es nichts zu komprimieren.
</p>
<p>
<span className={fett}>Kein Laufwerk sichtbar?</span> In einer VM muss das Laufwerk per
USB-Passthrough durchgereicht sein (nicht als emuliertes CD-ROM) Details in der README.
</p>
</Abschnitt>
</div>
)
}