Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s
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:
@@ -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) {
|
||||
|
||||
@@ -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 20–120 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 (20–120 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'}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user