Compare commits
5 Commits
1e81b889f9
...
39fa5065d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39fa5065d2 | ||
|
|
2b379e5e6a | ||
|
|
cf9d7b8334 | ||
|
|
6c47c63fa8 | ||
|
|
4607ba9cc6 |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.2",
|
"version": "4.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.2",
|
"version": "4.5.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.2",
|
"version": "4.5.3",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
30
src/main.ts
30
src/main.ts
@ -685,20 +685,26 @@ function formatDurationDashed(seconds: number): string {
|
|||||||
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const claimedFilenames = new Set<string>();
|
||||||
|
|
||||||
function ensureUniqueFilename(filePath: string): string {
|
function ensureUniqueFilename(filePath: string): string {
|
||||||
if (!fs.existsSync(filePath)) return filePath;
|
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
const base = path.basename(filePath, ext);
|
const base = path.basename(filePath, ext);
|
||||||
let counter = 1;
|
|
||||||
let candidate = filePath;
|
let candidate = filePath;
|
||||||
while (fs.existsSync(candidate)) {
|
let counter = 0;
|
||||||
candidate = path.join(dir, `${base}_${counter}${ext}`);
|
while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) {
|
||||||
counter++;
|
counter++;
|
||||||
|
candidate = path.join(dir, `${base}_${counter}${ext}`);
|
||||||
}
|
}
|
||||||
|
claimedFilenames.add(candidate);
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releaseClaimedFilename(filePath: string): void {
|
||||||
|
claimedFilenames.delete(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
||||||
const cleaned = (input || '')
|
const cleaned = (input || '')
|
||||||
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
||||||
@ -2204,17 +2210,19 @@ function downloadVODPart(
|
|||||||
if (speed > 0 && downloadedBytes > 0) {
|
if (speed > 0 && downloadedBytes > 0) {
|
||||||
const itemStartTime = itemTracking.startTime;
|
const itemStartTime = itemTracking.startTime;
|
||||||
const elapsedSec = (Date.now() - itemStartTime) / 1000;
|
const elapsedSec = (Date.now() - itemStartTime) / 1000;
|
||||||
if (elapsedSec > 3) {
|
if (elapsedSec > 5) { // Wait at least 5 seconds before showing ETA
|
||||||
const avgSpeed = downloadedBytes / elapsedSec;
|
const avgSpeed = downloadedBytes / elapsedSec;
|
||||||
if (expectedDurationSeconds && expectedDurationSeconds > 0) {
|
if (expectedDurationSeconds && expectedDurationSeconds > 0) {
|
||||||
const estimatedTotalBytes = avgSpeed * expectedDurationSeconds;
|
const estimatedTotalBytes = avgSpeed * expectedDurationSeconds;
|
||||||
if (estimatedTotalBytes > downloadedBytes) {
|
if (estimatedTotalBytes > downloadedBytes) {
|
||||||
const remainingSec = (estimatedTotalBytes - downloadedBytes) / avgSpeed;
|
const remainingSec = (estimatedTotalBytes - downloadedBytes) / avgSpeed;
|
||||||
|
if (remainingSec > 0 && remainingSec < 86400) { // Between 0 and 24 hours
|
||||||
etaStr = formatETA(remainingSec);
|
etaStr = formatETA(remainingSec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onProgress({
|
onProgress({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
@ -2798,6 +2806,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
|
|
||||||
item.last_error = '';
|
item.last_error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
||||||
const maxAttempts = getRetryAttemptLimit();
|
const maxAttempts = getRetryAttemptLimit();
|
||||||
|
|
||||||
@ -2861,8 +2870,6 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
|
|
||||||
if (!hasQueueItemId(item.id)) {
|
if (!hasQueueItemId(item.id)) {
|
||||||
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
||||||
activeDownloads.delete(item.id);
|
|
||||||
cancelledItemIds.delete(item.id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2877,9 +2884,6 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
runtimeMetrics.downloadsFailed += 1;
|
runtimeMetrics.downloadsFailed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeDownloads.delete(item.id);
|
|
||||||
cancelledItemIds.delete(item.id);
|
|
||||||
|
|
||||||
appendDebugLog('queue-item-finished', {
|
appendDebugLog('queue-item-finished', {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
@ -2888,6 +2892,12 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
|
|
||||||
saveQueue(downloadQueue);
|
saveQueue(downloadQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
|
} finally {
|
||||||
|
activeDownloads.delete(item.id);
|
||||||
|
cancelledItemIds.delete(item.id);
|
||||||
|
// Release any filenames claimed during this download (prevents stale claims blocking re-downloads)
|
||||||
|
claimedFilenames.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processQueue(): Promise<void> {
|
async function processQueue(): Promise<void> {
|
||||||
|
|||||||
@ -35,7 +35,7 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
|
|||||||
item.mergeGroup?.mergePhase || ''
|
item.mergeGroup?.mergePhase || ''
|
||||||
].join(':'));
|
].join(':'));
|
||||||
|
|
||||||
return `${lang}|${selectedQueueIds.join(',')}|${pieces.join('|')}`;
|
return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
|
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
|
||||||
@ -199,16 +199,35 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleQueueDetails(id: string): void {
|
function toggleQueueDetails(id: string): void {
|
||||||
const el = document.getElementById(`details-${id}`);
|
if (expandedQueueIds.has(id)) {
|
||||||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
expandedQueueIds.delete(id);
|
||||||
|
} else {
|
||||||
|
expandedQueueIds.add(id);
|
||||||
|
}
|
||||||
|
renderQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initQueueDragDrop(): void {
|
function initQueueDragDrop(): void {
|
||||||
|
if (queueDragDropInitialized) return;
|
||||||
|
queueDragDropInitialized = true;
|
||||||
|
|
||||||
const list = byId('queueList');
|
const list = byId('queueList');
|
||||||
|
|
||||||
list.addEventListener('dragstart', (e: DragEvent) => {
|
list.addEventListener('dragstart', (e: DragEvent) => {
|
||||||
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Prevent dragging items that are no longer pending (race window between status change and re-render)
|
||||||
|
const itemId = el.dataset.id;
|
||||||
|
if (itemId) {
|
||||||
|
const item = queue.find(i => i.id === itemId);
|
||||||
|
if (!item || item.status !== 'pending') {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'none';
|
||||||
|
e.dataTransfer.clearData();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
draggedQueueItemId = el.dataset.id || null;
|
draggedQueueItemId = el.dataset.id || null;
|
||||||
el.classList.add('dragging');
|
el.classList.add('dragging');
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||||
@ -303,7 +322,7 @@ function renderQueue(): void {
|
|||||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-progress-text">${safeProgressText}</div>
|
<div class="queue-progress-text">${safeProgressText}</div>
|
||||||
<div class="queue-details" id="details-${item.id}" style="display:none">
|
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
|
||||||
<div>URL: ${escapeHtml(item.url)}</div>
|
<div>URL: ${escapeHtml(item.url)}</div>
|
||||||
<div>Streamer: ${escapeHtml(item.streamer)}</div>
|
<div>Streamer: ${escapeHtml(item.streamer)}</div>
|
||||||
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
|
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
|
||||||
|
|||||||
@ -25,6 +25,8 @@ let isConnected = false;
|
|||||||
let downloading = false;
|
let downloading = false;
|
||||||
let queue: QueueItem[] = [];
|
let queue: QueueItem[] = [];
|
||||||
let selectedQueueIds: string[] = [];
|
let selectedQueueIds: string[] = [];
|
||||||
|
let expandedQueueIds: Set<string> = new Set();
|
||||||
|
let queueDragDropInitialized = false;
|
||||||
|
|
||||||
let cutterFile: string | null = null;
|
let cutterFile: string | null = null;
|
||||||
let cutterVideoInfo: VideoInfo | null = null;
|
let cutterVideoInfo: VideoInfo | null = null;
|
||||||
|
|||||||
@ -99,7 +99,7 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
// Update stats bar
|
// Update stats bar
|
||||||
updateStatsBar();
|
updateStatsBar();
|
||||||
setInterval(updateStatsBar, 5000);
|
const _statsInterval = setInterval(updateStatsBar, 5000);
|
||||||
|
|
||||||
if (config.client_id && config.client_secret) {
|
if (config.client_id && config.client_secret) {
|
||||||
await connect();
|
await connect();
|
||||||
@ -275,9 +275,14 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the higher progress value to prevent backward jumps from stale data
|
||||||
|
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
|
||||||
|
? prev.progress
|
||||||
|
: (item.progress > 0 ? item.progress : prev.progress);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
progress: item.progress > 0 ? item.progress : prev.progress,
|
progress: bestProgress,
|
||||||
speed: item.speed || prev.speed,
|
speed: item.speed || prev.speed,
|
||||||
eta: item.eta || prev.eta,
|
eta: item.eta || prev.eta,
|
||||||
currentPart: item.currentPart || prev.currentPart,
|
currentPart: item.currentPart || prev.currentPart,
|
||||||
|
|||||||
@ -358,8 +358,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-selector {
|
.queue-selector {
|
||||||
width: 22px;
|
min-width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
padding: 0 3px;
|
||||||
border: 2px solid var(--text-secondary);
|
border: 2px solid var(--text-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -367,7 +368,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user