Transcode-Stufe: HandBrake komprimiert NACH dem MakeMKV-Rip + --noscan-Fix
Ampel / ampel (push) Successful in 34s
Ampel / ampel (push) Successful in 34s
Commander-Entscheid 23.07.: 40-GB-Rohdateien sind kein brauchbares Endprodukt. Architektur bleibt zweistufig, weil HandBrake AACS nicht lesen kann: MakeMKV rippt verlustfrei nach /app/temp/raw, HandBrake macht daraus x265 auf Arbeitsgroesse in /app/media, Roh-Verzeichnis wird erst NACH komplettem Erfolg geloescht (keepOriginal behaelt es). - Worker: handbrake-cli im Image, run_handbrake + _komprimiere, Job-Status "transcoding", Settings aus Postgres (transcodeEnabled/ Preset/keepOriginal), Fortschritt je Datei aggregiert - makemkvcon: --noscan im Kommando verankert — der Geraete-Scan haengt (1.18.4) bzw. crasht (1.17.7) im Container, mit --noscan + dev:-Pfad laeuft es (Befund 23.07., DRV-Zeile beweist Laufwerks-Erkennung) - UI: Settings-Tab "Verarbeitung" (Toggle/Preset/Original behalten), Status-Badge "Komprimieren", LiveLog erkennt transcoding als aktiv - KONZEPT/ROADMAP entsprechend aktualisiert; Tests fuer HandBrake-Cmd (arbeitet auf DATEI, nie am Geraet) und Progress-Regex Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -44,7 +44,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
|
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
|
||||||
| GPL-v3-Compliance-Checkliste | ✔ M | | |
|
| GPL-v3-Compliance-Checkliste | ✔ M | | |
|
||||||
| Source-Release-Endpoint | ✔ M | | |
|
| Source-Release-Endpoint | ✔ M | | |
|
||||||
| HandBrake-Transcoding (optional) | | ✔ K | |
|
| HandBrake-Kompression NACH dem Lossless-Rip (Stufe 2) | ✔ M | | |
|
||||||
| Prometheus+Grafana Monitoring | | ✔ K | |
|
| Prometheus+Grafana Monitoring | | ✔ K | |
|
||||||
| PBS-Snapshot-Backup-Hooks | | ✔ K | |
|
| PBS-Snapshot-Backup-Hooks | | ✔ K | |
|
||||||
| Multi-Disc-Parallelisierung | | | ✔ S |
|
| Multi-Disc-Parallelisierung | | | ✔ S |
|
||||||
@@ -68,7 +68,12 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
- Commander bestätigt oder korrigiert manuell.
|
- Commander bestätigt oder korrigiert manuell.
|
||||||
6. **Ripping** (nach Bestätigung):
|
6. **Ripping** (nach Bestätigung):
|
||||||
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
|
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
|
||||||
- DVD/Blu-ray: `makemkvcon --all --progress` → MKV.
|
- DVD/Blu-ray: `makemkvcon` → verlustfreies MKV als ZWISCHENPRODUKT in /app/temp
|
||||||
|
(MakeMKV ist der einzige Weg durch AACS — HandBrake kann verschlüsselte
|
||||||
|
Discs nicht lesen), danach **HandBrake-Kompression auf Arbeitsgröße**
|
||||||
|
(x265; Commander-Entscheid 23.07.2026: 40-GB-Rohdateien sind kein
|
||||||
|
brauchbares Endprodukt). Roh-Datei wird nach Erfolg gelöscht
|
||||||
|
(Setting keepOriginal behält sie).
|
||||||
7. **Post-Processing & Jellyfin-Formatierung**:
|
7. **Post-Processing & Jellyfin-Formatierung**:
|
||||||
- Dateien in Jellyfin-konformer Ordnerstruktur.
|
- Dateien in Jellyfin-konformer Ordnerstruktur.
|
||||||
- NFO-Dateien im Kodi/NFO-Schema.
|
- NFO-Dateien im Kodi/NFO-Schema.
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class PreScan:
|
|||||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||||
if shutil.which("makemkvcon"):
|
if shutil.which("makemkvcon"):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
|
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120
|
timeout=120
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function LiveLogSection() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/jobs')
|
const response = await api.get('/jobs')
|
||||||
const jobs = response.data
|
const jobs = response.data
|
||||||
const current = jobs.find((j: any) => j.status === 'processing')
|
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
|
||||||
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Jobs:', error)
|
console.error('Fehler beim Laden der Jobs:', error)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LiveLogSection from '../components/LiveLogSection'
|
|||||||
interface Job {
|
interface Job {
|
||||||
id: string
|
id: string
|
||||||
type: 'cd' | 'dvd' | 'bluray'
|
type: 'cd' | 'dvd' | 'bluray'
|
||||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
status: 'pending' | 'processing' | 'transcoding' | 'completed' | 'failed'
|
||||||
device: string
|
device: string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
@@ -31,19 +31,22 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
? {
|
? {
|
||||||
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
||||||
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
|
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
|
||||||
|
transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20',
|
||||||
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
|
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
|
||||||
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
|
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
|
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
|
||||||
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
||||||
|
transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20',
|
||||||
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
|
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
|
||||||
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
pending: 'Wartend',
|
pending: 'Wartend',
|
||||||
processing: 'Bearbeitung',
|
processing: 'Rippen',
|
||||||
|
transcoding: 'Komprimieren',
|
||||||
completed: 'Fertig',
|
completed: 'Fertig',
|
||||||
failed: 'Fehler',
|
failed: 'Fehler',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Disc, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@ interface SettingsState {
|
|||||||
mainFeatureOnly: boolean
|
mainFeatureOnly: boolean
|
||||||
autoEject: boolean
|
autoEject: boolean
|
||||||
notificationWebhook: string
|
notificationWebhook: string
|
||||||
|
transcodeEnabled: boolean
|
||||||
|
transcodePreset: string
|
||||||
|
keepOriginal: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
@@ -29,9 +32,12 @@ const defaultSettings: SettingsState = {
|
|||||||
mainFeatureOnly: false,
|
mainFeatureOnly: false,
|
||||||
autoEject: true,
|
autoEject: true,
|
||||||
notificationWebhook: '',
|
notificationWebhook: '',
|
||||||
|
transcodeEnabled: true,
|
||||||
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
|
keepOriginal: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab = 'ripping' | 'verzeichnisse' | 'apis' | 'benachrichtigungen'
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'verzeichnisse' | 'apis' | 'benachrichtigungen'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
@@ -98,6 +104,7 @@ export default function SettingsPage() {
|
|||||||
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
|
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
|
||||||
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
||||||
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
||||||
|
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Database} label="Verzeichnisse" isActive={activeTab === 'verzeichnisse'} onClick={() => setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} />
|
<TabButton icon={Database} label="Verzeichnisse" isActive={activeTab === 'verzeichnisse'} onClick={() => setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||||
@@ -166,6 +173,52 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
|
{/* Verarbeitung: Kompressions-Stufe nach dem Rip */}
|
||||||
|
{activeTab === 'verarbeitung' && <section>
|
||||||
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
|
Verarbeitung (Kompression)
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake
|
||||||
|
komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 transition-colors duration-300">
|
||||||
|
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nach dem Rip komprimieren</h3>
|
||||||
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>x265 statt 40-GB-Rohdatei (empfohlen)</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={settings.transcodeEnabled} onChange={(v) => handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
|
HandBrake-Preset
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={settings.transcodePreset}
|
||||||
|
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg 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'}`}
|
||||||
|
>
|
||||||
|
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
||||||
|
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — für UHD-Quellen</option>
|
||||||
|
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||||
|
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Original behalten</h3>
|
||||||
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>}
|
||||||
|
|
||||||
{/* Output Directories */}
|
{/* Output Directories */}
|
||||||
{activeTab === 'verzeichnisse' && <section>
|
{activeTab === 'verzeichnisse' && <section>
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
|||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip
|
||||||
|
# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
libexpat1 \
|
libexpat1 \
|
||||||
@@ -68,6 +70,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cdparanoia \
|
cdparanoia \
|
||||||
cd-discid \
|
cd-discid \
|
||||||
flac \
|
flac \
|
||||||
|
handbrake-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=makemkv-build /usr/local /usr/local
|
COPY --from=makemkv-build /usr/local /usr/local
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ def init_db() -> None:
|
|||||||
metadata.create_all(engine)
|
metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
settings_table = Table(
|
||||||
|
"settings",
|
||||||
|
metadata,
|
||||||
|
Column("key", String(64), primary_key=True),
|
||||||
|
Column("value", Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(key: str = "ui") -> dict:
|
||||||
|
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||||
|
).first()
|
||||||
|
if zeile and zeile[0]:
|
||||||
|
return json.loads(zeile[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, **fields) -> None:
|
def update_job(job_id: str, **fields) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
|||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
||||||
|
--noscan KEIN Scan über alle Geräte — der brachte MakeMKV im
|
||||||
|
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
|
||||||
|
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
|
||||||
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
||||||
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
"makemkvcon",
|
||||||
"-r",
|
"-r",
|
||||||
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
f"dev:{device_path}",
|
||||||
@@ -68,6 +72,76 @@ def get_progress_from_prgv(line: str) -> int:
|
|||||||
return min(100, int(total * 100 / maximum))
|
return min(100, int(total * 100 / maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def check_handbrake_installed() -> bool:
|
||||||
|
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||||
|
return shutil.which("HandBrakeCLI") is not None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||||
|
|
||||||
|
|
||||||
|
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
|
||||||
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||||
|
|
||||||
|
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||||
|
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||||
|
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||||
|
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"HandBrakeCLI",
|
||||||
|
"--input", input_path,
|
||||||
|
"--output", output_path,
|
||||||
|
"--preset", preset,
|
||||||
|
"--all-audio",
|
||||||
|
"--all-subtitles",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_from_line(line: str) -> int:
|
||||||
|
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||||
|
|
||||||
|
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
||||||
|
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
||||||
|
"""
|
||||||
|
match = re.search(r'(\d+\.\d+)\s*%', line)
|
||||||
|
if match:
|
||||||
|
return int(float(match.group(1)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
|
||||||
|
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
||||||
|
if not check_handbrake_installed():
|
||||||
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
build_handbrake_cmd(input_path, output_path, preset),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in process.stdout:
|
||||||
|
progress = get_progress_from_line(line)
|
||||||
|
if progress > 0 and progress_cb:
|
||||||
|
progress_cb(progress)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
|
return {"status": "success", "output_path": output_path}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"HandBrake endete mit Code {process.returncode}",
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -149,13 +223,15 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = None) -> dict:
|
||||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||||
|
|
||||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift.
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||||
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||||
"""
|
"""
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
if output_dir is None:
|
||||||
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+86
-4
@@ -1,14 +1,30 @@
|
|||||||
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
|
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt, komprimiert, schreibt Status.
|
||||||
|
|
||||||
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
|
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
|
||||||
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
|
der Worker hält die Zeile aktuell (running → transcoding → completed/failed)
|
||||||
Ereignisse ins Log. Das UI liest beides über die API.
|
und schreibt Ereignisse ins Log. Das UI liest beides über die API.
|
||||||
|
|
||||||
|
Zwei Stufen (Commander-Entscheid 23.07.): MakeMKV rippt verlustfrei (einziger
|
||||||
|
Weg durch AACS — HandBrake kann verschlüsselte Discs nicht lesen), HandBrake
|
||||||
|
komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||||
|
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from detection import detect_disc_type
|
from detection import detect_disc_type
|
||||||
from ripping import rip_cd, rip_video
|
from ripping import (
|
||||||
|
DEFAULT_HB_PRESET,
|
||||||
|
RIP_OUTPUT_DIR,
|
||||||
|
rip_cd,
|
||||||
|
rip_video,
|
||||||
|
run_handbrake,
|
||||||
|
)
|
||||||
|
|
||||||
|
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||||
@@ -44,10 +60,27 @@ def rip_disc(self, device_path: str, job_id: str):
|
|||||||
)
|
)
|
||||||
db.update_job(job_id, progress=progress)
|
db.update_job(job_id, progress=progress)
|
||||||
|
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
transcode_an = (
|
||||||
|
disc_type in ("dvd", "bluray")
|
||||||
|
and einstellungen.get("transcodeEnabled", True)
|
||||||
|
)
|
||||||
|
|
||||||
if disc_type == "cd":
|
if disc_type == "cd":
|
||||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
|
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
|
||||||
|
elif transcode_an:
|
||||||
|
# Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht)
|
||||||
|
ergebnis = rip_video(
|
||||||
|
device_path, job_id, disc_type,
|
||||||
|
progress_cb=fortschritt,
|
||||||
|
output_dir=os.path.join(RAW_DIR, job_id),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
|
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
|
||||||
|
|
||||||
|
if ergebnis.get("status") == "success" and transcode_an:
|
||||||
|
ergebnis = _komprimiere(job_id, disc_type, ergebnis, einstellungen)
|
||||||
|
|
||||||
if ergebnis.get("status") == "success":
|
if ergebnis.get("status") == "success":
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -67,3 +100,52 @@ def rip_disc(self, device_path: str, job_id: str):
|
|||||||
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
||||||
|
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _komprimiere(job_id: str, disc_type: str, rip_ergebnis: dict, einstellungen: dict) -> dict:
|
||||||
|
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||||
|
|
||||||
|
Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis
|
||||||
|
gelöscht — bricht die Kompression ab, bleibt das Original in /app/temp
|
||||||
|
liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530).
|
||||||
|
"""
|
||||||
|
quellen = rip_ergebnis.get("files", [])
|
||||||
|
final_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, job_id)
|
||||||
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
|
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
|
||||||
|
original_behalten = einstellungen.get("keepOriginal", False)
|
||||||
|
|
||||||
|
db.update_job(job_id, status="transcoding", progress=0)
|
||||||
|
db.add_log(
|
||||||
|
"info", "worker",
|
||||||
|
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')",
|
||||||
|
)
|
||||||
|
|
||||||
|
anzahl = max(1, len(quellen))
|
||||||
|
for index, quelle in enumerate(quellen):
|
||||||
|
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||||||
|
|
||||||
|
def datei_fortschritt(p, _index=index):
|
||||||
|
gesamt = int((_index * 100 + p) / anzahl)
|
||||||
|
db.update_job(job_id, progress=min(99, gesamt))
|
||||||
|
|
||||||
|
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
|
||||||
|
if hb.get("status") != "success":
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||||
|
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_dir = os.path.dirname(quellen[0]) if quellen else None
|
||||||
|
if raw_dir:
|
||||||
|
if original_behalten:
|
||||||
|
ziel_original = os.path.join(final_dir, "original")
|
||||||
|
shutil.move(raw_dir, ziel_original)
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
return {"status": "success", "output_dir": final_dir}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import os
|
|||||||
|
|
||||||
from ripping import (
|
from ripping import (
|
||||||
build_abcde_cmd,
|
build_abcde_cmd,
|
||||||
|
build_handbrake_cmd,
|
||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
@@ -19,12 +21,33 @@ def test_makemkv_cmd_vollstaendig():
|
|||||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||||
assert cmd[0] == "makemkvcon"
|
assert cmd[0] == "makemkvcon"
|
||||||
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
|
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
||||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||||
assert "mkv" in cmd
|
assert "mkv" in cmd
|
||||||
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
||||||
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
||||||
|
"""HandBrake kann AACS nicht — es bekommt IMMER die MKV-Datei aus dem
|
||||||
|
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
||||||
|
für Blu-rays prinzipiell funktionsunfähig)."""
|
||||||
|
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
||||||
|
assert cmd[0] == "HandBrakeCLI"
|
||||||
|
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||||
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
|
assert "--preset" in cmd
|
||||||
|
assert "--all-audio" in cmd # alle Sprachen behalten
|
||||||
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_progress_parsing():
|
||||||
|
# Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
|
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||||
|
assert get_progress_from_line("kein Fortschritt hier") == 0
|
||||||
|
|
||||||
|
|
||||||
def test_prgv_parsing():
|
def test_prgv_parsing():
|
||||||
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
||||||
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
||||||
|
|||||||
Reference in New Issue
Block a user