From 556f0672dcd4e5c97eefb8cf54638bd658349794 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Feb 2026 05:50:14 +0100 Subject: [PATCH] Release v1.4.19 with 4SF/4SJ auto-rename support --- package-lock.json | 4 +- package.json | 3 +- src/main/constants.ts | 1 + src/main/download-manager.ts | 171 +++++++++++++++++++++++++++++- src/main/storage.ts | 1 + src/renderer/App.tsx | 3 +- src/shared/types.ts | 1 + tests/download-manager.test.ts | 187 +++++++++++++++++++++++++++++++++ 8 files changed, 366 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7fa06d..9beda0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.16", + "version": "1.4.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.16", + "version": "1.4.19", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 5715e06..18da2f1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "real-debrid-downloader", - "version": "1.4.18", + "version": "1.4.19", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", "license": "MIT", + "type": "module", "scripts": { "dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:renderer": "vite", diff --git a/src/main/constants.ts b/src/main/constants.ts index ad387f4..3e277cc 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -42,6 +42,7 @@ export function defaultSettings(): AppSettings { outputDir: baseDir, packageName: "", autoExtract: true, + autoRename4sf4sj: false, extractDir: path.join(baseDir, "_entpackt"), createExtractSubfolder: true, hybridExtract: true, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 92c3fc6..77a0cd8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -17,7 +17,7 @@ import { StartConflictResolutionResult, UiSnapshot } from "../shared/types"; -import { REQUEST_RETRIES } from "./constants"; +import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS } from "./constants"; import { cleanupCancelledPackageArtifactsAsync } from "./cleanup"; import { DebridService, MegaWebUnrestrictor } from "./debrid"; import { collectArchiveCleanupTargets, extractPackageArchives } from "./extractor"; @@ -194,6 +194,95 @@ function isPathInsideDir(filePath: string, dirPath: string): boolean { return file.startsWith(withSep); } +const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; +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_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; + +function extractEpisodeToken(fileName: string): string | null { + const match = String(fileName || "").match(SCENE_EPISODE_RE); + if (!match) { + return null; + } + + const season = Number(match[1]); + const episode = Number(match[2]); + if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || episode < 0) { + return null; + } + + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +function applyEpisodeTokenToFolderName(folderName: string, episodeToken: string): string { + const trimmed = String(folderName || "").trim(); + if (!trimmed) { + return episodeToken; + } + + const withEpisode = trimmed.replace( + /(^|[._\-\s])s\d{1,2}e\d{1,3}(?=[._\-\s]|$)/i, + `$1${episodeToken}` + ); + if (withEpisode !== trimmed) { + return withEpisode; + } + + const withSeason = trimmed.replace(SCENE_SEASON_ONLY_RE, `$1${episodeToken}`); + if (withSeason !== trimmed) { + return withSeason; + } + + const withSuffixInsert = trimmed.replace(/-(4sf|4sj)$/i, `.${episodeToken}-$1`); + if (withSuffixInsert !== trimmed) { + return withSuffixInsert; + } + + return `${trimmed}.${episodeToken}`; +} + +function sourceHasRpToken(fileName: string): boolean { + return SCENE_RP_TOKEN_RE.test(String(fileName || "")); +} + +function ensureRepackToken(baseName: string): string { + if (SCENE_REPACK_TOKEN_RE.test(baseName)) { + return baseName; + } + + const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, ".REPACK.$2"); + if (withQualityToken !== baseName) { + return withQualityToken; + } + + const withSuffixToken = baseName.replace(/-(4sf|4sj)$/i, ".REPACK-$1"); + if (withSuffixToken !== baseName) { + return withSuffixToken; + } + + return `${baseName}.REPACK`; +} + +function buildAutoRenameBaseName(folderName: string, sourceFileName: string): string | null { + if (!SCENE_RELEASE_FOLDER_RE.test(folderName)) { + return null; + } + + const episodeToken = extractEpisodeToken(sourceFileName); + if (!episodeToken) { + return null; + } + + let next = applyEpisodeTokenToFolderName(folderName, episodeToken); + if (sourceHasRpToken(sourceFileName)) { + next = ensureRepackToken(next); + } + + return sanitizeFilename(next); +} + export class DownloadManager extends EventEmitter { private settings: AppSettings; @@ -1015,6 +1104,83 @@ export class DownloadManager extends EventEmitter { return removed; } + private collectVideoFiles(rootDir: string): string[] { + if (!rootDir || !fs.existsSync(rootDir)) { + return []; + } + + const files: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + const extension = path.extname(entry.name).toLowerCase(); + if (!SAMPLE_VIDEO_EXTENSIONS.has(extension)) { + continue; + } + files.push(fullPath); + } + } + + return files; + } + + private autoRenameExtractedVideoFiles(extractDir: string): number { + if (!this.settings.autoRename4sf4sj) { + return 0; + } + + const videoFiles = this.collectVideoFiles(extractDir); + let renamed = 0; + + for (const sourcePath of videoFiles) { + const sourceName = path.basename(sourcePath); + const sourceExt = path.extname(sourceName); + const sourceBaseName = path.basename(sourceName, sourceExt); + const folderName = path.basename(path.dirname(sourcePath)); + const targetBaseName = buildAutoRenameBaseName(folderName, sourceBaseName); + if (!targetBaseName) { + continue; + } + + const targetPath = path.join(path.dirname(sourcePath), `${targetBaseName}${sourceExt}`); + if (pathKey(targetPath) === pathKey(sourcePath)) { + continue; + } + if (fs.existsSync(targetPath)) { + logger.warn(`Auto-Rename übersprungen (Ziel existiert): ${targetPath}`); + continue; + } + + try { + fs.renameSync(sourcePath, targetPath); + renamed += 1; + } catch (error) { + logger.warn(`Auto-Rename fehlgeschlagen (${sourceName}): ${compactErrorText(error)}`); + } + } + + if (renamed > 0) { + logger.info(`Auto-Rename (4SF/4SJ): ${renamed} Datei(en) umbenannt`); + } + return renamed; + } + public cancelPackage(packageId: string): void { const pkg = this.session.packages[packageId]; if (!pkg) { @@ -2827,6 +2993,9 @@ export class DownloadManager extends EventEmitter { pkg.status = "failed"; } else { const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir); + if (result.extracted > 0 || hasExtractedOutput) { + this.autoRenameExtractedVideoFiles(pkg.extractDir); + } const sourceExists = fs.existsSync(pkg.outputDir); let finalStatusText = ""; diff --git a/src/main/storage.ts b/src/main/storage.ts index 8f8cd5d..8f206a5 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -62,6 +62,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { outputDir: asText(settings.outputDir) || defaults.outputDir, packageName: asText(settings.packageName), autoExtract: Boolean(settings.autoExtract), + autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), extractDir: asText(settings.extractDir) || defaults.extractDir, createExtractSubfolder: Boolean(settings.createExtractSubfolder), hybridExtract: Boolean(settings.hybridExtract), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 00b4198..1f36dbc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -40,7 +40,7 @@ const emptySnapshot = (): UiSnapshot => ({ archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", - autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true, + autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", @@ -971,6 +971,7 @@ export function App(): ReactElement { +