Ship UI productivity upgrades and extraction progress flow in v1.4.0
This commit is contained in:
parent
7b5218ad98
commit
4fc0ce26f3
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.3.11",
|
"version": "1.4.0",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
allDebridToken: "",
|
allDebridToken: "",
|
||||||
|
archivePasswordList: "",
|
||||||
rememberToken: true,
|
rememberToken: true,
|
||||||
providerPrimary: "realdebrid",
|
providerPrimary: "realdebrid",
|
||||||
providerSecondary: "megadebrid",
|
providerSecondary: "megadebrid",
|
||||||
|
|||||||
@ -654,7 +654,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Nachtraegliches Cleanup geprueft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`);
|
logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`);
|
||||||
|
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const targetPath of targets) {
|
for (const targetPath of targets) {
|
||||||
@ -670,20 +670,20 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: ${removed} Datei(en) geloescht`);
|
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`);
|
||||||
if (!this.directoryHasAnyFiles(pkg.outputDir)) {
|
if (!this.directoryHasAnyFiles(pkg.outputDir)) {
|
||||||
const removedDirs = this.removeEmptyDirectoryTree(pkg.outputDir);
|
const removedDirs = this.removeEmptyDirectoryTree(pkg.outputDir);
|
||||||
if (removedDirs > 0) {
|
if (removedDirs > 0) {
|
||||||
logger.info(`Nachtraegliches Cleanup entfernte leere Download-Ordner fuer ${pkg.name}: ${removedDirs}`);
|
logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: keine Dateien entfernt`);
|
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.warn(`Nachtraegliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`);
|
logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1900,6 +1900,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
|
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
|
||||||
pkg.status = "extracting";
|
pkg.status = "extracting";
|
||||||
this.emitState();
|
this.emitState();
|
||||||
|
|
||||||
|
const updateExtractingStatus = (text: string): void => {
|
||||||
|
for (const entry of completedItems) {
|
||||||
|
entry.fullStatus = text;
|
||||||
|
entry.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateExtractingStatus("Entpacken 0%");
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
@ -1907,7 +1918,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
cleanupMode: this.settings.cleanupMode,
|
cleanupMode: this.settings.cleanupMode,
|
||||||
conflictMode: this.settings.extractConflictMode,
|
conflictMode: this.settings.extractConflictMode,
|
||||||
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
||||||
removeSamples: this.settings.removeSamplesAfterExtract
|
removeSamples: this.settings.removeSamplesAfterExtract,
|
||||||
|
passwordList: this.settings.archivePasswordList,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
const label = progress.phase === "done"
|
||||||
|
? "Entpacken 100%"
|
||||||
|
: `Entpacken ${progress.percent}% (${progress.current}/${progress.total})`;
|
||||||
|
updateExtractingStatus(label);
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
|
|||||||
@ -19,6 +19,16 @@ export interface ExtractOptions {
|
|||||||
conflictMode: ConflictMode;
|
conflictMode: ConflictMode;
|
||||||
removeLinks: boolean;
|
removeLinks: boolean;
|
||||||
removeSamples: boolean;
|
removeSamples: boolean;
|
||||||
|
passwordList?: string;
|
||||||
|
onProgress?: (update: ExtractProgressUpdate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractProgressUpdate {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
archiveName: string;
|
||||||
|
phase: "extracting" | "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
function findArchiveCandidates(packageDir: string): string[] {
|
function findArchiveCandidates(packageDir: string): string[] {
|
||||||
@ -49,12 +59,18 @@ function cleanErrorText(text: string): string {
|
|||||||
return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
function archivePasswords(): string[] {
|
function archivePasswords(listInput: string): string[] {
|
||||||
const custom = String(process.env.RD_ARCHIVE_PASSWORDS || "")
|
const custom = String(listInput || "")
|
||||||
|
.split(/\r?\n/g)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const fromEnv = String(process.env.RD_ARCHIVE_PASSWORDS || "")
|
||||||
.split(/[;,\n]/g)
|
.split(/[;,\n]/g)
|
||||||
.map((part) => part.trim())
|
.map((part) => part.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return Array.from(new Set([...DEFAULT_ARCHIVE_PASSWORDS, ...custom]));
|
|
||||||
|
return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function winRarCandidates(): string[] {
|
function winRarCandidates(): string[] {
|
||||||
@ -188,9 +204,14 @@ async function resolveExtractorCommand(): Promise<string> {
|
|||||||
throw new Error(resolveFailureReason);
|
throw new Error(resolveFailureReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExternalExtract(archivePath: string, targetDir: string, conflictMode: ConflictMode): Promise<void> {
|
async function runExternalExtract(
|
||||||
|
archivePath: string,
|
||||||
|
targetDir: string,
|
||||||
|
conflictMode: ConflictMode,
|
||||||
|
passwordCandidates: string[]
|
||||||
|
): Promise<void> {
|
||||||
const command = await resolveExtractorCommand();
|
const command = await resolveExtractorCommand();
|
||||||
const passwords = archivePasswords();
|
const passwords = passwordCandidates;
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
@ -449,17 +470,32 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const candidates = findArchiveCandidates(options.packageDir);
|
const candidates = findArchiveCandidates(options.packageDir);
|
||||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
logger.info(`Entpacken uebersprungen (keine Archive gefunden): ${options.packageDir}`);
|
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
||||||
return { extracted: 0, failed: 0, lastError: "" };
|
return { extracted: 0, failed: 0, lastError: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const conflictMode = effectiveConflictMode(options.conflictMode);
|
const conflictMode = effectiveConflictMode(options.conflictMode);
|
||||||
|
const passwordCandidates = archivePasswords(options.passwordList || "");
|
||||||
const beforeFingerprint = captureDirFingerprint(options.targetDir);
|
const beforeFingerprint = captureDirFingerprint(options.targetDir);
|
||||||
let extracted = 0;
|
let extracted = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
const extractedArchives: string[] = [];
|
const extractedArchives: string[] = [];
|
||||||
|
|
||||||
|
const emitProgress = (current: number, archiveName: string, phase: "extracting" | "done"): void => {
|
||||||
|
if (!options.onProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = Math.max(1, candidates.length);
|
||||||
|
const percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
|
||||||
|
options.onProgress({ current, total, percent, archiveName, phase });
|
||||||
|
};
|
||||||
|
|
||||||
|
emitProgress(0, "", "extracting");
|
||||||
|
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
|
const archiveName = path.basename(archivePath);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting");
|
||||||
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`);
|
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`);
|
||||||
try {
|
try {
|
||||||
const ext = path.extname(archivePath).toLowerCase();
|
const ext = path.extname(archivePath).toLowerCase();
|
||||||
@ -467,23 +503,26 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
try {
|
try {
|
||||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||||
} catch {
|
} catch {
|
||||||
await runExternalExtract(archivePath, options.targetDir, options.conflictMode);
|
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await runExternalExtract(archivePath, options.targetDir, options.conflictMode);
|
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates);
|
||||||
}
|
}
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
extractedArchives.push(archivePath);
|
extractedArchives.push(archivePath);
|
||||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
const errorText = String(error);
|
const errorText = String(error);
|
||||||
lastError = errorText;
|
lastError = errorText;
|
||||||
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`);
|
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting");
|
||||||
if (isNoExtractorError(errorText)) {
|
if (isNoExtractorError(errorText)) {
|
||||||
const remaining = candidates.length - (extracted + failed);
|
const remaining = candidates.length - (extracted + failed);
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
failed += remaining;
|
failed += remaining;
|
||||||
|
emitProgress(candidates.length, archiveName, "extracting");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -497,7 +536,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
lastError = "Keine entpackten Dateien erkannt";
|
lastError = "Keine entpackten Dateien erkannt";
|
||||||
failed += extracted;
|
failed += extracted;
|
||||||
extracted = 0;
|
extracted = 0;
|
||||||
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgefuehrt.`);
|
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
||||||
} else {
|
} else {
|
||||||
const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode);
|
const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode);
|
||||||
if (options.cleanupMode !== "none") {
|
if (options.cleanupMode !== "none") {
|
||||||
@ -529,6 +568,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitProgress(candidates.length, "", "done");
|
||||||
|
|
||||||
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
|
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
|
||||||
|
|
||||||
return { extracted, failed, lastError };
|
return { extracted, failed, lastError };
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
megaPassword: asText(settings.megaPassword),
|
megaPassword: asText(settings.megaPassword),
|
||||||
bestToken: asText(settings.bestToken),
|
bestToken: asText(settings.bestToken),
|
||||||
allDebridToken: asText(settings.allDebridToken),
|
allDebridToken: asText(settings.allDebridToken),
|
||||||
|
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"),
|
||||||
rememberToken: Boolean(settings.rememberToken),
|
rememberToken: Boolean(settings.rememberToken),
|
||||||
autoProviderFallback: Boolean(settings.autoProviderFallback),
|
autoProviderFallback: Boolean(settings.autoProviderFallback),
|
||||||
outputDir: asText(settings.outputDir) || defaults.outputDir,
|
outputDir: asText(settings.outputDir) || defaults.outputDir,
|
||||||
@ -115,7 +116,8 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
|||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
allDebridToken: ""
|
allDebridToken: "",
|
||||||
|
archivePasswordList: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const emptyStats = (): DownloadStats => ({
|
|||||||
const emptySnapshot = (): UiSnapshot => ({
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
settings: {
|
settings: {
|
||||||
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
|
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
|
||||||
|
archivePasswordList: "",
|
||||||
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
|
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
|
||||||
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
|
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||||
autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||||
@ -46,14 +47,6 @@ const providerLabels: Record<DebridProvider, string> = {
|
|||||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
|
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
|
||||||
};
|
};
|
||||||
|
|
||||||
const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [
|
|
||||||
{ value: "none", label: "Kein Fallback" },
|
|
||||||
{ value: "realdebrid", label: providerLabels.realdebrid },
|
|
||||||
{ value: "megadebrid", label: providerLabels.megadebrid },
|
|
||||||
{ value: "bestdebrid", label: providerLabels.bestdebrid },
|
|
||||||
{ value: "alldebrid", label: providerLabels.alldebrid }
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatSpeedMbps(speedBps: number): string {
|
function formatSpeedMbps(speedBps: number): string {
|
||||||
const mbps = Math.max(0, speedBps) / (1024 * 1024);
|
const mbps = Math.max(0, speedBps) / (1024 * 1024);
|
||||||
return `${mbps.toFixed(2)} MB/s`;
|
return `${mbps.toFixed(2)} MB/s`;
|
||||||
@ -85,6 +78,8 @@ export function App(): ReactElement {
|
|||||||
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
||||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||||
const draggedPackageIdRef = useRef<string | null>(null);
|
const draggedPackageIdRef = useRef<string | null>(null);
|
||||||
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
|
|
||||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
|
|
||||||
@ -148,6 +143,91 @@ export function App(): ReactElement {
|
|||||||
.map((id: string) => snapshot.session.packages[id])
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
.filter(Boolean), [snapshot]);
|
.filter(Boolean), [snapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsedPackages((prev) => {
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const pkg of packages) {
|
||||||
|
next[pkg.id] = prev[pkg.id] ?? false;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [packages]);
|
||||||
|
|
||||||
|
const filteredPackages = useMemo(() => {
|
||||||
|
const query = downloadSearch.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
return packages.filter((pkg) => pkg.name.toLowerCase().includes(query));
|
||||||
|
}, [packages, downloadSearch]);
|
||||||
|
|
||||||
|
const allPackagesCollapsed = useMemo(() => (
|
||||||
|
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
||||||
|
), [packages, collapsedPackages]);
|
||||||
|
|
||||||
|
const configuredProviders = useMemo(() => {
|
||||||
|
const list: DebridProvider[] = [];
|
||||||
|
if (settingsDraft.token.trim()) {
|
||||||
|
list.push("realdebrid");
|
||||||
|
}
|
||||||
|
if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) {
|
||||||
|
list.push("megadebrid");
|
||||||
|
}
|
||||||
|
if (settingsDraft.bestToken.trim()) {
|
||||||
|
list.push("bestdebrid");
|
||||||
|
}
|
||||||
|
if (settingsDraft.allDebridToken.trim()) {
|
||||||
|
list.push("alldebrid");
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]);
|
||||||
|
|
||||||
|
const primaryProviderValue: DebridProvider = useMemo(() => {
|
||||||
|
if (configuredProviders.includes(settingsDraft.providerPrimary)) {
|
||||||
|
return settingsDraft.providerPrimary;
|
||||||
|
}
|
||||||
|
return configuredProviders[0] ?? "realdebrid";
|
||||||
|
}, [configuredProviders, settingsDraft.providerPrimary]);
|
||||||
|
|
||||||
|
const secondaryProviderChoices = useMemo(() => (
|
||||||
|
configuredProviders.filter((provider) => provider !== primaryProviderValue)
|
||||||
|
), [configuredProviders, primaryProviderValue]);
|
||||||
|
|
||||||
|
const secondaryProviderValue: DebridFallbackProvider = useMemo(() => {
|
||||||
|
if (secondaryProviderChoices.includes(settingsDraft.providerSecondary as DebridProvider)) {
|
||||||
|
return settingsDraft.providerSecondary;
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}, [secondaryProviderChoices, settingsDraft.providerSecondary]);
|
||||||
|
|
||||||
|
const tertiaryProviderChoices = useMemo(() => {
|
||||||
|
const blocked = new Set<string>([primaryProviderValue]);
|
||||||
|
if (secondaryProviderValue !== "none") {
|
||||||
|
blocked.add(secondaryProviderValue);
|
||||||
|
}
|
||||||
|
return configuredProviders.filter((provider) => !blocked.has(provider));
|
||||||
|
}, [configuredProviders, primaryProviderValue, secondaryProviderValue]);
|
||||||
|
|
||||||
|
const tertiaryProviderValue: DebridFallbackProvider = useMemo(() => {
|
||||||
|
if (tertiaryProviderChoices.includes(settingsDraft.providerTertiary as DebridProvider)) {
|
||||||
|
return settingsDraft.providerTertiary;
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}, [tertiaryProviderChoices, settingsDraft.providerTertiary]);
|
||||||
|
|
||||||
|
const normalizedSettingsDraft: AppSettings = useMemo(() => ({
|
||||||
|
...settingsDraft,
|
||||||
|
providerPrimary: primaryProviderValue,
|
||||||
|
providerSecondary: configuredProviders.length >= 2 ? secondaryProviderValue : "none",
|
||||||
|
providerTertiary: configuredProviders.length >= 3 ? tertiaryProviderValue : "none"
|
||||||
|
}), [
|
||||||
|
settingsDraft,
|
||||||
|
primaryProviderValue,
|
||||||
|
configuredProviders.length,
|
||||||
|
secondaryProviderValue,
|
||||||
|
tertiaryProviderValue
|
||||||
|
]);
|
||||||
|
|
||||||
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
|
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
|
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
|
||||||
@ -166,11 +246,11 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onSaveSettings = async (): Promise<void> => {
|
const onSaveSettings = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.rd.updateSettings(settingsDraft);
|
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
setSettingsDraft(result);
|
setSettingsDraft(result);
|
||||||
applyTheme(result.theme);
|
applyTheme(result.theme);
|
||||||
showToast("Settings gespeichert", 1800);
|
showToast("Einstellungen gespeichert", 1800);
|
||||||
} catch (error) { showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); }
|
} catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCheckUpdates = async (): Promise<void> => {
|
const onCheckUpdates = async (): Promise<void> => {
|
||||||
@ -182,7 +262,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onAddLinks = async (): Promise<void> => {
|
const onAddLinks = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await window.rd.updateSettings(settingsDraft);
|
await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
|
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
|
||||||
if (result.addedLinks > 0) {
|
if (result.addedLinks > 0) {
|
||||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
||||||
@ -195,7 +275,7 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
const files = await window.rd.pickContainers();
|
const files = await window.rd.pickContainers();
|
||||||
if (files.length === 0) { return; }
|
if (files.length === 0) { return; }
|
||||||
await window.rd.updateSettings(settingsDraft);
|
await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
const result = await window.rd.addContainers(files);
|
const result = await window.rd.addContainers(files);
|
||||||
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||||
} catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }
|
} catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }
|
||||||
@ -209,7 +289,7 @@ export function App(): ReactElement {
|
|||||||
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
||||||
if (dlc.length > 0) {
|
if (dlc.length > 0) {
|
||||||
try {
|
try {
|
||||||
await window.rd.updateSettings(settingsDraft);
|
await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
const result = await window.rd.addContainers(dlc);
|
const result = await window.rd.addContainers(dlc);
|
||||||
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||||
} catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }
|
} catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }
|
||||||
@ -258,6 +338,10 @@ export function App(): ReactElement {
|
|||||||
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||||
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||||
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||||
|
const setSpeedLimitMbps = (value: number): void => {
|
||||||
|
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
||||||
|
};
|
||||||
|
|
||||||
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
|
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
|
||||||
try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); }
|
try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); }
|
||||||
@ -386,32 +470,34 @@ export function App(): ReactElement {
|
|||||||
<section className="control-strip">
|
<section className="control-strip">
|
||||||
<div className="buttons">
|
<div className="buttons">
|
||||||
<button className="btn accent" disabled={!snapshot.canStart} onClick={async () => {
|
<button className="btn accent" disabled={!snapshot.canStart} onClick={async () => {
|
||||||
await performQuickAction(async () => { await window.rd.updateSettings(settingsDraft); await window.rd.start(); });
|
await performQuickAction(async () => { await window.rd.updateSettings(normalizedSettingsDraft); await window.rd.start(); });
|
||||||
}}>Start</button>
|
}}>Start</button>
|
||||||
<button className="btn" disabled={!snapshot.canPause} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}>
|
<button className="btn" disabled={!snapshot.canPause} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}>
|
||||||
{snapshot.session.paused ? "Resume" : "Pause"}
|
{snapshot.session.paused ? "Fortsetzen" : "Pause"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" disabled={!snapshot.canStop} onClick={() => { void performQuickAction(() => window.rd.stop()); }}>Stop</button>
|
<button className="btn" disabled={!snapshot.canStop} onClick={() => { void performQuickAction(() => window.rd.stop()); }}>Stop</button>
|
||||||
<button className="btn" onClick={() => { void performQuickAction(() => window.rd.clearAll()); }}>Alles leeren</button>
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
const confirmed = window.confirm("Wirklich alle Einträge aus der Queue löschen?");
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void performQuickAction(() => window.rd.clearAll());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alles leeren
|
||||||
|
</button>
|
||||||
<button className={`btn${snapshot.clipboardActive ? " btn-active" : ""}`} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
|
<button className={`btn${snapshot.clipboardActive ? " btn-active" : ""}`} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
|
||||||
Clipboard {snapshot.clipboardActive ? "An" : "Aus"}
|
Clipboard {snapshot.clipboardActive ? "An" : "Aus"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="speed-config">
|
|
||||||
<label><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit</label>
|
|
||||||
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(e) => setNum("speedLimitKbps", Number(e.target.value) || 0)} />
|
|
||||||
<span>KB/s</span>
|
|
||||||
<select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)}>
|
|
||||||
<option value="global">global</option>
|
|
||||||
<option value="per_download">per_download</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav className="tabs">
|
<nav className="tabs">
|
||||||
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
||||||
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
||||||
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Settings</button>
|
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="tab-content">
|
<main className="tab-content">
|
||||||
@ -424,7 +510,7 @@ export function App(): ReactElement {
|
|||||||
<button className="btn" onClick={onImportDlc}>DLC import</button>
|
<button className="btn" onClick={onImportDlc}>DLC import</button>
|
||||||
<button className="btn" onClick={onExportQueue}>Queue Export</button>
|
<button className="btn" onClick={onExportQueue}>Queue Export</button>
|
||||||
<button className="btn" onClick={onImportQueue}>Queue Import</button>
|
<button className="btn" onClick={onImportQueue}>Queue Import</button>
|
||||||
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufugen</button>
|
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="collector-tabs">
|
<div className="collector-tabs">
|
||||||
@ -454,25 +540,55 @@ export function App(): ReactElement {
|
|||||||
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="downloads-toolbar">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={packages.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setCollapsedPackages((prev) => {
|
||||||
|
const next: Record<string, boolean> = { ...prev };
|
||||||
|
const targetState = !allPackagesCollapsed;
|
||||||
|
for (const pkg of packages) {
|
||||||
|
next[pkg.id] = targetState;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
type="search"
|
||||||
|
value={downloadSearch}
|
||||||
|
onChange={(event) => setDownloadSearch(event.target.value)}
|
||||||
|
placeholder="Pakete durchsuchen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="stats-bar">
|
<div className="stats-bar">
|
||||||
<span>Pakete: {snapshot.stats.totalPackages}</span>
|
<span>Pakete: {snapshot.stats.totalPackages}</span>
|
||||||
<span>Dateien: {snapshot.stats.totalFiles} fertig</span>
|
<span>Dateien: {snapshot.stats.totalFiles} fertig</span>
|
||||||
<span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span>
|
<span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span>
|
||||||
</div>
|
</div>
|
||||||
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
|
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
|
||||||
{packages.map((pkg, idx) => (
|
{packages.length > 0 && filteredPackages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>}
|
||||||
|
{filteredPackages.map((pkg) => (
|
||||||
<PackageCard
|
<PackageCard
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)}
|
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)}
|
||||||
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
|
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
|
||||||
isFirst={idx === 0}
|
isFirst={snapshot.session.packageOrder.indexOf(pkg.id) === 0}
|
||||||
isLast={idx === packages.length - 1}
|
isLast={snapshot.session.packageOrder.indexOf(pkg.id) === snapshot.session.packageOrder.length - 1}
|
||||||
isEditing={editingPackageId === pkg.id}
|
isEditing={editingPackageId === pkg.id}
|
||||||
editingName={editingName}
|
editingName={editingName}
|
||||||
|
collapsed={collapsedPackages[pkg.id] ?? false}
|
||||||
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
|
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
|
||||||
onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }}
|
onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }}
|
||||||
onEditChange={setEditingName}
|
onEditChange={setEditingName}
|
||||||
|
onToggleCollapse={() => {
|
||||||
|
setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) }));
|
||||||
|
}}
|
||||||
onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }}
|
onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }}
|
||||||
onMoveUp={() => movePackage(pkg.id, "up")}
|
onMoveUp={() => movePackage(pkg.id, "up")}
|
||||||
onMoveDown={() => movePackage(pkg.id, "down")}
|
onMoveDown={() => movePackage(pkg.id, "down")}
|
||||||
@ -494,7 +610,7 @@ export function App(): ReactElement {
|
|||||||
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
|
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-toolbar-actions">
|
<div className="settings-toolbar-actions">
|
||||||
<button className="btn" onClick={onCheckUpdates}>Updates prufen</button>
|
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
|
||||||
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
||||||
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
||||||
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
||||||
@ -502,7 +618,7 @@ export function App(): ReactElement {
|
|||||||
}}>
|
}}>
|
||||||
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
|
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
|
<button className="btn accent" onClick={onSaveSettings}>Einstellungen speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@ -519,18 +635,27 @@ export function App(): ReactElement {
|
|||||||
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
||||||
<label>AllDebrid API Key</label>
|
<label>AllDebrid API Key</label>
|
||||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
||||||
<div className="field-grid three">
|
{configuredProviders.length === 0 && (
|
||||||
<div><label>Primar</label><select value={settingsDraft.providerPrimary} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
<div className="hint">Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.</div>
|
||||||
{Object.entries(providerLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
)}
|
||||||
|
{configuredProviders.length >= 1 && (
|
||||||
|
<div><label>Hauptaccount</label><select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
||||||
|
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label>Sekundar</label><select value={settingsDraft.providerSecondary} onChange={(e) => setText("providerSecondary", e.target.value)}>
|
)}
|
||||||
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
|
{configuredProviders.length >= 2 && (
|
||||||
|
<div><label>1. Hoster-Alternative</label><select value={secondaryProviderValue} onChange={(e) => setText("providerSecondary", e.target.value)}>
|
||||||
|
<option value="none">Keine Alternative</option>
|
||||||
|
{secondaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label>Tertiar</label><select value={settingsDraft.providerTertiary} onChange={(e) => setText("providerTertiary", e.target.value)}>
|
)}
|
||||||
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
|
{configuredProviders.length >= 3 && (
|
||||||
|
<div><label>2. Hoster-Alternative</label><select value={tertiaryProviderValue} onChange={(e) => setText("providerTertiary", e.target.value)}>
|
||||||
|
<option value="none">Keine Alternative</option>
|
||||||
|
{tertiaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
)}
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nachsten Provider wechseln</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@ -539,17 +664,24 @@ export function App(): ReactElement {
|
|||||||
<label>Download-Ordner</label>
|
<label>Download-Ordner</label>
|
||||||
<div className="input-row">
|
<div className="input-row">
|
||||||
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
|
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
|
||||||
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wahlen</button>
|
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wählen</button>
|
||||||
</div>
|
</div>
|
||||||
<label>Paketname (optional)</label>
|
<label>Paketname (optional)</label>
|
||||||
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
|
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
|
||||||
<label>Entpacken nach</label>
|
<label>Entpacken nach</label>
|
||||||
<div className="input-row">
|
<div className="input-row">
|
||||||
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
|
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
|
||||||
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wahlen</button>
|
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wählen</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||||
|
<label>Passwortliste (eine Zeile pro Passwort)</label>
|
||||||
|
<textarea
|
||||||
|
className="password-list"
|
||||||
|
value={settingsDraft.archivePasswordList}
|
||||||
|
onChange={(e) => setText("archivePasswordList", e.target.value)}
|
||||||
|
placeholder={"serienfans.org\nserienjunkies.org\nmein-passwort"}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card settings-card">
|
<article className="card settings-card">
|
||||||
@ -559,9 +691,33 @@ export function App(): ReactElement {
|
|||||||
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} /></div>
|
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} /></div>
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label>
|
||||||
|
<div className="field-grid two">
|
||||||
|
<div>
|
||||||
|
<label>Limit (MB/s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={Number((settingsDraft.speedLimitKbps / 1024).toFixed(2))}
|
||||||
|
onChange={(e) => setSpeedLimitMbps(Number(e.target.value) || 0)}
|
||||||
|
disabled={!settingsDraft.speedLimitEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Limit-Modus</label>
|
||||||
|
<select
|
||||||
|
value={settingsDraft.speedLimitMode}
|
||||||
|
onChange={(e) => setText("speedLimitMode", e.target.value)}
|
||||||
|
disabled={!settingsDraft.speedLimitEnabled}
|
||||||
|
>
|
||||||
|
<option value="global">Global</option>
|
||||||
|
<option value="per_download">Pro Download</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage uberwachen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||||
<h4>Bandbreitenplanung</h4>
|
<h4>Bandbreitenplanung</h4>
|
||||||
{schedules.map((s, i) => (
|
{schedules.map((s, i) => (
|
||||||
@ -570,18 +726,25 @@ export function App(): ReactElement {
|
|||||||
<span>-</span>
|
<span>-</span>
|
||||||
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
||||||
<span>Uhr</span>
|
<span>Uhr</span>
|
||||||
<input type="number" min={0} value={s.speedLimitKbps} onChange={(e) => updateSchedule(i, "speedLimitKbps", Number(e.target.value) || 0)} title="KB/s (0=unbegrenzt)" />
|
<input
|
||||||
<span>KB/s</span>
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={Number((s.speedLimitKbps / 1024).toFixed(2))}
|
||||||
|
onChange={(e) => updateSchedule(i, "speedLimitKbps", Math.floor((Number(e.target.value) || 0) * 1024))}
|
||||||
|
title="MB/s (0=unbegrenzt)"
|
||||||
|
/>
|
||||||
|
<span>MB/s</span>
|
||||||
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
||||||
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={addSchedule}>Zeitregel hinzufugen</button>
|
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card settings-card">
|
<article className="card settings-card">
|
||||||
<h3>Integritat, Cleanup & Updates</h3>
|
<h3>Integrität, Cleanup & Updates</h3>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prufen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label>
|
||||||
<label>Fertiggestellte Downloads entfernen</label>
|
<label>Fertiggestellte Downloads entfernen</label>
|
||||||
@ -590,20 +753,20 @@ export function App(): ReactElement {
|
|||||||
</select>
|
</select>
|
||||||
<div className="field-grid two">
|
<div className="field-grid two">
|
||||||
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
|
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
|
||||||
<option value="none">keine Archive loschen</option>
|
<option value="none">keine Archive löschen</option>
|
||||||
<option value="trash">Archive in Papierkorb</option>
|
<option value="trash">Archive in Papierkorb</option>
|
||||||
<option value="delete">Archive loschen</option>
|
<option value="delete">Archive löschen</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
|
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
|
||||||
<option value="overwrite">uberschreiben</option>
|
<option value="overwrite">überschreiben</option>
|
||||||
<option value="skip">uberspringen</option>
|
<option value="skip">überspringen</option>
|
||||||
<option value="rename">umbenennen</option>
|
<option value="rename">umbenennen</option>
|
||||||
<option value="ask">nachfragen</option>
|
<option value="ask">nachfragen</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
<label>GitHub Repo</label>
|
<label>GitHub Repo</label>
|
||||||
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
|
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prufen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prüfen</label>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@ -624,9 +787,11 @@ interface PackageCardProps {
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editingName: string;
|
editingName: string;
|
||||||
|
collapsed: boolean;
|
||||||
onStartEdit: () => void;
|
onStartEdit: () => void;
|
||||||
onFinishEdit: (name: string) => void;
|
onFinishEdit: (name: string) => void;
|
||||||
onEditChange: (name: string) => void;
|
onEditChange: (name: string) => void;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onMoveUp: () => void;
|
onMoveUp: () => void;
|
||||||
onMoveDown: () => void;
|
onMoveDown: () => void;
|
||||||
@ -637,7 +802,7 @@ interface PackageCardProps {
|
|||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, onStartEdit, onFinishEdit, onEditChange, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||||
const done = items.filter((item) => item.status === "completed").length;
|
const done = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
@ -673,6 +838,7 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pkg-actions">
|
<div className="pkg-actions">
|
||||||
|
<button className="btn" onClick={onToggleCollapse}>{collapsed ? "Ausklappen" : "Einklappen"}</button>
|
||||||
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button>
|
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button>
|
||||||
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button>
|
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button>
|
||||||
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
|
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
|
||||||
@ -680,7 +846,7 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
|
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
|
||||||
<table>
|
{!collapsed && <table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th className="col-file">Datei</th>
|
<th className="col-file">Datei</th>
|
||||||
<th className="col-provider">Provider</th>
|
<th className="col-provider">Provider</th>
|
||||||
@ -697,13 +863,13 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi
|
|||||||
<td className="col-provider">{item.provider ? providerLabels[item.provider] : "-"}</td>
|
<td className="col-provider">{item.provider ? providerLabels[item.provider] : "-"}</td>
|
||||||
<td className="col-status" title={item.fullStatus}>{item.fullStatus}</td>
|
<td className="col-status" title={item.fullStatus}>{item.fullStatus}</td>
|
||||||
<td className="col-progress num">{item.progressPercent}%</td>
|
<td className="col-progress num">{item.progressPercent}%</td>
|
||||||
<td className="col-speed num">{formatSpeedMbps(item.speedBps)}</td>
|
<td className="col-speed num">{item.status === "completed" ? "-" : formatSpeedMbps(item.speedBps)}</td>
|
||||||
<td className="col-retries num">{item.retries}</td>
|
<td className="col-retries num">{item.retries}</td>
|
||||||
<td className="col-actions"><button className="btn-icon danger" onClick={() => onRemoveItem(item.id)} title="Entfernen">X</button></td>
|
<td className="col-actions"><button className="btn-icon danger" onClick={() => onRemoveItem(item.id)} title="Entfernen">X</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,6 +236,11 @@ body,
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-list {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.input-row input {
|
.input-row input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@ -256,6 +261,23 @@ body,
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
background: var(--field);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-header {
|
.collector-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export interface AppSettings {
|
|||||||
megaPassword: string;
|
megaPassword: string;
|
||||||
bestToken: string;
|
bestToken: string;
|
||||||
allDebridToken: string;
|
allDebridToken: string;
|
||||||
|
archivePasswordList: string;
|
||||||
rememberToken: boolean;
|
rememberToken: boolean;
|
||||||
providerPrimary: DebridProvider;
|
providerPrimary: DebridProvider;
|
||||||
providerSecondary: DebridFallbackProvider;
|
providerSecondary: DebridFallbackProvider;
|
||||||
|
|||||||
@ -188,6 +188,41 @@ describe("extractor", () => {
|
|||||||
expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports extraction progress from 0 to 100", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
const zip1 = new AdmZip();
|
||||||
|
zip1.addFile("a.txt", Buffer.from("a"));
|
||||||
|
zip1.writeZip(path.join(packageDir, "a.zip"));
|
||||||
|
|
||||||
|
const zip2 = new AdmZip();
|
||||||
|
zip2.addFile("b.txt", Buffer.from("b"));
|
||||||
|
zip2.writeZip(path.join(packageDir, "b.zip"));
|
||||||
|
|
||||||
|
const updates: number[] = [];
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
onProgress: (update) => {
|
||||||
|
updates.push(update.percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.extracted).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(updates[0]).toBe(0);
|
||||||
|
expect(updates.some((value) => value > 0 && value < 100)).toBe(true);
|
||||||
|
expect(updates[updates.length - 1]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats ask conflict mode as skip in zip extraction", async () => {
|
it("treats ask conflict mode as skip in zip extraction", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@ -139,4 +139,13 @@ describe("settings storage", () => {
|
|||||||
expect(normalized.providerSecondary).toBe("none");
|
expect(normalized.providerSecondary).toBe("none");
|
||||||
expect(normalized.providerTertiary).toBe("none");
|
expect(normalized.providerTertiary).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes archive password list line endings", () => {
|
||||||
|
const normalized = normalizeSettings({
|
||||||
|
...defaultSettings(),
|
||||||
|
archivePasswordList: "one\r\ntwo\r\nthree"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user