chore: migrate repository to Codeberg, bump version to 4.2.0, update update logic
This commit is contained in:
+520
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:;">
|
||||
<title>Twitch VOD Manager</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body class="theme-twitch">
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateText">Neue Version verfügbar!</span>
|
||||
<div id="updateProgress" style="display: none; flex: 1; margin: 0 15px;">
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 4px; height: 8px; overflow: hidden;">
|
||||
<div id="updateProgressBar" style="background: white; height: 100%; width: 0%; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal">
|
||||
<div class="modal" style="background: #2b2b2b; max-width: 500px;">
|
||||
<button class="modal-close" onclick="closeClipDialog()">x</button>
|
||||
<h2 style="color: #E5A00D; text-align: center; margin-bottom: 20px;" id="clipDialogTitle">Clip zuschneiden</h2>
|
||||
|
||||
<!-- Start Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('start')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('start')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('end')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('end')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dauer Anzeige -->
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<span style="color: #888;">Dauer: </span>
|
||||
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Teil Nummer -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div>
|
||||
</div>
|
||||
|
||||
<!-- Dateinamen Format -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
</label>
|
||||
|
||||
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4"
|
||||
placeholder="{date}_{part}.mp4"
|
||||
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
|
||||
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<div style="text-align: center;">
|
||||
<button class="btn-primary" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Guide Modal -->
|
||||
<div class="modal-overlay" id="templateGuideModal">
|
||||
<div class="modal template-guide-modal">
|
||||
<button class="modal-close" onclick="closeTemplateGuide()">x</button>
|
||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
|
||||
|
||||
<div class="template-guide-actions">
|
||||
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
</div>
|
||||
|
||||
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
|
||||
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
|
||||
|
||||
<div class="template-guide-preview-box">
|
||||
<div class="template-guide-preview-label" id="templateGuideOutputLabel">Live Vorschau</div>
|
||||
<div id="templateGuideOutput" class="template-guide-output">-</div>
|
||||
<div id="templateGuideContext" class="template-guide-context"></div>
|
||||
</div>
|
||||
|
||||
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
|
||||
<div class="template-guide-table-wrap">
|
||||
<table class="template-guide-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="templateGuideVarCol">Variable</th>
|
||||
<th id="templateGuideDescCol">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol">Beispiel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="templateGuideBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="template-guide-footer">
|
||||
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<span id="logoText">Twitch VOD Manager</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<span id="navVodsText">Twitch VODs</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span id="navClipsText">Twitch Clips</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<span id="navCutterText">Video schneiden</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<span id="navMergeText">Videos zusammenfugen</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span id="navSettingsText">Einstellungen</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="section-title">Streamer</div>
|
||||
<div class="streamers" id="streamerList"></div>
|
||||
|
||||
<div class="queue-section">
|
||||
<div class="queue-header">
|
||||
<span class="queue-title" id="queueTitleText">Warteschlange</span>
|
||||
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
|
||||
<span class="queue-count" id="queueCount">0</span>
|
||||
</div>
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
<div class="queue-actions">
|
||||
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<h1 id="pageTitle">VODs</h1>
|
||||
<div class="header-actions">
|
||||
<div class="header-search">
|
||||
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
||||
<button onclick="addStreamer()">+</button>
|
||||
</div>
|
||||
<button class="btn-icon" onclick="refreshVODs()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span id="refreshText">Aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
<h3>Keine VODs</h3>
|
||||
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clips Tab -->
|
||||
<div class="tab-content" id="clipsTab">
|
||||
<div class="clip-input">
|
||||
<h2 id="clipsHeading">Twitch Clip-Download</h2>
|
||||
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
||||
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
|
||||
<h3 id="clipsInfoTitle">Info</h3>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
|
||||
Unterstutzte Formate:
|
||||
- https://clips.twitch.tv/ClipName
|
||||
- https://www.twitch.tv/streamer/clip/ClipName
|
||||
|
||||
Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Cutter Tab -->
|
||||
<div class="tab-content" id="cutterTab">
|
||||
<div class="cutter-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="cutterSelectTitle">Video auswahlen</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<div class="placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo" style="display:none">
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Dauer</span>
|
||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Auflosung</span>
|
||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">FPS</span>
|
||||
<span class="cutter-info-value" id="infoFps">--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Auswahl</span>
|
||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" id="timelineContainer" style="display:none">
|
||||
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||
<div class="timeline-selection" id="timelineSelection"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"></div>
|
||||
</div>
|
||||
|
||||
<div class="time-inputs">
|
||||
<div class="time-input-group">
|
||||
<label>Start:</label>
|
||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
<div class="time-input-group">
|
||||
<label>Ende:</label>
|
||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="cutProgress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="cutProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="cutProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-actions">
|
||||
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Tab -->
|
||||
<div class="tab-content" id="mergeTab">
|
||||
<div class="merge-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="mergeTitle">Videos zusammenfugen</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
|
||||
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
|
||||
Die Reihenfolge kann per Drag & Drop geandert werden.
|
||||
</p>
|
||||
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="mergeFileList">
|
||||
<div class="empty-state" style="padding: 40px 20px;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="mergeProgress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="mergeProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="mergeProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="settingsTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="designTitle">Design</h3>
|
||||
<div class="form-group">
|
||||
<label id="themeLabel">Theme</label>
|
||||
<select id="themeSelect" onchange="changeTheme(this.value)">
|
||||
<option value="twitch">Twitch</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="apple">Apple</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="languageLabel">Sprache</label>
|
||||
<div class="language-picker" id="languagePicker">
|
||||
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
|
||||
<span class="flag-icon flag-de" aria-hidden="true"></span>
|
||||
<span id="languageDeText">Deutsch</span>
|
||||
</button>
|
||||
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
|
||||
<span class="flag-icon flag-en" aria-hidden="true"></span>
|
||||
<span id="languageEnText">English</span>
|
||||
</button>
|
||||
</div>
|
||||
<select id="languageSelect" onchange="changeLanguage(this.value)" style="display:none">
|
||||
<option value="de">de</option>
|
||||
<option value="en">en</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="apiTitle">Twitch API</h3>
|
||||
<div class="form-group">
|
||||
<label id="clientIdLabel">Client ID</label>
|
||||
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="clientSecretLabel">Client Secret</label>
|
||||
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
|
||||
</div>
|
||||
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
|
||||
<div class="form-group">
|
||||
<label id="storageLabel">Speicherort</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="downloadPath" readonly>
|
||||
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="modeLabel">Download-Modus</label>
|
||||
<select id="downloadMode">
|
||||
<option value="full" id="modeFullText">Ganzes VOD</option>
|
||||
<option value="parts" id="modePartsText">In Teile splitten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
|
||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
|
||||
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
|
||||
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" id="smartSchedulerToggle" checked>
|
||||
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<input type="checkbox" id="duplicatePreventionToggle" checked>
|
||||
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
|
||||
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
|
||||
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
|
||||
</div>
|
||||
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
|
||||
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
|
||||
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
|
||||
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
|
||||
</div>
|
||||
<div style="display: grid; gap: 8px; margin-top: 8px;">
|
||||
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
</div>
|
||||
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
|
||||
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="preflightTitle">System-Check</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px;">
|
||||
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
</div>
|
||||
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="debugLogTitle">Live Debug-Log</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
|
||||
<span id="autoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
|
||||
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre id="runtimeMetricsOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="versionText">v4.1.13</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="../dist/renderer-locale-de.js"></script>
|
||||
<script src="../dist/renderer-locale-en.js"></script>
|
||||
<script src="../dist/renderer-texts.js"></script>
|
||||
<script src="../dist/renderer-shared.js"></script>
|
||||
<script src="../dist/renderer-settings.js"></script>
|
||||
<script src="../dist/renderer-streamers.js"></script>
|
||||
<script src="../dist/renderer-queue.js"></script>
|
||||
<script src="../dist/renderer-updates.js"></script>
|
||||
<script src="../dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3587
File diff suppressed because it is too large
Load Diff
+186
@@ -0,0 +1,186 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// Types
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
customClip?: CustomClip;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
duplicateSkips: number;
|
||||
retriesScheduled: number;
|
||||
retriesExhausted: number;
|
||||
integrityFailures: number;
|
||||
downloadsStarted: number;
|
||||
downloadsCompleted: number;
|
||||
downloadsFailed: number;
|
||||
downloadedBytesTotal: number;
|
||||
lastSpeedBytesPerSec: number;
|
||||
avgSpeedBytesPerSec: number;
|
||||
activeItemId: string | null;
|
||||
activeItemTitle: string | null;
|
||||
lastErrorClass: string | null;
|
||||
lastRetryDelaySeconds: number;
|
||||
timestamp: string;
|
||||
queue: {
|
||||
pending: number;
|
||||
downloading: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
error: number;
|
||||
total: number;
|
||||
};
|
||||
caches: {
|
||||
loginToUserId: number;
|
||||
vodList: number;
|
||||
clipInfo: number;
|
||||
};
|
||||
config: {
|
||||
performanceMode: 'stability' | 'balanced' | 'speed';
|
||||
smartScheduler: boolean;
|
||||
metadataCacheMinutes: number;
|
||||
duplicatePrevention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// Auth
|
||||
login: () => ipcRenderer.invoke('login'),
|
||||
|
||||
// Twitch API
|
||||
getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username),
|
||||
getVODs: (userId: string, forceRefresh: boolean = false) => ipcRenderer.invoke('get-vods', userId, forceRefresh),
|
||||
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
|
||||
|
||||
// Download
|
||||
startDownload: () => ipcRenderer.invoke('start-download'),
|
||||
pauseDownload: () => ipcRenderer.invoke('pause-download'),
|
||||
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
||||
isDownloading: () => ipcRenderer.invoke('is-downloading'),
|
||||
downloadClip: (url: string) => ipcRenderer.invoke('download-clip', url),
|
||||
|
||||
// Files
|
||||
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
||||
selectVideoFile: () => ipcRenderer.invoke('select-video-file'),
|
||||
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
|
||||
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
|
||||
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
||||
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
|
||||
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
|
||||
|
||||
// Merge Videos
|
||||
mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||
ipcRenderer.invoke('merge-videos', inputFiles, outputFile),
|
||||
|
||||
// App
|
||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||
checkUpdate: () => ipcRenderer.invoke('check-update'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('download-update'),
|
||||
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
||||
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
|
||||
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
ipcRenderer.on('download-progress', (_, progress) => callback(progress));
|
||||
},
|
||||
onQueueUpdated: (callback: (queue: QueueItem[]) => void) => {
|
||||
ipcRenderer.on('queue-updated', (_, queue) => callback(queue));
|
||||
},
|
||||
onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => {
|
||||
ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload));
|
||||
},
|
||||
onDownloadStarted: (callback: () => void) => {
|
||||
ipcRenderer.on('download-started', () => callback());
|
||||
},
|
||||
onDownloadFinished: (callback: () => void) => {
|
||||
ipcRenderer.on('download-finished', () => callback());
|
||||
},
|
||||
onCutProgress: (callback: (percent: number) => void) => {
|
||||
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
|
||||
},
|
||||
onMergeProgress: (callback: (percent: number) => void) => {
|
||||
ipcRenderer.on('merge-progress', (_, percent) => callback(percent));
|
||||
},
|
||||
|
||||
// Auto-Update Events
|
||||
onUpdateChecking: (callback: () => void) => {
|
||||
ipcRenderer.on('update-checking', () => callback());
|
||||
},
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string }) => void) => {
|
||||
ipcRenderer.on('update-available', (_, info) => callback(info));
|
||||
},
|
||||
onUpdateNotAvailable: (callback: () => void) => {
|
||||
ipcRenderer.on('update-not-available', () => callback());
|
||||
},
|
||||
onUpdateDownloadProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void) => {
|
||||
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
|
||||
},
|
||||
onUpdateDownloaded: (callback: (info: { version: string }) => void) => {
|
||||
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
|
||||
},
|
||||
onUpdateError: (callback: (payload: { message: string }) => void) => {
|
||||
ipcRenderer.on('update-error', (_, payload) => callback(payload));
|
||||
}
|
||||
});
|
||||
Vendored
+206
@@ -0,0 +1,206 @@
|
||||
interface AppConfig {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
download_path?: string;
|
||||
streamers?: string[];
|
||||
theme?: string;
|
||||
download_mode?: 'parts' | 'full';
|
||||
part_minutes?: number;
|
||||
language?: 'de' | 'en';
|
||||
filename_template_vod?: string;
|
||||
filename_template_parts?: string;
|
||||
filename_template_clip?: string;
|
||||
smart_queue_scheduler?: boolean;
|
||||
performance_mode?: 'stability' | 'balanced' | 'speed';
|
||||
prevent_duplicate_downloads?: boolean;
|
||||
metadata_cache_minutes?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface VOD {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
duration: string;
|
||||
thumbnail_url: string;
|
||||
url: string;
|
||||
view_count: number;
|
||||
stream_id?: string;
|
||||
}
|
||||
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
progressStatus?: string;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
duplicateSkips: number;
|
||||
retriesScheduled: number;
|
||||
retriesExhausted: number;
|
||||
integrityFailures: number;
|
||||
downloadsStarted: number;
|
||||
downloadsCompleted: number;
|
||||
downloadsFailed: number;
|
||||
downloadedBytesTotal: number;
|
||||
lastSpeedBytesPerSec: number;
|
||||
avgSpeedBytesPerSec: number;
|
||||
activeItemId: string | null;
|
||||
activeItemTitle: string | null;
|
||||
lastErrorClass: string | null;
|
||||
lastRetryDelaySeconds: number;
|
||||
timestamp: string;
|
||||
queue: {
|
||||
pending: number;
|
||||
downloading: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
error: number;
|
||||
total: number;
|
||||
};
|
||||
caches: {
|
||||
loginToUserId: number;
|
||||
vodList: number;
|
||||
clipInfo: number;
|
||||
};
|
||||
config: {
|
||||
performanceMode: 'stability' | 'balanced' | 'speed';
|
||||
smartScheduler: boolean;
|
||||
metadataCacheMinutes: number;
|
||||
duplicatePrevention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
interface ClipDialogData {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
interface UpdateDownloadProgress {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface PreflightChecks {
|
||||
internet: boolean;
|
||||
streamlink: boolean;
|
||||
ffmpeg: boolean;
|
||||
ffprobe: boolean;
|
||||
downloadPathWritable: boolean;
|
||||
}
|
||||
|
||||
interface PreflightResult {
|
||||
ok: boolean;
|
||||
autoFixApplied: boolean;
|
||||
checks: PreflightChecks;
|
||||
messages: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ApiBridge {
|
||||
getConfig(): Promise<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
||||
login(): Promise<boolean>;
|
||||
getUserId(username: string): Promise<string | null>;
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
clearCompleted(): Promise<QueueItem[]>;
|
||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||
startDownload(): Promise<boolean>;
|
||||
pauseDownload(): Promise<boolean>;
|
||||
cancelDownload(): Promise<boolean>;
|
||||
isDownloading(): Promise<boolean>;
|
||||
downloadClip(url: string): Promise<{ success: boolean; error?: string }>;
|
||||
selectFolder(): Promise<string | null>;
|
||||
selectVideoFile(): Promise<string | null>;
|
||||
selectMultipleVideos(): Promise<string[] | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
getVersion(): Promise<string>;
|
||||
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>;
|
||||
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
|
||||
installUpdate(): Promise<void>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
runPreflight(autoFix: boolean): Promise<PreflightResult>;
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
onDownloadStarted(callback: () => void): void;
|
||||
onDownloadFinished(callback: () => void): void;
|
||||
onCutProgress(callback: (percent: number) => void): void;
|
||||
onMergeProgress(callback: (percent: number) => void): void;
|
||||
onUpdateChecking(callback: () => void): void;
|
||||
onUpdateAvailable(callback: (info: UpdateInfo) => void): void;
|
||||
onUpdateNotAvailable(callback: () => void): void;
|
||||
onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void;
|
||||
onUpdateDownloaded(callback: (info: UpdateInfo) => void): void;
|
||||
onUpdateError(callback: (payload: { message: string }) => void): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
api: ApiBridge;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
const UI_TEXT_DE = {
|
||||
appName: 'Twitch VOD Manager',
|
||||
static: {
|
||||
navVods: 'Twitch VODs',
|
||||
navClips: 'Twitch Clips',
|
||||
navCutter: 'Video schneiden',
|
||||
navMerge: 'Videos zusammenfugen',
|
||||
navSettings: 'Einstellungen',
|
||||
queueTitle: 'Warteschlange',
|
||||
retryFailed: 'Wiederholen',
|
||||
retryFailedHint: 'Nur fehlgeschlagene Downloads erneut starten',
|
||||
healthUnknown: 'System: Unbekannt',
|
||||
healthGood: 'System: Stabil',
|
||||
healthWarn: 'System: Warnung',
|
||||
healthBad: 'System: Problem',
|
||||
clearQueue: 'Leeren',
|
||||
refresh: 'Aktualisieren',
|
||||
streamerPlaceholder: 'Streamer hinzufugen...',
|
||||
clipsHeading: 'Twitch Clip-Download',
|
||||
clipsInfoTitle: 'Info',
|
||||
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
|
||||
cutterSelectTitle: 'Video auswahlen',
|
||||
cutterBrowse: 'Durchsuchen',
|
||||
mergeTitle: 'Videos zusammenfugen',
|
||||
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
|
||||
mergeAdd: '+ Videos hinzufugen',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
languageLabel: 'Sprache',
|
||||
languageDe: 'Deutsch',
|
||||
languageEn: 'Englisch',
|
||||
apiTitle: 'Twitch API',
|
||||
clientIdLabel: 'Client ID',
|
||||
clientSecretLabel: 'Client Secret',
|
||||
saveSettings: 'Speichern & Verbinden',
|
||||
downloadSettingsTitle: 'Download-Einstellungen',
|
||||
storageLabel: 'Speicherort',
|
||||
openFolder: 'Offnen',
|
||||
modeLabel: 'Download-Modus',
|
||||
modeFull: 'Ganzes VOD',
|
||||
modeParts: 'In Teile splitten',
|
||||
partMinutesLabel: 'Teil-Lange (Minuten)',
|
||||
performanceModeLabel: 'Performance-Profil',
|
||||
performanceModeStability: 'Max Stabilitat',
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
|
||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||
vodTemplateLabel: 'VOD-Template',
|
||||
partsTemplateLabel: 'VOD-Teile-Template',
|
||||
defaultClipTemplateLabel: 'Clip-Template',
|
||||
filenameTemplateHint: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
vodTemplatePlaceholder: '{title}.mp4',
|
||||
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
|
||||
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
|
||||
templateLintOk: 'Template-Check: OK',
|
||||
templateLintWarn: 'Unbekannte Platzhalter',
|
||||
templateGuideButton: 'Template Guide',
|
||||
templateGuideTitle: 'Dateinamen-Template Guide',
|
||||
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.',
|
||||
templateGuideTemplateLabel: 'Template',
|
||||
templateGuideOutputLabel: 'Live-Vorschau',
|
||||
templateGuideVarsTitle: 'Verfugbare Platzhalter',
|
||||
templateGuideVarCol: 'Platzhalter',
|
||||
templateGuideDescCol: 'Beschreibung',
|
||||
templateGuideExampleCol: 'Beispiel',
|
||||
templateGuideUseVod: 'VOD-Template nutzen',
|
||||
templateGuideUseParts: 'Teile-Template nutzen',
|
||||
templateGuideUseClip: 'Clip-Template nutzen',
|
||||
templateGuideClose: 'Schliessen',
|
||||
templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download',
|
||||
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
|
||||
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
|
||||
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Aktualisieren',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto-Refresh',
|
||||
runtimeMetricsLoading: 'Metriken werden geladen...',
|
||||
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
|
||||
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
|
||||
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
|
||||
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Modus',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricIntegrity: 'Integritatsfehler',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricBandwidth: 'Bandbreite',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricActive: 'Aktiver Job',
|
||||
runtimeMetricLastError: 'Letzte Fehlerklasse',
|
||||
runtimeMetricUpdated: 'Aktualisiert',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Nach Updates suchen',
|
||||
preflightTitle: 'System-Check',
|
||||
preflightRun: 'Check ausfuhren',
|
||||
preflightFix: 'Auto-Fix Tools',
|
||||
preflightEmpty: 'Noch kein Check ausgefuhrt.',
|
||||
preflightChecking: 'Prufe...',
|
||||
preflightFixing: 'Fixe...',
|
||||
preflightReady: 'Alles bereit.',
|
||||
preflightInternet: 'Internet',
|
||||
preflightStreamlink: 'Streamlink',
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download-Pfad',
|
||||
debugLogTitle: 'Live Debug-Log',
|
||||
refreshLog: 'Aktualisieren',
|
||||
autoRefresh: 'Auto-Refresh',
|
||||
notConnected: 'Nicht verbunden'
|
||||
},
|
||||
status: {
|
||||
noLogin: 'Ohne Login (Public Modus)',
|
||||
connecting: 'Verbinde...',
|
||||
connected: 'Verbunden',
|
||||
connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv'
|
||||
},
|
||||
tabs: {
|
||||
vods: 'VODs',
|
||||
clips: 'Clips',
|
||||
cutter: 'Video schneiden',
|
||||
merge: 'Videos zusammenfugen',
|
||||
settings: 'Einstellungen'
|
||||
},
|
||||
queue: {
|
||||
empty: 'Keine Downloads in der Warteschlange',
|
||||
start: 'Start',
|
||||
stop: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
statusDone: 'Abgeschlossen',
|
||||
statusFailed: 'Fehlgeschlagen',
|
||||
statusRunning: 'Laeuft',
|
||||
statusPaused: 'Pausiert',
|
||||
statusWaiting: 'Wartet',
|
||||
progressError: 'Fehler',
|
||||
progressReady: 'Bereit',
|
||||
progressLoading: 'Lade...',
|
||||
readyToDownload: 'Bereit zum Download',
|
||||
started: 'Download gestartet',
|
||||
done: 'Fertig',
|
||||
failed: 'Download fehlgeschlagen',
|
||||
speed: 'Geschwindigkeit',
|
||||
eta: 'Restzeit',
|
||||
part: 'Teil',
|
||||
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.'
|
||||
},
|
||||
vods: {
|
||||
noneTitle: 'Keine VODs',
|
||||
noneText: 'Wahle einen Streamer aus der Liste.',
|
||||
loading: 'Lade VODs...',
|
||||
notFound: 'Streamer nicht gefunden',
|
||||
noResultsTitle: 'Keine VODs gefunden',
|
||||
noResultsText: 'Dieser Streamer hat keine VODs.',
|
||||
untitled: 'Unbenanntes VOD',
|
||||
views: 'Aufrufe',
|
||||
addQueue: '+ Warteschlange'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Clip zuschneiden',
|
||||
invalidDuration: 'Ungultig!',
|
||||
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
|
||||
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
|
||||
enterUrl: 'Bitte URL eingeben',
|
||||
loadingButton: 'Lade...',
|
||||
loadingStatus: 'Download laeuft...',
|
||||
downloadButton: 'Clip herunterladen',
|
||||
success: 'Download erfolgreich!',
|
||||
errorPrefix: 'Fehler: ',
|
||||
unknownError: 'Unbekannter Fehler',
|
||||
formatSimple: '(Standard)',
|
||||
formatTimestamp: '(mit Zeitstempel)',
|
||||
formatTemplate: '(benutzerdefiniert)',
|
||||
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
||||
previewLoading: 'Lade Vorschau...',
|
||||
previewUnavailable: 'Vorschau nicht verfugbar',
|
||||
cutting: 'Schneidet...',
|
||||
cut: 'Schneiden',
|
||||
cutSuccess: 'Video erfolgreich geschnitten!',
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.'
|
||||
},
|
||||
merge: {
|
||||
empty: 'Keine Videos ausgewahlt',
|
||||
merging: 'Zusammenfugen...',
|
||||
merge: 'Zusammenfugen',
|
||||
success: 'Videos erfolgreich zusammengefugt!',
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.'
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'Neue Version verfugbar!',
|
||||
latest: 'Du hast die neueste Version!',
|
||||
checking: 'Suche nach Updates...',
|
||||
checkInProgress: 'Update-Prufung lauft bereits.',
|
||||
readyToInstall: 'Update ist bereit zur Installation.',
|
||||
checkFailed: 'Update-Prufung fehlgeschlagen.',
|
||||
downloading: 'Wird heruntergeladen...',
|
||||
downloadInProgress: 'Update-Download lauft bereits.',
|
||||
downloadFailed: 'Update-Download fehlgeschlagen.',
|
||||
available: 'verfugbar!',
|
||||
downloadNow: 'Jetzt herunterladen',
|
||||
downloadLabel: 'Download',
|
||||
ready: 'bereit zur Installation!',
|
||||
installNow: 'Jetzt installieren'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,213 @@
|
||||
const UI_TEXT_EN = {
|
||||
appName: 'Twitch VOD Manager',
|
||||
static: {
|
||||
navVods: 'Twitch VODs',
|
||||
navClips: 'Twitch Clips',
|
||||
navCutter: 'Video Cutter',
|
||||
navMerge: 'Merge Videos',
|
||||
navSettings: 'Settings',
|
||||
queueTitle: 'Queue',
|
||||
retryFailed: 'Retry',
|
||||
retryFailedHint: 'Retry failed downloads only',
|
||||
healthUnknown: 'System: Unknown',
|
||||
healthGood: 'System: Stable',
|
||||
healthWarn: 'System: Warning',
|
||||
healthBad: 'System: Problem',
|
||||
clearQueue: 'Clear',
|
||||
refresh: 'Refresh',
|
||||
streamerPlaceholder: 'Add streamer...',
|
||||
clipsHeading: 'Twitch Clip Download',
|
||||
clipsInfoTitle: 'Info',
|
||||
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
|
||||
cutterSelectTitle: 'Select video',
|
||||
cutterBrowse: 'Browse',
|
||||
mergeTitle: 'Merge videos',
|
||||
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
|
||||
mergeAdd: '+ Add videos',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
languageLabel: 'Language',
|
||||
languageDe: 'German',
|
||||
languageEn: 'English',
|
||||
apiTitle: 'Twitch API',
|
||||
clientIdLabel: 'Client ID',
|
||||
clientSecretLabel: 'Client Secret',
|
||||
saveSettings: 'Save & Connect',
|
||||
downloadSettingsTitle: 'Download Settings',
|
||||
storageLabel: 'Storage Path',
|
||||
openFolder: 'Open',
|
||||
modeLabel: 'Download Mode',
|
||||
modeFull: 'Full VOD',
|
||||
modeParts: 'Split into parts',
|
||||
partMinutesLabel: 'Part Length (Minutes)',
|
||||
performanceModeLabel: 'Performance Profile',
|
||||
performanceModeStability: 'Max Stability',
|
||||
performanceModeBalanced: 'Balanced',
|
||||
performanceModeSpeed: 'Max Speed',
|
||||
smartSchedulerLabel: 'Enable smart queue scheduler',
|
||||
duplicatePreventionLabel: 'Prevent duplicate queue entries',
|
||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||
filenameTemplatesTitle: 'Filename Templates',
|
||||
vodTemplateLabel: 'VOD Template',
|
||||
partsTemplateLabel: 'VOD Part Template',
|
||||
defaultClipTemplateLabel: 'Clip Template',
|
||||
filenameTemplateHint: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
vodTemplatePlaceholder: '{title}.mp4',
|
||||
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
|
||||
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
|
||||
templateLintOk: 'Template check: OK',
|
||||
templateLintWarn: 'Unknown placeholder(s)',
|
||||
templateGuideButton: 'Template Guide',
|
||||
templateGuideTitle: 'Filename Template Guide',
|
||||
templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.',
|
||||
templateGuideTemplateLabel: 'Template',
|
||||
templateGuideOutputLabel: 'Live preview',
|
||||
templateGuideVarsTitle: 'Available placeholders',
|
||||
templateGuideVarCol: 'Placeholder',
|
||||
templateGuideDescCol: 'Description',
|
||||
templateGuideExampleCol: 'Example',
|
||||
templateGuideUseVod: 'Use VOD template',
|
||||
templateGuideUseParts: 'Use part template',
|
||||
templateGuideUseClip: 'Use clip template',
|
||||
templateGuideClose: 'Close',
|
||||
templateGuideContextVod: 'Context: Sample full VOD download',
|
||||
templateGuideContextParts: 'Context: Sample split VOD part',
|
||||
templateGuideContextClip: 'Context: Sample clip trim',
|
||||
templateGuideContextClipLive: 'Context: Current clip dialog selection',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Refresh',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto refresh',
|
||||
runtimeMetricsLoading: 'Loading metrics...',
|
||||
runtimeMetricsError: 'Could not load runtime metrics.',
|
||||
runtimeMetricsExportDone: 'Runtime metrics exported successfully.',
|
||||
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
||||
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Mode',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricIntegrity: 'Integrity failures',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricBandwidth: 'Bandwidth',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricActive: 'Active item',
|
||||
runtimeMetricLastError: 'Last error class',
|
||||
runtimeMetricUpdated: 'Updated',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Check for updates',
|
||||
preflightTitle: 'System Check',
|
||||
preflightRun: 'Run check',
|
||||
preflightFix: 'Auto-fix tools',
|
||||
preflightEmpty: 'No checks run yet.',
|
||||
preflightChecking: 'Checking...',
|
||||
preflightFixing: 'Fixing...',
|
||||
preflightReady: 'Everything is ready.',
|
||||
preflightInternet: 'Internet',
|
||||
preflightStreamlink: 'Streamlink',
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download path',
|
||||
debugLogTitle: 'Live Debug Log',
|
||||
refreshLog: 'Refresh',
|
||||
autoRefresh: 'Auto refresh',
|
||||
notConnected: 'Not connected'
|
||||
},
|
||||
status: {
|
||||
noLogin: 'No login (public mode)',
|
||||
connecting: 'Connecting...',
|
||||
connected: 'Connected',
|
||||
connectFailedPublic: 'Connection failed - public mode active'
|
||||
},
|
||||
tabs: {
|
||||
vods: 'VODs',
|
||||
clips: 'Clips',
|
||||
cutter: 'Video Cutter',
|
||||
merge: 'Merge Videos',
|
||||
settings: 'Settings'
|
||||
},
|
||||
queue: {
|
||||
empty: 'No downloads in queue',
|
||||
start: 'Start',
|
||||
stop: 'Pause',
|
||||
resume: 'Resume',
|
||||
statusDone: 'Completed',
|
||||
statusFailed: 'Failed',
|
||||
statusRunning: 'Running',
|
||||
statusPaused: 'Paused',
|
||||
statusWaiting: 'Waiting',
|
||||
progressError: 'Error',
|
||||
progressReady: 'Ready',
|
||||
progressLoading: 'Loading...',
|
||||
readyToDownload: 'Ready to download',
|
||||
started: 'Download started',
|
||||
done: 'Done',
|
||||
failed: 'Download failed',
|
||||
speed: 'Speed',
|
||||
eta: 'ETA',
|
||||
part: 'Part',
|
||||
emptyAlert: 'Queue is empty. Add a VOD or clip first.',
|
||||
duplicateSkipped: 'This item is already active in the queue.'
|
||||
},
|
||||
vods: {
|
||||
noneTitle: 'No VODs',
|
||||
noneText: 'Select a streamer from the list.',
|
||||
loading: 'Loading VODs...',
|
||||
notFound: 'Streamer not found',
|
||||
noResultsTitle: 'No VODs found',
|
||||
noResultsText: 'This streamer has no VODs.',
|
||||
untitled: 'Untitled VOD',
|
||||
views: 'views',
|
||||
addQueue: '+ Queue'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim clip',
|
||||
invalidDuration: 'Invalid!',
|
||||
endBeforeStart: 'End time must be greater than start time!',
|
||||
outOfRange: 'Time is outside VOD range!',
|
||||
enterUrl: 'Please enter a URL',
|
||||
loadingButton: 'Loading...',
|
||||
loadingStatus: 'Downloading...',
|
||||
downloadButton: 'Download clip',
|
||||
success: 'Download successful!',
|
||||
errorPrefix: 'Error: ',
|
||||
unknownError: 'Unknown error',
|
||||
formatSimple: '(default)',
|
||||
formatTimestamp: '(with timestamp)',
|
||||
formatTemplate: '(custom template)',
|
||||
templateEmpty: 'Template cannot be empty in custom template mode.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
||||
previewLoading: 'Loading preview...',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
cutting: 'Cutting...',
|
||||
cut: 'Cut',
|
||||
cutSuccess: 'Video cut successfully!',
|
||||
cutFailed: 'Failed to cut video.'
|
||||
},
|
||||
merge: {
|
||||
empty: 'No videos selected',
|
||||
merging: 'Merging...',
|
||||
merge: 'Merge',
|
||||
success: 'Videos merged successfully!',
|
||||
failed: 'Failed to merge videos.'
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'New version available!',
|
||||
latest: 'You are on the latest version!',
|
||||
checking: 'Checking for updates...',
|
||||
checkInProgress: 'Update check is already running.',
|
||||
readyToInstall: 'Update is ready to install.',
|
||||
checkFailed: 'Update check failed.',
|
||||
downloading: 'Downloading...',
|
||||
downloadInProgress: 'Update download is already running.',
|
||||
downloadFailed: 'Update download failed.',
|
||||
available: 'available!',
|
||||
downloadNow: 'Download now',
|
||||
downloadLabel: 'Download',
|
||||
ready: 'ready to install!',
|
||||
installNow: 'Install now'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,208 @@
|
||||
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
|
||||
const clipFingerprint = customClip
|
||||
? [
|
||||
'clip',
|
||||
customClip.startSec,
|
||||
customClip.durationSec,
|
||||
customClip.startPart,
|
||||
customClip.filenameFormat,
|
||||
(customClip.filenameTemplate || '').trim().toLowerCase()
|
||||
].join(':')
|
||||
: 'vod';
|
||||
|
||||
return [
|
||||
(url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''),
|
||||
(streamer || '').trim().toLowerCase(),
|
||||
(date || '').trim(),
|
||||
clipFingerprint
|
||||
].join('|');
|
||||
}
|
||||
|
||||
let lastQueueRenderFingerprint = '';
|
||||
|
||||
function getQueueRenderFingerprint(items: QueueItem[]): string {
|
||||
const lang = typeof currentLanguage === 'string' ? currentLanguage : 'en';
|
||||
const pieces = items.map((item) => [
|
||||
item.id,
|
||||
item.status,
|
||||
Math.round((Number(item.progress) || 0) * 10),
|
||||
item.currentPart || 0,
|
||||
item.totalParts || 0,
|
||||
item.speed || '',
|
||||
item.eta || '',
|
||||
item.progressStatus || '',
|
||||
item.last_error || ''
|
||||
].join(':'));
|
||||
|
||||
return `${lang}|${pieces.join('|')}`;
|
||||
}
|
||||
|
||||
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
|
||||
const target = buildQueueFingerprint(url, streamer, date, customClip);
|
||||
return queue.some((item) => {
|
||||
if (item.status !== 'pending' && item.status !== 'downloading' && item.status !== 'paused') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return buildQueueFingerprint(item.url, item.streamer, item.date, item.customClip) === target;
|
||||
});
|
||||
}
|
||||
|
||||
async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise<void> {
|
||||
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(url, streamer, date)) {
|
||||
alert(UI_TEXT.queue.duplicateSkipped);
|
||||
return;
|
||||
}
|
||||
|
||||
queue = await window.api.addToQueue({
|
||||
url,
|
||||
title,
|
||||
date,
|
||||
streamer,
|
||||
duration_str: duration
|
||||
});
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function removeFromQueue(id: string): Promise<void> {
|
||||
queue = await window.api.removeFromQueue(id);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function clearCompleted(): Promise<void> {
|
||||
queue = await window.api.clearCompleted();
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function retryFailedDownloads(): Promise<void> {
|
||||
queue = await window.api.retryFailedDownloads();
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function getQueueStatusLabel(item: QueueItem): string {
|
||||
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
||||
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
||||
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
|
||||
if (item.status === 'downloading') return UI_TEXT.queue.statusRunning;
|
||||
return UI_TEXT.queue.statusWaiting;
|
||||
}
|
||||
|
||||
function getQueueProgressText(item: QueueItem): string {
|
||||
if (item.status === 'completed') return '100%';
|
||||
if (item.status === 'error') return UI_TEXT.queue.progressError;
|
||||
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
|
||||
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
|
||||
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
|
||||
return item.progressStatus || UI_TEXT.queue.progressLoading;
|
||||
}
|
||||
|
||||
function getQueueMetaText(item: QueueItem): string {
|
||||
if (item.status === 'error' && item.last_error) {
|
||||
return item.last_error;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (item.currentPart && item.totalParts) {
|
||||
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`);
|
||||
}
|
||||
|
||||
if (item.speed) {
|
||||
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`);
|
||||
}
|
||||
|
||||
if (item.eta) {
|
||||
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'pending') {
|
||||
parts.push(UI_TEXT.queue.readyToDownload);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'paused') {
|
||||
parts.push(UI_TEXT.queue.statusPaused);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'downloading') {
|
||||
parts.push(item.progressStatus || UI_TEXT.queue.started);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'completed') {
|
||||
parts.push(UI_TEXT.queue.done);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'error') {
|
||||
parts.push(UI_TEXT.queue.failed);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function renderQueue(): void {
|
||||
if (!Array.isArray(queue)) {
|
||||
queue = [];
|
||||
}
|
||||
|
||||
const list = byId('queueList');
|
||||
byId('queueCount').textContent = String(queue.length);
|
||||
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
|
||||
const hasFailed = queue.some((item) => item.status === 'error');
|
||||
retryBtn.disabled = !hasFailed;
|
||||
|
||||
const renderFingerprint = getQueueRenderFingerprint(queue);
|
||||
if (renderFingerprint === lastQueueRenderFingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = queue.map((item: QueueItem) => {
|
||||
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
|
||||
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
|
||||
const safeProgressText = escapeHtml(getQueueProgressText(item));
|
||||
const safeMeta = escapeHtml(getQueueMetaText(item));
|
||||
const isClip = item.customClip ? '* ' : '';
|
||||
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
|
||||
const progressValue = item.status === 'completed'
|
||||
? 100
|
||||
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
|
||||
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
|
||||
|
||||
return `
|
||||
<div class="queue-item">
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}">${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
</div>
|
||||
<div class="queue-meta">${safeMeta}</div>
|
||||
<div class="queue-progress-wrap">
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-text">${safeProgressText}</div>
|
||||
</div>
|
||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
}
|
||||
|
||||
async function toggleDownload(): Promise<void> {
|
||||
if (downloading) {
|
||||
await window.api.pauseDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
const started = await window.api.startDownload();
|
||||
if (!started) {
|
||||
renderQueue();
|
||||
alert(UI_TEXT.queue.emptyAlert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
let lastRuntimeMetricsOutput = '';
|
||||
let lastDebugLogOutput = '';
|
||||
|
||||
function canRunSettingsAutoRefresh(): boolean {
|
||||
if (document.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.querySelector('.tab-content.active')?.id === 'settingsTab';
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
|
||||
if (!hasCredentials) {
|
||||
isConnected = false;
|
||||
updateStatus(UI_TEXT.status.noLogin, false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(UI_TEXT.status.connecting, false);
|
||||
const success = await window.api.login();
|
||||
isConnected = success;
|
||||
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
|
||||
}
|
||||
|
||||
function formatBytesForMetrics(bytes: number): string {
|
||||
const value = Math.max(0, Number(bytes) || 0);
|
||||
if (value < 1024) return `${value.toFixed(0)} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function validateFilenameTemplates(showAlert = false): boolean {
|
||||
const templates = [
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
|
||||
];
|
||||
|
||||
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
|
||||
const uniqueUnknown = Array.from(new Set(unknown));
|
||||
const lintNode = byId('filenameTemplateLint');
|
||||
|
||||
if (!uniqueUnknown.length) {
|
||||
lintNode.style.color = '#8bc34a';
|
||||
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
lintNode.style.color = '#ff8a80';
|
||||
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
||||
|
||||
if (showAlert) {
|
||||
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTemplatePreset(preset: string): void {
|
||||
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
|
||||
default: {
|
||||
vod: '{title}.mp4',
|
||||
parts: '{date}_Part{part_padded}.mp4',
|
||||
clip: '{date}_{part}.mp4'
|
||||
},
|
||||
archive: {
|
||||
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
|
||||
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
|
||||
},
|
||||
clipper: {
|
||||
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
|
||||
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
|
||||
}
|
||||
};
|
||||
|
||||
const selected = presets[preset] || presets.default;
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
const output = byId('runtimeMetricsOutput');
|
||||
if (showLoading) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const lines = [
|
||||
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
|
||||
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
||||
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
||||
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
|
||||
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
|
||||
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
||||
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
||||
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
|
||||
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
|
||||
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
||||
];
|
||||
|
||||
const nextOutput = lines.join('\n');
|
||||
if (nextOutput !== lastRuntimeMetricsOutput) {
|
||||
output.textContent = nextOutput;
|
||||
lastRuntimeMetricsOutput = nextOutput;
|
||||
}
|
||||
} catch {
|
||||
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsError;
|
||||
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRuntimeMetrics(): Promise<void> {
|
||||
const result = await window.api.exportRuntimeMetrics();
|
||||
|
||||
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
|
||||
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
|
||||
if (typeof toast === 'function') {
|
||||
toast(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
|
||||
}
|
||||
|
||||
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
if (runtimeMetricsAutoRefreshTimer) {
|
||||
clearInterval(runtimeMetricsAutoRefreshTimer);
|
||||
runtimeMetricsAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics(false);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(text: string, connected: boolean): void {
|
||||
byId('statusText').textContent = text;
|
||||
const dot = byId('statusDot');
|
||||
dot.classList.remove('connected', 'error');
|
||||
dot.classList.add(connected ? 'connected' : 'error');
|
||||
}
|
||||
|
||||
function changeLanguage(lang: string): void {
|
||||
const normalized = setLanguage(lang);
|
||||
byId<HTMLSelectElement>('languageSelect').value = normalized;
|
||||
updateLanguagePicker(normalized);
|
||||
config.language = normalized;
|
||||
void window.api.saveConfig({ language: normalized });
|
||||
|
||||
const currentStatus = byId('statusText').textContent?.trim() || '';
|
||||
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
|
||||
|
||||
renderQueue();
|
||||
renderStreamers();
|
||||
|
||||
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
||||
const activeTab = activeTabId.replace('Tab', '');
|
||||
if (activeTab === 'vods' && currentStreamer) {
|
||||
byId('pageTitle').textContent = currentStreamer;
|
||||
} else {
|
||||
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics();
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
function updateLanguagePicker(lang: string): void {
|
||||
const de = byId<HTMLButtonElement>('langOptionDe');
|
||||
const en = byId<HTMLButtonElement>('langOptionEn');
|
||||
|
||||
const isDe = lang === 'de';
|
||||
de.classList.toggle('active', isDe);
|
||||
en.classList.toggle('active', !isDe);
|
||||
de.setAttribute('aria-pressed', String(isDe));
|
||||
en.setAttribute('aria-pressed', String(!isDe));
|
||||
}
|
||||
|
||||
function selectLanguageOption(lang: string): void {
|
||||
changeLanguage(lang);
|
||||
}
|
||||
|
||||
function renderPreflightResult(result: PreflightResult): void {
|
||||
const entries = [
|
||||
[UI_TEXT.static.preflightInternet, result.checks.internet],
|
||||
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
|
||||
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
|
||||
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
|
||||
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
|
||||
];
|
||||
|
||||
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
|
||||
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
|
||||
|
||||
byId('preflightResult').textContent = `${lines}${extra}`;
|
||||
|
||||
const badge = byId('healthBadge');
|
||||
badge.classList.remove('good', 'warn', 'bad', 'unknown');
|
||||
|
||||
if (result.ok) {
|
||||
badge.classList.add('good');
|
||||
badge.textContent = UI_TEXT.static.healthGood;
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
|
||||
if (failCount <= 2) {
|
||||
badge.classList.add('warn');
|
||||
badge.textContent = UI_TEXT.static.healthWarn;
|
||||
} else {
|
||||
badge.classList.add('bad');
|
||||
badge.textContent = UI_TEXT.static.healthBad;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreflight(autoFix = false): Promise<void> {
|
||||
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
|
||||
|
||||
try {
|
||||
const result = await window.api.runPreflight(autoFix);
|
||||
renderPreflightResult(result);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebugLog(): Promise<void> {
|
||||
const text = await window.api.getDebugLog(250);
|
||||
const panel = byId('debugLogOutput');
|
||||
const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20;
|
||||
|
||||
if (text !== lastDebugLogOutput) {
|
||||
panel.textContent = text;
|
||||
lastDebugLogOutput = text;
|
||||
}
|
||||
|
||||
if (keepAtBottom) {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebugAutoRefresh(enabled: boolean): void {
|
||||
if (debugLogAutoRefreshTimer) {
|
||||
clearInterval(debugLogAutoRefreshTimer);
|
||||
debugLogAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
debugLogAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshDebugLog();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(): Promise<void> {
|
||||
const clientId = byId<HTMLInputElement>('clientId').value.trim();
|
||||
const clientSecret = byId<HTMLInputElement>('clientSecret').value.trim();
|
||||
const downloadPath = byId<HTMLInputElement>('downloadPath').value;
|
||||
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
|
||||
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120;
|
||||
const performanceMode = byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed';
|
||||
const smartQueueScheduler = byId<HTMLInputElement>('smartSchedulerToggle').checked;
|
||||
const duplicatePrevention = byId<HTMLInputElement>('duplicatePreventionToggle').checked;
|
||||
const metadataCacheMinutes = parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10;
|
||||
const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4';
|
||||
const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
|
||||
const defaultClipFilenameTemplate = byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
|
||||
|
||||
if (!validateFilenameTemplates(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
config = await window.api.saveConfig({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
download_path: downloadPath,
|
||||
download_mode: downloadMode,
|
||||
part_minutes: partMinutes,
|
||||
performance_mode: performanceMode,
|
||||
smart_queue_scheduler: smartQueueScheduler,
|
||||
prevent_duplicate_downloads: duplicatePrevention,
|
||||
metadata_cache_minutes: metadataCacheMinutes,
|
||||
filename_template_vod: vodFilenameTemplate,
|
||||
filename_template_parts: partsFilenameTemplate,
|
||||
filename_template_clip: defaultClipFilenameTemplate
|
||||
});
|
||||
|
||||
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
||||
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
||||
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
|
||||
validateFilenameTemplates();
|
||||
|
||||
await connect();
|
||||
await refreshRuntimeMetrics();
|
||||
}
|
||||
|
||||
async function selectFolder(): Promise<void> {
|
||||
const folder = await window.api.selectFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
const folder = config.download_path;
|
||||
if (!folder || typeof folder !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.api.openFolder(folder);
|
||||
}
|
||||
|
||||
function changeTheme(theme: string): void {
|
||||
document.body.className = `theme-${theme}`;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
function byId<T = any>(id: string): T {
|
||||
return document.getElementById(id) as T;
|
||||
}
|
||||
|
||||
function query<T = any>(selector: string): T {
|
||||
return document.querySelector(selector) as T;
|
||||
}
|
||||
|
||||
function queryAll<T = any>(selector: string): T[] {
|
||||
return Array.from(document.querySelectorAll(selector)) as T[];
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
let config: AppConfig = {};
|
||||
let currentStreamer: string | null = null;
|
||||
let isConnected = false;
|
||||
let downloading = false;
|
||||
let queue: QueueItem[] = [];
|
||||
|
||||
let cutterFile: string | null = null;
|
||||
let cutterVideoInfo: VideoInfo | null = null;
|
||||
let cutterStartTime = 0;
|
||||
let cutterEndTime = 0;
|
||||
let isCutting = false;
|
||||
|
||||
let mergeFiles: string[] = [];
|
||||
let isMerging = false;
|
||||
|
||||
let clipDialogData: ClipDialogData | null = null;
|
||||
let clipTotalSeconds = 0;
|
||||
|
||||
let updateReady = false;
|
||||
let debugLogAutoRefreshTimer: number | null = null;
|
||||
let runtimeMetricsAutoRefreshTimer: number | null = null;
|
||||
let draggedQueueItemId: string | null = null;
|
||||
|
||||
const TEMPLATE_EXACT_TOKENS = new Set([
|
||||
'{title}',
|
||||
'{id}',
|
||||
'{channel}',
|
||||
'{channel_id}',
|
||||
'{date}',
|
||||
'{part}',
|
||||
'{part_padded}',
|
||||
'{trim_start}',
|
||||
'{trim_end}',
|
||||
'{trim_length}',
|
||||
'{length}',
|
||||
'{ext}',
|
||||
'{random_string}'
|
||||
]);
|
||||
|
||||
const TEMPLATE_CUSTOM_TOKEN_PATTERNS = [
|
||||
/^\{date_custom=".*"\}$/,
|
||||
/^\{trim_start_custom=".*"\}$/,
|
||||
/^\{trim_end_custom=".*"\}$/,
|
||||
/^\{trim_length_custom=".*"\}$/,
|
||||
/^\{length_custom=".*"\}$/
|
||||
];
|
||||
|
||||
function isKnownTemplateToken(token: string): boolean {
|
||||
if (TEMPLATE_EXACT_TOKENS.has(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return TEMPLATE_CUSTOM_TOKEN_PATTERNS.some((pattern) => pattern.test(token));
|
||||
}
|
||||
|
||||
function collectUnknownTemplatePlaceholders(template: string): string[] {
|
||||
const tokens = (template.match(/\{[^{}]+\}/g) || []).map((token) => token.trim());
|
||||
const unknown = tokens.filter((token) => !isKnownTemplateToken(token));
|
||||
return Array.from(new Set(unknown));
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
let selectStreamerRequestId = 0;
|
||||
let vodRenderTaskId = 0;
|
||||
const VOD_RENDER_CHUNK_SIZE = 64;
|
||||
|
||||
function buildVodCardHtml(vod: VOD, streamer: string): string {
|
||||
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
|
||||
const date = formatUiDate(vod.created_at);
|
||||
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
||||
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
||||
|
||||
return `
|
||||
<div class="vod-card">
|
||||
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
|
||||
<div class="vod-info">
|
||||
<div class="vod-title">${safeDisplayTitle}</div>
|
||||
<div class="vod-meta">
|
||||
<span>${date}</span>
|
||||
<span>${vod.duration}</span>
|
||||
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vod-actions">
|
||||
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button>
|
||||
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStreamers(): void {
|
||||
const list = byId('streamerList');
|
||||
list.innerHTML = '';
|
||||
|
||||
(config.streamers ?? []).forEach((streamer: string) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
||||
item.innerHTML = `
|
||||
<span>${streamer}</span>
|
||||
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">x</span>
|
||||
`;
|
||||
item.onclick = () => {
|
||||
void selectStreamer(streamer);
|
||||
};
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function addStreamer(): Promise<void> {
|
||||
const input = byId<HTMLInputElement>('newStreamer');
|
||||
const name = input.value.trim().toLowerCase();
|
||||
if (!name || (config.streamers ?? []).includes(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.streamers = [...(config.streamers ?? []), name];
|
||||
config = await window.api.saveConfig({ streamers: config.streamers });
|
||||
input.value = '';
|
||||
renderStreamers();
|
||||
await selectStreamer(name);
|
||||
}
|
||||
|
||||
async function removeStreamer(name: string): Promise<void> {
|
||||
config.streamers = (config.streamers ?? []).filter((s: string) => s !== name);
|
||||
config = await window.api.saveConfig({ streamers: config.streamers });
|
||||
renderStreamers();
|
||||
|
||||
if (currentStreamer !== name) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStreamer = null;
|
||||
byId('vodGrid').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
<h3>${UI_TEXT.vods.noneTitle}</h3>
|
||||
<p>${UI_TEXT.vods.noneText}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
|
||||
const requestId = ++selectStreamerRequestId;
|
||||
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
|
||||
|
||||
currentStreamer = name;
|
||||
renderStreamers();
|
||||
byId('pageTitle').textContent = name;
|
||||
|
||||
if (!isConnected) {
|
||||
await connect();
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
updateStatus(UI_TEXT.status.noLogin, false);
|
||||
}
|
||||
|
||||
byId('vodGrid').innerHTML = `<div class="empty-state"><p>${UI_TEXT.vods.loading}</p></div>`;
|
||||
|
||||
const userId = await window.api.getUserId(name);
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const vods = await window.api.getVODs(userId, forceRefresh);
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderVODs(vods, name);
|
||||
}
|
||||
|
||||
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
|
||||
const grid = byId('vodGrid');
|
||||
const renderTaskId = ++vodRenderTaskId;
|
||||
|
||||
const scheduleNextChunk = (nextStartIndex: number): void => {
|
||||
const delayMs = document.hidden ? 16 : 0;
|
||||
window.setTimeout(() => {
|
||||
renderChunk(nextStartIndex);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
if (!vods || vods.length === 0) {
|
||||
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
const renderChunk = (startIndex: number): void => {
|
||||
if (renderTaskId !== vodRenderTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
|
||||
if (!chunk.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
|
||||
|
||||
if (startIndex + chunk.length < vods.length) {
|
||||
scheduleNextChunk(startIndex + chunk.length);
|
||||
}
|
||||
};
|
||||
|
||||
renderChunk(0);
|
||||
}
|
||||
|
||||
async function refreshVODs(): Promise<void> {
|
||||
if (!currentStreamer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await selectStreamer(currentStreamer, true);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
type LanguageCode = 'de' | 'en';
|
||||
|
||||
const UI_TEXTS = {
|
||||
de: UI_TEXT_DE,
|
||||
en: UI_TEXT_EN
|
||||
} as const;
|
||||
|
||||
let currentLanguage: LanguageCode = 'en';
|
||||
let UI_TEXT: (typeof UI_TEXTS)[LanguageCode] = UI_TEXTS[currentLanguage];
|
||||
|
||||
function getIntlLocale(): string {
|
||||
return currentLanguage === 'en' ? 'en-US' : 'de-DE';
|
||||
}
|
||||
|
||||
function formatUiDate(input: string | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
return date.toLocaleDateString(getIntlLocale());
|
||||
}
|
||||
|
||||
function formatUiNumber(value: number): string {
|
||||
return value.toLocaleString(getIntlLocale());
|
||||
}
|
||||
|
||||
function setText(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function setPlaceholder(id: string, value: string): void {
|
||||
const node = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (node) node.placeholder = value;
|
||||
}
|
||||
|
||||
function setTitle(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.setAttribute('title', value);
|
||||
}
|
||||
|
||||
function setLanguage(lang: string): LanguageCode {
|
||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||
applyLanguageToStaticUI();
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
function applyLanguageToStaticUI(): void {
|
||||
setText('logoText', UI_TEXT.appName);
|
||||
setText('navVodsText', UI_TEXT.static.navVods);
|
||||
setText('navClipsText', UI_TEXT.static.navClips);
|
||||
setText('navCutterText', UI_TEXT.static.navCutter);
|
||||
setText('navMergeText', UI_TEXT.static.navMerge);
|
||||
setText('navSettingsText', UI_TEXT.static.navSettings);
|
||||
setText('queueTitleText', UI_TEXT.static.queueTitle);
|
||||
setText('healthBadge', UI_TEXT.static.healthUnknown);
|
||||
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
|
||||
setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint);
|
||||
setText('btnClear', UI_TEXT.static.clearQueue);
|
||||
setText('refreshText', UI_TEXT.static.refresh);
|
||||
setText('clipsHeading', UI_TEXT.static.clipsHeading);
|
||||
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
|
||||
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
|
||||
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
|
||||
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
|
||||
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||
setText('mergeDesc', UI_TEXT.static.mergeDesc);
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
setText('designTitle', UI_TEXT.static.designTitle);
|
||||
setText('themeLabel', UI_TEXT.static.themeLabel);
|
||||
setText('languageLabel', UI_TEXT.static.languageLabel);
|
||||
setText('languageDeText', UI_TEXT.static.languageDe);
|
||||
setText('languageEnText', UI_TEXT.static.languageEn);
|
||||
setText('apiTitle', UI_TEXT.static.apiTitle);
|
||||
setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
|
||||
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
|
||||
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
|
||||
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
|
||||
setText('storageLabel', UI_TEXT.static.storageLabel);
|
||||
setText('openFolderBtn', UI_TEXT.static.openFolder);
|
||||
setText('modeLabel', UI_TEXT.static.modeLabel);
|
||||
setText('modeFullText', UI_TEXT.static.modeFull);
|
||||
setText('modePartsText', UI_TEXT.static.modeParts);
|
||||
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
|
||||
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
|
||||
setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
|
||||
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
|
||||
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
|
||||
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
|
||||
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
|
||||
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
||||
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
||||
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
||||
setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
|
||||
setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
|
||||
setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
|
||||
setText('filenameTemplateLint', UI_TEXT.static.templateLintOk);
|
||||
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
||||
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
||||
setText('clipTemplateLint', UI_TEXT.static.templateLintOk);
|
||||
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
|
||||
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
|
||||
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
|
||||
setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
|
||||
setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
|
||||
setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
|
||||
setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
|
||||
setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
|
||||
setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
|
||||
setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
|
||||
setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
|
||||
setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
|
||||
setPlaceholder('templateGuideInput', UI_TEXT.static.vodTemplatePlaceholder);
|
||||
setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder);
|
||||
setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder);
|
||||
setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder);
|
||||
setText('updateTitle', UI_TEXT.static.updateTitle);
|
||||
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
|
||||
setText('preflightTitle', UI_TEXT.static.preflightTitle);
|
||||
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
|
||||
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
|
||||
setText('preflightResult', UI_TEXT.static.preflightEmpty);
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport);
|
||||
setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh);
|
||||
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
|
||||
setText('updateText', UI_TEXT.updates.bannerDefault);
|
||||
setText('updateButton', UI_TEXT.updates.downloadNow);
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
setText('statusText', UI_TEXT.static.notConnected);
|
||||
}
|
||||
|
||||
const guideRefresh = (window as unknown as { refreshTemplateGuideTexts?: () => void }).refreshTemplateGuideTexts;
|
||||
if (typeof guideRefresh === 'function') {
|
||||
guideRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function localizeCurrentStatusText(current: string): string {
|
||||
const map: Record<string, keyof typeof UI_TEXT.status> = {
|
||||
[UI_TEXTS.de.status.noLogin]: 'noLogin',
|
||||
[UI_TEXTS.en.status.noLogin]: 'noLogin',
|
||||
[UI_TEXTS.de.status.connecting]: 'connecting',
|
||||
[UI_TEXTS.en.status.connecting]: 'connecting',
|
||||
[UI_TEXTS.de.status.connected]: 'connected',
|
||||
[UI_TEXTS.en.status.connected]: 'connected',
|
||||
[UI_TEXTS.de.status.connectFailedPublic]: 'connectFailedPublic',
|
||||
[UI_TEXTS.en.status.connectFailedPublic]: 'connectFailedPublic'
|
||||
};
|
||||
|
||||
const key = map[current];
|
||||
return key ? UI_TEXT.status[key] : current;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
let updateCheckInProgress = false;
|
||||
let updateDownloadInProgress = false;
|
||||
let manualUpdateCheckPending = false;
|
||||
let latestUpdateVersion = '';
|
||||
|
||||
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (typeof toastFn === 'function') {
|
||||
toastFn(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckButtonCheckingState(enabled: boolean): void {
|
||||
const btn = byId<HTMLButtonElement>('checkUpdateBtn');
|
||||
btn.disabled = enabled;
|
||||
btn.textContent = enabled ? UI_TEXT.updates.checking : UI_TEXT.static.checkUpdates;
|
||||
}
|
||||
|
||||
function showUpdateBanner(): void {
|
||||
byId('updateBanner').style.display = 'flex';
|
||||
}
|
||||
|
||||
function setDownloadPendingUi(): void {
|
||||
showUpdateBanner();
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
byId('updateProgress').style.display = 'block';
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.add('downloading');
|
||||
bar.style.width = '30%';
|
||||
}
|
||||
|
||||
function setDownloadReadyUi(version: string): void {
|
||||
showUpdateBanner();
|
||||
updateReady = true;
|
||||
updateDownloadInProgress = false;
|
||||
latestUpdateVersion = version || latestUpdateVersion;
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '100%';
|
||||
|
||||
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.ready}`;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.installNow;
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
async function checkUpdateSilent(): Promise<void> {
|
||||
try {
|
||||
await window.api.checkUpdate();
|
||||
} catch {
|
||||
// ignore silent updater errors
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdate(): Promise<void> {
|
||||
manualUpdateCheckPending = true;
|
||||
setCheckButtonCheckingState(true);
|
||||
|
||||
try {
|
||||
const result = await window.api.checkUpdate();
|
||||
|
||||
if (result?.error) {
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const skippedReason = result?.skipped;
|
||||
if (skippedReason === 'ready-to-install') {
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.readyToInstall, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedReason === 'in-progress' || skippedReason === 'throttled') {
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkInProgress, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!updateReady && byId('updateBanner').style.display !== 'flex') {
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
}, 2500);
|
||||
} catch {
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUpdate(): void {
|
||||
if (updateReady) {
|
||||
void window.api.installUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateDownloadInProgress) {
|
||||
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
updateDownloadInProgress = true;
|
||||
setDownloadPendingUi();
|
||||
|
||||
void window.api.downloadUpdate().then((result) => {
|
||||
if (result?.error) {
|
||||
updateDownloadInProgress = false;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloadNow;
|
||||
button.disabled = false;
|
||||
byId('updateProgressBar').classList.remove('downloading');
|
||||
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.skipped === 'ready-to-install') {
|
||||
setDownloadReadyUi(latestUpdateVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.skipped === 'in-progress') {
|
||||
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
|
||||
}
|
||||
}).catch(() => {
|
||||
updateDownloadInProgress = false;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloadNow;
|
||||
button.disabled = false;
|
||||
byId('updateProgressBar').classList.remove('downloading');
|
||||
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
window.api.onUpdateChecking(() => {
|
||||
updateCheckInProgress = true;
|
||||
if (manualUpdateCheckPending) {
|
||||
setCheckButtonCheckingState(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
updateCheckInProgress = false;
|
||||
updateReady = false;
|
||||
updateDownloadInProgress = true;
|
||||
manualUpdateCheckPending = false;
|
||||
latestUpdateVersion = info.version;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateText').textContent = `Version ${info.version} ${UI_TEXT.updates.available}`;
|
||||
byId('updateButton').textContent = UI_TEXT.updates.downloading;
|
||||
byId<HTMLButtonElement>('updateButton').disabled = true;
|
||||
byId('updateProgress').style.display = 'block';
|
||||
byId('updateProgressBar').classList.add('downloading');
|
||||
});
|
||||
|
||||
window.api.onUpdateNotAvailable(() => {
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
if (manualUpdateCheckPending) {
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
|
||||
manualUpdateCheckPending = false;
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
updateDownloadInProgress = true;
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = progress.percent + '%';
|
||||
|
||||
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
|
||||
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
|
||||
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
setDownloadReadyUi(info.version);
|
||||
});
|
||||
|
||||
window.api.onUpdateError(() => {
|
||||
updateCheckInProgress = false;
|
||||
const wasDownloading = updateDownloadInProgress;
|
||||
updateDownloadInProgress = false;
|
||||
manualUpdateCheckPending = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
if (!updateReady) {
|
||||
button.textContent = UI_TEXT.updates.downloadNow;
|
||||
button.disabled = false;
|
||||
byId('updateProgressBar').classList.remove('downloading');
|
||||
}
|
||||
|
||||
notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn');
|
||||
});
|
||||
+1076
File diff suppressed because it is too large
Load Diff
+1415
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
export function normalizeUpdateVersion(version: string | null | undefined): string {
|
||||
return (version || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function parseVersionPart(part: string): number {
|
||||
const numeric = Number(part.replace(/[^0-9].*$/, ''));
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
export function compareUpdateVersions(left: string | null | undefined, right: string | null | undefined): number {
|
||||
const a = normalizeUpdateVersion(left);
|
||||
const b = normalizeUpdateVersion(right);
|
||||
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
|
||||
const aParts = a.split('.').map(parseVersionPart);
|
||||
const bParts = b.split('.').map(parseVersionPart);
|
||||
const maxLength = Math.max(aParts.length, bParts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const av = aParts[i] || 0;
|
||||
const bv = bParts[i] || 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isNewerUpdateVersion(candidate: string | null | undefined, baseline: string | null | undefined): boolean {
|
||||
return compareUpdateVersions(candidate, baseline) > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user