diff --git a/CHANGELOG.md b/CHANGELOG.md index 471a7d8..29fac2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. +## 1.4.67 - 2026-03-01 + +Hotfix fuer einen kritischen Start-Konflikt-Datenverlust und zusaetzliche Renamer-Haertung fuer reale Scene-Muster. + +### Fixes + +- Start-Konflikt `Überspringen` loescht keine Pakete/Items mehr: + - Bereits entpackte Dateien bleiben erhalten. + - Offene Downloads bleiben in der Queue und koennen normal fortgesetzt werden. + - Laufende Tasks werden dabei als Paket-Stop statt als Cancel behandelt. +- Start-Konflikt-Dialogtext in der UI praezisiert: + - `Entpacktes überspringen` statt missverstaendlichem `Überspringen`. + - Klare Info, dass nur erneutes Entpacken uebersprungen wird. +- Auto-Renamer verbessert: + - Erkennt jetzt auch Episode-only Tokens wie `e01`/`e02` mit Staffel-Hinweis aus dem Ordner. + - Akzeptiert lowercase Group-Suffixe wie `-tmsf`. + - Robuster bei Source-Formaten wie `4sf-bs-720p-s01e05`. + +### Tests + +- Neue/angepasste Tests in: + - `tests/download-manager.test.ts` (Start-Konflikt-Skip behaelt Paket + Partial-Queue) + - `tests/auto-rename.test.ts` (e01/e02, lowercase suffix, odd source order) + ## 1.4.66 - 2026-03-01 Hotfix fuer haengende "Link wird umgewandelt"-Faelle (insbesondere Mega-Web-Pfad), bei denen nur ein App-Neustart geholfen hat. diff --git a/package-lock.json b/package-lock.json index 655a290..25abb6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.66", + "version": "1.4.67", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.66", + "version": "1.4.67", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index fa9f8f5..b6beb4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.66", + "version": "1.4.67", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 87570f5..6e9e745 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -290,11 +290,61 @@ const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; +const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})(?=$|[._\-\s])/; const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i; const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i; const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i; +const SCENE_GROUP_SUFFIX_FALLBACK_RE = /-([A-Za-z0-9]{2,})$/; +const SCENE_NON_GROUP_SUFFIXES = new Set([ + "x264", + "x265", + "h264", + "h265", + "avc", + "hevc", + "web", + "webrip", + "webdl", + "bluray", + "bdrip", + "hdtv", + "dvdrip", + "remux" +]); + +function hasSceneGroupSuffix(fileName: string): boolean { + const text = String(fileName || "").trim(); + if (!text) { + return false; + } + + if (SCENE_GROUP_SUFFIX_RE.test(text)) { + return true; + } + + const fallbackMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); + const suffix = String(fallbackMatch?.[1] || "").trim(); + if (!suffix) { + return false; + } + + const lower = suffix.toLowerCase(); + if (SCENE_NON_GROUP_SUFFIXES.has(lower)) { + return false; + } + if (/^\d+p$/.test(lower) || /^\d+$/.test(lower)) { + return false; + } + if (/^\d/.test(suffix)) { + return false; + } + if (/4s(?:f|j)/i.test(suffix) && !/^(?:4sf|4sj)$/i.test(suffix)) { + return false; + } + return /[a-z]/i.test(suffix); +} export function extractEpisodeToken(fileName: string): string | null { const match = String(fileName || "").match(SCENE_EPISODE_RE); @@ -343,6 +393,18 @@ function extractPartEpisodeNumber(fileName: string): number | null { return episode; } +function extractEpisodeOnlyNumber(fileName: string): number | null { + const match = String(fileName || "").match(SCENE_EPISODE_ONLY_RE); + if (!match?.[1]) { + return null; + } + const episode = Number(match[1]); + if (!Number.isFinite(episode) || episode <= 0 || episode > 999) { + return null; + } + return episode; +} + function extractCompactEpisodeToken(fileName: string, seasonHint: number | null): string | null { const trimmed = String(fileName || "").trim(); if (!trimmed) { @@ -407,6 +469,15 @@ function resolveEpisodeTokenForAutoRename(sourceFileName: string, folderNames: s const seasonTokenHint = extractSeasonToken(sourceFileName) ?? folderNames.map((folderName) => extractSeasonToken(folderName)).find(Boolean) ?? null; + const episodeOnly = extractEpisodeOnlyNumber(sourceFileName) + ?? folderNames.map((folderName) => extractEpisodeOnlyNumber(folderName)).find((value) => Number.isFinite(value) && (value as number) > 0) + ?? null; + if (seasonTokenHint && episodeOnly) { + return { + token: `${seasonTokenHint}E${String(episodeOnly).padStart(2, "0")}`, + fromPart: false + }; + } const seasonHint = seasonTokenHint ? Number(seasonTokenHint.slice(1)) : null; const compactEpisode = extractCompactEpisodeToken(sourceFileName, seasonHint); if (compactEpisode) { @@ -493,7 +564,7 @@ export function buildAutoRenameBaseName(folderName: string, sourceFileName: stri } const isLegacy4sf4sjFolder = SCENE_RELEASE_FOLDER_RE.test(normalizedFolderName); - const isSceneGroupFolder = SCENE_GROUP_SUFFIX_RE.test(normalizedFolderName); + const isSceneGroupFolder = hasSceneGroupSuffix(normalizedFolderName); if (!isLegacy4sf4sjFolder && !isSceneGroupFolder) { return null; } @@ -551,7 +622,7 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( } let target = buildAutoRenameBaseName(folderName, normalizedSourceFileName); - if (!target && resolvedEpisode && SCENE_GROUP_SUFFIX_RE.test(folderName) && (folderHasSeason || folderHasEpisode)) { + if (!target && resolvedEpisode && hasSceneGroupSuffix(folderName) && (folderHasSeason || folderHasEpisode)) { target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); } if (!target) { @@ -560,14 +631,14 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( if (resolvedEpisode && forceEpisodeForSeasonFolder - && SCENE_GROUP_SUFFIX_RE.test(target) + && hasSceneGroupSuffix(target) && !extractEpisodeToken(target) && SCENE_SEASON_ONLY_RE.test(target)) { target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); } if (resolvedEpisode?.fromPart - && SCENE_GROUP_SUFFIX_RE.test(target) + && hasSceneGroupSuffix(target) && !extractEpisodeToken(target) && SCENE_SEASON_ONLY_RE.test(target)) { target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); @@ -1153,31 +1224,61 @@ export class DownloadManager extends EventEmitter { } if (policy === "skip") { + let hadPendingItems = false; for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + hadPendingItems = true; + } + const active = this.activeTasks.get(itemId); if (active) { - active.abortReason = "cancel"; - active.abortController.abort("cancel"); + active.abortReason = "package_toggle"; + active.abortController.abort("package_toggle"); } - this.releaseTargetPath(itemId); - this.runItemIds.delete(itemId); - this.runOutcomes.delete(itemId); - this.itemContributedBytes.delete(itemId); + + if (item.status === "queued" || item.status === "reconnect_wait") { + item.status = "queued"; + item.speedBps = 0; + item.lastError = ""; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + + if (!this.session.running) { + this.runItemIds.delete(itemId); + this.runOutcomes.delete(itemId); + } + this.retryAfterByItem.delete(itemId); - delete this.session.items[itemId]; - this.itemCount = Math.max(0, this.itemCount - 1); + this.retryStateByItem.delete(itemId); } + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); if (postProcessController && !postProcessController.signal.aborted) { - postProcessController.abort("cancel"); + postProcessController.abort("skip"); } this.packagePostProcessAbortControllers.delete(packageId); this.packagePostProcessTasks.delete(packageId); - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); this.hybridExtractRequeue.delete(packageId); + + if (!this.session.running) { + this.runPackageIds.delete(packageId); + } + this.runCompletedPackages.delete(packageId); + + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + const hasPendingNow = items.some((item) => item.status === "queued" || item.status === "reconnect_wait"); + if (hadPendingItems || hasPendingNow) { + pkg.status = pkg.enabled ? "queued" : "paused"; + } + pkg.updatedAt = nowMs(); + this.persistSoon(); this.emitState(true); return { skipped: true, overwritten: false }; @@ -1661,7 +1762,7 @@ export class DownloadManager extends EventEmitter { .map((value) => String(value || "").trim()) .filter((value) => value.length > 0); const fallbackTemplate = [...normalizedCandidates].reverse().find((folderName) => { - return SCENE_GROUP_SUFFIX_RE.test(folderName) && Boolean(extractSeasonToken(folderName)); + return hasSceneGroupSuffix(folderName) && Boolean(extractSeasonToken(folderName)); }) || ""; if (!fallbackTemplate) { return null; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5f2bf4e..3efc92c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1897,6 +1897,7 @@ export function App(): ReactElement {
{startConflictPrompt.entry.packageName} ist im Ziel bereits vorhanden.
+Bei "Überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.
{startConflictPrompt.entry.extractDir}