+ {/* Palette */}
+
+
Bausteine
+
Auf Schritt klicken oder in die Kette ziehen
+
+
+
Systemblöcke
+
handleChainPaletteDragStart(e, { stepType: 'wait' })}
+ onClick={() => addChainStep('wait')}
+ title="Wartezeit zwischen zwei Schritten"
+ >
+
+ {' '}Warten (Sekunden)
+
+
+
+ {scripts.length > 0 ? (
+
+
Scripte
+ {scripts.map((script) => (
+
handleChainPaletteDragStart(e, { stepType: 'script', scriptId: script.id, scriptName: script.name })}
+ onClick={() => addChainStep('script', script.id, script.name)}
+ title={`Script #${script.id} hinzufügen`}
+ >
+
+ {' '}{script.name}
+
+ ))}
+
+ ) : (
+
Keine Scripte verfügbar. Zuerst Scripte anlegen.
+ )}
+
+
+ {/* Chain canvas */}
+
+
Kette ({chainEditor.steps.length} Schritt{chainEditor.steps.length !== 1 ? 'e' : ''})
+
+ {chainEditor.steps.length === 0 ? (
+
handleChainDropzoneDrop(e, 0)}
+ >
+ Bausteine hierhin ziehen oder links anklicken
+
+ ) : (
+
+ {chainEditor.steps.map((step, index) => (
+
+ {/* Drop zone before step */}
+
handleChainDropzoneDrop(e, index)}
+ />
+
handleChainStepDragStart(e, index)}
+ onDragEnd={() => setChainDragSource(null)}
+ >
+
+
+
+
+ {step.stepType === 'wait' ? (
+
+
+ Warten:
+ updateChainStepWait(index, Number(e.target.value))}
+ className="chain-wait-input"
+ onClick={(e) => e.stopPropagation()}
+ />
+ Sekunden
+
+ ) : (
+
+
+ {step.scriptName || `Script #${step.scriptId}`}
+
+ )}
+
+
+
+ ))}
+ {/* Drop zone after last step */}
+
handleChainDropzoneDrop(e, chainEditor.steps.length)}
+ />
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
+ Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
+ in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
+
+
+ {userPresetsLoading ? (
+ Lade Presets ...
+ ) : userPresets.length === 0 ? (
+ Keine Presets vorhanden. Lege ein neues Preset an.
+ ) : (
+
+ {userPresets.map((preset) => (
+
+
+
+ #{preset.id} - {preset.name}
+
+ {preset.mediaType === 'bluray' ? 'Blu-ray'
+ : preset.mediaType === 'dvd' ? 'DVD'
+ : 'Universell'}
+
+
+
+ {preset.description || '-'}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {expertModeEnabled && activationBytes.length > 0 && (
+
+
+
+
+ | Checksum |
+ Activation Bytes |
+ Gespeichert am |
+
+
+
+ {activationBytes.map((entry) => (
+
+ | {entry.checksum} |
+ {entry.activation_bytes} |
+ {new Date(entry.created_at).toLocaleString('de-DE')} |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
new file mode 100644
index 0000000..8a715bb
--- /dev/null
+++ b/frontend/src/styles/app.css
@@ -0,0 +1,5879 @@
+:root {
+ --rip-ink: #2f180f;
+ --rip-brown-900: #3a1d12;
+ --rip-brown-800: #552919;
+ --rip-brown-700: #6f3922;
+ --rip-brown-600: #8e4d2d;
+ --rip-gold-400: #d49c56;
+ --rip-gold-300: #e7c17e;
+ --rip-gold-200: #f2dcad;
+ --rip-cream-100: #fbf2df;
+ --rip-cream-50: #fffaf0;
+ --rip-border: #d9bc8d;
+ --rip-muted: #6a4d38;
+ --rip-panel: #fffaf1;
+ --rip-panel-soft: #fdf5e7;
+ --ripper-side-width: 20rem;
+
+ /* PrimeReact theme tokens */
+ --primary-color: var(--rip-brown-600);
+ --primary-color-text: #fff7e9;
+ --primary-50: #fef6ec;
+ --primary-100: #f3d9b2;
+ --primary-200: #e7bd87;
+ --primary-300: #daa05c;
+ --primary-400: #cd8331;
+ --primary-500: #b56a2a;
+ --primary-600: #965724;
+ --primary-700: #77431c;
+ --primary-800: #583015;
+ --primary-900: #391e0d;
+ --green-50: #fcf7ee;
+ --green-100: #f4dec0;
+ --green-200: #ebc594;
+ --green-300: #e1ac68;
+ --green-400: #d8933d;
+ --green-500: #bf7832;
+ --green-600: #9f642a;
+ --green-700: #7f4f21;
+ --green-800: #603a18;
+ --green-900: #402510;
+ --yellow-50: #fff8e9;
+ --yellow-100: #f9e2b3;
+ --yellow-200: #f2cb7f;
+ --yellow-300: #ebb54c;
+ --yellow-400: #e49e19;
+ --yellow-500: #ca8616;
+ --yellow-600: #a66f12;
+ --yellow-700: #83580e;
+ --yellow-800: #60410a;
+ --yellow-900: #3d2a06;
+ --blue-50: #f8f4ee;
+ --blue-100: #e7d8c0;
+ --blue-200: #d7bd96;
+ --blue-300: #c7a16d;
+ --blue-400: #b78643;
+ --blue-500: #9d6d38;
+ --blue-600: #825a2e;
+ --blue-700: #664623;
+ --blue-800: #4b3319;
+ --blue-900: #301f0f;
+ --red-50: #fdf2ed;
+ --red-100: #f3cec0;
+ --red-200: #eaa995;
+ --red-300: #e18569;
+ --red-400: #d7613e;
+ --red-500: #bf4f33;
+ --red-600: #9f422a;
+ --red-700: #7f3521;
+ --red-800: #602818;
+ --red-900: #401b10;
+ --surface-ground: #f2e7d1;
+ --surface-section: var(--rip-cream-100);
+ --surface-card: var(--rip-panel);
+ --surface-overlay: #fffaf2;
+ --surface-border: var(--rip-border);
+ --surface-hover: #f4e7d0;
+ --focus-ring: 0 0 0 0.2rem rgba(182, 107, 44, 0.35);
+ --text-color: var(--rip-ink);
+ --text-color-secondary: var(--rip-muted);
+ --highlight-bg: var(--rip-gold-200);
+ --highlight-text-color: var(--rip-brown-900);
+
+ font-family: 'Source Sans 3', 'Segoe UI', sans-serif;
+ color: var(--rip-ink);
+ background: radial-gradient(circle at 12% 8%, #f8e3bc 0%, #f8ecd4 35%, #fdf8ee 68%, #fffdf8 100%);
+ line-height: 1.4;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: inherit;
+ color: var(--rip-ink);
+ overflow-x: hidden;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header {
+ padding: 0.95rem 1.25rem;
+ border-bottom: 1px solid var(--rip-border);
+ background: linear-gradient(105deg, #f1d09b 0%, #e1af6d 32%, #d89d57 60%, #be7441 100%);
+ box-shadow: 0 8px 18px rgba(58, 29, 18, 0.12);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.brand-block {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+}
+
+.brand-logo {
+ width: 72px;
+ height: 72px;
+ object-fit: contain;
+ filter: drop-shadow(0 5px 9px rgba(58, 29, 18, 0.28));
+}
+
+.brand-copy h1 {
+ margin: 0;
+ font-size: 1.75rem;
+ line-height: 1.05;
+ color: var(--rip-brown-900);
+}
+
+.brand-copy p {
+ margin: 0.28rem 0 0;
+ font-size: 0.92rem;
+ color: var(--rip-brown-700);
+ letter-spacing: 0.02em;
+}
+
+.brand-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.app-version {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.35rem;
+ padding: 0.1rem 0.45rem;
+ border: 1px solid rgba(58, 29, 18, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 250, 240, 0.45);
+ color: rgba(58, 29, 18, 0.72);
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+}
+
+.nav-buttons {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.nav-btn.p-button {
+ border-color: rgba(58, 29, 18, 0.36);
+ color: var(--rip-brown-900);
+}
+
+.nav-btn.p-button:not(.p-button-outlined) {
+ background: var(--rip-brown-700);
+ border-color: var(--rip-brown-700);
+ color: #fff8eb;
+}
+
+.nav-btn.p-button.p-button-outlined {
+ background: rgba(255, 247, 232, 0.45);
+}
+
+.nav-btn.p-button.p-button-outlined:hover {
+ background: rgba(255, 247, 232, 0.78);
+ border-color: var(--rip-brown-700);
+ color: var(--rip-brown-900);
+}
+
+.nav-btn-active.p-button {
+ box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
+}
+
+.zip-status-indicator {
+ border: 1px solid rgba(58, 29, 18, 0.28);
+ border-radius: 999px;
+ background: rgba(255, 247, 232, 0.55);
+ color: var(--rip-brown-900);
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.55rem 0.8rem;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
+}
+
+.zip-status-indicator:hover {
+ background: rgba(255, 247, 232, 0.82);
+ border-color: var(--rip-brown-700);
+ transform: translateY(-1px);
+}
+
+.zip-status-indicator i {
+ font-size: 0.95rem;
+}
+
+.zip-status-indicator span {
+ white-space: nowrap;
+}
+
+.zip-status-indicator-ready {
+ background: rgba(231, 247, 233, 0.9);
+ border-color: rgba(28, 138, 58, 0.28);
+ color: #1f6d35;
+}
+
+.zip-status-indicator-error {
+ background: rgba(255, 236, 230, 0.9);
+ border-color: rgba(184, 74, 39, 0.26);
+ color: #9f3b1f;
+}
+
+.app-main {
+ width: 100%;
+ max-width: none;
+ margin: 1rem 0 2rem;
+ padding: 0 1rem;
+}
+
+.app-upload-banner {
+ width: auto;
+ max-width: none;
+ margin: 0.9rem 1rem 0;
+ padding: 0.8rem 0.95rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.7rem;
+ background: linear-gradient(135deg, rgba(255, 250, 241, 0.96), rgba(250, 237, 210, 0.92));
+ box-shadow: 0 10px 24px rgba(58, 29, 18, 0.08);
+ display: grid;
+ grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr) auto;
+ gap: 0.85rem;
+ align-items: center;
+}
+
+.app-upload-banner.phase-error {
+ border-color: #d8a19a;
+ background: linear-gradient(135deg, #fff8f5, #f9e1dc);
+}
+
+.app-upload-banner.phase-completed {
+ border-color: #a7cda5;
+ background: linear-gradient(135deg, #f8fff7, #e8f3de);
+}
+
+.app-upload-banner-copy,
+.app-upload-banner-progress {
+ min-width: 0;
+ display: grid;
+ gap: 0.22rem;
+}
+
+.app-upload-banner-head {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+}
+
+.app-upload-banner-copy small,
+.app-upload-banner-progress small {
+ color: var(--rip-muted);
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.app-upload-banner-progress .p-progressbar {
+ height: 0.52rem;
+ background: rgba(111, 57, 34, 0.12);
+}
+
+.app-upload-banner-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ justify-self: end;
+}
+
+.page-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.page-grid > * {
+ min-width: 0;
+}
+
+.page-grid .p-card,
+.page-grid .p-card .p-card-body,
+.page-grid .p-card .p-card-content {
+ min-width: 0;
+}
+
+.ripper-3col-grid {
+ display: grid;
+ width: 100%;
+ grid-template-columns: var(--ripper-side-width) minmax(0, 1fr) var(--ripper-side-width);
+ gap: 1rem;
+ align-items: start;
+}
+
+.ripper-col {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.ripper-subpage-content {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.ripper-subpage-content > * {
+ min-width: 0;
+}
+
+.job-inbox-toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ margin-bottom: 0.85rem;
+}
+
+.job-inbox-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.job-inbox-filter.p-button,
+.job-inbox-filter-active.p-button {
+ border-radius: 999px;
+}
+
+.job-inbox-search-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.job-inbox-search-input {
+ width: min(40rem, 100%);
+}
+
+.job-inbox-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.job-inbox-row {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+ border: 1px solid var(--surface-border);
+ border-radius: 12px;
+ padding: 0.75rem 0.85rem;
+ background: var(--surface-card);
+}
+
+.job-inbox-row-main {
+ display: flex;
+ flex-direction: column;
+ gap: 0.22rem;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.job-inbox-row-main strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.job-inbox-row-main small {
+ color: var(--text-color-secondary);
+}
+
+.job-inbox-row-tags {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.job-inbox-row-actions {
+ display: flex;
+ justify-content: flex-end;
+ flex: 0 0 auto;
+}
+
+.job-inbox-pagination {
+ margin-top: 1rem;
+}
+
+.job-inbox-pagination .p-paginator {
+ justify-content: flex-end;
+}
+
+@media (max-width: 900px) {
+ .job-inbox-row {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ .job-inbox-row-tags,
+ .job-inbox-row-actions {
+ justify-content: flex-start;
+ }
+ .job-inbox-pagination .p-paginator {
+ justify-content: flex-start;
+ }
+}
+
+@media (max-width: 1100px) {
+ .ripper-3col-grid {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
+ }
+ .ripper-col-right {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 700px) {
+ .ripper-3col-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.status-row span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.progress-wrap {
+ margin-bottom: 1rem;
+}
+
+.actions-row {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.actions-section {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.actions-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.actions-group-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-color-secondary);
+ margin-bottom: 0.1rem;
+}
+
+.action-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.actions-group .action-item .p-button {
+ flex-shrink: 0;
+ width: 13rem;
+}
+
+.action-desc {
+ font-size: 0.8rem;
+ color: var(--text-color-secondary);
+ line-height: 1.35;
+}
+
+.action-delete-entry {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ border-top: 1px solid var(--surface-border);
+ padding-top: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+.action-delete-entry .action-desc {
+ text-align: right;
+}
+
+.ripper-drive-state {
+ margin: 0.5rem 0;
+}
+
+.settings-expert-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.45rem 0.7rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.settings-expert-toggle > span {
+ font-size: 0.9rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.hardware-monitor-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-bottom: 0.7rem;
+}
+
+.hardware-monitor-grid {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ gap: 0.75rem;
+ align-items: stretch;
+}
+
+.hardware-monitor-grid > .hardware-monitor-block {
+ flex: 0 0 calc(33.333% - 0.5rem);
+ min-width: 0;
+}
+
+.hardware-monitor-block {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+ padding: 0.65rem;
+ display: grid;
+ gap: 0.55rem;
+ align-content: start;
+}
+
+.hardware-monitor-block h4 {
+ margin: 0;
+}
+
+.hardware-monitor-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.35rem 0.55rem;
+}
+
+.hardware-cpu-summary {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 0.35rem;
+ align-items: center;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.hardware-cpu-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 999px;
+ background: var(--rip-panel);
+ padding: 0.18rem 0.42rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ white-space: nowrap;
+ min-width: 0;
+ flex-shrink: 1;
+}
+
+.hardware-cpu-chip span {
+ line-height: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.hardware-cpu-chip .pi {
+ font-size: 0.75rem;
+ color: var(--rip-brown-700);
+ flex-shrink: 0;
+}
+
+.hardware-cpu-load-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ min-width: 0;
+ flex-shrink: 1;
+}
+
+.hardware-cpu-core-toggle-btn {
+ border: 1px solid var(--rip-border);
+ background: var(--rip-panel);
+ color: var(--rip-brown-700);
+ width: 1.6rem;
+ min-width: 1.6rem;
+ height: 1.6rem;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+.hardware-cpu-core-toggle-btn:hover {
+ background: #f1e1c8;
+}
+
+.hardware-cpu-core-toggle-btn .pi {
+ font-size: 0.78rem;
+}
+
+.hardware-monitor-meta > div,
+.hardware-core-item,
+.hardware-gpu-item,
+.hardware-storage-item {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.hardware-core-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.45rem;
+}
+
+.hardware-core-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.42rem 0.5rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.12rem;
+}
+
+.hardware-core-grid.compact {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.32rem;
+}
+
+.hardware-core-item.compact {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.2rem 0.3rem;
+ padding: 0.3rem 0.45rem;
+ text-align: left;
+ overflow: hidden;
+}
+
+.hardware-core-title {
+ font-size: 0.74rem;
+ font-weight: 700;
+ color: var(--rip-brown-700);
+ min-width: 2.2rem;
+}
+
+.hardware-core-metric {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ justify-self: start;
+}
+
+.hardware-core-metric .pi {
+ font-size: 0.72rem;
+ color: var(--rip-muted);
+}
+
+.hardware-gpu-list,
+.hardware-storage-list {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.hardware-gpu-item,
+.hardware-storage-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.45rem 0.55rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.15rem;
+}
+
+.hardware-gpu-item {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem 0.35rem;
+}
+
+.hardware-gpu-item strong {
+ flex: 0 0 100%;
+ font-size: 0.82rem;
+}
+
+.hardware-gpu-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 999px;
+ background: var(--rip-panel-soft);
+ padding: 0.18rem 0.42rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ white-space: nowrap;
+ color: var(--rip-brown-800);
+}
+
+.hardware-storage-item.compact {
+ gap: 0.3rem;
+}
+
+.hardware-storage-group-label {
+ color: var(--rip-muted);
+ font-size: 0.75rem;
+ line-height: 1.3;
+}
+
+.hardware-storage-bar-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.hardware-storage-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 0.5rem;
+}
+
+.hardware-storage-head strong {
+ display: block;
+ min-width: 0;
+ line-height: 1.3;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.hardware-storage-percent {
+ font-size: 0.74rem;
+ font-weight: 700;
+ border-radius: 999px;
+ padding: 0.1rem 0.45rem;
+ border: 1px solid transparent;
+ white-space: nowrap;
+ justify-self: end;
+ align-self: start;
+}
+
+.hardware-storage-percent.tone-ok {
+ color: #18643a;
+ background: #d8f1df;
+ border-color: #9ed5ad;
+}
+
+.hardware-storage-percent.tone-warn {
+ color: #7d4f00;
+ background: #f8ebc4;
+ border-color: #d7b46a;
+}
+
+.hardware-storage-percent.tone-high {
+ color: #8e3d00;
+ background: #f5dcc9;
+ border-color: #d9a578;
+}
+
+.hardware-storage-percent.tone-critical {
+ color: #8f1f17;
+ background: #f6d6d5;
+ border-color: #d89a97;
+}
+
+.hardware-storage-percent.tone-unknown {
+ color: #5b4636;
+ background: #eadfcc;
+ border-color: #c8ad88;
+}
+
+.hardware-storage-bar .p-progressbar {
+ height: 0.5rem;
+ background: #eadbc1;
+}
+
+.hardware-storage-bar.tone-ok .p-progressbar-value {
+ background: #2d9c4e;
+}
+
+.hardware-storage-bar.tone-warn .p-progressbar-value {
+ background: #c58f00;
+}
+
+.hardware-storage-bar.tone-high .p-progressbar-value {
+ background: #d6761f;
+}
+
+.hardware-storage-bar.tone-critical .p-progressbar-value {
+ background: #c43c2e;
+}
+
+.hardware-storage-bar.tone-unknown .p-progressbar-value {
+ background: #8a6a53;
+}
+
+.hardware-storage-summary {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.hardware-storage-path {
+ color: var(--rip-muted);
+}
+
+.hardware-storage-paths {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.4rem;
+ align-items: baseline;
+ margin-top: 0.1rem;
+}
+
+.hardware-storage-label-tag {
+ color: var(--rip-muted);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.pipeline-queue-meta {
+ display: flex;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.7rem;
+}
+
+.pipeline-queue-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.75rem;
+}
+
+.pipeline-queue-col {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ padding: 0.55rem 0.6rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ align-content: start;
+ grid-auto-rows: min-content;
+ gap: 0.45rem;
+}
+
+.pipeline-queue-col.queue-col-running {
+ order: 1;
+}
+
+.pipeline-queue-col.queue-col-queued {
+ order: 2;
+}
+
+.pipeline-queue-col.queue-col-idle {
+ order: 3;
+}
+
+.pipeline-queue-col h4 {
+ margin: 0;
+}
+
+.pipeline-queue-col-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.queue-add-entry-btn {
+ background: none;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.35rem;
+ color: var(--rip-muted);
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 0.15rem 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
+ white-space: nowrap;
+}
+
+.queue-add-entry-btn:hover {
+ color: var(--rip-primary, #6366f1);
+ border-color: var(--rip-primary, #6366f1);
+ background: var(--rip-panel);
+}
+
+.queue-empty-hint {
+ color: var(--rip-muted);
+ font-size: 0.82rem;
+}
+
+.script-results-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.script-summary-block {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.script-summary-counts {
+ display: flex;
+ gap: 0.6rem;
+ font-size: 0.82rem;
+ margin-left: 0.2rem;
+}
+
+.script-summary-counts .tone-success { color: #1c8a3a; font-weight: 600; }
+.script-summary-counts .tone-danger { color: #9c2d2d; font-weight: 600; }
+.script-summary-counts .tone-warning { color: #b45309; font-weight: 600; }
+
+.script-result-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-left: 0.5rem;
+ margin-top: 0.15rem;
+}
+
+.script-result-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.83rem;
+}
+
+.script-result-name {
+ flex: 1;
+ color: var(--rip-text);
+}
+
+.script-result-status {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.script-result-status.tone-success { color: #1c8a3a; }
+.script-result-status.tone-danger { color: #9c2d2d; }
+.script-result-status.tone-warning { color: #b45309; }
+
+.script-result-error {
+ font-size: 0.75rem;
+ color: #9c2d2d;
+ font-style: italic;
+}
+
+.job-step-inline-warn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ color: #b45309;
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.job-step-inline-warn .pi {
+ font-size: 0.85rem;
+}
+
+.pipeline-queue-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.42rem 0.5rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.15rem;
+}
+
+.pipeline-queue-item.running {
+ border-style: solid;
+}
+
+.pipeline-queue-item.queue-job-item {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.pipeline-queue-item.queued {
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.pipeline-queue-item.queued.non-job {
+ border-style: dotted;
+ background: var(--rip-surface);
+ opacity: 0.92;
+}
+
+.pipeline-queue-type-icon {
+ font-size: 0.82rem;
+ color: var(--rip-muted);
+ flex-shrink: 0;
+}
+
+.queue-job-tag {
+ font-size: 0.72rem;
+ color: var(--rip-muted);
+ margin-left: 0.3rem;
+ vertical-align: middle;
+}
+
+.pipeline-queue-item-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.15rem;
+}
+
+.queue-job-expand-btn {
+ width: 1.6rem;
+ height: 1.6rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.35rem;
+ background: transparent;
+ color: var(--rip-muted);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.queue-job-expand-btn:hover {
+ border-color: var(--rip-brown-600);
+ color: var(--rip-brown-700);
+ background: var(--rip-surface);
+}
+
+.queue-job-script-details {
+ margin-top: 0.2rem;
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ background: var(--rip-panel-soft);
+ padding: 0.4rem 0.5rem;
+ display: grid;
+ gap: 0.3rem;
+}
+
+.queue-job-script-group {
+ display: grid;
+ gap: 0.1rem;
+}
+
+.queue-job-script-group strong {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: var(--rip-muted);
+}
+
+.queue-job-script-group small {
+ font-size: 0.8rem;
+ color: var(--rip-ink);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.pipeline-queue-entry-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.queue-insert-btn {
+ align-self: center;
+ background: none;
+ border: 1px dashed var(--rip-border);
+ border-radius: 999px;
+ color: var(--rip-muted);
+ cursor: pointer;
+ font-size: 0.7rem;
+ line-height: 1;
+ padding: 0.1rem 0.5rem;
+ margin: 0.15rem auto;
+ opacity: 0.4;
+ transition: opacity 0.15s;
+}
+
+.queue-insert-btn:hover {
+ opacity: 1;
+ color: var(--rip-primary, #6366f1);
+ border-color: var(--rip-primary, #6366f1);
+}
+
+.pipeline-queue-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.queue-insert-dialog-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.queue-insert-dialog-hint {
+ margin: 0;
+ font-size: 0.82rem;
+ color: var(--rip-muted);
+}
+
+.queue-insert-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--rip-border);
+}
+
+.queue-insert-section:first-of-type {
+ border-top: none;
+}
+
+.queue-insert-section-label {
+ font-weight: 600;
+ font-size: 0.88rem;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.queue-insert-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.queue-insert-option {
+ background: var(--rip-surface);
+ border: 1px solid var(--rip-border);
+ border-radius: 0.35rem;
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.79rem;
+ padding: 0.2rem 0.5rem;
+ transition: background 0.12s;
+}
+
+.queue-insert-option:hover {
+ background: var(--rip-panel);
+ border-color: var(--rip-primary, #6366f1);
+}
+
+.queue-insert-system-block {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ background: var(--rip-surface);
+ border: 1px solid var(--rip-border);
+ border-radius: 0.35rem;
+ padding: 0.2rem 0.5rem;
+}
+
+.queue-insert-system-name {
+ font-size: 0.79rem;
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ white-space: nowrap;
+}
+
+.pipeline-queue-item.queued.dragging {
+ opacity: 0.65;
+}
+
+.pipeline-queue-item-main {
+ min-width: 0;
+ display: grid;
+ gap: 0.15rem;
+}
+
+.pipeline-queue-item-main strong,
+.pipeline-queue-item-main small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pipeline-queue-drag-handle {
+ cursor: grab;
+ color: var(--rip-muted);
+ font-size: 0.9rem;
+ border: 1px solid transparent;
+ border-radius: 0.3rem;
+ padding: 0.2rem;
+}
+
+.pipeline-queue-drag-handle.disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.pipeline-queue-remove-btn {
+ width: 1.9rem;
+ height: 1.9rem;
+}
+
+.ripper-job-list {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.ripper-job-row {
+ width: 100%;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ padding: 0.6rem 0.7rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ gap: 0.7rem;
+ align-items: center;
+ text-align: left;
+ color: var(--rip-ink);
+ cursor: pointer;
+}
+
+.ripper-job-row:hover {
+ border-color: var(--rip-brown-600);
+ background: #fbf0df;
+}
+
+.ripper-job-row-content {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.3rem 0.7rem;
+ align-items: start;
+}
+
+.ripper-job-row-main {
+ display: grid;
+ gap: 0.3rem;
+ min-width: 0;
+}
+
+.ripper-job-row-main strong,
+.ripper-job-row-main small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ripper-job-title-line {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+}
+
+.ripper-job-title-line > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.media-indicator-icon {
+ width: 1.1rem;
+ height: 1.1rem;
+ flex: 0 0 auto;
+ display: inline-block;
+}
+
+.ripper-job-row-progress {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 0.2rem;
+}
+
+.ripper-job-row-progress .p-progressbar {
+ height: 0.42rem;
+}
+
+.ripper-job-row-progress small {
+ color: var(--rip-muted);
+ white-space: normal;
+}
+
+.p-fileupload-buttonbar {
+ padding: 0.4rem 0.6rem !important;
+ gap: 0.35rem !important;
+}
+
+.p-fileupload-buttonbar .p-button.p-button-icon-only {
+ width: 2rem !important;
+ height: 2rem !important;
+ padding: 0 !important;
+}
+
+.p-fileupload-buttonbar .p-button.p-button-icon-only .p-button-icon {
+ font-size: 0.85rem !important;
+}
+
+.aax-drop-zone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 2rem 1rem;
+ text-align: center;
+ color: var(--rip-muted);
+}
+
+.aax-drop-zone p {
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.aax-drop-zone small {
+ font-size: 0.8rem;
+}
+
+.aax-drop-icon {
+ font-size: 2.2rem;
+ color: var(--rip-gold-400);
+}
+
+.aax-file-item {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.6rem 0.75rem;
+ border-top: 1px solid var(--rip-border);
+}
+
+.aax-file-icon {
+ font-size: 1.4rem;
+ color: var(--rip-brown-600);
+ flex-shrink: 0;
+}
+
+.aax-file-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+ flex: 1;
+}
+
+.aax-file-name {
+ font-size: 0.875rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.audiobook-upload-status {
+ margin-top: 0.8rem;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.35rem;
+}
+
+.audiobook-upload-status.tone-warning {
+ border-color: #d9b26d;
+ background: #fff7e8;
+}
+
+.p-fileupload .p-fileupload-content {
+ padding: 0;
+}
+
+.audiobook-upload-status.tone-info {
+ border-color: #c7b086;
+ background: #fbf5ea;
+}
+
+.audiobook-upload-status.tone-success {
+ border-color: #9cc7a1;
+ background: #f5fbf3;
+}
+
+.audiobook-upload-status.tone-danger {
+ border-color: #d8a19a;
+ background: #fff6f3;
+}
+
+.audiobook-upload-status-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.audiobook-upload-file {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.audiobook-upload-progress {
+ margin-top: 0.15rem;
+}
+
+.ripper-job-badges {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+}
+
+.job-step-checks {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.job-step-inline-ok {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ color: #1c8a3a;
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.job-step-inline-ok .pi {
+ font-size: 0.85rem;
+}
+
+.job-step-inline-no {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ color: #9c2d2d;
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.job-step-inline-no .pi {
+ font-size: 0.85rem;
+}
+
+.job-step-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.job-step-ok-icon {
+ color: #1c8a3a;
+}
+
+.muted-inline {
+ color: var(--rip-muted);
+ display: block;
+}
+
+.ripper-job-poster-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.56rem;
+ color: var(--rip-muted);
+ background: #f6ebd6;
+}
+
+.ripper-job-expanded {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.6rem;
+ padding: 0.6rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.6rem;
+}
+
+.ripper-job-expanded-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.ripper-job-expanded-title {
+ display: grid;
+ gap: 0.35rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.playlist-waiting-box {
+ margin-top: 0.75rem;
+ padding: 0.6rem 0.7rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.25rem;
+}
+
+.playlist-title-choice-list {
+ display: grid;
+ gap: 0.3rem;
+ margin-top: 0.25rem;
+}
+
+.playlist-title-choice {
+ display: grid;
+ gap: 0.12rem;
+}
+
+.playlist-title-choice label {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.45rem;
+}
+
+.playlist-title-choice input {
+ margin-top: 0.2rem;
+}
+
+.playlist-recommendation-box {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ background: var(--rip-panel-soft);
+ padding: 0.5rem 0.65rem;
+ display: grid;
+ gap: 0.2rem;
+}
+
+.dialog-actions {
+ margin-top: 1rem;
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.playlist-selection-box {
+ margin-top: 0.9rem;
+ padding: 0.75rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.45rem;
+}
+
+.playlist-selection-box h4 {
+ margin: 0;
+}
+
+.playlist-candidate-list {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.search-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.metadata-filter-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+ align-items: start;
+}
+
+.metadata-filter-grid-single {
+ grid-template-columns: 1fr;
+}
+
+.metadata-filter-disc {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.metadata-filter-subtitle {
+ color: var(--text-color-secondary);
+ font-size: 0.78rem;
+ line-height: 1.25;
+}
+
+.metadata-grid {
+ margin-top: 0.75rem;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.cd-rip-config-panel {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.cd-meta-summary {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ background: var(--rip-panel-soft);
+ padding: 0.55rem 0.65rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.cd-media-meta-layout {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.75rem;
+ align-items: start;
+}
+
+.cd-cover-wrap {
+ width: 7rem;
+ min-width: 7rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ overflow: hidden;
+ background: #fff;
+}
+
+.cd-cover-image {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1/1;
+ object-fit: cover;
+}
+
+.cd-format-field {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.cd-format-field label {
+ font-weight: 600;
+}
+
+.cd-format-field small {
+ color: var(--rip-muted);
+}
+
+.cd-track-selection {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+ padding: 0.65rem;
+ display: grid;
+ gap: 0.55rem;
+}
+
+.cd-track-list {
+ overflow-x: auto;
+}
+
+.cd-track-table {
+ width: 100%;
+ min-width: 44rem;
+ border-collapse: collapse;
+}
+
+.cd-track-table th,
+.cd-track-table td {
+ border-bottom: 1px solid var(--rip-border);
+ padding: 0.45rem 0.5rem;
+ text-align: left;
+ vertical-align: middle;
+}
+
+.cd-track-table thead th {
+ color: var(--rip-muted);
+ font-size: 0.8rem;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.cd-track-table tbody tr.selected {
+ background: rgba(175, 114, 7, 0.09);
+}
+
+.cd-track-table td.check,
+.cd-track-table th.check {
+ width: 4.5rem;
+ text-align: center;
+}
+
+.cd-track-table td.num,
+.cd-track-table th.num {
+ width: 4rem;
+ white-space: nowrap;
+}
+
+.cd-track-table td.duration,
+.cd-track-table th.duration {
+ width: 5.5rem;
+ white-space: nowrap;
+}
+
+.cd-track-table td.status,
+.cd-track-table th.status {
+ width: 6.5rem;
+ white-space: nowrap;
+ text-align: center;
+}
+
+.cd-track-table td.artist .p-inputtext,
+.cd-track-table td.title .p-inputtext {
+ width: 100%;
+}
+
+.device-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.settings-grid {
+ display: grid;
+ gap: 0.5rem;
+}
+
+/* Hierarchische Settings — Baumstruktur */
+.settings-grid--depth-1 {
+ margin-left: 1.5rem;
+ padding-left: 0.9rem;
+ border-left: 3px solid var(--rip-amber, #f59e0b);
+ margin-top: 0.2rem;
+ margin-bottom: 0.1rem;
+ background: rgba(245, 158, 11, 0.04);
+ border-radius: 0 0.35rem 0.35rem 0;
+}
+
+.settings-grid--depth-2 {
+ margin-left: 1.25rem;
+ padding-left: 0.75rem;
+ border-left: 2px solid var(--rip-border, #e5e7eb);
+ margin-top: 0.15rem;
+ border-radius: 0 0.25rem 0.25rem 0;
+}
+
+/* Label für readonly/not-yet-implemented Toggles */
+.setting-label--readonly {
+ opacity: 0.55;
+}
+
+/* Readonly-Zeile leicht ausgrauen */
+.settings-grid--depth-1 .setting-row:has(.p-inputswitch.p-disabled),
+.settings-grid--depth-2 .setting-row:has(.p-inputswitch.p-disabled) {
+ opacity: 0.6;
+}
+
+.setting-badge--coming-soon {
+ font-size: 0.68rem;
+ color: var(--rip-muted, #9ca3af);
+ font-weight: 400;
+ font-style: italic;
+ margin-left: 0.3rem;
+}
+
+.settings-tabview {
+ margin-top: 0.75rem;
+}
+
+.settings-root-tabview {
+ margin-top: 0.75rem;
+}
+
+.settings-root-tabview .p-tabview-panels {
+ padding: 1rem 0 0;
+}
+
+.settings-tabview .p-tabview-panels {
+ padding: 1rem 0 0;
+}
+
+.settings-sections {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.settings-section.grouped {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: #fff7ea;
+ padding: 0.75rem;
+}
+
+.settings-section-head {
+ display: grid;
+ gap: 0.15rem;
+ margin-bottom: 0.55rem;
+}
+
+.settings-section-head h4 {
+ margin: 0;
+ color: var(--rip-brown-800);
+ font-size: 1rem;
+}
+
+.settings-section-head small {
+ color: var(--rip-muted);
+}
+
+.setting-row {
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.45rem 0.6rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.setting-row .saved-tag {
+ width: fit-content;
+}
+
+.setting-description {
+ white-space: pre-wrap;
+ font-size: 0.78rem;
+ color: var(--rip-muted);
+ line-height: 1.35;
+}
+
+.notification-toggle-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1rem;
+ align-items: stretch;
+}
+
+.settings-grid + .notification-toggle-grid {
+ margin-top: 1rem;
+}
+
+/* Detected drives info block (Settings page → Laufwerk) */
+.detected-drives-info {
+ margin-bottom: 1.25rem;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.detected-drives-header {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.detected-drives-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--rip-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ flex: 1;
+}
+
+.detected-drives-empty {
+ font-size: 0.85rem;
+ color: var(--rip-muted);
+}
+
+.detected-drives-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.detected-drives-table th {
+ text-align: left;
+ padding: 0.2rem 0.5rem;
+ color: var(--rip-muted);
+ font-weight: 600;
+ border-bottom: 1px solid var(--rip-border);
+}
+
+.detected-drives-table td {
+ padding: 0.25rem 0.5rem;
+ vertical-align: middle;
+}
+
+.detected-drives-table tr:not(:last-child) td {
+ border-bottom: 1px solid var(--rip-border);
+}
+
+/* Drive devices list editor (Settings page) */
+.drive-devices-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.drive-devices-empty {
+ color: var(--rip-muted);
+ font-size: 0.85rem;
+ margin: 0;
+}
+
+.drive-device-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.drive-device-input {
+ flex: 1;
+ font-family: monospace;
+ font-size: 0.9rem;
+}
+
+.external-storage-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.external-storage-empty {
+ color: var(--rip-muted);
+ font-size: 0.85rem;
+ margin: 0;
+}
+
+.external-storage-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.external-storage-name-input {
+ width: 14rem;
+ min-width: 10rem;
+}
+
+.external-storage-input {
+ flex: 1;
+ font-family: monospace;
+ font-size: 0.9rem;
+}
+
+.converter-scan-extensions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
+ gap: 0.45rem 0.7rem;
+ margin-top: 0.1rem;
+}
+
+.converter-scan-extension-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.converter-scan-extension-option span {
+ font-family: monospace;
+ font-size: 0.85rem;
+}
+
+/* Per-drive CD sections in Ripper */
+/* Unified per-drive list (Disk-Information card) */
+.drive-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-top: 0.5rem;
+}
+
+.drive-list-empty {
+ font-size: 0.85rem;
+ color: var(--rip-muted);
+ margin: 0.5rem 0 0;
+}
+
+.drive-list-item {
+ padding: 0.35rem 0.55rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.4rem;
+ background: var(--rip-panel-soft);
+}
+
+.drive-list-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.drive-list-info {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.28rem;
+ min-width: 0;
+}
+
+.drive-list-path {
+ display: inline-block;
+ font-size: 0.8rem;
+ color: var(--rip-muted);
+ white-space: nowrap;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.drive-list-model {
+ font-size: 0.75rem;
+ color: var(--rip-muted);
+ max-width: 14rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.drive-list-disc-arg {
+ font-size: 0.72rem;
+ color: var(--rip-muted);
+ opacity: 0.85;
+ flex: 0 0 auto;
+}
+
+.drive-list-state {
+ justify-self: start;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+}
+
+.drive-list-status-icon {
+ width: 1.05rem;
+ height: 1.05rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ color: var(--rip-muted);
+}
+
+.drive-list-status-icon.tone-success {
+ color: #1c8a3a;
+}
+
+.drive-list-status-icon.tone-danger {
+ color: #9c2d2d;
+}
+
+.drive-list-status-icon.tone-warning {
+ color: #b45309;
+}
+
+.drive-list-status-icon.tone-info {
+ color: #24588b;
+}
+
+.drive-list-status-icon.tone-secondary {
+ color: var(--rip-muted);
+}
+
+.drive-list-lock-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ padding: 0.08rem 0.32rem;
+ border: 1px solid rgba(155, 111, 22, 0.4);
+ border-radius: 999px;
+ background: rgba(251, 233, 194, 0.65);
+ color: #8a5a0a;
+ font-size: 0.66rem;
+ font-weight: 600;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.drive-list-lock-indicator .pi {
+ font-size: 0.62rem;
+}
+
+.drive-list-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.16rem;
+ justify-self: end;
+}
+
+.drive-list-action-btn.p-button.p-button-icon-only {
+ width: 1.65rem;
+ height: 1.65rem;
+ padding: 0;
+}
+
+.drive-list-action-btn .p-button-icon {
+ font-size: 0.82rem;
+}
+
+.drive-list-disc-meta {
+ margin-top: 0.08rem;
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ min-width: 0;
+}
+
+.drive-list-disc-label {
+ font-size: 0.82rem;
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.drive-list-status-text {
+ font-size: 0.75rem;
+ color: var(--rip-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.drive-list-global-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.drive-list-global-actions .p-button {
+ width: 100%;
+}
+
+@media (max-width: 860px) {
+ .drive-list-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "info info"
+ "state actions";
+ }
+
+ .drive-list-info {
+ grid-area: info;
+ }
+
+ .drive-list-state {
+ grid-area: state;
+ }
+
+ .drive-list-actions {
+ grid-area: actions;
+ }
+}
+
+/* Legacy per-CD-drive section (kept for compat) */
+.cd-drives-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--rip-border);
+}
+
+.cd-drive-item {
+ display: grid;
+ gap: 0.3rem;
+ padding: 0.5rem 0.6rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.cd-drive-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.cd-drive-path {
+ font-size: 0.8rem;
+ color: var(--rip-muted);
+}
+
+.cd-drive-disc-label {
+ font-size: 0.85rem;
+}
+
+.cd-drive-actions {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.cd-drive-status-text {
+ font-size: 0.75rem;
+ color: var(--rip-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.notification-toggle-box {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ height: 100%;
+}
+
+.notification-toggle-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.65rem;
+}
+
+.notification-toggle-head > label {
+ margin: 0;
+ line-height: 1.25;
+}
+
+.notification-toggle-head .p-inputswitch {
+ flex-shrink: 0;
+}
+
+.notification-toggle-box .setting-description {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ line-height: 1.25;
+}
+
+.notification-toggle-box .saved-tag {
+ margin-top: auto;
+}
+
+.setting-owner-row {
+ display: grid;
+ gap: 0.25rem;
+ margin-top: 0.35rem;
+ padding-top: 0.5rem;
+ border-top: 1px dashed var(--rip-border);
+}
+
+.setting-owner-label {
+ font-size: 0.8rem;
+ color: var(--rip-muted);
+ font-weight: 500;
+}
+
+.setting-owner-row .p-inputtext:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.setting-owner-row .saved-tag {
+ width: fit-content;
+}
+
+/* ── Path Category Tab ─────────────────────────────────────────────────────── */
+
+.path-category-tab {
+ display: grid;
+ gap: 1.25rem;
+}
+
+.path-overview-card {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: var(--rip-panel-soft);
+ padding: 1rem;
+}
+
+.path-overview-header {
+ display: grid;
+ gap: 0.2rem;
+ margin-bottom: 0.75rem;
+}
+
+.path-overview-header h4 {
+ margin: 0;
+ color: var(--rip-brown-800);
+ font-size: 1rem;
+}
+
+.path-overview-header small {
+ color: var(--rip-muted);
+}
+
+.path-overview-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.path-overview-table th {
+ text-align: left;
+ padding: 0.4rem 0.6rem;
+ color: var(--rip-muted);
+ font-weight: 600;
+ border-bottom: 1px solid var(--rip-border);
+}
+
+.path-overview-table td {
+ padding: 0.5rem 0.6rem;
+ border-bottom: 1px solid var(--rip-border);
+ vertical-align: middle;
+}
+
+.path-overview-table tr:last-child td {
+ border-bottom: none;
+}
+
+.path-overview-table code {
+ font-size: 0.8rem;
+ background: rgba(0, 0, 0, 0.06);
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ word-break: break-all;
+}
+
+.path-default-badge {
+ display: inline-block;
+ margin-left: 0.5rem;
+ padding: 0.1rem 0.45rem;
+ font-size: 0.7rem;
+ background: var(--rip-border);
+ color: var(--rip-muted);
+ border-radius: 999px;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.path-overview-extra {
+ margin-top: 0.85rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.path-overview-extra code {
+ font-size: 0.8rem;
+ background: rgba(0, 0, 0, 0.06);
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ word-break: break-all;
+}
+
+.path-medium-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.path-medium-card {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: #fff7ea;
+ overflow: hidden;
+}
+
+.path-medium-card-header {
+ padding: 0.6rem 0.9rem;
+ border-bottom: 1px solid var(--rip-border);
+ background: rgba(0, 0, 0, 0.03);
+}
+
+.path-medium-card-header h4 {
+ margin: 0;
+ font-size: 0.95rem;
+ color: var(--rip-brown-800);
+}
+
+.path-medium-card .settings-grid {
+ padding: 0.75rem;
+}
+
+.script-manager-wrap {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.script-list-box {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: #fff7ea;
+ padding: 0.75rem;
+ display: grid;
+ gap: 0.6rem;
+ width: 100%;
+}
+
+.script-list-box h4 {
+ margin: 0;
+ color: var(--rip-brown-800);
+}
+
+.script-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+
+.script-list--reorderable {
+ grid-template-columns: 1fr;
+}
+
+.script-order-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.script-order-wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.script-order-drop-zone {
+ min-height: 0.5rem;
+ border-radius: 0.25rem;
+ transition: min-height 0.12s, background 0.12s;
+}
+
+.script-order-drop-zone--end {
+ min-height: 1rem;
+}
+
+.script-order-drop-zone:hover,
+.script-order-drop-zone:focus-within {
+ min-height: 1.2rem;
+ background: var(--rip-gold-200);
+}
+
+.script-list-item {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ background: var(--rip-panel-soft);
+ padding: 0.5rem 0.6rem;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.55rem;
+}
+
+.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
+ grid-template-columns: auto minmax(0, 1fr) minmax(21rem, auto);
+ align-items: center;
+ gap: 0.65rem;
+}
+
+.script-list-item--dragging {
+ opacity: 0.55;
+}
+
+.script-list-drag-handle {
+ color: var(--rip-muted);
+ cursor: grab;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.4rem;
+ min-height: 1.4rem;
+ border-radius: 0.25rem;
+}
+
+.script-list-drag-handle:hover {
+ background: var(--rip-gold-200);
+ color: var(--rip-brown-700);
+}
+
+.script-list-drag-handle.disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.script-list-item-editing {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.55rem;
+}
+
+.script-list-main {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.script-title-line {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.script-id-title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.script-title-input {
+ width: 100%;
+}
+
+.script-list-actions {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+
+.script-list-actions--two {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.script-list-actions .p-button {
+ width: 100%;
+ justify-content: center;
+ font-size: 0.82rem;
+ padding: 0.38rem 0.7rem;
+}
+
+.script-list-actions .p-button .p-button-icon {
+ font-size: 0.8rem;
+}
+
+.script-action-spacer {
+ display: block;
+}
+
+.preset-media-type-tag {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ white-space: nowrap;
+}
+
+.preset-description-line {
+ display: block;
+ margin-top: 0.2rem;
+ opacity: 0.8;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.script-editor-fields {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.script-editor-fields .p-inputtextarea {
+ width: 100%;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.82rem;
+}
+
+.script-test-result {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+ padding: 0.6rem 0.7rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.script-test-result h4 {
+ margin: 0;
+}
+
+.script-test-result pre {
+ margin: 0;
+ background: #f7ecd7;
+ border-radius: 0.35rem;
+ padding: 0.5rem 0.55rem;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.76rem;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.script-test-step {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.script-test-step strong {
+ font-size: 0.82rem;
+}
+
+.required {
+ color: #9d261b;
+ margin-left: 0.25rem;
+}
+
+.error-text {
+ color: #9d261b;
+}
+
+.full-width {
+ width: 100%;
+}
+
+.table-filters {
+ margin-bottom: 1rem;
+ display: grid;
+ grid-template-columns: 1fr 16rem auto;
+ gap: 0.5rem;
+}
+
+.downloads-summary-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-bottom: 0.85rem;
+}
+
+.downloads-title-cell,
+.downloads-path-cell {
+ display: grid;
+ gap: 0.2rem;
+ min-width: 0;
+}
+
+.downloads-title-cell strong,
+.downloads-title-cell small,
+.downloads-path-cell code,
+.downloads-path-cell small {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.downloads-path-cell code {
+ width: fit-content;
+ max-width: 100%;
+ background: rgba(0, 0, 0, 0.06);
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+}
+
+.downloads-error-text {
+ color: #9f3b1f;
+}
+
+.downloads-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.history-dataview .p-dataview-header {
+ background: transparent;
+ border: none;
+ padding: 0;
+ margin-bottom: 0.85rem;
+}
+
+.history-dataview .p-dataview-content {
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.history-dataview .p-paginator {
+ margin-top: 0.75rem;
+}
+
+.history-dv-toolbar {
+ margin-bottom: 0.5rem;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 12rem 10rem 13rem auto auto;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+@media (max-width: 1150px) and (min-width: 901px) {
+ .history-dv-toolbar {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto auto;
+ }
+
+ .history-dv-toolbar > *:first-child {
+ grid-column: 1 / -1;
+ }
+}
+
+.history-dv-layout-toggle {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.history-dataview .p-dataview-content .p-grid.grid {
+ display: flex;
+ flex-wrap: wrap;
+ margin: 0 -0.35rem;
+}
+
+.history-dataview .p-dataview-content .p-grid.grid > .col-12,
+.history-dataview .p-dataview-content .p-grid.grid > .md-col-6,
+.history-dataview .p-dataview-content .p-grid.grid > .xl-col-4 {
+ width: 100%;
+ padding: 0.35rem;
+ display: flex;
+ flex-direction: column;
+}
+
+@media (min-width: 900px) {
+ .history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .md-col-6 {
+ width: 50%;
+ }
+}
+
+@media (min-width: 1280px) {
+ .history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .xl-col-4 {
+ width: 33.3333%;
+ }
+}
+
+.history-dv-item {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.55rem;
+ background: var(--rip-panel-soft);
+ box-shadow: 0 2px 7px rgba(58, 29, 18, 0.06);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.history-dv-item:focus-visible {
+ outline: none;
+ box-shadow: var(--focus-ring), 0 2px 7px rgba(58, 29, 18, 0.06);
+}
+
+.history-dv-item-list {
+ display: grid;
+ grid-template-columns: 60px minmax(0, 1fr) auto;
+ gap: 0.75rem;
+ align-items: start;
+ padding: 0.6rem 0.7rem;
+}
+
+.history-dv-main {
+ min-width: 0;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.history-dv-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.history-dv-title-block {
+ min-width: 0;
+ display: grid;
+ gap: 0.1rem;
+}
+
+.history-dv-title {
+ margin: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.history-dv-subtle {
+ color: var(--rip-muted);
+ font-size: 0.78rem;
+}
+
+.history-dv-meta-row,
+.history-dv-flags-row,
+.history-dv-ratings-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: center;
+}
+
+.history-dv-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 999px;
+ padding: 0.12rem 0.48rem;
+ font-size: 0.76rem;
+ background: var(--rip-panel);
+}
+
+.history-dv-chip.tone-ok {
+ color: #176635;
+ border-color: #9ed5ad;
+ background: #dcf3e2;
+}
+
+.history-dv-chip.tone-no {
+ color: #8b2c2c;
+ border-color: #d4a29e;
+ background: #f8e1df;
+}
+
+.history-dv-chip.tone-warn {
+ color: #92400e;
+ border-color: #f2c286;
+ background: #fff1db;
+}
+
+.history-dv-rating-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.34rem;
+ border: 1px dashed var(--rip-border);
+ border-radius: 999px;
+ padding: 0.12rem 0.5rem;
+ font-size: 0.76rem;
+ background: var(--rip-panel);
+}
+
+.history-dv-poster-wrap {
+ width: 60px;
+}
+
+.history-dv-poster,
+.history-dv-poster-audio,
+.history-dv-poster-grid,
+.history-dv-poster-grid-audio {
+ display: block;
+ width: 100%;
+ border-radius: 0.35rem;
+ border: 1px solid #cba266;
+ background: #f6ebd6;
+}
+
+.history-dv-poster {
+ height: 88px;
+ object-fit: cover;
+}
+
+.history-dv-poster-audio {
+ aspect-ratio: 1;
+ object-fit: cover;
+}
+
+.history-dv-poster-grid {
+ height: 180px;
+}
+
+.history-dv-poster-grid-audio {
+ aspect-ratio: 1;
+ object-fit: cover;
+}
+
+.history-dv-grid-poster-wrap {
+ width: min(120px, 100%);
+ height: 180px;
+ margin: 0 auto;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.history-dv-poster-fallback {
+ width: 100%;
+ height: 88px;
+ border-radius: 0.35rem;
+ border: 1px dashed var(--rip-border);
+ background: #f3e5cc;
+ color: var(--rip-muted);
+ font-size: 0.72rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 0.3rem;
+}
+
+.history-dv-actions {
+ display: flex;
+ align-items: flex-start;
+}
+
+.history-dv-item-grid {
+ position: relative;
+ display: grid;
+ gap: 0.5rem;
+ padding: 0.65rem;
+ grid-template-rows: auto 1fr auto;
+ flex: 1;
+}
+
+.history-dv-grid-status-overlay {
+ position: absolute;
+ top: 0.5rem;
+ left: 0.5rem;
+ z-index: 1;
+}
+
+.history-dv-grid-status-overlay .p-tag {
+ box-shadow: 0 1px 4px rgba(0,0,0,0.15);
+}
+
+.history-dv-grid-main {
+ display: grid;
+ gap: 0.35rem;
+ min-width: 0;
+}
+
+.history-dv-actions-grid {
+ justify-content: flex-end;
+}
+
+.history-delete-preview-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+}
+
+.history-delete-preview-grid h4 {
+ margin: 0 0 0.35rem;
+ font-size: 0.9rem;
+}
+
+.history-delete-preview-list {
+ margin: 0;
+ padding-left: 1rem;
+ display: grid;
+ gap: 0.22rem;
+ max-height: 12rem;
+ overflow: auto;
+ font-size: 0.78rem;
+}
+
+.history-delete-preview-list li {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.history-delete-preview-checkbox-row {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+
+.history-delete-preview-checkbox-row input[type="checkbox"] {
+ margin-top: 0.15rem;
+}
+
+.history-delete-preview-list .exists-yes {
+ color: #176635;
+ font-weight: 600;
+}
+
+.history-delete-preview-list .exists-no {
+ color: #8b2c2c;
+ font-weight: 600;
+}
+
+.table-scroll-wrap {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.table-scroll-wrap .p-datatable {
+ min-width: 0;
+}
+
+.table-scroll-wrap .p-datatable-wrapper {
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.table-scroll-wrap .p-datatable-table {
+ min-width: 44rem;
+}
+
+.table-scroll-wrap.table-scroll-medium .p-datatable-table {
+ min-width: 54rem;
+}
+
+.table-scroll-wrap.table-scroll-wide .p-datatable-table {
+ min-width: 72rem;
+}
+
+.clickable-table tbody tr {
+ cursor: pointer;
+}
+
+.job-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.job-meta-grid > div,
+.device-meta > div,
+.selected-meta > div,
+.pipeline-meta-inline .device-meta > div {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.job-head-row {
+ display: grid;
+ grid-template-columns: 11rem 1fr;
+ gap: 1rem;
+}
+
+.job-detail-meta-wrap {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.job-film-info-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.55rem;
+}
+
+.job-meta-block {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.45rem;
+}
+
+.job-meta-block.job-meta-block-film {
+ padding: 0.55rem 0.65rem;
+}
+
+.job-meta-list {
+ display: grid;
+ gap: 0.4rem;
+ font-size: 0.84rem;
+ line-height: 1.3;
+}
+
+.job-meta-item {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 8rem minmax(0, 1fr);
+ gap: 0.35rem;
+ align-items: start;
+}
+
+.job-meta-item strong,
+.job-meta-item span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.job-meta-block h4 {
+ margin: 0;
+ font-size: 0.88rem;
+}
+
+.job-meta-grid.job-meta-grid-compact {
+ margin-bottom: 0;
+ gap: 0.4rem 0.65rem;
+ font-size: 0.84rem;
+}
+
+.job-meta-grid.job-meta-grid-compact strong {
+ font-weight: 600;
+}
+
+.job-path-field {
+ min-width: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.15rem 0.4rem;
+}
+
+.job-path-field-value {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.job-path-field-value > span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.job-path-download-button {
+ flex-shrink: 0;
+}
+
+.job-path-download-button.p-button.p-button-icon-only {
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+}
+
+.job-path-download-button .p-button-icon {
+ font-size: 0.8rem;
+}
+
+.job-meta-col-span-2 {
+ grid-column: 1 / -1;
+}
+
+.job-infos-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.job-infos-header h4 {
+ margin: 0;
+}
+
+.job-infos-badges {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+ font-size: 0.84rem;
+ flex-wrap: wrap;
+}
+
+.job-infos-sep {
+ color: var(--rip-border);
+ font-weight: 300;
+}
+
+.job-infos-layout {
+ display: grid;
+ gap: 0.4rem;
+ font-size: 0.84rem;
+}
+
+.job-infos-layout strong {
+ font-weight: 600;
+}
+
+.job-infos-row-2 {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.ripper-downloads-row {
+ border-top: 1px solid var(--surface-border);
+ padding-top: 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.ripper-downloads-row h4 {
+ margin: 0 0 0.45rem;
+ font-size: 0.88rem;
+}
+
+.ripper-downloads-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.job-meta-subtle {
+ font-size: 0.82rem;
+ color: var(--text-color-secondary);
+}
+
+.job-db-link {
+ font-size: 0.82rem;
+ color: var(--text-color-secondary);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+.job-db-link:hover {
+ color: var(--primary-color);
+ text-decoration: underline;
+}
+
+.job-meta-encode-config {
+ margin-bottom: 0.5rem;
+}
+
+.job-configured-selection-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.35rem 0.65rem;
+ font-size: 0.84rem;
+}
+
+.job-configured-selection-grid > div {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.job-status-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+}
+
+.job-status-icon .pi {
+ font-size: 1rem;
+}
+
+.job-status-icon.tone-success {
+ color: #1c8a3a;
+}
+
+.job-status-icon.tone-danger {
+ color: #9c2d2d;
+}
+
+.job-status-icon.tone-warning {
+ color: #7a4f00;
+}
+
+.job-status-icon.tone-info {
+ color: #0d5a86;
+}
+
+.job-status-icon.tone-secondary {
+ color: #46556a;
+}
+
+.job-detail-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.job-detail-body > h4 {
+ margin: 0.25rem 0 0;
+}
+
+.job-json-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+
+.job-titles-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.83rem;
+}
+
+.job-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.25rem 0.4rem;
+ border-radius: 0.3rem;
+ background: var(--surface-ground);
+ flex-wrap: wrap;
+}
+
+.job-title-row.job-title-row-selected {
+ background: color-mix(in srgb, var(--primary-color) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--primary-color) 30%, transparent);
+}
+
+.job-title-id {
+ font-weight: 600;
+ color: var(--text-color-secondary);
+ min-width: 2rem;
+}
+
+.job-title-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.job-title-duration,
+.job-title-size {
+ color: var(--text-color-secondary);
+ white-space: nowrap;
+}
+
+.job-title-playlist {
+ color: var(--text-color-secondary);
+ font-size: 0.78rem;
+ white-space: nowrap;
+}
+
+.job-title-selected-badge {
+ font-size: 0.72rem;
+ font-weight: 600;
+ background: var(--primary-color);
+ color: var(--primary-color-text);
+ padding: 0.1rem 0.4rem;
+ border-radius: 999px;
+ white-space: nowrap;
+}
+
+.json-box {
+ background: #2a140d;
+ color: #f8e2b8;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ height: 16rem;
+ overflow: auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.78rem;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.poster-thumb {
+ width: 46px;
+ height: 68px;
+ object-fit: cover;
+ border-radius: 0.35rem;
+ border: 1px solid #cba266;
+}
+
+.poster-thumb-lg {
+ width: 64px;
+ height: 92px;
+ object-fit: cover;
+ border-radius: 0.35rem;
+ border: 1px solid #cba266;
+ background: #f6ebd6;
+}
+
+.omdb-row {
+ display: grid;
+ grid-template-columns: 64px 1fr;
+ gap: 0.65rem;
+ align-items: center;
+}
+
+.omdb-row > div {
+ min-width: 0;
+}
+
+.orphan-path-cell {
+ max-width: 36rem;
+ white-space: normal;
+ word-break: break-all;
+}
+
+.selected-meta {
+ display: grid;
+ grid-template-columns: 12rem 1fr;
+ gap: 1rem;
+}
+
+.pipeline-meta-inline {
+ margin-top: 1rem;
+ display: grid;
+ grid-template-columns: 12rem 1fr;
+ gap: 1rem;
+}
+
+.poster-large {
+ width: 100%;
+ height: 16rem;
+ object-fit: cover;
+ border-radius: 0.5rem;
+ border: 1px solid #cba266;
+ background: #f6ebd6;
+}
+
+.poster-large-audio {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ border-radius: 0.5rem;
+ border: 1px solid #cba266;
+ background: #f6ebd6;
+}
+
+.poster-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--rip-muted);
+}
+
+.log-box {
+ background: #231109;
+ color: #f6ddb2;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ height: 25rem;
+ max-width: 100%;
+ min-width: 0;
+ overflow: auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.82rem;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.live-log-block {
+ margin-top: 1rem;
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.live-log-block h4 {
+ margin: 0 0 0.5rem;
+}
+
+.live-log-block .log-box {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ overflow-x: auto;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.playlist-decision-block {
+ margin-top: 1rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ display: grid;
+ gap: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.playlist-decision-block h3 {
+ margin: 0;
+}
+
+.playlist-decision-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.ready-job-list {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.ready-job-item {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.35rem;
+}
+
+.ready-job-item > summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.playlist-decision-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.5rem 0.6rem;
+ display: grid;
+ gap: 0.2rem;
+ background: var(--rip-panel);
+}
+
+.mediainfo-review-block {
+ margin-top: 1rem;
+ border-top: 1px solid var(--rip-border);
+ padding-top: 1rem;
+}
+
+.mediainfo-review-block h3 {
+ margin: 0 0 0.65rem;
+}
+
+.media-review-wrap {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.media-review-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.4rem 0.9rem;
+}
+
+.media-review-notes {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.post-script-box {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.45rem;
+}
+
+.post-script-box h4 {
+ margin: 0;
+}
+
+.post-script-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.post-script-row.editable {
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
+}
+
+.post-script-type-icon {
+ color: var(--rip-muted);
+ font-size: 0.95rem;
+ width: 1rem;
+ text-align: center;
+}
+
+.post-script-drag-handle {
+ cursor: grab;
+ color: var(--rip-muted);
+ font-size: 0.95rem;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ border: 1px solid transparent;
+}
+
+.post-script-drag-handle:hover {
+ border-color: var(--rip-border);
+ background: color-mix(in srgb, var(--rip-panel-soft) 75%, #ffffff 25%);
+}
+
+.post-script-drag-handle:active {
+ cursor: grabbing;
+}
+
+.post-script-drag-handle.disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.cd-encode-item-order {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.15rem;
+}
+
+.cd-encode-item-order .p-button {
+ width: 1.5rem;
+ min-width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+}
+
+.media-title-list,
+.media-track-list {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.media-track-item {
+ display: grid;
+ gap: 0.15rem;
+}
+
+.subtitle-footnote-list {
+ display: grid;
+ gap: 0.2rem;
+ margin-top: 0.2rem;
+ width: 100%;
+ font-size: 0.72rem;
+}
+
+.subtitle-footnote-list .track-action-note {
+ margin-left: 0;
+ display: block;
+ width: 100%;
+ max-width: none;
+ line-height: 1.3;
+ font-size: inherit;
+}
+
+.track-action-note {
+ margin-left: 1.7rem;
+ color: var(--rip-muted);
+}
+
+.track-selection-inline-neutral {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ color: var(--rip-muted);
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.track-selection-inline-neutral .pi {
+ font-size: 0.85rem;
+}
+
+.spurauswahl-block {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.spurauswahl-label {
+ font-weight: 600;
+ font-size: 0.85rem;
+ color: var(--rip-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.track-title-block {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.track-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.track-input-badge {
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.3rem;
+ background: var(--rip-accent-soft, #e8f4ea);
+ color: var(--rip-success, #2d7a3a);
+ border: 1px solid var(--rip-success, #2d7a3a);
+}
+
+.track-groups-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.5rem;
+ align-items: start;
+ border-top: 1px solid var(--rip-border);
+ padding-top: 0.5rem;
+ margin-top: 0.1rem;
+}
+
+.track-group {
+ display: grid;
+ gap: 0.25rem;
+ padding-left: 0.5rem;
+ border-left: 2px solid var(--rip-border);
+}
+
+.track-group-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--rip-muted);
+ margin-bottom: 0.1rem;
+}
+
+.track-item {
+ display: grid;
+ gap: 0.1rem;
+ font-size: 0.85rem;
+}
+
+.track-item-inline-status {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+}
+
+.track-item-inline-status .track-item-main {
+ min-width: 0;
+}
+
+.track-item-inline-status .track-action-note {
+ margin-left: 0;
+}
+
+.media-title-block {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.6rem;
+}
+
+.episode-fill-box {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+}
+
+.episode-fill-field {
+ display: grid;
+ gap: 0.3rem;
+}
+
+.episode-fill-field label {
+ font-weight: 600;
+}
+
+.episode-fill-field small {
+ color: var(--text-color-secondary);
+ font-size: 0.78rem;
+}
+
+.episode-title-input-row {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.episode-title-input-row label {
+ font-weight: 600;
+ font-size: 0.82rem;
+}
+
+.episode-global-selection-box {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.45rem;
+}
+
+.episode-global-selection-box h4 {
+ margin: 0;
+}
+
+.episode-track-accordion {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.35rem 0.5rem;
+ background: var(--rip-panel);
+}
+
+.episode-track-accordion > summary {
+ cursor: pointer;
+ user-select: none;
+ font-weight: 600;
+ color: var(--rip-muted);
+ margin: 0.1rem 0;
+}
+
+.episode-track-accordion[open] > summary {
+ margin-bottom: 0.45rem;
+}
+
+.series-batch-progress-wrap {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ gap: 0.45rem;
+ margin-bottom: 0.65rem;
+}
+
+.series-batch-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.series-batch-episodes {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.series-batch-episode-row {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.38rem;
+ padding: 0.4rem 0.5rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.22rem;
+}
+
+.series-batch-episode-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.45rem;
+}
+
+.series-batch-episode-title {
+ font-size: 0.86rem;
+ font-weight: 600;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.playlist-info-box {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.4rem 0.55rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.2rem;
+}
+
+.playlist-segment-output {
+ margin: 0;
+ padding: 0.4rem 0.5rem;
+ border-radius: 0.35rem;
+ background: #f7ecd7;
+ color: var(--rip-brown-900);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.76rem;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.playlist-segment-toggle {
+ display: grid;
+ gap: 0.3rem;
+}
+
+.playlist-segment-toggle > summary {
+ cursor: pointer;
+ font-size: 0.82rem;
+ color: var(--rip-muted);
+ user-select: none;
+}
+
+.readonly-check-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ font-size: 0.92rem;
+ line-height: 1.3;
+}
+
+.readonly-check-row input {
+ margin-top: 0.2rem;
+}
+
+.readonly-check-row span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.media-track-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.handbrake-command-preview {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.45rem 0.55rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.25rem;
+}
+
+.handbrake-command-preview pre {
+ margin: 0;
+ padding: 0.45rem 0.55rem;
+ border-radius: 0.35rem;
+ background: #f7ecd7;
+ color: var(--rip-brown-900);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.76rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.job-detail-dialog .p-dialog-content,
+.metadata-selection-dialog .p-dialog-content,
+.disc-detected-dialog .p-dialog-content {
+ max-height: min(80vh, 56rem);
+ overflow: auto;
+}
+
+.job-detail-dialog,
+.metadata-selection-dialog,
+.disc-detected-dialog {
+ max-width: calc(100vw - 0.75rem);
+}
+
+.job-detail-dialog .p-dialog-header-title,
+.metadata-selection-dialog .p-dialog-header-title,
+.disc-detected-dialog .p-dialog-header-title {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+@media (max-width: 900px) {
+ .app-header {
+ padding: 0.8rem 1rem;
+ }
+
+ .nav-buttons {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .app-upload-banner {
+ width: calc(100% - 1.5rem);
+ grid-template-columns: 1fr;
+ justify-items: stretch;
+ }
+
+ .app-upload-banner-actions {
+ justify-self: start;
+ }
+
+ .brand-logo {
+ width: 60px;
+ height: 60px;
+ }
+
+ .brand-copy h1 {
+ font-size: 1.45rem;
+ }
+
+ .brand-copy p {
+ font-size: 0.82rem;
+ }
+
+ .app-version {
+ font-size: 0.68rem;
+ }
+
+ .metadata-grid,
+ .device-meta,
+ .hardware-monitor-grid,
+ .hardware-monitor-meta,
+ .pipeline-queue-grid,
+ .media-review-meta,
+ .media-track-grid,
+ .job-meta-grid,
+ .job-configured-selection-grid,
+ .job-film-info-grid,
+ .history-delete-preview-grid,
+ .table-filters,
+ .history-dv-toolbar,
+ .job-head-row,
+ .job-json-grid,
+ .selected-meta,
+ .pipeline-meta-inline {
+ grid-template-columns: 1fr;
+ }
+
+ .notification-toggle-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ripper-job-row {
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ }
+
+ .ripper-job-badges {
+ justify-content: flex-start;
+ }
+
+ .ripper-job-expanded-head {
+ flex-wrap: wrap;
+ }
+
+ .cd-track-table {
+ min-width: 36rem;
+ }
+
+ .cd-media-meta-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .cd-cover-wrap {
+ width: 6.5rem;
+ min-width: 6.5rem;
+ }
+
+ .script-list {
+ grid-template-columns: 1fr;
+ }
+
+ .script-list-item {
+ grid-template-columns: 1fr;
+ }
+
+ .script-list--reorderable .script-list-item:not(.script-list-item-editing) {
+ grid-template-columns: 1fr;
+ }
+
+ .post-script-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .post-script-row.editable {
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
+ }
+
+ .orphan-path-cell {
+ max-width: 100%;
+ }
+
+ .metadata-selection-dialog .p-datatable-wrapper {
+ max-height: 16rem !important;
+ }
+
+ .hardware-core-item.compact {
+ grid-template-columns: auto auto auto;
+ align-items: center;
+ justify-content: start;
+ gap: 0.3rem;
+ text-align: left;
+ }
+
+ .hardware-core-grid.compact {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .hardware-core-title {
+ min-width: 2rem;
+ }
+
+ .hardware-core-metric {
+ justify-self: start;
+ }
+
+ .history-dv-item-list {
+ grid-template-columns: 52px minmax(0, 1fr);
+ }
+
+ .history-dv-actions {
+ grid-column: 1 / -1;
+ justify-content: flex-end;
+ }
+
+ .history-dv-toolbar {
+ grid-template-columns: 1fr;
+ }
+
+ .history-dv-poster-grid {
+ height: 148px;
+ }
+}
+
+@media (max-width: 640px) {
+ .app-main {
+ width: min(1280px, 98vw);
+ }
+
+ .app-upload-banner {
+ width: min(1280px, 98vw);
+ padding: 0.75rem 0.8rem;
+ }
+
+ .hardware-storage-head {
+ grid-template-columns: 1fr;
+ }
+
+ .hardware-storage-percent {
+ justify-self: start;
+ }
+
+ .table-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .zip-status-indicator {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .history-dv-item-list {
+ grid-template-columns: 1fr;
+ }
+
+ .history-dv-poster-wrap {
+ width: 54px;
+ }
+
+ .history-dv-poster {
+ height: 80px;
+ }
+
+ .history-dv-poster-fallback {
+ height: 80px;
+ }
+
+ .history-dv-poster-grid {
+ height: 136px;
+ }
+
+ .search-row {
+ grid-template-columns: 1fr;
+ }
+
+ .metadata-filter-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .actions-row .p-button {
+ width: 100%;
+ }
+
+ .settings-expert-toggle {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .notification-toggle-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .dialog-actions {
+ justify-content: stretch;
+ }
+
+ .dialog-actions .p-button {
+ width: 100%;
+ }
+
+ .script-title-line {
+ grid-template-columns: 1fr;
+ gap: 0.3rem;
+ }
+
+ .script-list-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .script-list--reorderable .script-list-item:not(.script-list-item-editing) {
+ grid-template-columns: 1fr;
+ }
+
+ .script-list-drag-handle {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .script-action-spacer {
+ display: none;
+ }
+
+ .ripper-job-row {
+ grid-template-columns: 1fr;
+ }
+
+ .ripper-job-row .poster-thumb,
+ .ripper-job-row .ripper-job-poster-fallback {
+ width: 52px;
+ height: 76px;
+ }
+
+ .ripper-job-row-main strong,
+ .ripper-job-row-main small {
+ white-space: normal;
+ }
+
+ .pipeline-queue-item-main strong,
+ .pipeline-queue-item-main small {
+ white-space: normal;
+ }
+
+ .ripper-job-title-line > span {
+ white-space: normal;
+ }
+
+ .job-detail-dialog .p-dialog-header,
+ .metadata-selection-dialog .p-dialog-header,
+ .disc-detected-dialog .p-dialog-header {
+ padding: 0.85rem 1rem;
+ }
+
+ .job-detail-dialog .p-dialog-content,
+ .metadata-selection-dialog .p-dialog-content,
+ .disc-detected-dialog .p-dialog-content {
+ padding: 0.9rem 1rem 1rem;
+ max-height: 78vh;
+ }
+
+ .chain-editor-body {
+ flex-direction: column;
+ }
+
+ .chain-palette,
+ .chain-canvas {
+ min-width: unset;
+ max-width: unset;
+ width: 100%;
+ }
+}
+
+/* ── Chain Editor ─────────────────────────────────────── */
+.chain-editor-name-row {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.chain-editor-body {
+ display: flex;
+ gap: 1rem;
+ min-height: 20rem;
+}
+
+.chain-palette {
+ min-width: 14rem;
+ max-width: 18rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ background: var(--rip-panel-soft);
+}
+
+.chain-palette h4 {
+ margin: 0 0 0.25rem;
+ font-size: 0.9rem;
+ color: var(--rip-muted);
+}
+
+.chain-palette-hint {
+ font-size: 0.72rem;
+ color: var(--rip-muted);
+ margin: 0 0 0.5rem;
+}
+
+.chain-palette-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.chain-palette-section strong {
+ font-size: 0.78rem;
+ color: var(--rip-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.chain-palette-item {
+ padding: 0.4rem 0.6rem;
+ border-radius: 0.3rem;
+ border: 1px solid var(--rip-border);
+ cursor: grab;
+ font-size: 0.85rem;
+ user-select: none;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.chain-palette-item:hover {
+ background: var(--rip-gold-200);
+ border-color: var(--rip-gold-400);
+}
+
+.chain-palette-item--system {
+ background: #fff3cd;
+ border-color: #ffc107;
+}
+
+.chain-palette-item--script {
+ background: var(--rip-cream-100);
+}
+
+.chain-canvas {
+ flex: 1;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ background: var(--rip-panel);
+ overflow-y: auto;
+ max-height: 60vh;
+}
+
+.chain-canvas h4 {
+ margin: 0 0 0.25rem;
+ font-size: 0.9rem;
+ color: var(--rip-muted);
+}
+
+.chain-canvas-empty {
+ flex: 1;
+ border: 2px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--rip-muted);
+ font-size: 0.85rem;
+ min-height: 8rem;
+}
+
+.chain-steps-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.chain-step-wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.chain-drop-zone {
+ height: 0.5rem;
+ border-radius: 0.25rem;
+ transition: height 0.12s, background 0.12s;
+}
+
+.chain-drop-zone--end {
+ flex: 1;
+ min-height: 1.5rem;
+}
+
+.chain-drop-zone:hover,
+.chain-drop-zone:focus-within {
+ height: 1.5rem;
+ background: var(--rip-gold-200);
+}
+
+.chain-step {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.35rem;
+ padding: 0.4rem 0.5rem;
+ cursor: grab;
+ user-select: none;
+ margin: 0.1rem 0;
+}
+
+.chain-step--wait {
+ background: #fffbe6;
+ border-color: #ffc107;
+}
+
+.chain-step--script {
+ background: var(--rip-cream-100);
+}
+
+.chain-step-drag-handle {
+ color: var(--rip-muted);
+ cursor: grab;
+ flex-shrink: 0;
+}
+
+.chain-step-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.chain-step-wait,
+.chain-step-script {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.88rem;
+}
+
+.chain-wait-input {
+ width: 4rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 0.25rem;
+ padding: 0.15rem 0.3rem;
+ font-size: 0.88rem;
+ text-align: center;
+ background: #fff;
+}
+
+.chain-step-remove {
+ flex-shrink: 0;
+}
+
+.chain-selection-groups {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.chain-selection-group {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 0.5rem 0;
+ border-top: 1px dashed var(--rip-border);
+}
+
+.chain-selection-group:first-child {
+ border-top: none;
+}
+
+.chain-selection-group strong {
+ font-size: 0.82rem;
+ color: var(--rip-muted);
+}
+
+.chain-add-dropdown {
+ max-width: 100%;
+}
+
+@media (max-width: 640px) {
+ .chain-selection-groups {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .chain-selection-group {
+ flex: unset;
+ width: 100%;
+ }
+}
+
+/* ── Cronjobs Tab ─────────────────────────────────────────────────────────── */
+
+.cron-tab {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.cron-empty-hint {
+ color: var(--rip-muted, #888);
+ font-size: 0.9rem;
+ margin: 0.5rem 0;
+}
+
+/* Job-Liste */
+.cron-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.cron-item {
+ border: 1px solid var(--surface-border, #dee2e6);
+ border-radius: 8px;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ background: var(--surface-card, #fff);
+ transition: opacity 0.2s;
+}
+
+.cron-item--disabled {
+ opacity: 0.55;
+}
+
+.cron-item-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.cron-item-name {
+ font-weight: 600;
+ font-size: 1rem;
+}
+
+.cron-item-expr {
+ background: var(--surface-ground, #f4f4f4);
+ border-radius: 4px;
+ padding: 0.15rem 0.45rem;
+ font-size: 0.82rem;
+ color: var(--text-color-secondary, #555);
+ font-family: monospace;
+}
+
+.cron-item-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1.25rem;
+ font-size: 0.85rem;
+}
+
+.cron-meta-entry {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.cron-meta-label {
+ color: var(--rip-muted, #777);
+ font-size: 0.8rem;
+}
+
+.cron-meta-value {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+/* Status-Badge */
+.cron-status {
+ display: inline-block;
+ padding: 0.1rem 0.45rem;
+ border-radius: 99px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.cron-status--success { background: #d4edda; color: #1c5e2e; }
+.cron-status--error { background: #f8d7da; color: #842029; }
+.cron-status--running { background: #d0e4ff; color: #0c3b7c; }
+.cron-status--none { background: transparent; color: var(--rip-muted, #888); }
+
+/* Toggles-Zeile */
+.cron-item-toggles {
+ display: flex;
+ gap: 1.25rem;
+ flex-wrap: wrap;
+}
+
+.cron-toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-size: 0.88rem;
+}
+
+/* Aktionen-Zeile */
+.cron-item-actions {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+/* Editor-Dialog */
+.cron-editor-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.cron-editor-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.cron-editor-label {
+ font-weight: 600;
+ font-size: 0.88rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.cron-help-link {
+ color: var(--rip-muted, #999);
+ font-size: 0.85rem;
+ text-decoration: none;
+}
+.cron-help-link:hover { color: var(--primary-color, #3b82f6); }
+
+.cron-expr-hint {
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+.cron-expr-hint--ok { color: #1c5e2e; }
+.cron-expr-hint--err { color: #842029; }
+.cron-expr-hint--checking { color: var(--rip-muted, #888); }
+
+.cron-expr-examples {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.25rem;
+}
+
+.cron-expr-chip {
+ background: var(--surface-ground, #f4f4f4);
+ border: 1px solid var(--surface-border, #dee2e6);
+ border-radius: 99px;
+ padding: 0.2rem 0.7rem;
+ font-size: 0.78rem;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.cron-expr-chip:hover { background: var(--surface-hover, #e9ecef); }
+
+/* Quell-Typ-Auswahl */
+.cron-source-type-row {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.cron-source-type-btn {
+ flex: 1;
+ padding: 0.45rem 0.75rem;
+ border: 1px solid var(--surface-border, #dee2e6);
+ border-radius: 6px;
+ background: var(--surface-ground, #f4f4f4);
+ font-size: 0.88rem;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.cron-source-type-btn.active {
+ border-color: var(--primary-color, #3b82f6);
+ background: var(--primary-50, #eff6ff);
+ color: var(--primary-color, #3b82f6);
+ font-weight: 600;
+}
+
+/* Editor-Toggles */
+.cron-editor-toggles {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+/* Logs-Dialog */
+.cron-log-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.cron-log-entry {
+ border: 1px solid var(--surface-border, #dee2e6);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.cron-log-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.6rem 0.75rem;
+ cursor: pointer;
+ background: var(--surface-ground, #f8f9fa);
+ list-style: none;
+ font-size: 0.88rem;
+ flex-wrap: wrap;
+}
+
+.cron-log-time {
+ font-weight: 600;
+}
+
+.cron-log-duration {
+ color: var(--rip-muted, #777);
+ font-size: 0.8rem;
+}
+
+.cron-log-errmsg {
+ color: #842029;
+ font-size: 0.8rem;
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 260px;
+}
+
+.cron-log-output {
+ margin: 0;
+ padding: 0.75rem;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ font-size: 0.78rem;
+ max-height: 300px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.runtime-activity-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.runtime-activity-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.75rem;
+}
+
+.runtime-activity-col {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ padding: 0.55rem 0.6rem;
+ background: var(--rip-panel-soft);
+ display: grid;
+ align-content: start;
+ grid-auto-rows: min-content;
+ gap: 0.45rem;
+}
+
+.runtime-activity-col h4 {
+ margin: 0;
+}
+
+.runtime-activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+
+.runtime-activity-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.45rem 0.55rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.2rem;
+}
+
+.runtime-activity-item.running {
+ border-style: solid;
+}
+
+.runtime-activity-item.done {
+ border-style: dotted;
+ background: var(--rip-surface);
+ opacity: 0.94;
+}
+
+.runtime-activity-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.runtime-activity-head strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.runtime-activity-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.runtime-activity-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-top: 0.15rem;
+}
+
+.runtime-activity-details {
+ margin-top: 0.2rem;
+}
+
+.runtime-chain-current {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ padding: 0.3rem 0.45rem;
+ border-left: 2px solid var(--rip-border);
+ margin: 0.1rem 0;
+ background: var(--rip-surface);
+ border-radius: 0 0.3rem 0.3rem 0;
+}
+
+.runtime-activity-details summary {
+ cursor: pointer;
+ font-size: 0.82rem;
+ color: var(--rip-muted, #666);
+}
+
+.runtime-activity-details-block {
+ margin-top: 0.4rem;
+}
+
+.runtime-activity-details-block pre {
+ margin: 0.25rem 0 0;
+ padding: 0.55rem;
+ border-radius: 6px;
+ border: 1px solid var(--surface-border, #d8d3c6);
+ background: #1f1f1f;
+ color: #e8e8e8;
+ max-height: 180px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.78rem;
+}
+
+@media (max-width: 980px) {
+ .runtime-activity-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.audiobook-config-panel {
+ display: grid;
+ gap: 1rem;
+}
+
+.audiobook-config-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.audiobook-config-summary {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.audiobook-config-cover {
+ width: 120px;
+ min-width: 120px;
+ aspect-ratio: 1 / 1;
+ border-radius: 14px;
+ overflow: hidden;
+ border: 1px solid var(--surface-border, #d8d3c6);
+ background: var(--surface-ground, #f6f1e8);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
+}
+
+.audiobook-config-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.audiobook-config-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: flex-start;
+}
+
+.audiobook-config-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+.audiobook-config-settings,
+.audiobook-config-chapters {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.audiobook-description-preview {
+ display: grid;
+ gap: 0.25rem;
+ margin-top: 0.25rem;
+}
+
+.audiobook-description-preview .p-button {
+ justify-self: flex-start;
+ padding-left: 0;
+}
+
+.audiobook-config-chapters h4 {
+ margin: 0;
+}
+
+.audiobook-chapter-list {
+ display: grid;
+ gap: 0.55rem;
+ max-height: 18rem;
+ overflow: auto;
+ padding-right: 0.25rem;
+}
+
+.audiobook-chapter-row {
+ display: grid;
+ gap: 0.15rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--surface-border, #d8d3c6);
+ border-radius: 10px;
+ background: var(--surface-card, #fff);
+}
+
+.audiobook-chapter-row small {
+ color: var(--rip-muted, #666);
+}
+
+.audiobook-chapter-row-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.audiobook-chapter-row-editable {
+ gap: 0.55rem;
+}
+
+.audiobook-chapter-status {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.audiobook-chapter-status-list {
+ display: grid;
+ gap: 0.25rem;
+ max-height: 16rem;
+ overflow: auto;
+ padding-right: 0.25rem;
+}
+
+.audiobook-chapter-status-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.3rem 0.5rem;
+ border-radius: 6px;
+ font-size: 0.85rem;
+}
+
+.chapter-status-done {
+ background: var(--green-50, #f0fdf4);
+ color: var(--green-700, #15803d);
+}
+
+.chapter-status-active {
+ background: var(--blue-50, #eff6ff);
+ color: var(--blue-700, #1d4ed8);
+ font-weight: 600;
+}
+
+.chapter-status-pending {
+ color: var(--rip-muted, #666);
+}
+
+.chapter-status-nr {
+ font-variant-numeric: tabular-nums;
+ flex-shrink: 0;
+}
+
+.chapter-status-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.audiobook-description-dialog p {
+ margin: 0;
+ white-space: pre-wrap;
+ line-height: 1.6;
+}
+
+.audiobook-output-path {
+ padding: 0.75rem 0.85rem;
+ border-radius: 10px;
+ border: 1px solid var(--surface-border, #d8d3c6);
+ background: var(--surface-ground, #f7f7f7);
+ word-break: break-word;
+}
+
+@media (max-width: 980px) {
+ .audiobook-config-summary {
+ width: 100%;
+ }
+
+ .audiobook-config-cover {
+ width: 96px;
+ min-width: 96px;
+ }
+
+ .audiobook-config-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ── ReencodeConflictModal ──────────────────────────────────────────────── */
+.reencode-conflict-modal .p-dialog-content {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.reencode-conflict-intro {
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.reencode-conflict-folders {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ border: 1px solid var(--surface-border, #d8d3c6);
+ border-radius: 8px;
+ padding: 0.75rem;
+ background: var(--surface-ground, #f7f7f7);
+}
+
+.reencode-conflict-folders-header {
+ padding-bottom: 0.4rem;
+ border-bottom: 1px solid var(--surface-border, #d8d3c6);
+ margin-bottom: 0.25rem;
+}
+
+.reencode-conflict-check-all {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+.reencode-conflict-folder-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ padding: 0.3rem 0.25rem;
+ border-radius: 4px;
+ transition: background 0.15s;
+}
+
+.reencode-conflict-folder-row:hover {
+ background: var(--surface-hover, #eeebe4);
+}
+
+.reencode-conflict-folder-row.is-checked {
+ background: var(--red-50, #fff5f5);
+}
+
+.reencode-conflict-folder-name {
+ flex: 1;
+ font-size: 0.875rem;
+ word-break: break-all;
+}
+
+.reencode-conflict-folder-date {
+ color: var(--text-color-secondary, #6c757d);
+ white-space: nowrap;
+ font-size: 0.78rem;
+}
+
+.reencode-conflict-hint {
+ font-size: 0.82rem;
+ color: var(--text-color-secondary, #6c757d);
+ line-height: 1.5;
+ padding: 0.5rem 0.75rem;
+ border-left: 3px solid var(--primary-color, #a89f91);
+ background: var(--surface-ground, #f7f7f7);
+ border-radius: 0 4px 4px 0;
+}
+
+.reencode-conflict-no-folders {
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--text-color-secondary, #6c757d);
+}
+
+.reencode-conflict-footer {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
+
+/* ===== Converter Page ===== */
+
+.converter-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 1rem 1.25rem 0;
+}
+
+.converter-card-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-color, #333);
+}
+
+.converter-jobs-group-label {
+ margin: 0 0 0.6rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--rip-muted, #6a4d38);
+}
+
+.converter-jobs-empty-hint {
+ margin: 0;
+ color: var(--rip-muted, #6a4d38);
+}
+
+/* ===== File Explorer (Klangkiste 1:1 mit Ripster-Farben) ===== */
+
+/* Äußere Hülle */
+.cfx-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+/* Obere Infozeile: rawdir + Aktualisieren */
+.cfx-top-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.45rem 0.75rem;
+ font-size: 0.8rem;
+}
+
+.cfx-rawdir {
+ flex: 1;
+ font-size: 0.75rem;
+ color: var(--rip-muted, #6a4d38);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ── Klangkiste Explorer-Layout ───────────────────────────────────────────── */
+
+.explorer {
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ grid-template-rows: 1fr auto;
+ gap: 0;
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 12px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ background: var(--rip-panel, #fffaf1);
+ min-height: 260px;
+ align-items: stretch;
+ font-size: 13px;
+}
+
+.explorer-sidebar {
+ grid-row: 1;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: var(--rip-panel-soft, #fdf5e7);
+ border-right: 1px solid var(--rip-border, #d9bc8d);
+ height: 100%;
+}
+
+.explorer-toolbar,
+.sidebar-toolbar {
+ height: 40px;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.sidebar-toolbar {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ background: var(--rip-cream-50, #fffaf0);
+ padding: 0 10px;
+ border-radius: 8px;
+ flex-shrink: 0;
+}
+
+.sidebar-tree {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 10px;
+ background: var(--rip-cream-50, #fffaf0);
+ padding: 8px;
+ overflow: auto;
+ flex: 1;
+}
+
+.sidebar-search {
+ flex: 1;
+ width: 100%;
+ border: 1px solid var(--rip-border, #d9bc8d);
+ background: var(--rip-cream-50, #fffaf0);
+ border-radius: 8px;
+ padding: 0 10px;
+ font-size: 13px;
+ color: var(--rip-ink, #2f180f);
+ min-width: 0;
+ height: 28px;
+ line-height: 28px;
+ outline: none;
+}
+.sidebar-search:focus {
+ border-color: var(--rip-gold-400, #d49c56);
+ box-shadow: 0 0 0 2px rgba(212, 156, 86, 0.2);
+}
+
+.explorer-main {
+ grid-row: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px 14px;
+ background: var(--rip-panel, #fffaf1);
+ height: 100%;
+ overflow: hidden;
+}
+
+.explorer-toolbar {
+ flex-wrap: wrap;
+ padding: 0 10px;
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 10px;
+ background: var(--rip-cream-50, #fffaf0);
+ flex-shrink: 0;
+}
+
+.explorer-toolbar .icon-button {
+ width: 30px;
+ height: 30px;
+}
+
+.explorer-path {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+ font-size: 13px;
+ color: var(--rip-muted, #6a4d38);
+ align-items: center;
+ line-height: 1;
+ flex: 1;
+ min-width: 0;
+}
+
+.breadcrumb-root {
+ font-weight: 600;
+ color: var(--rip-ink, #2f180f);
+}
+
+.path-link {
+ background: transparent;
+ border: none;
+ color: var(--rip-ink, #2f180f);
+ cursor: pointer;
+ padding: 0;
+ font-size: 13px;
+ font-weight: 600;
+}
+.path-link:hover,
+.path-link:focus { background: transparent; color: var(--primary-color, #8e4d2d); outline: none; text-decoration: underline; }
+
+.toolbar-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.explorer-list {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 10px;
+ background: var(--rip-cream-50, #fffaf0);
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.explorer-row {
+ display: grid;
+ grid-template-columns: 28px 2fr minmax(90px, 0.8fr) minmax(90px, 0.8fr) minmax(170px, 1.4fr);
+ gap: 10px;
+ align-items: center;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--rip-border, #d9bc8d);
+ font-size: 13px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.08s, border-left 0.08s;
+}
+.explorer-row:hover:not(.header):not(.footer) { background: var(--rip-cream-100, #fbf2df); }
+.explorer-row.selected {
+ background: color-mix(in srgb, var(--primary-color) 10%, transparent);
+ border-left: 3px solid var(--primary-color);
+ padding-left: 9px;
+}
+.explorer-row.row-active {
+ background: color-mix(in srgb, var(--primary-color) 18%, transparent);
+ outline: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
+ outline-offset: -1px;
+}
+.explorer-row.row-active.selected {
+ background: color-mix(in srgb, var(--primary-color) 22%, transparent);
+}
+.explorer-row.header {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--rip-muted, #6a4d38);
+ background: var(--rip-panel-soft, #fdf5e7);
+ cursor: default;
+ border-left: 3px solid transparent;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+.explorer-row.footer {
+ background: var(--rip-panel-soft, #fdf5e7);
+ cursor: default;
+ border-left: 3px solid transparent;
+}
+.explorer-row:last-child { border-bottom: none; }
+
+.row-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.row-icon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--rip-brown-700, #6f3922);
+ flex-shrink: 0;
+}
+
+.row-checkbox {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.row-checkbox input[type="checkbox"] {
+ width: 15px;
+ height: 15px;
+ cursor: pointer;
+ accent-color: var(--primary-color);
+ border-radius: 3px;
+}
+.explorer-row.header .row-checkbox { pointer-events: none; }
+
+.row-job {
+ min-width: 0;
+ text-align: left;
+}
+
+.row-job-assignment {
+ display: inline-block;
+ max-width: 100%;
+ font-size: 0.74rem;
+ color: var(--primary-color, #8e4d2d);
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.row-job-empty {
+ font-size: 0.74rem;
+ color: var(--rip-muted, #6a4d38);
+}
+
+.explorer-row.row-locked {
+ opacity: 0.7;
+ cursor: default;
+ background: repeating-linear-gradient(
+ -45deg,
+ transparent,
+ transparent 6px,
+ rgba(0,0,0,0.025) 6px,
+ rgba(0,0,0,0.025) 12px
+ ) !important;
+}
+
+.footer-count {
+ font-weight: 600;
+ color: var(--rip-ink, #2f180f);
+ text-align: right;
+}
+
+.explorer-footer {
+ grid-column: 1 / -1;
+ padding: 8px 14px;
+ font-size: 12px;
+ color: var(--rip-muted, #6a4d38);
+ border-top: 1px solid var(--rip-border, #d9bc8d);
+ background: var(--rip-panel-soft, #fdf5e7);
+ border-radius: 0 0 11px 11px;
+}
+
+.explorer-loading,
+.explorer-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ min-height: 180px;
+ gap: 0.55rem;
+}
+
+.audiobook-output-explorer .audiobook-output-row {
+ grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
+}
+
+/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
+
+.icon-button {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ background: var(--rip-panel, #fffaf1);
+ color: var(--rip-ink, #2f180f);
+ border-radius: 8px;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ line-height: 1;
+ font-size: 14px;
+ transition: background 0.1s;
+ flex-shrink: 0;
+}
+.icon-button:hover:not(:disabled) { background: var(--rip-cream-100, #fbf2df); }
+.icon-button.danger {
+ border-color: rgba(239, 68, 68, 0.55);
+ color: rgb(220, 38, 38);
+ background: rgba(239, 68, 68, 0.08);
+}
+.icon-button.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.15); }
+.icon-button:disabled { cursor: not-allowed; opacity: 0.4; }
+
+/* ── Ordnerbaum ──────────────────────────────────────────────────────────── */
+
+.tree-node { display: flex; flex-direction: column; gap: 2px; }
+
+.depth-1 .tree-row { margin-left: 16px; }
+.depth-2 .tree-row { margin-left: 32px; }
+.depth-3 .tree-row { margin-left: 48px; }
+.depth-4 .tree-row { margin-left: 64px; }
+.depth-5 .tree-row { margin-left: 80px; }
+
+.tree-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ color: var(--rip-muted, #6a4d38);
+ user-select: none;
+}
+.tree-row:hover { background: var(--rip-cream-100, #fbf2df); color: var(--rip-ink, #2f180f); }
+.tree-row.active { background: var(--primary-50, #fef6ec); color: var(--rip-ink, #2f180f); font-weight: 600; }
+
+.tree-caret {
+ border: none;
+ background: transparent;
+ color: var(--rip-muted, #6a4d38);
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ flex: 0 0 18px;
+ cursor: pointer;
+}
+.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
+
+.tree-checkbox {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 18px;
+}
+.tree-checkbox input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+ accent-color: var(--primary-color);
+ border-radius: 3px;
+}
+
+.tree-icon.folder { color: var(--rip-brown-700, #6f3922); }
+
+.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* Selection bar */
+.converter-selection-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--primary-50, #fef6ec);
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 6px;
+ font-size: 0.85rem;
+}
+
+/* ===== Upload Panel ===== */
+
+.converter-upload-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.converter-upload-dropzone {
+ border: 2px dashed var(--rip-border, #d9bc8d);
+ border-radius: 8px;
+ padding: 1.25rem 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ background: var(--rip-panel, #fffaf1);
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.converter-upload-dropzone.drag-over {
+ border-color: var(--primary-color, #8e4d2d);
+ background: var(--primary-50, #fef6ec);
+}
+
+.converter-upload-hint {
+ text-align: center;
+ font-size: 0.85rem;
+ color: var(--rip-muted, #6a4d38);
+ line-height: 1.5;
+}
+
+.converter-upload-buttons {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.converter-upload-status {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--rip-panel-soft, #fdf5e7);
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 6px;
+}
+
+.converter-upload-status-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+}
+
+/* ===== Job Card ===== */
+
+.converter-jobs-list {
+ gap: 0.6rem;
+}
+
+.converter-jobs-empty {
+ padding: 1rem 0;
+ color: var(--rip-muted, #6a4d38);
+}
+
+.converter-job-card {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 0.6rem;
+ padding: 0.6rem 0.7rem;
+ background: var(--rip-panel-soft, #fdf5e7);
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.converter-job-card.status-done,
+.converter-job-card.status-finished {
+ border-color: var(--green-300, #74c69d);
+ background: #f6fff9;
+}
+
+.converter-job-card.status-error {
+ border-color: var(--red-300, #ff8080);
+ background: #fff5f5;
+}
+
+.converter-job-card.status-cancelled {
+ opacity: 0.7;
+}
+
+.converter-job-card-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.converter-job-card-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.converter-job-card-title {
+ font-weight: 600;
+ font-size: 0.92rem;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.converter-job-card-meta {
+ font-size: 0.8rem;
+ color: var(--rip-muted, #6a4d38);
+}
+
+.converter-job-card-progress {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.converter-job-card-progress-meta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.78rem;
+ color: var(--rip-muted, #6a4d38);
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.converter-job-card-error {
+ color: var(--red-600, #c62828);
+ font-size: 0.8rem;
+ background: #fff0f0;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+}
+
+.converter-job-card-output {
+ font-size: 0.8rem;
+ color: var(--rip-muted, #6a4d38);
+}
+
+.converter-job-card-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-top: 0.1rem;
+}
+
+/* Inline-Konfiguration für READY_TO_START Jobs */
+.converter-job-card.is-ready {
+ border-color: #d9b26d;
+ background: #fff7e8;
+}
+
+.cjc-config {
+ margin-top: 0.75rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--surface-200, #e5e7eb);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.cjc-config-row {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.cjc-config-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ flex: 1;
+ min-width: 160px;
+}
+
+.cjc-config-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--text-color-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.cjc-config-value {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding-top: 0.1rem;
+}
+
+.cjc-config-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ padding-top: 0.25rem;
+}
+
+.cjc-section-label {
+ margin: 0 0 0.5rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--rip-muted, #6a4d38);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.converter-mb-results {
+ border: 1px solid var(--rip-border, #d9bc8d);
+ border-radius: 6px;
+ overflow-y: auto;
+ max-height: 12rem;
+ margin-bottom: 0.75rem;
+}
+
+.converter-mb-result-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ border-bottom: 1px solid var(--rip-border, #d9bc8d);
+ transition: background 0.12s;
+}
+
+.converter-mb-result-row:last-child {
+ border-bottom: none;
+}
+
+.converter-mb-result-row:hover {
+ background: var(--surface-100, #f5f0eb);
+}
+
+.converter-mb-result-row.selected {
+ background: rgba(142, 77, 45, 0.1);
+}
diff --git a/frontend/src/utils/confirmModal.js b/frontend/src/utils/confirmModal.js
new file mode 100644
index 0000000..a5c25fe
--- /dev/null
+++ b/frontend/src/utils/confirmModal.js
@@ -0,0 +1,49 @@
+import { confirmDialog } from 'primereact/confirmdialog';
+
+export function confirmModal(options = {}) {
+ const message = String(options?.message || '').trim();
+ if (!message) {
+ return Promise.resolve(false);
+ }
+
+ const header = String(options?.header || 'Bitte bestätigen').trim() || 'Bitte bestätigen';
+ const acceptLabel = String(options?.acceptLabel || 'Ja').trim() || 'Ja';
+ const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
+ const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
+ const danger = Boolean(options?.danger);
+ const style = options?.style && typeof options.style === 'object'
+ ? options.style
+ : null;
+ const breakpoints = options?.breakpoints && typeof options.breakpoints === 'object'
+ ? options.breakpoints
+ : null;
+
+ return new Promise((resolve) => {
+ let finished = false;
+ const settle = (value) => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ resolve(Boolean(value));
+ };
+
+ confirmDialog({
+ header,
+ message,
+ icon,
+ closable: true,
+ closeOnEscape: true,
+ draggable: false,
+ dismissableMask: true,
+ acceptLabel,
+ rejectLabel,
+ ...(style ? { style } : {}),
+ ...(breakpoints ? { breakpoints } : {}),
+ ...(danger ? { acceptClassName: 'p-button-danger' } : {}),
+ accept: () => settle(true),
+ reject: () => settle(false),
+ onHide: () => settle(false)
+ });
+ });
+}
diff --git a/frontend/src/utils/jobTaxonomy.js b/frontend/src/utils/jobTaxonomy.js
new file mode 100644
index 0000000..5bd398d
--- /dev/null
+++ b/frontend/src/utils/jobTaxonomy.js
@@ -0,0 +1,285 @@
+function safeParseJson(value, fallback = null) {
+ if (!value) {
+ return fallback;
+ }
+ if (typeof value === 'object') {
+ return value;
+ }
+ try {
+ return JSON.parse(value);
+ } catch (_error) {
+ return fallback;
+ }
+}
+
+function getEncodePlan(job) {
+ if (!job || typeof job !== 'object') {
+ return null;
+ }
+ return safeParseJson(job.encodePlan || job.encode_plan_json, null);
+}
+
+function normalizeConverterMediaType(value) {
+ const raw = String(value || '').trim().toLowerCase();
+ if (raw === 'audio' || raw === 'video' || raw === 'iso') {
+ return raw;
+ }
+ return null;
+}
+
+function normalizeMediaType(value) {
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) {
+ return null;
+ }
+ if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
+ return 'bluray';
+ }
+ if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
+ return 'dvd';
+ }
+ if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
+ return 'cd';
+ }
+ if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
+ return 'audiobook';
+ }
+ if (raw === 'converter') {
+ return 'converter';
+ }
+ return null;
+}
+
+function normalizeJobKind(value) {
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) {
+ return null;
+ }
+ if (['audiobook', 'cd', 'dvd', 'bluray', 'converter_audio', 'converter_video', 'converter_iso'].includes(raw)) {
+ return raw;
+ }
+ if (raw === 'converter') {
+ return 'converter_video';
+ }
+ if (raw === 'converter-audio' || raw === 'converter audio') {
+ return 'converter_audio';
+ }
+ if (raw === 'converter-video' || raw === 'converter video') {
+ return 'converter_video';
+ }
+ if (raw === 'converter-iso' || raw === 'converter iso') {
+ return 'converter_iso';
+ }
+ return null;
+}
+
+function converterJobKindFromMediaType(converterMediaType) {
+ const normalized = normalizeConverterMediaType(converterMediaType);
+ if (normalized === 'audio') {
+ return 'converter_audio';
+ }
+ if (normalized === 'iso') {
+ return 'converter_iso';
+ }
+ return 'converter_video';
+}
+
+function mediaTypeFromJobKind(jobKind) {
+ const normalized = normalizeJobKind(jobKind);
+ if (!normalized) {
+ return null;
+ }
+ if (normalized.startsWith('converter_')) {
+ return 'converter';
+ }
+ return normalized;
+}
+
+export function resolveJobKind(job) {
+ const encodePlan = getEncodePlan(job);
+ const converterMediaType = normalizeConverterMediaType(
+ encodePlan?.converterMediaType || job?.converterMediaType
+ );
+ const directCandidates = [
+ job?.job_kind,
+ job?.jobKind,
+ encodePlan?.jobKind,
+ job?.makemkvInfo?.jobKind,
+ job?.makemkvInfo?.analyzeContext?.jobKind,
+ job?.mediainfoInfo?.jobKind,
+ job?.handbrakeInfo?.jobKind
+ ];
+ for (const candidate of directCandidates) {
+ const normalized = normalizeJobKind(candidate);
+ if (!normalized) {
+ continue;
+ }
+ if (normalized.startsWith('converter_')) {
+ return converterMediaType ? converterJobKindFromMediaType(converterMediaType) : normalized;
+ }
+ return normalized;
+ }
+
+ const mediaCandidates = [
+ job?.mediaType,
+ job?.media_type,
+ job?.mediaProfile,
+ job?.media_profile,
+ encodePlan?.mediaProfile,
+ job?.makemkvInfo?.analyzeContext?.mediaProfile,
+ job?.makemkvInfo?.mediaProfile,
+ job?.mediainfoInfo?.mediaProfile
+ ];
+ for (const candidate of mediaCandidates) {
+ const normalized = normalizeMediaType(candidate);
+ if (!normalized) {
+ continue;
+ }
+ if (normalized === 'converter') {
+ return converterJobKindFromMediaType(converterMediaType);
+ }
+ return normalized;
+ }
+
+ if (converterMediaType) {
+ return converterJobKindFromMediaType(converterMediaType);
+ }
+
+ const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
+ if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
+ return 'cd';
+ }
+
+ const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
+ const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
+ if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
+ return 'cd';
+ }
+ if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
+ return 'cd';
+ }
+ if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
+ return 'cd';
+ }
+ if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
+ return 'audiobook';
+ }
+ if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
+ return 'audiobook';
+ }
+
+ return null;
+}
+
+export function resolveMediaType(job) {
+ const jobKind = resolveJobKind(job);
+ const mediaTypeFromKind = mediaTypeFromJobKind(jobKind);
+ if (mediaTypeFromKind) {
+ return mediaTypeFromKind;
+ }
+
+ const encodePlan = getEncodePlan(job);
+ const directCandidates = [
+ job?.mediaType,
+ job?.media_type,
+ job?.mediaProfile,
+ job?.media_profile,
+ encodePlan?.mediaProfile,
+ job?.makemkvInfo?.analyzeContext?.mediaProfile,
+ job?.makemkvInfo?.mediaProfile,
+ job?.mediainfoInfo?.mediaProfile
+ ];
+ for (const candidate of directCandidates) {
+ const normalized = normalizeMediaType(candidate);
+ if (normalized) {
+ return normalized;
+ }
+ }
+
+ const converterMediaType = normalizeConverterMediaType(encodePlan?.converterMediaType || job?.converterMediaType);
+ if (converterMediaType) {
+ return 'converter';
+ }
+
+ return 'other';
+}
+
+export function isSeriesDvdJob(job) {
+ if (resolveMediaType(job) !== 'dvd') {
+ return false;
+ }
+
+ const analyzeContext = job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
+ ? job.makemkvInfo.analyzeContext
+ : {};
+ const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
+ ? analyzeContext.selectedMetadata
+ : (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
+ ? job.makemkvInfo.selectedMetadata
+ : {});
+
+ const metadataProvider = String(
+ analyzeContext?.metadataProvider
+ || selectedMetadata?.metadataProvider
+ || ''
+ ).trim().toLowerCase();
+ const metadataKind = String(
+ selectedMetadata?.metadataKind
+ || analyzeContext?.metadataKind
+ || ''
+ ).trim().toLowerCase();
+ const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
+ const seasonNumberText = String(seasonNumberRaw ?? '').trim();
+ const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
+ const hasSeasonNumber = seasonNumberText
+ ? (Number.isFinite(parsedSeasonNumber) ? parsedSeasonNumber > 0 : true)
+ : false;
+ const seasonName = String(selectedMetadata?.seasonName || '').trim();
+ const episodeCount = Number(selectedMetadata?.episodeCount ?? 0);
+ const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0;
+ const isSeriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind);
+
+ if (isSeriesKind || hasSeasonNumber || Boolean(seasonName) || hasEpisodeList || (Number.isFinite(episodeCount) && episodeCount > 0)) {
+ return true;
+ }
+
+ const tmdbIdRaw = selectedMetadata?.tmdbId
+ ?? analyzeContext?.tmdbId
+ ?? selectedMetadata?.providerId
+ ?? analyzeContext?.providerId
+ ?? null;
+ const tmdbIdText = String(tmdbIdRaw ?? '').trim();
+ const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
+
+ return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
+}
+
+export function isConverterJob(job) {
+ return resolveMediaType(job) === 'converter';
+}
+
+export function classifyJob(job) {
+ const jobKind = resolveJobKind(job);
+ const mediaType = resolveMediaType(job);
+ const converterMediaType = normalizeConverterMediaType(
+ (jobKind && jobKind.startsWith('converter_'))
+ ? jobKind.replace('converter_', '')
+ : (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
+ );
+
+ let family = 'other';
+ if (mediaType === 'converter') {
+ family = 'converter';
+ } else if (mediaType === 'audiobook') {
+ family = 'audiobook';
+ } else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
+ family = 'rip';
+ }
+
+ return {
+ jobKind,
+ mediaType,
+ converterMediaType,
+ family
+ };
+}
diff --git a/frontend/src/utils/statusPresentation.js b/frontend/src/utils/statusPresentation.js
new file mode 100644
index 0000000..26fa99d
--- /dev/null
+++ b/frontend/src/utils/statusPresentation.js
@@ -0,0 +1,158 @@
+const STATUS_LABELS = {
+ IDLE: 'Bereit',
+ DISC_DETECTED: 'Medium erkannt',
+ ANALYZING: 'Analyse',
+ METADATA_SELECTION: 'Metadatenauswahl',
+ WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
+ READY_TO_START: 'Startbereit',
+ MEDIAINFO_CHECK: 'Mediainfo-Prüfung',
+ READY_TO_ENCODE: 'Bereit zum Encodieren',
+ RIPPING: 'Rippen',
+ ENCODING: 'Encodieren',
+ POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
+ FINISHED: 'Fertig',
+ CANCELLED: 'Abgebrochen',
+ ERROR: 'Fehler',
+ CD_ANALYZING: 'CD-Analyse',
+ CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
+ CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
+ CD_RIPPING: 'CD-Rippen',
+ CD_ENCODING: 'CD-Encodieren'
+};
+
+const PROCESS_STATUS_LABELS = {
+ SUCCESS: 'Erfolgreich',
+ ERROR: 'Fehler',
+ CANCELLED: 'Abgebrochen',
+ RUNNING: 'Läuft',
+ STARTED: 'Gestartet',
+ PENDING: 'Ausstehend',
+ DONE: 'Erledigt',
+ FAILED: 'Fehlgeschlagen',
+ COMPLETE: 'Abgeschlossen',
+ COMPLETED: 'Abgeschlossen',
+ SKIPPED: 'Übersprungen',
+ OK: 'OK'
+};
+
+const FALLBACK_TOKEN_LABELS = {
+ IDLE: 'Bereit',
+ DISC: 'Medium',
+ DETECTED: 'erkannt',
+ READY: 'bereit',
+ TO: 'zu',
+ RIP: 'rippen',
+ RIPPING: 'rippen',
+ ENCODE: 'encodieren',
+ ENCODING: 'encodieren',
+ ANALYZING: 'analyse',
+ METADATA: 'metadaten',
+ SELECTION: 'auswahl',
+ WAITING: 'warte',
+ FOR: 'auf',
+ USER: 'Benutzer',
+ DECISION: 'entscheidung',
+ CHECK: 'prüfung',
+ POST: 'post',
+ SCRIPTS: 'skripte',
+ FINISHED: 'fertig',
+ CANCELLED: 'abgebrochen',
+ ERROR: 'fehler',
+ SUCCESS: 'erfolgreich',
+ RUNNING: 'läuft',
+ STARTED: 'gestartet',
+ PENDING: 'ausstehend',
+ FAILED: 'fehlgeschlagen',
+ SKIPPED: 'übersprungen',
+ CD: 'CD',
+ DVD: 'DVD',
+ RAW: 'RAW'
+};
+
+function prettifyUnknownStatus(status) {
+ const raw = String(status || '').trim();
+ if (!raw) {
+ return '-';
+ }
+ if (!raw.includes('_')) {
+ return raw;
+ }
+ const parts = raw
+ .split('_')
+ .map((part) => String(part || '').trim().toUpperCase())
+ .filter(Boolean);
+ if (parts.length === 0) {
+ return raw;
+ }
+ const mapped = parts.map((token) => FALLBACK_TOKEN_LABELS[token] || token.toLowerCase());
+ const joined = mapped.join(' ').trim();
+ if (!joined) {
+ return raw;
+ }
+ return joined.charAt(0).toUpperCase() + joined.slice(1);
+}
+
+export function normalizeStatus(status) {
+ return String(status || '').trim().toUpperCase();
+}
+
+export function getStatusLabel(status, options = {}) {
+ if (options?.queued) {
+ return 'In der Queue';
+ }
+ const normalized = normalizeStatus(status);
+ return STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
+}
+
+export function getStatusSeverity(status, options = {}) {
+ if (options?.queued) {
+ return 'info';
+ }
+ const normalized = normalizeStatus(status);
+ if (normalized === 'FINISHED') return 'success';
+ if (normalized === 'CANCELLED') return 'warning';
+ if (normalized === 'ERROR') return 'danger';
+ if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
+ if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
+ if (normalized === 'CD_READY_TO_RIP') return 'info';
+ if (normalized === 'CD_METADATA_SELECTION') return 'warning';
+ if (
+ normalized === 'RIPPING'
+ || normalized === 'ENCODING'
+ || normalized === 'ANALYZING'
+ || normalized === 'MEDIAINFO_CHECK'
+ || normalized === 'METADATA_SELECTION'
+ || normalized === 'POST_ENCODE_SCRIPTS'
+ || normalized === 'CD_ANALYZING'
+ || normalized === 'CD_RIPPING'
+ || normalized === 'CD_ENCODING'
+ ) {
+ return 'warning';
+ }
+ return 'secondary';
+}
+
+export function getProcessStatusLabel(status) {
+ const normalized = normalizeStatus(status);
+ return PROCESS_STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
+}
+
+export const STATUS_FILTER_OPTIONS = [
+ { label: 'Alle', value: '' },
+ { label: getStatusLabel('FINISHED'), value: 'FINISHED' },
+ { label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
+ { label: getStatusLabel('ERROR'), value: 'ERROR' },
+ { label: getStatusLabel('CD_METADATA_SELECTION'), value: 'CD_METADATA_SELECTION' },
+ { label: getStatusLabel('CD_READY_TO_RIP'), value: 'CD_READY_TO_RIP' },
+ { label: getStatusLabel('CD_ANALYZING'), value: 'CD_ANALYZING' },
+ { label: getStatusLabel('CD_RIPPING'), value: 'CD_RIPPING' },
+ { label: getStatusLabel('CD_ENCODING'), value: 'CD_ENCODING' },
+ { label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
+ { label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
+ { label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
+ { label: getStatusLabel('MEDIAINFO_CHECK'), value: 'MEDIAINFO_CHECK' },
+ { label: getStatusLabel('RIPPING'), value: 'RIPPING' },
+ { label: getStatusLabel('ENCODING'), value: 'ENCODING' },
+ { label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
+ { label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
+];
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..6d46048
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,56 @@
+import { readFileSync } from 'node:fs';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
+const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
+const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+const allowedHosts = parsedAllowedHosts.length > 0 ? parsedAllowedHosts : true;
+
+let hmr = undefined;
+if (publicOrigin) {
+ const url = new URL(publicOrigin);
+ const defaultClientPort = url.port
+ ? Number(url.port)
+ : (url.protocol === 'https:' ? 443 : 80);
+
+ hmr = {
+ protocol: process.env.VITE_HMR_PROTOCOL || (url.protocol === 'https:' ? 'wss' : 'ws'),
+ host: process.env.VITE_HMR_HOST || url.hostname,
+ clientPort: Number(process.env.VITE_HMR_CLIENT_PORT || defaultClientPort)
+ };
+}
+
+export default defineConfig({
+ plugins: [react()],
+ define: {
+ __APP_VERSION__: JSON.stringify(appPackage.version)
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ strictPort: true,
+ origin: publicOrigin || undefined,
+ allowedHosts,
+ hmr,
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:3001',
+ changeOrigin: true
+ },
+ '/ws': {
+ target: 'ws://127.0.0.1:3001',
+ ws: true,
+ changeOrigin: true
+ }
+ }
+ },
+ preview: {
+ host: '0.0.0.0',
+ port: 5173,
+ strictPort: true
+ }
+});
diff --git a/install-dev.sh b/install-dev.sh
new file mode 100644
index 0000000..40a293c
--- /dev/null
+++ b/install-dev.sh
@@ -0,0 +1,1037 @@
+#!/usr/bin/env bash
+# =============================================================================
+# Ripster – Installationsskript
+# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
+# Benötigt: sudo / root
+#
+# Verwendung:
+# chmod +x install.sh
+# sudo ./install.sh [Optionen]
+#
+# Optionen:
+# --dir
Installationsverzeichnis (Standard: /opt/ripster)
+# --user Systembenutzer für den Dienst (Standard: ripster)
+# --port Backend-Port (Standard: 3001)
+# --host Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
+# --no-makemkv MakeMKV-Installation überspringen
+# --no-handbrake HandBrake-Installation überspringen
+# --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen
+# --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0)
+# --handbrake-update-policy
+# Bei NVDEC-Self-Build: bei neuer Git-Release behalten,
+# nachfragen oder direkt neu bauen (Standard: keep)
+# --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173)
+# --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten)
+# -h, --help Diese Hilfe anzeigen
+# =============================================================================
+set -euo pipefail
+
+# --- Farben -------------------------------------------------------------------
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
+BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
+
+info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
+ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
+warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
+error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
+header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
+ echo -e "${BOLD} $*${RESET}"; \
+ echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
+fatal() { error "$*"; exit 1; }
+
+# --- Standard-Optionen --------------------------------------------------------
+INSTALL_DIR="/opt/ripster"
+SERVICE_USER="ripster"
+BACKEND_PORT="3001"
+FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer
+SKIP_MAKEMKV=false
+SKIP_HANDBRAKE=false
+BUILD_HANDBRAKE_NVDEC=false
+HANDBRAKE_MODE_SELECTED=false
+HANDBRAKE_VERSION="1.9.0"
+HANDBRAKE_UPDATE_POLICY="keep"
+HANDBRAKE_UPDATE_POLICY_SELECTED=false
+SKIP_NGINX=false
+REINSTALL=false
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+HANDBRAKE_SELFBUILD_MARKER="/usr/local/share/ripster/handbrake-selfbuild.env"
+
+# --- Argumente parsen ---------------------------------------------------------
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dir) INSTALL_DIR="$2"; shift 2 ;;
+ --user) SERVICE_USER="$2"; shift 2 ;;
+ --port) BACKEND_PORT="$2"; shift 2 ;;
+ --host) FRONTEND_HOST="$2"; shift 2 ;;
+ --no-makemkv) SKIP_MAKEMKV=true; shift ;;
+ --no-handbrake) SKIP_HANDBRAKE=true; shift ;;
+ --build-handbrake) BUILD_HANDBRAKE_NVDEC=true; HANDBRAKE_MODE_SELECTED=true; shift ;;
+ --handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;;
+ --handbrake-update-policy)
+ case "$2" in
+ keep|prompt|build) HANDBRAKE_UPDATE_POLICY="$2"; HANDBRAKE_UPDATE_POLICY_SELECTED=true ;;
+ *) fatal "Ungültige --handbrake-update-policy: $2 (erlaubt: keep|prompt|build)" ;;
+ esac
+ shift 2 ;;
+ --no-nginx) SKIP_NGINX=true; shift ;;
+ --reinstall) REINSTALL=true; shift ;;
+ -h|--help)
+ sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
+ exit 0 ;;
+ *) fatal "Unbekannte Option: $1" ;;
+ esac
+done
+
+# --- Voraussetzungen prüfen ---------------------------------------------------
+header "Ripster Installationsskript"
+
+if [[ $EUID -ne 0 ]]; then
+ fatal "Dieses Skript muss als root ausgeführt werden (sudo ./install.sh)"
+fi
+
+# OS-Erkennung
+if [[ ! -f /etc/os-release ]]; then
+ fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
+fi
+. /etc/os-release
+case "$ID" in
+ debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
+ *) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
+esac
+
+# Host-IP ermitteln
+if [[ -z "$FRONTEND_HOST" ]]; then
+ FRONTEND_HOST=$(hostname -I | awk '{print $1}')
+ info "Erkannte IP: $FRONTEND_HOST"
+fi
+
+# Quelldirectory prüfen
+[[ -f "$SOURCE_DIR/backend/package.json" ]] || \
+ fatal "Ripster-Quellen nicht gefunden in: $SOURCE_DIR"
+
+info "Quellverzeichnis: $SOURCE_DIR"
+info "Installationsverzeichnis: $INSTALL_DIR"
+info "Systembenutzer: $SERVICE_USER"
+info "Backend-Port: $BACKEND_PORT"
+info "Frontend-Host: $FRONTEND_HOST"
+
+# --- Hilfsfunktionen ----------------------------------------------------------
+
+command_exists() { command -v "$1" &>/dev/null; }
+
+nginx_replace_or_insert_directive() {
+ local file="$1"
+ local directive_regex="$2"
+ local desired_line="$3"
+ local anchor_regex="$4"
+ local directive_sed_regex="${directive_regex//\//\\/}"
+ local anchor_sed_regex="${anchor_regex//\//\\/}"
+ local desired_sed_line="${desired_line//\//\\/}"
+
+ if grep -Eq "$directive_regex" "$file"; then
+ sed -i -E "0,/$directive_sed_regex/s//${desired_sed_line}/" "$file"
+ return 0
+ fi
+
+ sed -i "/$anchor_sed_regex/a\\$desired_line" "$file"
+}
+
+patch_existing_ripster_nginx_site() {
+ local file="$1"
+ local backup_file="${file}.bak-$(date +%Y%m%d%H%M%S)"
+
+ [[ -f "$file" ]] || return 1
+
+ cp -a "$file" "$backup_file"
+ info "Bestehende nginx-Konfiguration erkannt - ergänze Upload-/Proxy-Settings"
+ info "Backup erstellt: $backup_file"
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*client_max_body_size[[:space:]]+[^;]+;' \
+ ' client_max_body_size 8G;' \
+ 'server_name .*;'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_connect_timeout[[:space:]]+[^;]+;' \
+ ' proxy_connect_timeout 60s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_send_timeout[[:space:]]+[^;]+;' \
+ ' proxy_send_timeout 3600s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_read_timeout[[:space:]]+[^;]+;' \
+ ' proxy_read_timeout 3600s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_request_buffering[[:space:]]+[^;]+;' \
+ ' proxy_request_buffering off;' \
+ 'location /api/ {'
+}
+
+install_node() {
+ header "Node.js installieren"
+ local required_major=20
+
+ if command_exists node; then
+ local current_major
+ current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
+ if [[ "$current_major" -ge "$required_major" ]]; then
+ ok "Node.js $(node --version) bereits installiert"
+ return
+ fi
+ warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
+ fi
+
+ info "Installiere Node.js 20.x über NodeSource..."
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y nodejs
+ ok "Node.js $(node --version) installiert"
+}
+
+install_makemkv() {
+ header "MakeMKV installieren"
+
+ if command_exists makemkvcon; then
+ local mk_version_line
+ mk_version_line="$(makemkvcon --version 2>&1 | head -1 || true)"
+ if [[ -z "$mk_version_line" || "$mk_version_line" == *"unrecognized option"* ]]; then
+ mk_version_line="$(makemkvcon 2>&1 | head -1 || true)"
+ fi
+ ok "makemkvcon bereits installiert (${mk_version_line:-Version unbekannt})"
+ return
+ fi
+
+ info "Installiere Build-Abhängigkeiten für MakeMKV..."
+ apt-get install -y \
+ build-essential pkg-config libc6-dev libssl-dev \
+ libexpat1-dev libavcodec-dev libgl1-mesa-dev \
+ qtbase5-dev zlib1g-dev wget
+
+ local makemkv_fallback="1.18.3"
+ info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
+ local makemkv_version
+ makemkv_version=$(curl -s --max-time 15 \
+ "https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
+ | grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
+
+ if [[ -z "$makemkv_version" ]]; then
+ warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
+ makemkv_version="$makemkv_fallback"
+ else
+ info "Gefundene Version: $makemkv_version"
+ fi
+
+ info "Baue MakeMKV $makemkv_version..."
+ local tmp_dir
+ tmp_dir=$(mktemp -d)
+ cd "$tmp_dir"
+
+ local base_url="https://www.makemkv.com/download"
+ wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
+ wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
+
+ tar xf "makemkv-oss-${makemkv_version}.tar.gz"
+ cd "makemkv-oss-${makemkv_version}"
+ ./configure
+ make -j"$(nproc)"
+ make install
+
+ cd "$tmp_dir"
+ tar xf "makemkv-bin-${makemkv_version}.tar.gz"
+ cd "makemkv-bin-${makemkv_version}"
+ mkdir -p tmp && echo "accepted" > tmp/eula_accepted
+ make -j"$(nproc)"
+ make install
+
+ cd /
+ rm -rf "$tmp_dir"
+ ok "MakeMKV $makemkv_version installiert"
+ warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
+ warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
+}
+
+handbrake_has_nvdec() {
+ command_exists HandBrakeCLI || return 1
+ HandBrakeCLI --help 2>&1 | grep -qi "nvdec"
+}
+
+handbrake_installed_version() {
+ command_exists HandBrakeCLI || return 1
+ HandBrakeCLI --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+){1,3}' | head -1
+}
+
+handbrake_latest_git_version() {
+ local latest=""
+ latest=$(curl -fsSL --max-time 10 "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" 2>/dev/null \
+ | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
+ | head -1 \
+ | sed -E 's/.*"([^"]+)".*/\1/' \
+ | sed 's/^v//')
+ [[ -n "$latest" ]] || return 1
+ [[ "$latest" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || return 1
+ printf '%s\n' "$latest"
+}
+
+handbrake_is_self_built() {
+ local hb_path="${1:-$(command -v HandBrakeCLI 2>/dev/null || true)}"
+ local resolved_path=""
+ [[ -n "$hb_path" ]] || return 1
+ [[ "$hb_path" == "/usr/local/bin/HandBrakeCLI" ]] || return 1
+ [[ -f "$HANDBRAKE_SELFBUILD_MARKER" ]] && return 0
+ resolved_path=$(readlink -f "$hb_path" 2>/dev/null || true)
+ dpkg -S "$hb_path" >/dev/null 2>&1 && return 1
+ [[ -n "$resolved_path" ]] && dpkg -S "$resolved_path" >/dev/null 2>&1 && return 1
+ return 0
+}
+
+remove_non_selfbuilt_handbrake() {
+ info "Entferne nicht-selbst-gebaute HandBrake-Installationen..."
+ apt-get remove -y handbrake-cli handbrake 2>/dev/null || true
+ snap remove handbrake-cli 2>/dev/null || true
+
+ rm -f /usr/bin/HandBrakeCLI \
+ /snap/bin/handbrake-cli \
+ /snap/bin/HandBrakeCLI
+
+ if [[ -e /usr/local/bin/HandBrakeCLI ]] && ! handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
+ warn "Entferne fremdes /usr/local/bin/HandBrakeCLI"
+ rm -f /usr/local/bin/HandBrakeCLI
+ fi
+
+ hash -r 2>/dev/null || true
+ ok "Bereinigung abgeschlossen"
+}
+
+remove_selfbuilt_handbrake() {
+ if [[ -e /usr/local/bin/HandBrakeCLI ]] && handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
+ warn "Entferne selbst gebautes /usr/local/bin/HandBrakeCLI"
+ rm -f /usr/local/bin/HandBrakeCLI
+ fi
+ rm -f "$HANDBRAKE_SELFBUILD_MARKER"
+ hash -r 2>/dev/null || true
+}
+
+build_handbrake_nvdec() {
+ header "HandBrake ${HANDBRAKE_VERSION} mit NVDEC aus Quellcode bauen"
+
+ local cache_dir="/var/cache/ripster/handbrake"
+ local src_url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
+ local tarball="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
+ local src_dir="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}"
+
+ if [[ -t 0 && -d "$cache_dir" ]]; then
+ local clear_cache_answer=""
+ warn "Build-Cache gefunden: $cache_dir"
+ warn "Wenn du ihn löschst, startet der Source-Build wieder komplett von vorne."
+ read -r -p "Build-Cache jetzt löschen (sudo rm -rf $cache_dir)? [y/N] " clear_cache_answer
+ case "${clear_cache_answer,,}" in
+ y|yes|j|ja)
+ rm -rf "$cache_dir"
+ info "Build-Cache gelöscht."
+ ;;
+ esac
+ fi
+ mkdir -p "$cache_dir"
+
+ # Build-Abhängigkeiten
+ info "Installiere Build-Abhängigkeiten..."
+ apt-get install -y \
+ autoconf automake build-essential clang cmake git \
+ libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \
+ libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \
+ libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \
+ libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin libva-dev \
+ libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \
+ m4 meson nasm ninja-build patch pkg-config python3 tar yasm zlib1g-dev \
+ >/dev/null
+
+ # CUDA Toolkit für NVDEC-Header
+ info "Installiere CUDA Toolkit (für NVDEC-Header)..."
+ if ! dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then
+ apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1 || {
+ warn "nvidia-cuda-toolkit nicht verfügbar – versuche Fallback-Header..."
+ local cuda_keyring="/tmp/cuda-keyring.deb"
+ local ubuntu_ver="${VERSION_ID//./}"
+ curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${ubuntu_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \
+ -o "$cuda_keyring" 2>/dev/null && \
+ dpkg -i "$cuda_keyring" 2>/dev/null && \
+ apt-get update -qq && \
+ apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 || \
+ warn "CUDA-Header konnten nicht installiert werden – NVDEC wird möglicherweise nicht verfügbar sein."
+ }
+ fi
+ ok "Build-Abhängigkeiten installiert"
+
+ if [[ ! -d "$src_dir" ]]; then
+ if [[ ! -f "$tarball" ]]; then
+ info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..."
+ curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \
+ wget -q "$src_url" -O "$tarball" || \
+ fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})"
+ else
+ info "Nutze vorhandenes HandBrake-Source-Archiv: $tarball"
+ fi
+
+ info "Entpacke Quellcode..."
+ tar xjf "$tarball" -C "$cache_dir"
+ [[ -d "$src_dir" ]] || src_dir=$(find "$cache_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1)
+ [[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $cache_dir"
+ else
+ info "Nutze vorhandenen HandBrake-Source-Baum: $src_dir"
+ fi
+
+ cd "$src_dir"
+
+ local configure_log="${src_dir}/build/configure-ripster.log"
+ local configure_stamp="${src_dir}/build/.ripster-config"
+ local configure_args="--enable-nvdec --disable-gtk --prefix=/usr/local"
+ local need_configure="false"
+ local configure_cmd=(
+ ./configure
+ --launch-jobs="$(nproc)"
+ --enable-nvdec
+ --disable-gtk
+ --prefix=/usr/local
+ )
+
+ if [[ ! -f "$src_dir/build/GNUmakefile" ]]; then
+ need_configure="true"
+ elif [[ ! -f "$configure_stamp" ]]; then
+ need_configure="true"
+ elif ! grep -qx "args=${configure_args}" "$configure_stamp"; then
+ need_configure="true"
+ fi
+
+ if [[ "$need_configure" == "true" ]]; then
+ if [[ -d "$src_dir/build" ]]; then
+ configure_cmd=(./configure --force "${configure_cmd[@]:1}")
+ fi
+
+ if [[ -f "$src_dir/build/GNUmakefile" ]]; then
+ info "Vorhandener Build gefunden – aktualisiere Konfiguration (CLI-only)."
+ else
+ info "Konfiguriere HandBrake mit NVDEC (CLI-only)..."
+ fi
+
+ if ! "${configure_cmd[@]}" >"$configure_log" 2>&1; then
+ tail -n 50 "$configure_log" >&2 || true
+ fatal "HandBrake-Konfiguration fehlgeschlagen. Vollständiges Log: $configure_log"
+ fi
+ tail -n 10 "$configure_log"
+
+ mkdir -p "${src_dir}/build"
+ cat > "$configure_stamp" </dev/null || true
+ if ! command_exists HandBrakeCLI; then
+ warn "HandBrakeCLI nach direkter Installation nicht gefunden – setze Build fort."
+ make --directory=build -j"$(nproc)"
+ make --directory=build install
+ fi
+ else
+ info "Baue HandBrake ($(nproc) Threads – bitte warten)..."
+ make --directory=build -j"$(nproc)"
+ info "Installiere HandBrake nach /usr/local/bin..."
+ make --directory=build install
+ fi
+
+ cd /
+ hash -r 2>/dev/null || true
+
+ if command_exists HandBrakeCLI; then
+ local ver
+ ver=$(HandBrakeCLI --version 2>&1 | head -1)
+ handbrake_has_nvdec || fatal "HandBrakeCLI ist installiert, aber ohne NVDEC-Unterstützung."
+
+ mkdir -p "$(dirname "$HANDBRAKE_SELFBUILD_MARKER")"
+ cat > "$HANDBRAKE_SELFBUILD_MARKER" </dev/null | grep -q libnvcuvid; then
+ ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar."
+ else
+ warn "libnvcuvid NICHT gefunden. NVDEC benötigt den installierten NVIDIA-Treiber."
+ fi
+ else
+ fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen."
+ fi
+}
+
+has_nvidia_gpu() {
+ [[ -e /dev/nvidia0 ]] && return 0
+ command_exists nvidia-smi && nvidia-smi &>/dev/null && return 0
+ command_exists lspci && lspci 2>/dev/null | grep -qi "nvidia" && return 0
+ return 1
+}
+
+install_handbrake() {
+ header "HandBrake CLI installieren"
+
+ local hb_path
+ local current_mode="none"
+ hb_path=$(command -v HandBrakeCLI 2>/dev/null || true)
+ if [[ -n "$hb_path" ]]; then
+ if handbrake_has_nvdec; then
+ current_mode="nvdec"
+ else
+ current_mode="standard"
+ fi
+ fi
+
+ if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then
+ info "Installmodus: Source-Build mit NVDEC-Support"
+ if has_nvidia_gpu; then
+ info "NVIDIA-GPU erkannt – NVDEC-Build wird verwendet."
+ fi
+
+ if handbrake_has_nvdec; then
+ if handbrake_is_self_built "$hb_path"; then
+ local installed_ver latest_ver answer
+ installed_ver=$(handbrake_installed_version || true)
+ latest_ver=$(handbrake_latest_git_version || true)
+
+ if [[ -n "$installed_ver" && -n "$latest_ver" ]] && dpkg --compare-versions "$latest_ver" gt "$installed_ver"; then
+ case "$HANDBRAKE_UPDATE_POLICY" in
+ keep)
+ warn "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – behalte aktuelle Installation."
+ return
+ ;;
+ prompt)
+ if [[ -t 0 ]]; then
+ read -r -p "Neue HandBrake-Version ${latest_ver} verfügbar (installiert ${installed_ver}). Neu bauen? [y/N] " answer
+ case "${answer,,}" in
+ y|yes|j|ja)
+ info "Aktualisiere auf HandBrake ${latest_ver}."
+ HANDBRAKE_VERSION="$latest_ver"
+ ;;
+ *)
+ info "Behalte bestehende Installation (${installed_ver})."
+ return
+ ;;
+ esac
+ else
+ warn "Neuere HandBrake-Version verfügbar (${latest_ver}), aber kein TTY für Rückfrage – behalte aktuelle Installation."
+ return
+ fi
+ ;;
+ build)
+ info "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – aktualisiere."
+ HANDBRAKE_VERSION="$latest_ver"
+ ;;
+ esac
+ else
+ ok "Selbst gebautes HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
+ return
+ fi
+ fi
+ warn "HandBrakeCLI mit NVDEC gefunden (${hb_path}), aber nicht als Selbst-Build erkannt."
+ fi
+
+ if [[ -n "$hb_path" ]] && ! handbrake_has_nvdec; then
+ warn "HandBrakeCLI ohne NVDEC gefunden (${hb_path}) – wird ersetzt durch Selbst-Build."
+ elif [[ -z "$hb_path" ]]; then
+ info "Kein HandBrakeCLI gefunden – baue aus Quellcode."
+ fi
+
+ remove_non_selfbuilt_handbrake
+ build_handbrake_nvdec
+ return
+ fi
+
+ info "Installmodus: Standard (APT, ohne NVDEC-Zwang)"
+ if [[ "$current_mode" == "standard" ]] && ! handbrake_is_self_built "$hb_path"; then
+ ok "HandBrakeCLI bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
+ return
+ fi
+
+ if [[ "$current_mode" == "nvdec" ]]; then
+ warn "Umschalten von NVDEC-Build auf Standard-Installation."
+ fi
+
+ remove_selfbuilt_handbrake
+ remove_non_selfbuilt_handbrake
+
+ info "Installiere HandBrakeCLI aus den Standard-Repositories..."
+ apt_update
+ apt-get install -y handbrake-cli >/dev/null
+ hash -r 2>/dev/null || true
+
+ if command_exists HandBrakeCLI; then
+ ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
+ return
+ fi
+
+ if command_exists handbrake-cli; then
+ ln -sf "$(command -v handbrake-cli)" /usr/local/bin/HandBrakeCLI
+ hash -r 2>/dev/null || true
+ ok "HandBrakeCLI Alias angelegt auf: $(command -v handbrake-cli)"
+ return
+ fi
+
+ fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
+}
+
+# --- apt-Hilfsfunktionen ------------------------------------------------------
+
+apt_update() {
+ local output
+ if output=$(apt-get update 2>&1); then
+ return 0
+ fi
+
+ if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
+ warn "apt-Sources fehlerhaft. Versuche Reparatur..."
+
+ if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
+ ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
+ return 0
+ fi
+
+ if [[ -n "${VERSION_CODENAME:-}" ]]; then
+ warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
+ local main_list=/etc/apt/sources.list
+ cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
+
+ case "$ID" in
+ ubuntu)
+ cat > "$main_list" < "$main_list" </dev/null; then
+ ok "apt-Update nach Sources-Reparatur erfolgreich"
+ return 0
+ fi
+ fi
+
+ warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
+ local broken_files
+ broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
+ xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
+ if [[ -n "$broken_files" ]]; then
+ echo "$broken_files" | while read -r f; do
+ warn "Deaktiviere: $f"
+ mv "$f" "${f}.disabled" 2>/dev/null || true
+ done
+ if apt-get update -qq 2>/dev/null; then
+ ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
+ return 0
+ fi
+ fi
+
+ error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
+ echo "$output"
+ fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
+ else
+ error "apt-Update fehlgeschlagen:"
+ echo "$output"
+ fatal "Installation abgebrochen."
+ fi
+}
+
+# --- Systemabhängigkeiten -----------------------------------------------------
+header "Systemabhängigkeiten installieren"
+
+info "Paketlisten aktualisieren..."
+apt_update
+
+info "Installiere Basispakete..."
+apt-get install -y \
+ curl wget git \
+ ffmpeg \
+ mediainfo \
+ util-linux udev \
+ ca-certificates gnupg \
+ lsb-release
+
+ok "Basispakete installiert"
+
+# Node.js
+install_node
+
+# MakeMKV
+if [[ "$SKIP_MAKEMKV" == false ]]; then
+ install_makemkv
+else
+ warn "MakeMKV-Installation übersprungen (--no-makemkv)"
+fi
+
+# HandBrake
+if [[ "$SKIP_HANDBRAKE" == false ]]; then
+ install_handbrake
+else
+ warn "HandBrake-Installation übersprungen (--no-handbrake)"
+fi
+
+# Nginx
+if [[ "$SKIP_NGINX" == false ]]; then
+ if ! command_exists nginx; then
+ info "Installiere nginx..."
+ apt-get install -y nginx
+ fi
+ ok "nginx installiert"
+fi
+
+# --- Systembenutzer anlegen ---------------------------------------------------
+header "Systembenutzer anlegen"
+
+if id "$SERVICE_USER" &>/dev/null; then
+ ok "Benutzer '$SERVICE_USER' existiert bereits"
+else
+ info "Lege Systembenutzer '$SERVICE_USER' an..."
+ useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
+ ok "Benutzer '$SERVICE_USER' angelegt"
+fi
+
+SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
+if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
+ SERVICE_HOME="/home/$SERVICE_USER"
+fi
+mkdir -p "$SERVICE_HOME"
+chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
+chmod 755 "$SERVICE_HOME" 2>/dev/null || true
+info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
+
+# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
+for grp in cdrom optical disk; do
+ if getent group "$grp" &>/dev/null; then
+ usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
+ info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
+ fi
+done
+
+# --- Dateien kopieren ---------------------------------------------------------
+header "Ripster-Dateien installieren"
+
+if [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
+ fatal "Verzeichnis $INSTALL_DIR existiert bereits.\nVerwende --reinstall um zu überschreiben (Daten bleiben erhalten)."
+fi
+
+# Bei Reinstall: Daten sichern
+if [[ -d "$INSTALL_DIR/backend/data" ]]; then
+ info "Sichere vorhandene Datenbank..."
+ cp -a "$INSTALL_DIR/backend/data" "/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
+ ok "Datenbank gesichert"
+fi
+
+info "Kopiere Quellen nach $INSTALL_DIR..."
+mkdir -p "$INSTALL_DIR"
+
+rsync -a --delete \
+ --exclude='.git' \
+ --exclude='node_modules' \
+ --exclude='backend/node_modules' \
+ --exclude='frontend/node_modules' \
+ --exclude='backend/data' \
+ --exclude='backend/logs' \
+ --exclude='frontend/dist' \
+ --exclude='*.sh' \
+ --exclude='deploy-ripster.sh' \
+ --exclude='debug/' \
+ --exclude='site/' \
+ --exclude='docs/' \
+ "$SOURCE_DIR/" "$INSTALL_DIR/"
+
+# Datenbank-/Log-Verzeichnisse anlegen
+mkdir -p "$INSTALL_DIR/backend/data"
+mkdir -p "$INSTALL_DIR/backend/logs"
+mkdir -p "$INSTALL_DIR/backend/data/output/raw"
+mkdir -p "$INSTALL_DIR/backend/data/output/movies"
+mkdir -p "$INSTALL_DIR/backend/data/output/series"
+mkdir -p "$INSTALL_DIR/backend/data/output/cd"
+mkdir -p "$INSTALL_DIR/backend/data/downloads"
+mkdir -p "$INSTALL_DIR/backend/data/logs"
+
+# Bei Reinstall: Daten wiederherstellen
+if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
+ cp -a /tmp/ripster-data-backup-*/ "$INSTALL_DIR/backend/data/" 2>/dev/null || true
+fi
+
+ok "Dateien kopiert"
+
+# --- npm-Abhängigkeiten installieren -----------------------------------------
+header "npm-Abhängigkeiten installieren"
+
+info "Installiere Root-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR" --omit=dev --silent
+
+info "Installiere Backend-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
+
+info "Installiere Frontend-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR/frontend" --silent
+
+ok "npm-Abhängigkeiten installiert"
+
+# --- Frontend bauen -----------------------------------------------------------
+header "Frontend bauen"
+
+info "Baue Frontend für $FRONTEND_HOST..."
+
+# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
+# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
+# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
+# Domainnamen aufgerufen wird.
+rm -f "$INSTALL_DIR/frontend/.env.production.local"
+
+npm run build --prefix "$INSTALL_DIR/frontend" --silent
+ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
+
+# --- Backend-Konfiguration ---------------------------------------------------
+header "Backend konfigurieren"
+
+ENV_FILE="$INSTALL_DIR/backend/.env"
+
+if [[ -f "$ENV_FILE" && "$REINSTALL" == false ]]; then
+ warn "Backend .env existiert bereits – wird nicht überschrieben"
+else
+ info "Erstelle Backend .env..."
+ cat > "$ENV_FILE" </dev/null || true
+chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
+
+# --- Systemd-Dienst: Backend -------------------------------------------------
+header "Systemd-Dienst (Backend) erstellen"
+
+cat > /etc/systemd/system/ripster-backend.service < /etc/nginx/sites-available/ripster < Git-Branch (Standard: main)
+# --dir Installationsverzeichnis (Standard: /opt/ripster)
+# --user Systembenutzer für den Dienst (Standard: ripster)
+# --port Backend-Port (Standard: 3001)
+# --host Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
+# --no-makemkv MakeMKV-Installation überspringen
+# --no-handbrake HandBrake-Installation überspringen
+# --no-nginx Nginx-Einrichtung überspringen
+# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten)
+# -h, --help Diese Hilfe anzeigen
+# =============================================================================
+set -euo pipefail
+
+REPO_URL="https://github.com/Mboehmlaender/ripster.git"
+REPO_RAW_BASE="https://raw.githubusercontent.com/Mboehmlaender/ripster"
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
+BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI"
+
+# --- Farben -------------------------------------------------------------------
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
+BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
+
+info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
+ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
+warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
+error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
+header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
+ echo -e "${BOLD} $*${RESET}"; \
+ echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
+fatal() { error "$*"; exit 1; }
+
+# --- Standard-Optionen --------------------------------------------------------
+GIT_BRANCH="dev"
+INSTALL_DIR="/opt/ripster"
+SERVICE_USER="ripster"
+BACKEND_PORT="3001"
+FRONTEND_HOST=""
+SKIP_MAKEMKV=false
+SKIP_HANDBRAKE=false
+HANDBRAKE_INSTALL_MODE=""
+SKIP_NGINX=false
+REINSTALL=false
+
+# --- Argumente parsen ---------------------------------------------------------
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --branch) GIT_BRANCH="$2"; shift 2 ;;
+ --dir) INSTALL_DIR="$2"; shift 2 ;;
+ --user) SERVICE_USER="$2"; shift 2 ;;
+ --port) BACKEND_PORT="$2"; shift 2 ;;
+ --host) FRONTEND_HOST="$2"; shift 2 ;;
+ --no-makemkv) SKIP_MAKEMKV=true; shift ;;
+ --no-handbrake) SKIP_HANDBRAKE=true; shift ;;
+ --no-nginx) SKIP_NGINX=true; shift ;;
+ --reinstall) REINSTALL=true; shift ;;
+ -h|--help)
+ sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
+ exit 0 ;;
+ *) fatal "Unbekannte Option: $1" ;;
+ esac
+done
+
+# --- Voraussetzungen prüfen ---------------------------------------------------
+header "Ripster Installationsskript (Git)"
+
+if [[ $EUID -ne 0 ]]; then
+ fatal "Dieses Skript muss als root ausgeführt werden (sudo bash install.sh)"
+fi
+
+if [[ ! -f /etc/os-release ]]; then
+ fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
+fi
+. /etc/os-release
+case "$ID" in
+ debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
+ *) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
+esac
+
+if [[ -z "$FRONTEND_HOST" ]]; then
+ FRONTEND_HOST=$(hostname -I | awk '{print $1}')
+ info "Erkannte IP: $FRONTEND_HOST"
+fi
+
+info "Repository: $REPO_URL"
+info "Branch: $GIT_BRANCH"
+info "Installationsverzeichnis: $INSTALL_DIR"
+info "Systembenutzer: $SERVICE_USER"
+info "Backend-Port: $BACKEND_PORT"
+info "Frontend-Host: $FRONTEND_HOST"
+
+# --- Hilfsfunktionen ----------------------------------------------------------
+
+command_exists() { command -v "$1" &>/dev/null; }
+
+download_file() {
+ local url="$1"
+ local target="$2"
+
+ if command_exists curl; then
+ curl -fsSL "$url" -o "$target"
+ return 0
+ fi
+
+ if command_exists wget; then
+ wget -q "$url" -O "$target"
+ return 0
+ fi
+
+ return 1
+}
+
+nginx_replace_or_insert_directive() {
+ local file="$1"
+ local directive_regex="$2"
+ local desired_line="$3"
+ local anchor_regex="$4"
+ local directive_sed_regex="${directive_regex//\//\\/}"
+ local anchor_sed_regex="${anchor_regex//\//\\/}"
+ local desired_sed_line="${desired_line//\//\\/}"
+
+ if grep -Eq "$directive_regex" "$file"; then
+ sed -i -E "0,/$directive_sed_regex/s//${desired_sed_line}/" "$file"
+ return 0
+ fi
+
+ sed -i "/$anchor_sed_regex/a\\$desired_line" "$file"
+}
+
+patch_existing_ripster_nginx_site() {
+ local file="$1"
+ local backup_file="${file}.bak-$(date +%Y%m%d%H%M%S)"
+
+ [[ -f "$file" ]] || return 1
+
+ cp -a "$file" "$backup_file"
+ info "Bestehende nginx-Konfiguration erkannt - ergänze Upload-/Proxy-Settings"
+ info "Backup erstellt: $backup_file"
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*client_max_body_size[[:space:]]+[^;]+;' \
+ ' client_max_body_size 8G;' \
+ 'server_name .*;'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_connect_timeout[[:space:]]+[^;]+;' \
+ ' proxy_connect_timeout 60s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_send_timeout[[:space:]]+[^;]+;' \
+ ' proxy_send_timeout 3600s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_read_timeout[[:space:]]+[^;]+;' \
+ ' proxy_read_timeout 3600s;' \
+ 'location /api/ {'
+
+ nginx_replace_or_insert_directive \
+ "$file" \
+ '^[[:space:]]*proxy_request_buffering[[:space:]]+[^;]+;' \
+ ' proxy_request_buffering off;' \
+ 'location /api/ {'
+}
+
+install_node() {
+ header "Node.js installieren"
+ local required_major=20
+
+ if command_exists node; then
+ local current_major
+ current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
+ if [[ "$current_major" -ge "$required_major" ]]; then
+ ok "Node.js $(node --version) bereits installiert"
+ return
+ fi
+ warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
+ fi
+
+ info "Installiere Node.js 20.x über NodeSource..."
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y nodejs
+ ok "Node.js $(node --version) installiert"
+}
+
+install_makemkv() {
+ header "MakeMKV installieren"
+
+ if command_exists makemkvcon; then
+ ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))"
+ return
+ fi
+
+ info "Installiere Build-Abhängigkeiten für MakeMKV..."
+ apt-get install -y \
+ build-essential pkg-config libc6-dev libssl-dev \
+ libexpat1-dev libavcodec-dev libgl1-mesa-dev \
+ qtbase5-dev zlib1g-dev wget
+
+ # Aktuelle Version aus dem offiziellen Linux-Forum-Thread ermitteln.
+ # Der Titel lautet immer: "MakeMKV X.Y.Z for Linux is available"
+ local makemkv_fallback="1.18.3"
+ info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
+ local makemkv_version
+ makemkv_version=$(curl -s --max-time 15 \
+ "https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
+ | grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
+
+ if [[ -z "$makemkv_version" ]]; then
+ warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
+ makemkv_version="$makemkv_fallback"
+ else
+ info "Aktuelle Version: $makemkv_version"
+ fi
+
+ info "Baue MakeMKV $makemkv_version..."
+ local tmp_dir
+ tmp_dir=$(mktemp -d)
+ cd "$tmp_dir"
+
+ local base_url="https://www.makemkv.com/download"
+ wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
+ wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
+
+ tar xf "makemkv-oss-${makemkv_version}.tar.gz"
+ cd "makemkv-oss-${makemkv_version}"
+ ./configure
+ make -j"$(nproc)"
+ make install
+
+ cd "$tmp_dir"
+ tar xf "makemkv-bin-${makemkv_version}.tar.gz"
+ cd "makemkv-bin-${makemkv_version}"
+ mkdir -p tmp && echo "accepted" > tmp/eula_accepted
+ make -j"$(nproc)"
+ make install
+
+ cd /
+ rm -rf "$tmp_dir"
+ ok "MakeMKV $makemkv_version installiert"
+ warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
+ warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
+}
+
+select_handbrake_mode() {
+ [[ "$SKIP_HANDBRAKE" == true ]] && return
+
+ local mode_answer=""
+ echo ""
+ echo "Install HandBrake:"
+ echo ""
+ echo "1. Standard version (apt install handbrake-cli)"
+ echo "2. GPU version with NVDEC (use bundled binary)"
+
+ if [[ -t 0 ]]; then
+ read -r -p "Select option [1/2]: " mode_answer
+ elif [[ -r /dev/tty ]]; then
+ read -r -p "Select option [1/2]: " mode_answer /dev/null || true
+
+ if command_exists HandBrakeCLI; then
+ ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
+ return
+ fi
+
+ if command_exists handbrake-cli; then
+ ok "handbrake-cli installiert: $(handbrake-cli --version 2>&1 | head -1)"
+ return
+ fi
+
+ fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
+}
+
+install_handbrake_gpu_bundled() {
+ info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
+ local bundled_source="$BUNDLED_HANDBRAKE_CLI"
+ local downloaded_tmp=""
+
+ if [[ ! -f "$bundled_source" ]]; then
+ local remote_url="${REPO_RAW_BASE}/${GIT_BRANCH}/bin/HandBrakeCLI"
+ downloaded_tmp=$(mktemp)
+ info "Lokale Binary fehlt – lade aus Branch '$GIT_BRANCH' nach..."
+ if download_file "$remote_url" "$downloaded_tmp"; then
+ chmod 0755 "$downloaded_tmp"
+ bundled_source="$downloaded_tmp"
+ ok "Bundled HandBrakeCLI temporär heruntergeladen"
+ else
+ rm -f "$downloaded_tmp" 2>/dev/null || true
+ fatal "Bundled Binary fehlt lokal ($BUNDLED_HANDBRAKE_CLI) und Download schlug fehl: $remote_url"
+ fi
+ fi
+
+ install -m 0755 "$bundled_source" /usr/local/bin/HandBrakeCLI
+ hash -r 2>/dev/null || true
+ if [[ -n "$downloaded_tmp" ]]; then
+ rm -f "$downloaded_tmp" 2>/dev/null || true
+ fi
+
+ ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI"
+ if command_exists HandBrakeCLI; then
+ ok "HandBrakeCLI Version: $(HandBrakeCLI --version 2>&1 | head -1)"
+ fi
+}
+
+install_handbrake() {
+ header "HandBrake CLI installieren"
+
+ if [[ -z "$HANDBRAKE_INSTALL_MODE" ]]; then
+ HANDBRAKE_INSTALL_MODE="standard"
+ fi
+
+ case "$HANDBRAKE_INSTALL_MODE" in
+ standard) install_handbrake_standard ;;
+ gpu) install_handbrake_gpu_bundled ;;
+ *) fatal "Unbekannter HandBrake-Modus: $HANDBRAKE_INSTALL_MODE" ;;
+ esac
+}
+
+# --- apt-Hilfsfunktionen ------------------------------------------------------
+
+# Führt apt-get update aus. Bei Release-Fehlern wird versucht, die Sources zu
+# reparieren (Proxmox-Container, veraltete Spiegelserver, etc.).
+apt_update() {
+ local output
+ if output=$(apt-get update 2>&1); then
+ return 0
+ fi
+
+ # Release-Datei fehlt → versuche Repair
+ if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
+ warn "apt-Sources fehlerhaft. Versuche Reparatur..."
+
+ # Strategie 1: --allow-releaseinfo-change
+ if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
+ ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
+ return 0
+ fi
+
+ # Strategie 2: Kaputte Einträge aus sources.list.d entfernen und Fallback
+ # auf offizielle Spiegel schreiben
+ if [[ -n "${VERSION_CODENAME:-}" ]]; then
+ warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
+ local main_list=/etc/apt/sources.list
+
+ # Backup
+ cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
+
+ case "$ID" in
+ ubuntu)
+ cat > "$main_list" < "$main_list" </dev/null; then
+ ok "apt-Update nach Sources-Reparatur erfolgreich"
+ return 0
+ fi
+ fi
+
+ # Strategie 3: Kaputte .list-Dateien in sources.list.d deaktivieren
+ warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
+ local broken_files
+ broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
+ xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
+ if [[ -n "$broken_files" ]]; then
+ echo "$broken_files" | while read -r f; do
+ warn "Deaktiviere: $f"
+ mv "$f" "${f}.disabled" 2>/dev/null || true
+ done
+ if apt-get update -qq 2>/dev/null; then
+ ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
+ return 0
+ fi
+ fi
+
+ error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
+ echo "$output"
+ fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
+ else
+ error "apt-Update fehlgeschlagen:"
+ echo "$output"
+ fatal "Installation abgebrochen."
+ fi
+}
+
+# --- HandBrake-Installmodus auswählen ----------------------------------------
+select_handbrake_mode
+
+# --- Systemabhängigkeiten -----------------------------------------------------
+header "Systemabhängigkeiten installieren"
+
+info "Paketlisten aktualisieren..."
+apt_update
+
+info "Installiere Basispakete..."
+apt-get install -y \
+ curl wget git jq \
+ ffmpeg \
+ mediainfo \
+ util-linux udev \
+ ca-certificates gnupg \
+ lsb-release
+
+ok "Basispakete installiert"
+
+info "Installiere CD-Ripping-Tools..."
+apt-get install -y \
+ cdparanoia \
+ flac \
+ lame \
+ opus-tools \
+ vorbis-tools
+
+ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
+
+install_node
+
+if [[ "$SKIP_MAKEMKV" == false ]]; then
+ install_makemkv
+else
+ warn "MakeMKV-Installation übersprungen (--no-makemkv)"
+fi
+
+if [[ "$SKIP_HANDBRAKE" == false ]]; then
+ install_handbrake
+else
+ warn "HandBrake-Installation übersprungen (--no-handbrake)"
+fi
+
+if [[ "$SKIP_NGINX" == false ]]; then
+ if ! command_exists nginx; then
+ info "Installiere nginx..."
+ apt-get install -y nginx
+ fi
+ ok "nginx installiert"
+fi
+
+# --- Systembenutzer anlegen ---------------------------------------------------
+header "Systembenutzer anlegen"
+
+if id "$SERVICE_USER" &>/dev/null; then
+ ok "Benutzer '$SERVICE_USER' existiert bereits"
+else
+ info "Lege Systembenutzer '$SERVICE_USER' an..."
+ useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
+ ok "Benutzer '$SERVICE_USER' angelegt"
+fi
+
+SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
+if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
+ SERVICE_HOME="/home/$SERVICE_USER"
+fi
+mkdir -p "$SERVICE_HOME"
+chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
+chmod 755 "$SERVICE_HOME" 2>/dev/null || true
+info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
+
+for grp in cdrom optical disk video render; do
+ if getent group "$grp" &>/dev/null; then
+ usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
+ info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
+ fi
+done
+
+# --- Repository klonen / aktualisieren ----------------------------------------
+header "Repository holen (Git)"
+
+# Prüfen ob der gewünschte Branch auf dem Remote existiert
+info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
+if ! git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" &>/dev/null; then
+ fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: $(git ls-remote --heads "$REPO_URL" | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')"
+fi
+ok "Branch '$GIT_BRANCH' gefunden"
+
+if [[ -d "$INSTALL_DIR/.git" ]]; then
+ if [[ "$REINSTALL" == true ]]; then
+ info "Aktualisiere bestehendes Repository..."
+ # Daten sichern
+ if [[ -d "$INSTALL_DIR/backend/data" ]]; then
+ DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
+ cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
+ info "Datenbank gesichert nach: $DATA_BACKUP"
+ fi
+ # safe.directory nötig wenn das Verzeichnis einem anderen User gehört
+ # (z.B. ripster-Serviceuser nach erstem Install)
+ git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
+ git -C "$INSTALL_DIR" remote set-branches origin '*'
+ git -C "$INSTALL_DIR" fetch --quiet origin
+ git -C "$INSTALL_DIR" reset --hard HEAD
+ git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
+ git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
+ ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
+ else
+ fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren."
+ fi
+elif [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
+ fatal "Verzeichnis $INSTALL_DIR existiert bereits (kein Git-Repo).\nBitte manuell entfernen oder --reinstall verwenden."
+else
+ info "Klone $REPO_URL (Branch: $GIT_BRANCH)..."
+ git clone --quiet --branch "$GIT_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR"
+ ok "Repository geklont nach $INSTALL_DIR"
+fi
+
+# Daten- und Log-Verzeichnisse sicherstellen
+mkdir -p "$INSTALL_DIR/backend/data"
+mkdir -p "$INSTALL_DIR/backend/logs"
+mkdir -p "$INSTALL_DIR/backend/data/output/raw"
+mkdir -p "$INSTALL_DIR/backend/data/output/movies"
+mkdir -p "$INSTALL_DIR/backend/data/output/series"
+mkdir -p "$INSTALL_DIR/backend/data/output/cd"
+mkdir -p "$INSTALL_DIR/backend/data/downloads"
+mkdir -p "$INSTALL_DIR/backend/data/logs"
+
+# Gesicherte Daten zurückspielen
+if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
+ cp -a "$DATA_BACKUP/." "$INSTALL_DIR/backend/data/"
+ ok "Datenbank wiederhergestellt"
+fi
+
+# --- npm-Abhängigkeiten installieren -----------------------------------------
+header "npm-Abhängigkeiten installieren"
+
+info "Root-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR" --omit=dev --silent
+
+info "Backend-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
+
+info "Frontend-Abhängigkeiten..."
+npm install --prefix "$INSTALL_DIR/frontend" --silent
+
+ok "npm-Abhängigkeiten installiert"
+
+# --- Frontend bauen -----------------------------------------------------------
+header "Frontend bauen"
+
+info "Baue Frontend für $FRONTEND_HOST..."
+
+# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
+# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
+# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
+# Domainnamen aufgerufen wird.
+rm -f "$INSTALL_DIR/frontend/.env.production.local"
+
+npm run build --prefix "$INSTALL_DIR/frontend" --silent
+ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
+
+# --- Backend-Konfiguration ---------------------------------------------------
+header "Backend konfigurieren"
+
+ENV_FILE="$INSTALL_DIR/backend/.env"
+
+if [[ -f "$ENV_FILE" && "$REINSTALL" == true ]]; then
+ warn "Bestehende .env bleibt erhalten (--reinstall)"
+else
+ info "Erstelle Backend .env..."
+ cat > "$ENV_FILE" </dev/null || true
+chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
+
+# --- Systemd-Dienst: Backend -------------------------------------------------
+header "Systemd-Dienst (Backend) erstellen"
+
+cat > /etc/systemd/system/ripster-backend.service < /etc/nginx/sites-available/ripster <=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/concurrently": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
+ "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.8.3",
+ "supports-color": "8.1.1",
+ "tree-kill": "1.2.2",
+ "yargs": "17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..81b267a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "ripster",
+ "private": true,
+ "version": "0.13.1-4",
+ "scripts": {
+ "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
+ "dev:backend": "npm run dev --prefix backend",
+ "dev:frontend": "npm run dev --prefix frontend",
+ "start": "npm run start --prefix backend",
+ "build:frontend": "npm run build --prefix frontend",
+ "qa:cd:analysis": "node ./scripts/smoketest/qa-cd-analysis.js",
+ "qa:dvd:analysis": "node ./scripts/smoketest/qa-dvd-analysis.js",
+ "qa:bluray:analysis": "node ./scripts/smoketest/qa-bluray-analysis.js",
+ "qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js",
+ "qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh",
+ "qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh",
+ "qa:logs:ripper": "node ./scripts/smoketest/qa-log-ripper.js",
+ "qa:jobs:kind-check": "node ./scripts/smoketest/qa-jobs-kind-check.js",
+ "release:interactive": "bash ./scripts/release.sh"
+ },
+ "devDependencies": {
+ "concurrently": "^9.1.2"
+ }
+}
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 0000000..319483c
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,4 @@
+mkdocs>=1.6.0
+mkdocs-material>=9.5.0
+mkdocs-minify-plugin>=0.8.0
+pymdown-extensions>=10.7
diff --git a/setup.sh b/setup.sh
new file mode 100644
index 0000000..38979e7
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_OWNER="Mboehmlaender"
+REPO_NAME="ripster"
+REPO_RAW_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
+BRANCHES_API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches?per_page=100"
+
+usage() {
+ cat <<'EOF'
+Verwendung:
+ bash setup.sh [Optionen]
+
+Optionen (wie install.sh):
+ --branch Branch direkt setzen (ohne Auswahlmenue)
+ --dir Installationsverzeichnis
+ --user Systembenutzer fuer den Dienst
+ --port Backend-Port
+ --host Hostname/IP fuer die Weboberflaeche
+ --no-makemkv MakeMKV-Installation ueberspringen
+ --no-handbrake HandBrake-Installation ueberspringen
+ --no-nginx Nginx-Einrichtung ueberspringen
+ --reinstall Vorhandene Installation aktualisieren
+ -h, --help Hilfe anzeigen
+EOF
+}
+
+SELECTED_BRANCH=""
+FORWARDED_ARGS=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --branch)
+ [[ $# -ge 2 ]] || { echo "Fehlender Wert fuer --branch" >&2; exit 1; }
+ SELECTED_BRANCH="$2"
+ shift 2
+ ;;
+ --dir|--user|--port|--host)
+ [[ $# -ge 2 ]] || { echo "Fehlender Wert fuer $1" >&2; exit 1; }
+ FORWARDED_ARGS+=("$1" "$2")
+ shift 2
+ ;;
+ --no-makemkv|--no-handbrake|--no-nginx|--reinstall)
+ FORWARDED_ARGS+=("$1")
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unbekannter Parameter: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+fetch_url() {
+ local url="$1"
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$url"
+ return
+ fi
+
+ if command -v wget >/dev/null 2>&1; then
+ wget -qO- "$url"
+ return
+ fi
+
+ echo "Weder curl noch wget gefunden. Bitte eines davon installieren." >&2
+ exit 1
+}
+
+download_file() {
+ local url="$1"
+ local target="$2"
+ fetch_url "$url" > "$target"
+}
+
+select_branch() {
+ local branches_json
+ local -a branches
+ local selection
+
+ branches_json="$(fetch_url "$BRANCHES_API_URL")"
+ mapfile -t branches < <(
+ printf '%s\n' "$branches_json" \
+ | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
+ | sed -E 's/"name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/'
+ )
+
+ if [[ ${#branches[@]} -eq 0 ]]; then
+ echo "Keine Branches gefunden oder API-Antwort ungültig." >&2
+ exit 1
+ fi
+
+ if [[ -n "$SELECTED_BRANCH" ]]; then
+ local found=false
+ for branch in "${branches[@]}"; do
+ if [[ "$branch" == "$SELECTED_BRANCH" ]]; then
+ found=true
+ break
+ fi
+ done
+ if [[ "$found" == false ]]; then
+ echo "Branch '$SELECTED_BRANCH' nicht gefunden." >&2
+ exit 1
+ fi
+ return
+ fi
+
+ if [[ ! -t 0 ]]; then
+ echo "Kein interaktives Terminal für die Branch-Auswahl verfügbar." >&2
+ exit 1
+ fi
+
+ echo "Verfügbare Branches:"
+ for i in "${!branches[@]}"; do
+ printf " %2d) %s\n" "$((i + 1))" "${branches[$i]}"
+ done
+
+ while true; do
+ read -r -p "Bitte Branch auswählen [1-${#branches[@]}]: " selection
+ if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#branches[@]} )); then
+ SELECTED_BRANCH="${branches[$((selection - 1))]}"
+ return
+ fi
+ echo "Ungültige Auswahl."
+ done
+}
+
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+select_branch
+
+INSTALL_SCRIPT="${TMP_DIR}/install.sh"
+INSTALL_URL="${REPO_RAW_BASE}/${SELECTED_BRANCH}/install.sh"
+
+echo "Lade install.sh aus Branch '${SELECTED_BRANCH}'..."
+download_file "$INSTALL_URL" "$INSTALL_SCRIPT"
+chmod +x "$INSTALL_SCRIPT"
+
+if [[ $EUID -eq 0 ]]; then
+ bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
+else
+ if ! command -v sudo >/dev/null 2>&1; then
+ echo "sudo nicht gefunden. Bitte als root ausführen." >&2
+ exit 1
+ fi
+ sudo bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
+fi
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..6df7171
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+USE_NODE20_NPX=0
+
+normalize_semver() {
+ local version="$1"
+ echo "${version#v}" | sed -E 's/[^0-9.].*$//'
+}
+
+version_gte() {
+ local current required
+ local c_major c_minor c_patch r_major r_minor r_patch
+
+ current=$(normalize_semver "$1")
+ required=$(normalize_semver "$2")
+
+ IFS='.' read -r c_major c_minor c_patch <<< "$current"
+ IFS='.' read -r r_major r_minor r_patch <<< "$required"
+
+ c_major=${c_major:-0}
+ c_minor=${c_minor:-0}
+ c_patch=${c_patch:-0}
+ r_major=${r_major:-0}
+ r_minor=${r_minor:-0}
+ r_patch=${r_patch:-0}
+
+ if (( c_major > r_major )); then return 0; fi
+ if (( c_major < r_major )); then return 1; fi
+ if (( c_minor > r_minor )); then return 0; fi
+ if (( c_minor < r_minor )); then return 1; fi
+ if (( c_patch >= r_patch )); then return 0; fi
+ return 1
+}
+
+load_nvm_if_available() {
+ if command -v nvm >/dev/null 2>&1; then
+ return 0
+ fi
+
+ export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
+ if [[ -s "$NVM_DIR/nvm.sh" ]]; then
+ # shellcheck source=/dev/null
+ . "$NVM_DIR/nvm.sh" >/dev/null 2>&1
+ fi
+
+ command -v nvm >/dev/null 2>&1
+}
+
+try_use_project_node() {
+ local project_root="$1"
+ local required_version="$2"
+ local current_version
+
+ current_version=$(node -v 2>/dev/null || true)
+ if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then
+ return 0
+ fi
+
+ if ! load_nvm_if_available; then
+ return 1
+ fi
+
+ pushd "$project_root" >/dev/null
+ if [[ -f ".nvmrc" ]]; then
+ nvm use --silent >/dev/null 2>&1 || true
+ fi
+ popd >/dev/null
+
+ current_version=$(node -v 2>/dev/null || true)
+ if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then
+ echo "Projekt-Node aktiviert: ${current_version}"
+ return 0
+ fi
+
+ return 1
+}
+
+ensure_minimum_node_version() {
+ local project_root="$1"
+ local min_version="20.19.0"
+ local current_version
+ local switched=0
+
+ if ! command -v node >/dev/null 2>&1; then
+ echo "Node.js wurde nicht gefunden. Bitte Node.js >= ${min_version} installieren."
+ return 1
+ fi
+
+ if try_use_project_node "$project_root" "$min_version"; then
+ switched=1
+ fi
+
+ current_version=$(node -v 2>/dev/null || true)
+ if [[ -z "$current_version" ]]; then
+ echo "Konnte die Node.js-Version nicht ermitteln."
+ return 1
+ fi
+
+ if ! version_gte "$current_version" "$min_version"; then
+ if (( switched == 0 )) && load_nvm_if_available; then
+ echo "Node.js ${current_version} erkannt. Versuche automatische Aktivierung von ${min_version} via nvm ..."
+ nvm install "${min_version}" >/dev/null
+ nvm use "${min_version}" >/dev/null
+ current_version=$(node -v 2>/dev/null || true)
+ fi
+
+ if ! version_gte "$current_version" "$min_version"; then
+ if command -v npx >/dev/null 2>&1; then
+ echo "Node.js ${current_version} erkannt. Nutze node@20 Fallback via npx."
+ if npx -y node@20 -v >/dev/null 2>&1; then
+ USE_NODE20_NPX=1
+ return 0
+ fi
+ fi
+
+ echo "Node.js ${current_version} erkannt. Erforderlich: >= ${min_version}."
+ echo "Projektlokal:"
+ echo "nvm install ${min_version} && nvm use"
+ return 1
+ fi
+ fi
+}
+
+run_npm() {
+ if (( USE_NODE20_NPX == 1 )); then
+ npx -y node@20 "$(command -v npm)" "$@"
+ else
+ npm "$@"
+ fi
+}
+
+ensure_minimum_node_version "$ROOT_DIR"
+
+if [[ ! -d "$ROOT_DIR/node_modules" ]]; then
+ echo "Installiere Abhaengigkeiten in $ROOT_DIR ..."
+ run_npm install
+fi
+
+if [[ ! -d "$ROOT_DIR/backend/node_modules" ]]; then
+ echo "Installiere Abhaengigkeiten in $ROOT_DIR/backend ..."
+ run_npm --prefix backend install
+fi
+
+if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
+ echo "Installiere Abhaengigkeiten in $ROOT_DIR/frontend ..."
+ run_npm --prefix frontend install
+fi
+
+echo "Starte Ripster Dev-Umgebung ..."
+if (( USE_NODE20_NPX == 1 )); then
+ exec npx -y node@20 "$(command -v npm)" run dev
+else
+ exec npm run dev
+fi