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:
@@ -118,12 +118,19 @@ steckt im Image: `docker compose build worker && docker compose up -d worker`.
|
|||||||
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
|
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
|
||||||
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
|
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
|
||||||
|
|
||||||
### Optional: GPU-Maschine im Netz als Transcode-Worker
|
### Optional: weitere Maschinen als Transcode-Worker
|
||||||
|
|
||||||
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode` —
|
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode` —
|
||||||
JEDE Maschine im Netz kann sie übernehmen (siehe
|
JEDE Maschine im Netz kann sie übernehmen. **Einstellungen → Worker** zeigt
|
||||||
`deploy/remote-transcode-worker.yml`). Ohne Zusatz-Worker macht der
|
für beide Varianten einen Copy-Paste-Befehl:
|
||||||
eingebaute CPU-Worker alles selbst — Rippy bleibt All-in-one.
|
- **Linux (Docker)**: `deploy/remote-transcode-worker.yml`
|
||||||
|
- **Windows (nativ, ohne Docker)**: braucht nur Python 3.10+ — der
|
||||||
|
Installer (`install.ps1`) kommt von der Rippy-Instanz selbst
|
||||||
|
(`/api/worker-setup/windows`), lädt Worker-Code und HandBrakeCLI
|
||||||
|
automatisch und erzeugt eine `start-worker.bat` (optional `-Autostart`).
|
||||||
|
|
||||||
|
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy
|
||||||
|
bleibt All-in-one.
|
||||||
|
|
||||||
## Umgebungsvariablen (.env)
|
## Umgebungsvariablen (.env)
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -398,20 +398,33 @@ irrelevant und kann komplett raus."
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Etappe 16 (24.07.2026): Track-Tabelle + nativer Windows-Worker + Anleitung
|
||||||
|
|
||||||
|
- [x] **Volle Titel-Auswahl vor dem Rip**: scan_tracks-Worker-Task,
|
||||||
|
Polling-Endpoints, Tabelle (Dauer/Größe/Kapitel, Vorauswahl ≥ 5 min)
|
||||||
|
im Rip-Dialog, Multi-Titel-Rip (ein makemkvcon-Aufruf je Titel,
|
||||||
|
Fortschritt anteilig). Parser mit Tests.
|
||||||
|
- [x] **Nativer Windows-Worker ohne Docker**: install.ps1 + Worker-Code
|
||||||
|
werden von der Rippy-Instanz selbst serviert (/worker-setup/*);
|
||||||
|
HandBrakeCLI 1.9.2 vom offiziellen GitHub-Release; celery
|
||||||
|
--pool=solo -Q transcode; optional Autostart. UI bietet Linux- und
|
||||||
|
Windows-Variante. E2E auf echtem Windows-PC bewiesen.
|
||||||
|
- [x] **Anleitungs-Tab**: Rippy erklärt sich selbst (Disc-Weg, Serien,
|
||||||
|
NAS, Media-Server, Benachrichtigungen, Key, Worker, FAQ).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Ideen-Katalog (Rest) — bewusst offen
|
## Ideen-Katalog (Rest) — bewusst offen
|
||||||
|
|
||||||
1. **Volle Track-Auswahl-Tabelle vor dem Rip** (awaiting_selection):
|
1. **Design 2.0** (kommt in eine frische Session, Commander-Entscheid):
|
||||||
Scan-Task → Titel-Tabelle im UI → Auswahl → Rip. Der 80%-Fall ist mit
|
`dark:`-Klassen statt Theme-Ternaries — Infrastruktur steht komplett,
|
||||||
„Nur Hauptfilm" abgedeckt; die Tabelle lohnt für Bonus-Material-Discs.
|
reine mechanische Konvertierung pro Komponente. Der neue Anleitungs-Tab
|
||||||
2. **Nativer Windows-Worker (ohne Docker)**: PyInstaller-Paket (Celery
|
gehört mit in den Feinschliff.
|
||||||
solo-Pool + HandBrakeCLI.exe gebündelt) als Windows-Dienst; verbindet
|
2. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
|
||||||
sich über die jetzt offenen Ports (6379/5432/8000). Braucht einen
|
|
||||||
echten Testlauf auf einer Windows-Maschine — nichts ungetestet liefern.
|
|
||||||
3. **Design 2.0**: `dark:`-Klassen statt Theme-Ternaries — die
|
|
||||||
Infrastruktur (darkMode: 'class' + html.dark) steht KOMPLETT, es ist
|
|
||||||
reine mechanische Konvertierung pro Komponente.
|
|
||||||
4. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
|
|
||||||
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
|
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
|
||||||
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
|
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
|
||||||
5. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
|
3. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
|
||||||
Jellyfin/Emby.
|
Jellyfin/Emby.
|
||||||
|
4. **Windows-Worker als echter Dienst** (statt start-worker.bat/onlogon):
|
||||||
|
z. B. via NSSM oder PyInstaller-Paket — Komfort-Ausbau der jetzt
|
||||||
|
funktionierenden nativen Variante.
|
||||||
|
|||||||
+27
-1
@@ -1,6 +1,32 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
|
## Aktueller Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
|
||||||
|
|
||||||
|
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
|
||||||
|
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
||||||
|
'tracks:<device>', UI pollt) → Tabelle mit Checkbox/Dauer/Größe/Kapiteln,
|
||||||
|
Vorauswahl ab 5 min. Gewählte Titel gehen als meta.titles in den Job;
|
||||||
|
der Worker rippt sie einzeln (makemkvcon kann pro Aufruf nur einen Titel
|
||||||
|
oder all), Fortschritt anteilig. Parser mit Tests (apdefs-Attr 8/9/11).
|
||||||
|
- **Nativer Windows-Worker (ohne Docker!)**: Einstellungen → Worker bietet
|
||||||
|
jetzt ZWEI Varianten — Linux (Docker, wie gehabt) und Windows (nativ,
|
||||||
|
braucht nur Python). Die Rippy-Instanz versorgt sich selbst:
|
||||||
|
GET /worker-setup/windows liefert install.ps1, /worker-setup/paket den
|
||||||
|
Worker-Code als Zip (beides liegt via api-Dockerfile im Image).
|
||||||
|
install.ps1: venv + Abhängigkeiten, HandBrakeCLI 1.9.2 vom offiziellen
|
||||||
|
GitHub-Release (URL verifiziert), start-worker.bat (celery -Q transcode
|
||||||
|
--pool=solo), optional -Autostart (schtasks onlogon). tasks.py lädt auf
|
||||||
|
Windows ohne fcntl (Import-Guard); rip_disc ist dort hart verriegelt.
|
||||||
|
**E2E-BEWEIS: von diesem Windows-PC aus installiert, verbunden und ein
|
||||||
|
echter Transcode-Job gegen die VM gefahren** (Ergebnis siehe unten).
|
||||||
|
- **Anleitungs-Tab** im UI: kompletter Selbsterklärer (Disc-Weg, Serien,
|
||||||
|
NAS, Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).
|
||||||
|
- Design 2.0 kommt bewusst in eine FRISCHE Session (Commander-Entscheid) —
|
||||||
|
Infrastruktur steht (darkMode 'class'), reine Konvertierungsarbeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
|
||||||
|
|
||||||
**Commander-Entscheid: AUTH IST KOMPLETT RAUS** (Heimnetz-only; das UI hatte
|
**Commander-Entscheid: AUTH IST KOMPLETT RAUS** (Heimnetz-only; das UI hatte
|
||||||
nie einen Login, die Endpoints waren Placebo, passlib/bcrypt brach die Ampel).
|
nie einen Login, die Endpoints waren Placebo, passlib/bcrypt brach die Ampel).
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Rippy: Nativer Windows-Transcode-Worker — Installation OHNE Docker.
|
||||||
|
#
|
||||||
|
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
||||||
|
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
||||||
|
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
|
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
|
# 4. Erzeugt start-worker.bat — Doppelklick startet den Worker
|
||||||
|
#
|
||||||
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) — gerippt wird
|
||||||
|
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
||||||
|
#
|
||||||
|
# Aufruf (PowerShell):
|
||||||
|
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||||
|
#
|
||||||
|
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||||
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||||
|
[switch]$Autostart
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$HandBrakeVersion = "1.9.2"
|
||||||
|
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
|
||||||
|
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Python finden (py-Launcher oder python im PATH)
|
||||||
|
$python = $null
|
||||||
|
foreach ($kandidat in @("py -3", "python")) {
|
||||||
|
try {
|
||||||
|
$version = Invoke-Expression "$kandidat --version" 2>&1
|
||||||
|
if ("$version" -match "Python 3\.(\d+)") {
|
||||||
|
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $python) {
|
||||||
|
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
||||||
|
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausfuehren)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Python gefunden: $python"
|
||||||
|
|
||||||
|
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
|
||||||
|
$ziel = Join-Path (Get-Location) "rippy-worker"
|
||||||
|
New-Item -ItemType Directory -Force $ziel | Out-Null
|
||||||
|
Set-Location $ziel
|
||||||
|
|
||||||
|
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
||||||
|
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||||
|
Expand-Archive worker.zip -DestinationPath . -Force
|
||||||
|
Remove-Item worker.zip
|
||||||
|
|
||||||
|
# 3. venv + Abhaengigkeiten
|
||||||
|
if (-not (Test-Path "venv")) {
|
||||||
|
Write-Host "Lege Python-Umgebung an ..."
|
||||||
|
Invoke-Expression "$python -m venv venv"
|
||||||
|
}
|
||||||
|
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||||
|
Write-Host "Python-Abhaengigkeiten installiert."
|
||||||
|
|
||||||
|
# 4. HandBrakeCLI besorgen (falls noetig)
|
||||||
|
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
||||||
|
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
||||||
|
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
||||||
|
Expand-Archive hb.zip -DestinationPath . -Force
|
||||||
|
Remove-Item hb.zip
|
||||||
|
}
|
||||||
|
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
||||||
|
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "HandBrakeCLI bereit."
|
||||||
|
|
||||||
|
# 5. Start-Skript erzeugen
|
||||||
|
$bat = @"
|
||||||
|
@echo off
|
||||||
|
rem Rippy Windows-Transcode-Worker — verbindet sich mit $RippyHost
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
|
set API_URL=http://${RippyHost}:8000
|
||||||
|
set WORKER_NAME=$WorkerName
|
||||||
|
set PATH=%~dp0;%PATH%
|
||||||
|
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
||||||
|
"@
|
||||||
|
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||||
|
Write-Host "start-worker.bat erzeugt."
|
||||||
|
|
||||||
|
# 6. Optional: Autostart bei Anmeldung
|
||||||
|
if ($Autostart) {
|
||||||
|
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-worker.bat`"" /sc onlogon | Out-Null
|
||||||
|
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (bei Anmeldung)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FERTIG. Worker starten mit: $ziel\start-worker.bat" -ForegroundColor Green
|
||||||
|
Write-Host "Er erscheint danach in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
||||||
@@ -17,4 +17,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY docker/api/ .
|
COPY docker/api/ .
|
||||||
|
|
||||||
|
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
|
||||||
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
|
COPY deploy/worker-windows/install.ps1 worker_dist/
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ class JobCreateRequest(BaseModel):
|
|||||||
series: Optional[str] = None # Serien-Flow: Ablage <Serie>/Season NN
|
series: Optional[str] = None # Serien-Flow: Ablage <Serie>/Season NN
|
||||||
season: Optional[int] = None
|
season: Optional[int] = None
|
||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -390,6 +391,10 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_dict["season"] = max(1, int(request.season or 1))
|
meta_dict["season"] = max(1, int(request.season or 1))
|
||||||
if request.main_feature_only is not None:
|
if request.main_feature_only is not None:
|
||||||
meta_dict["main_feature_only"] = request.main_feature_only
|
meta_dict["main_feature_only"] = request.main_feature_only
|
||||||
|
if request.titles:
|
||||||
|
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
||||||
|
if titel_liste:
|
||||||
|
meta_dict["titles"] = titel_liste
|
||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -542,6 +547,34 @@ async def eject_device(name: str):
|
|||||||
return {"status": "ejected", "device": device_path}
|
return {"status": "ejected", "device": device_path}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/devices/{name}/scan-tracks")
|
||||||
|
async def scan_tracks_starten(name: str):
|
||||||
|
"""Titel-Scan der eingelegten Disc anstoßen (Track-Auswahl-Tabelle).
|
||||||
|
|
||||||
|
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
||||||
|
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
||||||
|
"""
|
||||||
|
device_path = f"/dev/{name}"
|
||||||
|
if device_path not in device_discovery.list_optical_devices():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||||
|
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||||
|
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||||
|
|
||||||
|
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
|
||||||
|
celery_client.send_task("worker.tasks.scan_tracks", args=[device_path])
|
||||||
|
return {"status": "scanning"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/devices/{name}/tracks")
|
||||||
|
async def scan_tracks_ergebnis(name: str):
|
||||||
|
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
||||||
|
device_path = f"/dev/{name}"
|
||||||
|
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
||||||
|
if not daten:
|
||||||
|
return {"status": "none"}
|
||||||
|
return daten
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jobs/{job_id}/retry-transcode")
|
@app.post("/jobs/{job_id}/retry-transcode")
|
||||||
async def retry_transcode(job_id: str):
|
async def retry_transcode(job_id: str):
|
||||||
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
|
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
|
||||||
@@ -902,6 +935,50 @@ async def browse_mkdir(request: MkdirRequest):
|
|||||||
return {"path": ziel}
|
return {"path": ziel}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/windows")
|
||||||
|
async def worker_setup_windows():
|
||||||
|
"""Windows-Installer-Skript für den nativen Transcode-Worker.
|
||||||
|
|
||||||
|
Das UI (Einstellungen → Worker → Windows) zeigt den passenden
|
||||||
|
Zwei-Zeilen-Aufruf — alles kommt von dieser Rippy-Instanz selbst.
|
||||||
|
"""
|
||||||
|
pfad = "worker_dist/install.ps1"
|
||||||
|
if not os.path.isfile(pfad):
|
||||||
|
raise HTTPException(status_code=404, detail="Installer nicht im Image — API neu bauen")
|
||||||
|
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/paket")
|
||||||
|
async def worker_setup_paket():
|
||||||
|
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||||
|
|
||||||
|
Kein git, kein Docker auf der Zielmaschine nötig: die Rippy-Instanz
|
||||||
|
versorgt ihre Worker selbst (Dateien liegen via Dockerfile im Image).
|
||||||
|
"""
|
||||||
|
def baue():
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
if not os.path.isdir("worker_dist"):
|
||||||
|
return None
|
||||||
|
puffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(puffer, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for name in sorted(os.listdir("worker_dist")):
|
||||||
|
if name.endswith(".py") and not name.startswith(("test_", "conftest")):
|
||||||
|
z.write(os.path.join("worker_dist", name), name)
|
||||||
|
elif name == "requirements.txt":
|
||||||
|
z.write(os.path.join("worker_dist", name), name)
|
||||||
|
return puffer.getvalue()
|
||||||
|
|
||||||
|
inhalt = await asyncio.to_thread(baue)
|
||||||
|
if not inhalt:
|
||||||
|
raise HTTPException(status_code=404, detail="Worker-Paket nicht im Image — API neu bauen")
|
||||||
|
return Response(
|
||||||
|
content=inhalt, media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="rippy-worker.zip"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/system/info")
|
@app.get("/system/info")
|
||||||
async def system_info():
|
async def system_info():
|
||||||
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
|
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from './lib/api'
|
||||||
import { useDarkMode } from './context/ThemeContext'
|
import { useDarkMode } from './context/ThemeContext'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import AnleitungPage from './pages/Anleitung'
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
|
|
||||||
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
|
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
|
||||||
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
|
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
|
||||||
type Page = 'dashboard' | 'settings' | 'logs'
|
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [page, setPage] = useState<Page>('dashboard')
|
||||||
@@ -28,6 +29,7 @@ function App() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
{ id: 'logs', label: 'Logs', icon: Terminal },
|
||||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
{ 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">
|
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
<div className="max-w-7xl mx-auto p-6">
|
||||||
{page === 'dashboard' && <Dashboard />}
|
{page === 'dashboard' && <Dashboard />}
|
||||||
|
{page === 'anleitung' && <AnleitungPage />}
|
||||||
{page === 'logs' && <LogsPage />}
|
{page === 'logs' && <LogsPage />}
|
||||||
{page === 'settings' && <SettingsPage />}
|
{page === 'settings' && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ export default function DeviceDiscovery() {
|
|||||||
isOpen={modalDevice !== null}
|
isOpen={modalDevice !== null}
|
||||||
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||||
discTitle={modalDevice?.disc?.title}
|
discTitle={modalDevice?.disc?.title}
|
||||||
|
deviceId={modalDevice?.id}
|
||||||
onClose={() => setModalDevice(null)}
|
onClose={() => setModalDevice(null)}
|
||||||
onSave={(target, optionen) => {
|
onSave={(target, optionen) => {
|
||||||
if (modalDevice) {
|
if (modalDevice) {
|
||||||
|
|||||||
@@ -15,16 +15,31 @@ export interface RipOptionen {
|
|||||||
series?: string
|
series?: string
|
||||||
season?: number
|
season?: number
|
||||||
mainFeatureOnly?: boolean
|
mainFeatureOnly?: boolean
|
||||||
|
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitelInfo {
|
||||||
|
nr: number
|
||||||
|
dauer_s: number
|
||||||
|
groesse_bytes: number
|
||||||
|
kapitel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RipTargetModalProps {
|
interface RipTargetModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||||
discTitle?: string // Vorschlag für den Serien-Namen
|
discTitle?: string // Vorschlag für den Serien-Namen
|
||||||
|
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (target: TargetConfig, optionen: RipOptionen) => 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 {
|
interface BrowseDir {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
@@ -32,11 +47,48 @@ interface BrowseDir {
|
|||||||
|
|
||||||
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
||||||
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
|
// 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 [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||||
const [serienName, setSerienName] = useState('')
|
const [serienName, setSerienName] = useState('')
|
||||||
const [staffel, setStaffel] = useState(1)
|
const [staffel, setStaffel] = useState(1)
|
||||||
const [nurHauptfilm, setNurHauptfilm] = useState(false)
|
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[]>([
|
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', 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('')
|
setCustomPath('')
|
||||||
setSerienName(discTitle || '')
|
setSerienName(discTitle || '')
|
||||||
setStaffel(1)
|
setStaffel(1)
|
||||||
|
setScanStatus('idle')
|
||||||
|
setScanFehler('')
|
||||||
|
setTitelListe([])
|
||||||
|
setGewaehlt(new Set())
|
||||||
api.get('/settings').then(r => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
@@ -88,6 +144,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
|||||||
optionen.series = serienName.trim()
|
optionen.series = serienName.trim()
|
||||||
optionen.season = Math.max(1, staffel || 1)
|
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)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
|
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
|
||||||
{selectedType === 'movies' && (
|
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'}`}>
|
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -184,6 +247,74 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
|||||||
</label>
|
</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 */}
|
{/* Ordner-Browser */}
|
||||||
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
<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'}`}>
|
<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() {
|
export default function WorkerVerwaltung() {
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -59,11 +60,17 @@ export default function WorkerVerwaltung() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const rippyHost = window.location.hostname
|
const rippyHost = window.location.hostname
|
||||||
const installBefehl = [
|
const installBefehl = variante === 'linux'
|
||||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
? [
|
||||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
||||||
].join('\n')
|
` 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 = () => {
|
const kopieren = () => {
|
||||||
navigator.clipboard.writeText(installBefehl).then(() => {
|
navigator.clipboard.writeText(installBefehl).then(() => {
|
||||||
@@ -131,15 +138,28 @@ export default function WorkerVerwaltung() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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'}`}>
|
<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'}`}>
|
<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
|
{variante === 'linux'
|
||||||
Befehl ausführen — WORKER_NAME frei wählen, unter dem Namen taucht die Maschine
|
? '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.'
|
||||||
oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden?
|
: '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.'}
|
||||||
Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen
|
|
||||||
Rippy-Host im Netz.
|
|
||||||
</p>
|
</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'}`}>
|
<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}
|
{installBefehl}
|
||||||
@@ -152,10 +172,8 @@ export default function WorkerVerwaltung() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<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
|
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier,
|
||||||
HandBrake + Celery, und Docker ist der einzige Weg, das auf jeder Maschine
|
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
||||||
identisch und ohne Installations-Roulette auszuliefern. Ein nativer
|
|
||||||
Windows-Dienst (Installer, ohne Docker) steht als Ausbaustufe in der ROADMAP.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -148,6 +148,28 @@ def get_job(job_id: str) -> dict:
|
|||||||
return dict(zeile) if zeile else None
|
return dict(zeile) if zeile else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(werte: dict, key: str = "ui") -> None:
|
||||||
|
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||||
|
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
payload = json.dumps(werte)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
vorhanden = conn.execute(
|
||||||
|
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||||
|
).first()
|
||||||
|
if vorhanden:
|
||||||
|
conn.execute(
|
||||||
|
settings_table.update()
|
||||||
|
.where(settings_table.c.key == key)
|
||||||
|
.values(value=payload)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(settings_table.insert().values(key=key, value=payload))
|
||||||
|
|
||||||
|
|
||||||
def get_job_status(job_id: str) -> str:
|
def get_job_status(job_id: str) -> str:
|
||||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -78,6 +78,62 @@ def parse_tinfo_dauern(ausgabe: str) -> dict:
|
|||||||
return dauern
|
return dauern
|
||||||
|
|
||||||
|
|
||||||
|
def parse_titel_info(ausgabe: str) -> list:
|
||||||
|
"""TINFO-Zeilen → volle Titel-Liste für die Auswahl-Tabelle (pure Funktion).
|
||||||
|
|
||||||
|
Attribut-IDs laut MakeMKV apdefs.h (makemkvgui/inc/lgpl/apdefs.h, LGPL):
|
||||||
|
8 = ChapterCount, 9 = Duration ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||||
|
Rückgabe: [{"nr", "dauer_s", "groesse_bytes", "kapitel"}, …] sortiert.
|
||||||
|
"""
|
||||||
|
titel: dict = {}
|
||||||
|
for zeile in ausgabe.splitlines():
|
||||||
|
treffer = re.match(r'TINFO:(\d+),(\d+),\d+,"(.*)"\s*$', zeile.strip())
|
||||||
|
if not treffer:
|
||||||
|
continue
|
||||||
|
nr, attr, wert = int(treffer.group(1)), int(treffer.group(2)), treffer.group(3)
|
||||||
|
eintrag = titel.setdefault(nr, {"nr": nr, "dauer_s": 0, "groesse_bytes": 0, "kapitel": 0})
|
||||||
|
if attr == 9:
|
||||||
|
teile = wert.split(":")
|
||||||
|
if len(teile) == 3 and all(t.isdigit() for t in teile):
|
||||||
|
h, m, s = (int(t) for t in teile)
|
||||||
|
eintrag["dauer_s"] = h * 3600 + m * 60 + s
|
||||||
|
elif attr == 11 and wert.isdigit():
|
||||||
|
eintrag["groesse_bytes"] = int(wert)
|
||||||
|
elif attr == 8 and wert.isdigit():
|
||||||
|
eintrag["kapitel"] = int(wert)
|
||||||
|
return [titel[nr] for nr in sorted(titel)]
|
||||||
|
|
||||||
|
|
||||||
|
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
||||||
|
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
return parse_titel_info(ergebnis.stdout or "")
|
||||||
|
|
||||||
|
|
||||||
|
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||||
|
progress_cb=None) -> dict:
|
||||||
|
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
||||||
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
|
gesamt = len(titel_liste)
|
||||||
|
alle_dateien = []
|
||||||
|
for index, nr in enumerate(titel_liste):
|
||||||
|
def anteilig(p, _i=index):
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(int((_i * 100 + p) / gesamt))
|
||||||
|
|
||||||
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
|
||||||
|
if ergebnis.get("status") == "cancelled":
|
||||||
|
return ergebnis
|
||||||
|
if ergebnis.get("status") != "success":
|
||||||
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
return ergebnis
|
||||||
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict):
|
def laengster_titel(dauern: dict):
|
||||||
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||||
if not dauern:
|
if not dauern:
|
||||||
@@ -316,18 +372,24 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
|
|
||||||
|
|
||||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||||
output_dir: str = None, nur_hauptfilm: bool = False) -> dict:
|
output_dir: str = None, nur_hauptfilm: bool = False,
|
||||||
|
titel_liste: list = 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. output_dir
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||||
|
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||||
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||||
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||||
"""
|
"""
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
|
|
||||||
|
if titel_liste:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb)
|
||||||
|
|
||||||
titel = "all"
|
titel = "all"
|
||||||
if nur_hauptfilm:
|
if nur_hauptfilm:
|
||||||
dauern = lies_titel_dauern(device_path)
|
dauern = lies_titel_dauern(device_path)
|
||||||
|
|||||||
+51
-5
@@ -23,12 +23,22 @@ import db
|
|||||||
import medien
|
import medien
|
||||||
import notify
|
import notify
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from detection import detect_disc_type, disc_size_bytes
|
|
||||||
|
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
|
||||||
|
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
|
||||||
|
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
|
||||||
|
try:
|
||||||
|
from detection import detect_disc_type, disc_size_bytes
|
||||||
|
except ImportError: # Windows: kein fcntl
|
||||||
|
detect_disc_type = None
|
||||||
|
disc_size_bytes = None
|
||||||
|
|
||||||
from ripping import (
|
from ripping import (
|
||||||
DEFAULT_HB_PRESET,
|
DEFAULT_HB_PRESET,
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
RipAbbruch,
|
RipAbbruch,
|
||||||
lies_datei_dauer,
|
lies_datei_dauer,
|
||||||
|
lies_titel_info,
|
||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
@@ -252,6 +262,16 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
"""
|
"""
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
||||||
|
if detect_disc_type is None:
|
||||||
|
ergebnis = {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Dieser Worker kann nur komprimieren (nativer "
|
||||||
|
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
|
||||||
|
"wird auf der Rippy-Hauptmaschine.",
|
||||||
|
}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
if _abbruch_angefordert(job_id):
|
if _abbruch_angefordert(job_id):
|
||||||
_job_abschliessen(job_id, {"status": "cancelled"})
|
_job_abschliessen(job_id, {"status": "cancelled"})
|
||||||
return {"status": "cancelled"}
|
return {"status": "cancelled"}
|
||||||
@@ -311,12 +331,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
job.get("title") or "", meta.get("year"), job_id,
|
job.get("title") or "", meta.get("year"), job_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nur Hauptfilm (längster Titel): pro Rip wählbar, sonst Setting;
|
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
|
||||||
# bei Serien-Discs sinnlos (dort zählen ALLE Episoden-Titel).
|
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
|
||||||
|
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
|
||||||
|
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
|
||||||
nur_hauptfilm = meta.get("main_feature_only")
|
nur_hauptfilm = meta.get("main_feature_only")
|
||||||
if nur_hauptfilm is None:
|
if nur_hauptfilm is None:
|
||||||
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||||||
if meta.get("series"):
|
if meta.get("series") or titel_liste:
|
||||||
nur_hauptfilm = False
|
nur_hauptfilm = False
|
||||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
db.update_job(job_id, output_path=final_dir)
|
db.update_job(job_id, output_path=final_dir)
|
||||||
@@ -326,7 +348,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||||
try:
|
try:
|
||||||
disc_bytes = disc_size_bytes(device_path)
|
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
|
||||||
except OSError:
|
except OSError:
|
||||||
disc_bytes = 0
|
disc_bytes = 0
|
||||||
marge = 1024**3 # 1 GB Sicherheitsabstand
|
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||||
@@ -349,12 +371,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
progress_cb=fortschritt,
|
progress_cb=fortschritt,
|
||||||
output_dir=raw_dir,
|
output_dir=raw_dir,
|
||||||
nur_hauptfilm=nur_hauptfilm,
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
|
titel_liste=titel_liste,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
device_path, job_id, disc_type,
|
device_path, job_id, disc_type,
|
||||||
progress_cb=fortschritt, output_dir=final_dir,
|
progress_cb=fortschritt, output_dir=final_dir,
|
||||||
nur_hauptfilm=nur_hauptfilm,
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
|
titel_liste=titel_liste,
|
||||||
)
|
)
|
||||||
|
|
||||||
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
||||||
@@ -397,6 +421,28 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||||
|
def scan_tracks(device_path: str):
|
||||||
|
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||||
|
|
||||||
|
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
|
||||||
|
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
|
||||||
|
Laufwerk). Ein Info-Lauf dauert je nach Disc 20–120 s.
|
||||||
|
"""
|
||||||
|
db.init_db()
|
||||||
|
key = f"tracks:{device_path}"
|
||||||
|
db.save_settings({"status": "running"}, key)
|
||||||
|
try:
|
||||||
|
titel = lies_titel_info(device_path)
|
||||||
|
db.save_settings({"status": "done", "tracks": titel}, key)
|
||||||
|
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
||||||
|
return {"status": "done", "anzahl": len(titel)}
|
||||||
|
except Exception as e:
|
||||||
|
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||||
|
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
||||||
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||||
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from ripping import (
|
|||||||
laengster_titel,
|
laengster_titel,
|
||||||
parse_scan_dauer,
|
parse_scan_dauer,
|
||||||
parse_tinfo_dauern,
|
parse_tinfo_dauern,
|
||||||
|
parse_titel_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
|
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
|
||||||
@@ -39,6 +40,22 @@ def test_makemkv_cmd_default_bleibt_all():
|
|||||||
assert cmd[-2:] == ["all", "/out"]
|
assert cmd[-2:] == ["all", "/out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_titel_info_volle_tabelle():
|
||||||
|
"""Attr 8=Kapitel, 9=Dauer, 11=Bytes (MakeMKV apdefs.h) → Auswahl-Tabelle."""
|
||||||
|
ausgabe = """
|
||||||
|
TINFO:0,8,0,"24"
|
||||||
|
TINFO:0,9,0,"1:52:29"
|
||||||
|
TINFO:0,11,0,"31234567890"
|
||||||
|
TINFO:1,9,0,"0:03:12"
|
||||||
|
TINFO:1,11,0,"98765432"
|
||||||
|
"""
|
||||||
|
titel = parse_titel_info(ausgabe)
|
||||||
|
assert titel == [
|
||||||
|
{"nr": 0, "dauer_s": 6749, "groesse_bytes": 31234567890, "kapitel": 24},
|
||||||
|
{"nr": 1, "dauer_s": 192, "groesse_bytes": 98765432, "kapitel": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_parse_scan_dauer():
|
def test_parse_scan_dauer():
|
||||||
ausgabe = "…\n + duration: 01:52:29\n…"
|
ausgabe = "…\n + duration: 01:52:29\n…"
|
||||||
assert parse_scan_dauer(ausgabe) == 6749
|
assert parse_scan_dauer(ausgabe) == 6749
|
||||||
|
|||||||
Reference in New Issue
Block a user