release: prepare v2.0.18 interface and reset reliability update
Preserve package progress and history across immediate cleanup, make extraction resets wait for all post-processing tasks, and keep archive diagnostics out of compact status cells. Rework account creation and settings selectors, improve context-menu placement, remove accidental row dragging, and expand regression coverage for the corrected workflows.
This commit is contained in:
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.18] - 2026-08-11
|
||||||
|
|
||||||
|
### Downloads and status handling
|
||||||
|
|
||||||
|
- Preserved package byte totals, completed-item counts, and extraction progress when finished files are removed immediately from the queue.
|
||||||
|
- Persisted cleaned package contributions so progress remains stable across application restarts.
|
||||||
|
- Preserved final history file counts, byte totals, providers, and source URLs after immediate cleanup.
|
||||||
|
- Reset selected files and their package post-processing state atomically after extraction failures.
|
||||||
|
- Waited for cancelled extraction work and resume-state cleanup before restarting reset downloads.
|
||||||
|
- Replaced contradictory unchecked package availability counters with a compact unchecked state.
|
||||||
|
- Reduced extraction errors, pending extraction phases, password phases, and archive processing to concise visible status labels while retaining full diagnostics in tooltips and logs.
|
||||||
|
- Cleared stale archive labels before final package state notifications and history updates.
|
||||||
|
- Removed native whole-row dragging that could create a large drag preview while preserving header column reordering and explicit package move actions.
|
||||||
|
- Expanded inline package-name editing to the full available name-column width.
|
||||||
|
|
||||||
|
### Settings and account management
|
||||||
|
|
||||||
|
- Added history retention choices for the latest 100 or 250 entries.
|
||||||
|
- Kept permanent history retention selectable after using a bounded history preset.
|
||||||
|
- Replaced native settings selectors with smooth, keyboard-accessible dropdowns for consistent opening and closing motion.
|
||||||
|
- Reworked account creation into a compact searchable service table with separate service and access-type columns.
|
||||||
|
- Displayed only the credentials required by the selected account type.
|
||||||
|
- Separated usernames and email addresses in the account overview so verified email data no longer replaces a stored username.
|
||||||
|
- Kept Mega-Debrid access types explicit as `Mega-Debrid (API)` and `Mega-Debrid (Web)`.
|
||||||
|
|
||||||
|
### Interface fixes
|
||||||
|
|
||||||
|
- Centered the package sidebar heading and added a high-contrast light-blue module accent.
|
||||||
|
- Positioned context menus and nested menus before they become visible, preventing first-frame jumps at window edges.
|
||||||
|
- Closed open context menus immediately when another package or file is clicked.
|
||||||
|
- Kept context menus inside narrow application windows without introducing horizontal overflow.
|
||||||
|
|
||||||
|
### Reliability and testing
|
||||||
|
|
||||||
|
- Added regression coverage for cleanup-safe package progress, persisted progress aggregates, extraction reset state, compact availability, extraction diagnostics, native drag suppression, full-width renaming, animated settings selectors, account identity fields, and viewport-safe context menus.
|
||||||
|
|
||||||
## [2.0.17] - 2026-08-10
|
## [2.0.17] - 2026-08-10
|
||||||
|
|
||||||
### Interface fixes
|
### Interface fixes
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.17",
|
"version": "2.0.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.17",
|
"version": "2.0.18",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.17",
|
"version": "2.0.18",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+142
-30
@@ -1794,8 +1794,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private packageDeferredPostProcessAbortControllers = new Map<string, AbortController>();
|
private packageDeferredPostProcessAbortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
private packageDeferredPostProcessTasks = new Map<string, Set<Promise<void>>>();
|
||||||
|
|
||||||
private packageHybridPostProcessControllers = new Map<string, Set<AbortController>>();
|
private packageHybridPostProcessControllers = new Map<string, Set<AbortController>>();
|
||||||
|
|
||||||
|
private packageHybridPostProcessTasks = new Map<string, Set<Promise<void>>>();
|
||||||
|
|
||||||
private packagePostProcessVersions = new Map<string, number>();
|
private packagePostProcessVersions = new Map<string, number>();
|
||||||
|
|
||||||
private hybridExtractRequeue = new Set<string>();
|
private hybridExtractRequeue = new Set<string>();
|
||||||
@@ -2561,11 +2565,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): void {
|
private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise<void>[] {
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
if (invalidateDeferred) {
|
if (invalidateDeferred) {
|
||||||
this.bumpPackagePostProcessVersion(packageId);
|
this.bumpPackagePostProcessVersion(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postProcessTask = this.packagePostProcessTasks.get(packageId);
|
||||||
|
if (postProcessTask) tasks.push(postProcessTask);
|
||||||
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
|
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
|
||||||
if (postProcessController && !postProcessController.signal.aborted) {
|
if (postProcessController && !postProcessController.signal.aborted) {
|
||||||
postProcessController.abort(reason);
|
postProcessController.abort(reason);
|
||||||
@@ -2578,6 +2585,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
deferredController.abort(reason);
|
deferredController.abort(reason);
|
||||||
}
|
}
|
||||||
this.packageDeferredPostProcessAbortControllers.delete(packageId);
|
this.packageDeferredPostProcessAbortControllers.delete(packageId);
|
||||||
|
const deferredTasks = this.packageDeferredPostProcessTasks.get(packageId);
|
||||||
|
if (deferredTasks) tasks.push(...deferredTasks);
|
||||||
|
this.packageDeferredPostProcessTasks.delete(packageId);
|
||||||
|
|
||||||
const hybridSet = this.packageHybridPostProcessControllers.get(packageId);
|
const hybridSet = this.packageHybridPostProcessControllers.get(packageId);
|
||||||
if (hybridSet) {
|
if (hybridSet) {
|
||||||
@@ -2588,9 +2598,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.packageHybridPostProcessControllers.delete(packageId);
|
this.packageHybridPostProcessControllers.delete(packageId);
|
||||||
}
|
}
|
||||||
|
const hybridTasks = this.packageHybridPostProcessTasks.get(packageId);
|
||||||
|
if (hybridTasks) tasks.push(...hybridTasks);
|
||||||
|
this.packageHybridPostProcessTasks.delete(packageId);
|
||||||
|
|
||||||
this.hybridExtractRequeue.delete(packageId);
|
this.hybridExtractRequeue.delete(packageId);
|
||||||
this.clearHybridArchiveState(packageId);
|
this.clearHybridArchiveState(packageId);
|
||||||
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDeferredPostProcessStillCurrent(
|
private isDeferredPostProcessStillCurrent(
|
||||||
@@ -2893,7 +2907,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packagePostProcessTasks.clear();
|
this.packagePostProcessTasks.clear();
|
||||||
this.packagePostProcessAbortControllers.clear();
|
this.packagePostProcessAbortControllers.clear();
|
||||||
this.packageDeferredPostProcessAbortControllers.clear();
|
this.packageDeferredPostProcessAbortControllers.clear();
|
||||||
|
this.packageDeferredPostProcessTasks.clear();
|
||||||
this.packageHybridPostProcessControllers.clear();
|
this.packageHybridPostProcessControllers.clear();
|
||||||
|
this.packageHybridPostProcessTasks.clear();
|
||||||
this.hybridExtractRequeue.clear();
|
this.hybridExtractRequeue.clear();
|
||||||
this.hybridExtractedPaths.clear();
|
this.hybridExtractedPaths.clear();
|
||||||
this.hybridFailedArchives.clear();
|
this.hybridFailedArchives.clear();
|
||||||
@@ -2934,6 +2950,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: "normal",
|
priority: "normal",
|
||||||
|
cleanedCompletedItemCount: 0,
|
||||||
|
cleanedExtractedItemCount: 0,
|
||||||
|
cleanedDownloadedBytes: 0,
|
||||||
|
cleanedTotalBytes: 0,
|
||||||
|
cleanedUrls: [],
|
||||||
|
cleanedProviders: [],
|
||||||
downloadStartedAt: 0,
|
downloadStartedAt: 0,
|
||||||
downloadCompletedAt: 0,
|
downloadCompletedAt: 0,
|
||||||
createdAt: nowMs(),
|
createdAt: nowMs(),
|
||||||
@@ -4726,6 +4748,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasDeferredPostProcessPending(packageId: string): boolean {
|
private hasDeferredPostProcessPending(packageId: string): boolean {
|
||||||
|
if ((this.packageDeferredPostProcessTasks.get(packageId)?.size || 0) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const controller = this.packageDeferredPostProcessAbortControllers.get(packageId);
|
const controller = this.packageDeferredPostProcessAbortControllers.get(packageId);
|
||||||
if (controller && !controller.signal.aborted) {
|
if (controller && !controller.signal.aborted) {
|
||||||
return true;
|
return true;
|
||||||
@@ -4742,6 +4770,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasAnyDeferredPostProcessPending(): boolean {
|
private hasAnyDeferredPostProcessPending(): boolean {
|
||||||
|
for (const tasks of this.packageDeferredPostProcessTasks.values()) {
|
||||||
|
if (tasks.size > 0) return true;
|
||||||
|
}
|
||||||
|
for (const tasks of this.packageHybridPostProcessTasks.values()) {
|
||||||
|
if (tasks.size > 0) return true;
|
||||||
|
}
|
||||||
for (const controller of this.packageDeferredPostProcessAbortControllers.values()) {
|
for (const controller of this.packageDeferredPostProcessAbortControllers.values()) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
return true;
|
return true;
|
||||||
@@ -4770,6 +4804,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
target.add(id);
|
target.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [id, tasks] of this.packageHybridPostProcessTasks) {
|
||||||
|
if (tasks.size > 0) target.add(id);
|
||||||
|
}
|
||||||
for (const [id, hybridSet] of this.packageHybridPostProcessControllers) {
|
for (const [id, hybridSet] of this.packageHybridPostProcessControllers) {
|
||||||
for (const c of hybridSet) {
|
for (const c of hybridSet) {
|
||||||
if (!c.signal.aborted) {
|
if (!c.signal.aborted) {
|
||||||
@@ -5183,7 +5220,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetPackage(packageId: string): void {
|
public async resetPackage(packageId: string): Promise<void> {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (!pkg) return;
|
if (!pkg) return;
|
||||||
|
|
||||||
@@ -5227,17 +5264,22 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.abortPackagePostProcessing(packageId, "reset");
|
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
|
||||||
this.runCompletedPackages.delete(packageId);
|
this.runCompletedPackages.delete(packageId);
|
||||||
|
|
||||||
if (pkg.outputDir) {
|
|
||||||
clearExtractResumeState(pkg.outputDir, packageId).catch(() => {});
|
|
||||||
clearExtractResumeState(pkg.outputDir).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg.status = "queued";
|
pkg.status = "queued";
|
||||||
pkg.cancelled = false;
|
pkg.cancelled = false;
|
||||||
pkg.enabled = true;
|
pkg.enabled = true;
|
||||||
|
pkg.postProcessLabel = undefined;
|
||||||
|
pkg.audioStripSummary = undefined;
|
||||||
|
pkg.cleanedCompletedItemCount = 0;
|
||||||
|
pkg.cleanedExtractedItemCount = 0;
|
||||||
|
pkg.cleanedDownloadedBytes = 0;
|
||||||
|
pkg.cleanedTotalBytes = 0;
|
||||||
|
pkg.cleanedUrls = [];
|
||||||
|
pkg.cleanedProviders = [];
|
||||||
|
pkg.downloadStartedAt = 0;
|
||||||
|
pkg.downloadCompletedAt = 0;
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
this.historyRecordedPackages.delete(packageId);
|
this.historyRecordedPackages.delete(packageId);
|
||||||
this.notifiedPackages.delete(packageId);
|
this.notifiedPackages.delete(packageId);
|
||||||
@@ -5249,6 +5291,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runPackageIds.add(packageId);
|
this.runPackageIds.add(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(postProcessTasks);
|
||||||
|
if (pkg.outputDir) {
|
||||||
|
await Promise.allSettled([
|
||||||
|
clearExtractResumeState(pkg.outputDir, packageId),
|
||||||
|
clearExtractResumeState(pkg.outputDir)
|
||||||
|
]);
|
||||||
|
}
|
||||||
logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`);
|
logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
@@ -5257,8 +5306,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetItems(itemIds: string[]): void {
|
public async resetItems(itemIds: string[]): Promise<void> {
|
||||||
const affectedPackageIds = new Set<string>();
|
const affectedPackageIds = new Set<string>();
|
||||||
|
const postProcessTasks = new Set<Promise<void>>();
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
@@ -5305,15 +5355,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const pkgId of affectedPackageIds) {
|
for (const pkgId of affectedPackageIds) {
|
||||||
this.abortPackagePostProcessing(pkgId, "reset");
|
for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task);
|
||||||
this.runCompletedPackages.delete(pkgId);
|
this.runCompletedPackages.delete(pkgId);
|
||||||
this.historyRecordedPackages.delete(pkgId);
|
this.historyRecordedPackages.delete(pkgId);
|
||||||
this.notifiedPackages.delete(pkgId);
|
this.notifiedPackages.delete(pkgId);
|
||||||
|
|
||||||
const pkg = this.session.packages[pkgId];
|
const pkg = this.session.packages[pkgId];
|
||||||
if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) {
|
if (pkg) {
|
||||||
pkg.status = "queued";
|
|
||||||
pkg.cancelled = false;
|
pkg.cancelled = false;
|
||||||
|
pkg.postProcessLabel = undefined;
|
||||||
|
pkg.audioStripSummary = undefined;
|
||||||
|
pkg.downloadCompletedAt = 0;
|
||||||
|
this.refreshPackageStatus(pkg);
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
@@ -5321,6 +5374,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled([...postProcessTasks]);
|
||||||
|
await Promise.allSettled([...affectedPackageIds].flatMap((pkgId) => {
|
||||||
|
const pkg = this.session.packages[pkgId];
|
||||||
|
return pkg?.outputDir ? [clearExtractResumeState(pkg.outputDir, pkgId)] : [];
|
||||||
|
}));
|
||||||
|
|
||||||
logger.info(`${itemIds.length} Item(s) zurückgesetzt`);
|
logger.info(`${itemIds.length} Item(s) zurückgesetzt`);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
@@ -7762,26 +7821,31 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const completedItems = items.filter(item => item.status === "completed");
|
const completedItems = items.filter(item => item.status === "completed");
|
||||||
if (completedItems.length === 0) {
|
const cleanedCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0));
|
||||||
|
if (completedItems.length + cleanedCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.historyRecordedPackages.add(packageId);
|
this.historyRecordedPackages.add(packageId);
|
||||||
const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
const totalBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0))
|
||||||
|
+ completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
||||||
const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
|
const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
|
||||||
const providers = new Set(completedItems.map(item => item.provider).filter(Boolean));
|
const providers = new Set([
|
||||||
|
...(pkg.cleanedProviders || []),
|
||||||
|
...completedItems.map(item => item.provider).filter(Boolean)
|
||||||
|
]);
|
||||||
const provider = providers.size === 1 ? [...providers][0] : null;
|
const provider = providers.size === 1 ? [...providers][0] : null;
|
||||||
const entry: HistoryEntry = {
|
const entry: HistoryEntry = {
|
||||||
id: generateHistoryId(),
|
id: generateHistoryId(),
|
||||||
name: pkg.name,
|
name: pkg.name,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
downloadedBytes: totalBytes,
|
downloadedBytes: totalBytes,
|
||||||
fileCount: completedItems.length,
|
fileCount: cleanedCount + completedItems.length,
|
||||||
provider,
|
provider,
|
||||||
completedAt: nowMs(),
|
completedAt: nowMs(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
outputDir: pkg.outputDir,
|
outputDir: pkg.outputDir,
|
||||||
urls: completedItems.map(item => item.url).filter(Boolean),
|
urls: [...new Set([...(pkg.cleanedUrls || []), ...completedItems.map(item => item.url).filter(Boolean)])],
|
||||||
};
|
};
|
||||||
this.onHistoryEntryCallback(entry);
|
this.onHistoryEntryCallback(entry);
|
||||||
}
|
}
|
||||||
@@ -7797,11 +7861,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (pkg && this.onHistoryEntryCallback && reason === "deleted" && !this.historyRecordedPackages.has(packageId)) {
|
if (pkg && this.onHistoryEntryCallback && reason === "deleted" && !this.historyRecordedPackages.has(packageId)) {
|
||||||
const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||||
const completedItems = allItems.filter(item => item.status === "completed");
|
const completedItems = allItems.filter(item => item.status === "completed");
|
||||||
const completedCount = completedItems.length;
|
const cleanedCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0));
|
||||||
|
const completedCount = cleanedCount + completedItems.length;
|
||||||
if (completedCount > 0) {
|
if (completedCount > 0) {
|
||||||
const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
const totalBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0))
|
||||||
|
+ completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
||||||
const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
|
const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
|
||||||
const providers = new Set(completedItems.map(item => item.provider).filter(Boolean));
|
const providers = new Set([
|
||||||
|
...(pkg.cleanedProviders || []),
|
||||||
|
...completedItems.map(item => item.provider).filter(Boolean)
|
||||||
|
]);
|
||||||
const provider = providers.size === 1 ? [...providers][0] : null;
|
const provider = providers.size === 1 ? [...providers][0] : null;
|
||||||
const entry: HistoryEntry = {
|
const entry: HistoryEntry = {
|
||||||
id: generateHistoryId(),
|
id: generateHistoryId(),
|
||||||
@@ -7814,7 +7883,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
durationSeconds,
|
durationSeconds,
|
||||||
status: "deleted",
|
status: "deleted",
|
||||||
outputDir: pkg.outputDir,
|
outputDir: pkg.outputDir,
|
||||||
urls: completedItems.map(item => item.url).filter(Boolean),
|
urls: [...new Set([...(pkg.cleanedUrls || []), ...completedItems.map(item => item.url).filter(Boolean)])],
|
||||||
};
|
};
|
||||||
this.onHistoryEntryCallback(entry);
|
this.onHistoryEntryCallback(entry);
|
||||||
}
|
}
|
||||||
@@ -11511,7 +11580,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
},
|
},
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "preparing") {
|
if (progress.phase === "preparing") {
|
||||||
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
pkg.postProcessLabel = "Entpacken - Ausstehend";
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -11605,7 +11674,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
|
const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
|
||||||
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
|
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
|
||||||
if (progress.passwordFound) {
|
if (progress.passwordFound) {
|
||||||
pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`;
|
pkg.postProcessLabel = "Passwort gefunden";
|
||||||
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
||||||
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
||||||
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
|
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
|
||||||
@@ -11671,7 +11740,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
hybridSet.add(hybridController);
|
hybridSet.add(hybridController);
|
||||||
const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg;
|
const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg;
|
||||||
void (async () => {
|
const hybridHandle: { task?: Promise<void> } = {};
|
||||||
|
const hybridTask = (async () => {
|
||||||
try {
|
try {
|
||||||
await this.chainPackageFileOp(pkg.id, async () => {
|
await this.chainPackageFileOp(pkg.id, async () => {
|
||||||
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
|
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
|
||||||
@@ -11688,8 +11758,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packageHybridPostProcessControllers.delete(packageId);
|
this.packageHybridPostProcessControllers.delete(packageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const tasks = this.packageHybridPostProcessTasks.get(packageId);
|
||||||
|
if (hybridHandle.task) tasks?.delete(hybridHandle.task);
|
||||||
|
if (tasks?.size === 0) {
|
||||||
|
this.packageHybridPostProcessTasks.delete(packageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
hybridHandle.task = hybridTask;
|
||||||
|
const hybridTasks = this.packageHybridPostProcessTasks.get(packageId) || new Set<Promise<void>>();
|
||||||
|
hybridTasks.add(hybridTask);
|
||||||
|
this.packageHybridPostProcessTasks.set(packageId, hybridTasks);
|
||||||
}
|
}
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
logger.warn(`Hybrid-Extract: ${result.failed} Archive fehlgeschlagen, werden erst nach echter Aenderung oder manuellem Retry erneut versucht`);
|
logger.warn(`Hybrid-Extract: ${result.failed} Archive fehlgeschlagen, werden erst nach echter Aenderung oder manuellem Retry erneut versucht`);
|
||||||
@@ -12031,7 +12110,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
},
|
},
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "preparing") {
|
if (progress.phase === "preparing") {
|
||||||
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
pkg.postProcessLabel = "Entpacken - Ausstehend";
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -12251,14 +12330,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pkg.postProcessLabel = undefined;
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
|
||||||
if (pkg.status === "completed") {
|
if (pkg.status === "completed") {
|
||||||
this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`);
|
this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`);
|
||||||
} else if (pkg.status === "failed") {
|
} else if (pkg.status === "failed") {
|
||||||
this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`);
|
this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitState();
|
|
||||||
|
|
||||||
if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) {
|
if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) {
|
||||||
this.recordPackageHistory(packageId, pkg, items);
|
this.recordPackageHistory(packageId, pkg, items);
|
||||||
}
|
}
|
||||||
@@ -12270,8 +12350,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runCompletedPackages.delete(packageId);
|
this.runCompletedPackages.delete(packageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkg.postProcessLabel = undefined;
|
this.emitState();
|
||||||
pkg.updatedAt = nowMs();
|
|
||||||
logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status} (deferred work wird im Hintergrund ausgeführt)`);
|
logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status} (deferred work wird im Hintergrund ausgeführt)`);
|
||||||
this.logPackageForPackage(pkg, "INFO", "Post-Processing Ende", {
|
this.logPackageForPackage(pkg, "INFO", "Post-Processing Ende", {
|
||||||
status: pkg.status,
|
status: pkg.status,
|
||||||
@@ -12284,7 +12363,29 @@ export class DownloadManager extends EventEmitter {
|
|||||||
void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount);
|
void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runDeferredPostExtraction(
|
private runDeferredPostExtraction(
|
||||||
|
packageId: string,
|
||||||
|
pkg: PackageEntry,
|
||||||
|
success: number,
|
||||||
|
failed: number,
|
||||||
|
alreadyMarkedExtracted: boolean,
|
||||||
|
extractedCount: number
|
||||||
|
): Promise<void> {
|
||||||
|
const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount)
|
||||||
|
.finally(() => {
|
||||||
|
const tasks = this.packageDeferredPostProcessTasks.get(packageId);
|
||||||
|
tasks?.delete(task);
|
||||||
|
if (tasks?.size === 0) {
|
||||||
|
this.packageDeferredPostProcessTasks.delete(packageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set<Promise<void>>();
|
||||||
|
tasks.add(task);
|
||||||
|
this.packageDeferredPostProcessTasks.set(packageId, tasks);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeDeferredPostExtraction(
|
||||||
packageId: string,
|
packageId: string,
|
||||||
pkg: PackageEntry,
|
pkg: PackageEntry,
|
||||||
success: number,
|
success: number,
|
||||||
@@ -12537,6 +12638,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1;
|
||||||
|
if (isExtractedLabel(item.fullStatus || "")) {
|
||||||
|
pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1;
|
||||||
|
}
|
||||||
|
pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0);
|
||||||
|
pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0);
|
||||||
|
pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))];
|
||||||
|
pkg.cleanedProviders = item.provider
|
||||||
|
? [...new Set([...(pkg.cleanedProviders || []), item.provider])]
|
||||||
|
: [...(pkg.cleanedProviders || [])];
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||||
this.releaseTargetPath(itemId);
|
this.releaseTargetPath(itemId);
|
||||||
this.dropItemContribution(itemId);
|
this.dropItemContribution(itemId);
|
||||||
|
|||||||
@@ -784,6 +784,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
||||||
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
|
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
|
||||||
audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary),
|
audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary),
|
||||||
|
cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000),
|
||||||
|
cleanedExtractedItemCount: clampNumber(pkg.cleanedExtractedItemCount, 0, 0, 1_000_000),
|
||||||
|
cleanedDownloadedBytes: clampNumber(pkg.cleanedDownloadedBytes, 0, 0, 10_000_000_000_000),
|
||||||
|
cleanedTotalBytes: clampNumber(pkg.cleanedTotalBytes, 0, 0, 10_000_000_000_000),
|
||||||
|
cleanedUrls: Array.isArray(pkg.cleanedUrls)
|
||||||
|
? [...new Set(pkg.cleanedUrls.map((value) => asText(value)).filter(Boolean))].slice(0, 1_000_000)
|
||||||
|
: [],
|
||||||
|
cleanedProviders: Array.isArray(pkg.cleanedProviders)
|
||||||
|
? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))]
|
||||||
|
: [],
|
||||||
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||||
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||||
|
|||||||
+8
-52
@@ -36,7 +36,7 @@ import {
|
|||||||
getProviderDailyUsageBytes,
|
getProviderDailyUsageBytes,
|
||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||||
import type { AccountModeFilter } from "./account-ui";
|
import type { AccountModeFilter } from "./account-ui";
|
||||||
@@ -107,6 +107,7 @@ import {
|
|||||||
buildSettingsFormViewModel,
|
buildSettingsFormViewModel,
|
||||||
buildTargetedAccountCheck,
|
buildTargetedAccountCheck,
|
||||||
projectAccountRows,
|
projectAccountRows,
|
||||||
|
resolveHistoryRetentionSelection,
|
||||||
sortAccountRows,
|
sortAccountRows,
|
||||||
type AccountAddOption,
|
type AccountAddOption,
|
||||||
type AccountRowSource,
|
type AccountRowSource,
|
||||||
@@ -1765,7 +1766,6 @@ export function App(): ReactElement {
|
|||||||
const serverPackageOrderRef = useRef<string[]>([]);
|
const serverPackageOrderRef = useRef<string[]>([]);
|
||||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||||
const pendingPackageOrderAtRef = useRef(0);
|
const pendingPackageOrderAtRef = useRef(0);
|
||||||
const draggedPackageIdRef = useRef<string | null>(null);
|
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||||
@@ -3871,34 +3871,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
|
||||||
const currentOrder = packageOrderRef.current;
|
|
||||||
const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId);
|
|
||||||
const unchanged = nextOrder.length === currentOrder.length
|
|
||||||
&& nextOrder.every((id, index) => id === currentOrder[index]);
|
|
||||||
if (unchanged) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDownloadsSortDescending(false);
|
|
||||||
pendingPackageOrderRef.current = [...nextOrder];
|
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
|
||||||
packageOrderRef.current = [...nextOrder];
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
|
||||||
pendingPackageOrderRef.current = null;
|
|
||||||
pendingPackageOrderAtRef.current = 0;
|
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
|
||||||
});
|
|
||||||
}, [showToast]);
|
|
||||||
|
|
||||||
const addCollectorTab = (): void => {
|
const addCollectorTab = (): void => {
|
||||||
const id = `tab-${nextCollectorId++}`;
|
const id = `tab-${nextCollectorId++}`;
|
||||||
setCollectorTabs((prev) => {
|
setCollectorTabs((prev) => {
|
||||||
@@ -3992,23 +3964,6 @@ export function App(): ReactElement {
|
|||||||
setCollectorError("");
|
setCollectorError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPackageDragStart = useCallback((packageId: string) => {
|
|
||||||
draggedPackageIdRef.current = packageId;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPackageDrop = useCallback((targetPackageId: string) => {
|
|
||||||
const draggedPackageId = draggedPackageIdRef.current;
|
|
||||||
draggedPackageIdRef.current = null;
|
|
||||||
if (!draggedPackageId || draggedPackageId === targetPackageId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reorderPackagesByDrop(draggedPackageId, targetPackageId);
|
|
||||||
}, [reorderPackagesByDrop]);
|
|
||||||
|
|
||||||
const onPackageDragEnd = useCallback(() => {
|
|
||||||
draggedPackageIdRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
|
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
|
||||||
setEditingPackageId(packageId);
|
setEditingPackageId(packageId);
|
||||||
setEditingName(packageName);
|
setEditingName(packageName);
|
||||||
@@ -5026,9 +4981,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onShowAllPackages: () => setShowAllPackages(true),
|
onShowAllPackages: () => setShowAllPackages(true),
|
||||||
onPackageDragStart,
|
|
||||||
onPackageDrop,
|
|
||||||
onPackageDragEnd,
|
|
||||||
onSetVisibleSelection: (ids, selected) => {
|
onSetVisibleSelection: (ids, selected) => {
|
||||||
setSelectedIds((current) => {
|
setSelectedIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -5358,6 +5310,12 @@ export function App(): ReactElement {
|
|||||||
applyTheme(next);
|
applyTheme(next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (fieldId === "historyRetentionMode" && typeof value === "string") {
|
||||||
|
const next = resolveHistoryRetentionSelection(settingsDraft.historyRetentionMode, settingsDraft.historyMaxEntries, value);
|
||||||
|
setText("historyRetentionMode", next.historyRetentionMode);
|
||||||
|
setNum("historyMaxEntries", next.historyMaxEntries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
setBool(fieldId as keyof AppSettings, value);
|
setBool(fieldId as keyof AppSettings, value);
|
||||||
return;
|
return;
|
||||||
@@ -5579,7 +5537,6 @@ export function App(): ReactElement {
|
|||||||
className={`md-runtime-root${dragOver ? " drag-over" : ""}${tab === "settings" ? " settings-active" : ""}`}
|
className={`md-runtime-root${dragOver ? " drag-over" : ""}${tab === "settings" ? " settings-active" : ""}`}
|
||||||
onDragEnter={(event) => {
|
onDragEnter={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (draggedPackageIdRef.current) { return; }
|
|
||||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||||
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
||||||
if (!hasFiles && !hasUri) { return; }
|
if (!hasFiles && !hasUri) { return; }
|
||||||
@@ -5593,7 +5550,6 @@ export function App(): ReactElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => {
|
onDragLeave={() => {
|
||||||
if (draggedPackageIdRef.current) { return; }
|
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
if (dragDepthRef.current === 0 && dragOverRef.current) {
|
if (dragDepthRef.current === 0 && dragOverRef.current) {
|
||||||
dragOverRef.current = false;
|
dragOverRef.current = false;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function compactProviderLabels(labels: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDownloadServiceLabel(label: string): string {
|
export function normalizeDownloadServiceLabel(label: string): string {
|
||||||
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 $2")).filter(Boolean))].join(", ");
|
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 ($2)")).filter(Boolean))].join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactDownloadServiceLabel(label: string): string {
|
export function compactDownloadServiceLabel(label: string): string {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const pairs = [
|
|||||||
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
|
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
|
||||||
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
|
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
|
||||||
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
|
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
|
||||||
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Dauerhaft", "Permanent"],
|
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
|
||||||
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
|
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
|
||||||
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
||||||
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||||
@@ -29,7 +29,7 @@ const pairs = [
|
|||||||
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
||||||
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||||
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
||||||
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
||||||
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
||||||
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
|
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
|
||||||
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
|
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
|
||||||
@@ -75,7 +75,7 @@ const pairs = [
|
|||||||
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
|
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
|
||||||
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
|
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
|
||||||
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
|
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
|
||||||
["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||||
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
|
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
|
||||||
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
|
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
|
||||||
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
||||||
@@ -238,6 +238,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
if (assignment) return `Remove ${assignment[1]} assignment`;
|
if (assignment) return `Remove ${assignment[1]} assignment`;
|
||||||
const providerFor = value.match(/^Provider für (.+)$/);
|
const providerFor = value.match(/^Provider für (.+)$/);
|
||||||
if (providerFor) return `Provider for ${providerFor[1]}`;
|
if (providerFor) return `Provider for ${providerFor[1]}`;
|
||||||
|
const credentialsFor = value.match(/^Zugangsdaten für (.+)$/);
|
||||||
|
if (credentialsFor) return `Credentials for ${credentialsFor[1]}`;
|
||||||
const move = value.match(/^(.+) nach (oben|unten)$/);
|
const move = value.match(/^(.+) nach (oben|unten)$/);
|
||||||
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
|
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
|
||||||
const audio = value.match(/^Tonspur: (.+)$/);
|
const audio = value.match(/^Tonspur: (.+)$/);
|
||||||
@@ -385,6 +387,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
if (extracting) return `Entpacken ${extracting[1]}`;
|
if (extracting) return `Entpacken ${extracting[1]}`;
|
||||||
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
|
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
|
||||||
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
|
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
|
||||||
|
const credentialsFor = value.match(/^Credentials for (.+)$/);
|
||||||
|
if (credentialsFor) return `Zugangsdaten für ${credentialsFor[1]}`;
|
||||||
const checked = value.match(/^Account checked — (.+)$/);
|
const checked = value.match(/^Account checked — (.+)$/);
|
||||||
if (checked) return `Account geprüft — ${checked[1]}`;
|
if (checked) return `Account geprüft — ${checked[1]}`;
|
||||||
const invalid = value.match(/^Invalid account — (.+)$/);
|
const invalid = value.match(/^Invalid account — (.+)$/);
|
||||||
|
|||||||
@@ -773,10 +773,6 @@
|
|||||||
background: var(--ui-border);
|
background: var(--ui-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-context-menu .ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-toast {
|
.md-toast {
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 84px;
|
bottom: 84px;
|
||||||
|
|||||||
+14
-3
@@ -2711,7 +2711,7 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctx-menu-sub-items {
|
.ctx-menu-sub-items {
|
||||||
display: none;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -2722,10 +2722,16 @@ td {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-menu-sub:hover .ctx-menu-sub-items {
|
.ctx-menu-sub:hover > .ctx-menu-sub-items.is-positioned,
|
||||||
display: block;
|
.ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items.is-positioned {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-menu-active {
|
.ctx-menu-active {
|
||||||
@@ -3060,6 +3066,11 @@ td {
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctx-menu:not(.is-positioned) {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ctx-menu-item {
|
.ctx-menu-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>)
|
|||||||
parts.items.style.position = "fixed";
|
parts.items.style.position = "fixed";
|
||||||
parts.items.style.left = `${position.x}px`;
|
parts.items.style.left = `${position.x}px`;
|
||||||
parts.items.style.top = `${position.y}px`;
|
parts.items.style.top = `${position.y}px`;
|
||||||
|
parts.items.classList.add("is-positioned");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||||
@@ -191,6 +192,7 @@ function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
parts.container.classList.remove("is-keyboard-open");
|
parts.container.classList.remove("is-keyboard-open");
|
||||||
|
parts.items.classList.remove("is-positioned");
|
||||||
parts.trigger.setAttribute("aria-expanded", "false");
|
parts.trigger.setAttribute("aria-expanded", "false");
|
||||||
parts.trigger.focus();
|
parts.trigger.focus();
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
|||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
|
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
|
||||||
const [position, setPosition] = useState({ x, y });
|
const [position, setPosition] = useState({ x, y, sourceX: x, sourceY: y, ready: false });
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
|
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
|
||||||
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
|
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
|
||||||
@@ -223,7 +225,9 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
|||||||
}
|
}
|
||||||
const rect = menuRef.current.getBoundingClientRect();
|
const rect = menuRef.current.getBoundingClientRect();
|
||||||
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
||||||
setPosition((current) => current.x === next.x && current.y === next.y ? current : next);
|
setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready
|
||||||
|
? current
|
||||||
|
: { ...next, sourceX: x, sourceY: y, ready: true });
|
||||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||||
}, [open, x, y]);
|
}, [open, x, y]);
|
||||||
|
|
||||||
@@ -241,10 +245,10 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
|||||||
}
|
}
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
};
|
};
|
||||||
window.addEventListener("mousedown", onOutside);
|
window.addEventListener("pointerdown", onOutside, true);
|
||||||
window.addEventListener("contextmenu", onOutside);
|
window.addEventListener("contextmenu", onOutside);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("mousedown", onOutside);
|
window.removeEventListener("pointerdown", onOutside, true);
|
||||||
window.removeEventListener("contextmenu", onOutside);
|
window.removeEventListener("contextmenu", onOutside);
|
||||||
const previousFocus = previousFocusRef.current;
|
const previousFocus = previousFocusRef.current;
|
||||||
previousFocusRef.current = null;
|
previousFocusRef.current = null;
|
||||||
@@ -295,7 +299,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={["ctx-menu", "md-context-menu", className].filter(Boolean).join(" ")}
|
className={["ctx-menu", "md-context-menu", position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : "", className].filter(Boolean).join(" ")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ export function compactDownloadStatus(value: string): string {
|
|||||||
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
|
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
|
||||||
if (/Download läuft\b/i.test(status)) return "Download läuft";
|
if (/Download läuft\b/i.test(status)) return "Download läuft";
|
||||||
if (/Download running\b/i.test(status)) return "Download running";
|
if (/Download running\b/i.test(status)) return "Download running";
|
||||||
|
if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden";
|
||||||
|
if (/^Password found\b/i.test(status)) return "Password found";
|
||||||
|
if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
|
||||||
|
if (/^Extraction error\b/i.test(status)) return "Extraction error";
|
||||||
|
const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i);
|
||||||
|
if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`;
|
||||||
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
||||||
if (extracting) return `Entpacken - ${extracting[1]}%`;
|
if (extracting) return `Entpacken - ${extracting[1]}%`;
|
||||||
const extractingEnglish = status.match(/Extracting\s+(\d+)%/i);
|
const extractingEnglish = status.match(/Extracting\s+(\d+)%/i);
|
||||||
@@ -340,15 +346,15 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||||
let done = 0;
|
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let cancelled = 0;
|
let cancelled = 0;
|
||||||
let extracted = 0;
|
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
|
||||||
let extracting = false;
|
let extracting = false;
|
||||||
let activeProgress = 0;
|
let activeProgress = 0;
|
||||||
let extractingProgress = 0;
|
let extractingProgress = 0;
|
||||||
for (const item of row.items) {
|
for (const item of row.allItems) {
|
||||||
if (item.status === "completed") done += 1;
|
if (item.status === "completed") done += 1;
|
||||||
else if (item.status === "failed") failed += 1;
|
else if (item.status === "failed") failed += 1;
|
||||||
else if (item.status === "cancelled") cancelled += 1;
|
else if (item.status === "cancelled") cancelled += 1;
|
||||||
@@ -364,7 +370,7 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe
|
|||||||
activeProgress += (item.progressPercent || 0) / 100;
|
activeProgress += (item.progressPercent || 0) / 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const total = Math.max(1, row.items.length);
|
const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length);
|
||||||
const allDownloaded = done + failed + cancelled >= total;
|
const allDownloaded = done + failed + cancelled >= total;
|
||||||
const allExtracted = extracted >= total;
|
const allExtracted = extracted >= total;
|
||||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
||||||
@@ -374,9 +380,17 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe
|
|||||||
return { done, failed, cancelled, total, value };
|
return { done, failed, cancelled, total, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
|
||||||
|
const downloaded = Math.max(0, Number(row.package.cleanedDownloadedBytes || 0))
|
||||||
|
+ row.allItems.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
||||||
|
const total = Math.max(0, Number(row.package.cleanedTotalBytes || 0))
|
||||||
|
+ row.allItems.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
|
||||||
|
return { downloaded, total, value: total > 0 ? progress((downloaded / total) * 100) : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
|
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
|
||||||
const entry = row.package;
|
const entry = row.package;
|
||||||
const stats = packageProgress(row);
|
const stats = getPackageProgress(row);
|
||||||
if (column === "name") {
|
if (column === "name") {
|
||||||
return (
|
return (
|
||||||
<span className="downloads-cell downloads-name-cell">
|
<span className="downloads-cell downloads-name-cell">
|
||||||
@@ -397,9 +411,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (column === "size") {
|
if (column === "size") {
|
||||||
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
|
const { downloaded, total, value } = getPackageSizeProgress(row);
|
||||||
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
|
||||||
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
|
|
||||||
const text = `${humanSize(downloaded)} / ${humanSize(total)}`;
|
const text = `${humanSize(downloaded)} / ${humanSize(total)}`;
|
||||||
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
|
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
|
||||||
}
|
}
|
||||||
@@ -415,18 +427,25 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||||
if (column === "status") {
|
if (column === "status") {
|
||||||
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
||||||
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
const rawPostProcessLabel = entry.postProcessLabel?.trim() || "";
|
||||||
|
const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
||||||
|
? "Entpacken - Ausstehend"
|
||||||
|
: compactDownloadStatus(rawPostProcessLabel);
|
||||||
|
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||||
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
|
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
|
||||||
const status = entry.postProcessLabel && /Entpacken\s+\d+%/i.test(entry.postProcessLabel)
|
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||||
? entry.postProcessLabel
|
? postProcessLabel
|
||||||
: downloading ? "Download läuft" : details;
|
: downloading ? "Download läuft" : details;
|
||||||
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
|
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
|
||||||
return <DownloadStatusCell status={status} title={title} />;
|
return <DownloadStatusCell status={status} title={title} />;
|
||||||
}
|
}
|
||||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||||
if (column === "availability") {
|
if (column === "availability") {
|
||||||
const availability = getAvailabilitySummary(row.items);
|
const availability = getAvailabilitySummary(row.allItems);
|
||||||
return <Availability {...availability} />;
|
const text = availability.state === "checking"
|
||||||
|
? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft"
|
||||||
|
: undefined;
|
||||||
|
return <Availability {...availability} text={text} />;
|
||||||
}
|
}
|
||||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||||
return null;
|
return null;
|
||||||
@@ -443,13 +462,9 @@ export interface PackageCardProps {
|
|||||||
columnOrder: readonly string[];
|
columnOrder: readonly string[];
|
||||||
gridTemplate: string;
|
gridTemplate: string;
|
||||||
actions: DownloadsTableActions;
|
actions: DownloadsTableActions;
|
||||||
draggable?: boolean;
|
|
||||||
onDragStart?: (packageId: string) => void;
|
|
||||||
onDrop?: (packageId: string) => void;
|
|
||||||
onDragEnd?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement {
|
||||||
const entry = row.package;
|
const entry = row.package;
|
||||||
let renameFinished = false;
|
let renameFinished = false;
|
||||||
const finishRename = (value: string): void => {
|
const finishRename = (value: string): void => {
|
||||||
@@ -461,16 +476,12 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
<article
|
<article
|
||||||
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
||||||
data-download-package-id={entry.id}
|
data-download-package-id={entry.id}
|
||||||
draggable={draggable}
|
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
|
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
|
||||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }}
|
|
||||||
onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="downloads-package-row"
|
className="downloads-package-row"
|
||||||
@@ -498,7 +509,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa
|
|||||||
const a = previous.row.package;
|
const a = previous.row.package;
|
||||||
const b = next.row.package;
|
const b = next.row.package;
|
||||||
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
||||||
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions || previous.draggable !== next.draggable || previous.onDragStart !== next.onDragStart || previous.onDrop !== next.onDrop || previous.onDragEnd !== next.onDragEnd) return false;
|
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions) return false;
|
||||||
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
||||||
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
||||||
for (const itemId of b.itemIds) {
|
for (const itemId of b.itemIds) {
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ export interface DownloadsViewActions extends DownloadsTableActions {
|
|||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
onToggleAllPackages: () => void;
|
onToggleAllPackages: () => void;
|
||||||
onShowAllPackages: () => void;
|
onShowAllPackages: () => void;
|
||||||
onPackageDragStart: (packageId: string) => void;
|
|
||||||
onPackageDrop: (packageId: string) => void;
|
|
||||||
onPackageDragEnd: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||||
@@ -152,9 +149,6 @@ function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions):
|
|||||||
gridTemplate={model.gridTemplate}
|
gridTemplate={model.gridTemplate}
|
||||||
key={row.package.id}
|
key={row.package.id}
|
||||||
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
||||||
onDragEnd={actions.onPackageDragEnd}
|
|
||||||
onDragStart={actions.onPackageDragStart}
|
|
||||||
onDrop={actions.onPackageDrop}
|
|
||||||
row={row}
|
row={row}
|
||||||
selectedIds={model.selectedIds}
|
selectedIds={model.selectedIds}
|
||||||
selectedVersion={model.actionableSelectedIds.length}
|
selectedVersion={model.actionableSelectedIds.length}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface DownloadFilterCounts {
|
|||||||
export interface DownloadPackageRow {
|
export interface DownloadPackageRow {
|
||||||
package: PackageEntry;
|
package: PackageEntry;
|
||||||
items: DownloadItem[];
|
items: DownloadItem[];
|
||||||
|
allItems: DownloadItem[];
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +140,10 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
|||||||
const collapsed = new Set(input.collapsedPackageIds);
|
const collapsed = new Set(input.collapsedPackageIds);
|
||||||
const selectedIds = new Set(input.selectedIds);
|
const selectedIds = new Set(input.selectedIds);
|
||||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||||
const items = entry.itemIds
|
const allPackageItems = entry.itemIds
|
||||||
.map((id) => input.items[id])
|
.map((id) => input.items[id])
|
||||||
.filter((item): item is DownloadItem => Boolean(item))
|
.filter((item): item is DownloadItem => Boolean(item));
|
||||||
|
const items = allPackageItems
|
||||||
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
||||||
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
||||||
const matchingItems = items.filter((item) => {
|
const matchingItems = items.filter((item) => {
|
||||||
@@ -158,7 +160,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
|||||||
const visibleItems = packageMatchesQuery && query !== ""
|
const visibleItems = packageMatchesQuery && query !== ""
|
||||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||||
: matchingItems;
|
: matchingItems;
|
||||||
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
|
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPackageRows = packageRows.length;
|
const totalPackageRows = packageRows.length;
|
||||||
|
|||||||
@@ -43,11 +43,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--ui-text);
|
color: #0a0f1a;
|
||||||
background: var(--ui-active);
|
background: #90cdf4;
|
||||||
|
text-align: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +399,12 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-name-cell .downloads-rename-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-selection-cell,
|
.downloads-selection-cell,
|
||||||
.downloads-action-cell {
|
.downloads-action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ function AccountRow({
|
|||||||
</span>
|
</span>
|
||||||
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
|
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
|
||||||
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
|
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
|
||||||
|
<span className="settings-account-email settings-copyable" role="cell" title={row.email}>{row.email}</span>
|
||||||
<span className="settings-account-expires" role="cell">{row.expires}</span>
|
<span className="settings-account-expires" role="cell">{row.expires}</span>
|
||||||
<span className="settings-account-credential" role="cell">{row.credential}</span>
|
<span className="settings-account-credential" role="cell">{row.credential}</span>
|
||||||
<span className="settings-account-column-actions" role="cell">
|
<span className="settings-account-column-actions" role="cell">
|
||||||
@@ -498,31 +499,48 @@ export function AccountAddDialog({
|
|||||||
size="account"
|
size="account"
|
||||||
title="Account hinzufügen"
|
title="Account hinzufügen"
|
||||||
>
|
>
|
||||||
<label className="settings-account-picker-selector">
|
<div className="settings-account-picker-selector">
|
||||||
<span>Dienst / Zugangstyp</span>
|
<span>Dienst / Zugangstyp</span>
|
||||||
<select
|
<input
|
||||||
aria-label="Dienst / Zugangstyp"
|
aria-label="Dienst oder Zugangstyp suchen"
|
||||||
className="settings-control"
|
className="settings-control"
|
||||||
onChange={(event) => actions.onOptionSelect(event.target.value)}
|
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||||
value={model.selectedOptionId ?? ""}
|
placeholder="Dienst oder Zugangstyp suchen"
|
||||||
>
|
type="search"
|
||||||
|
value={model.query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-account-picker-table">
|
||||||
|
<div aria-hidden="true" className="settings-account-picker-header">
|
||||||
|
<span>Dienst</span>
|
||||||
|
<span>Typ/Funktion</span>
|
||||||
|
</div>
|
||||||
|
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" role="listbox">
|
||||||
{model.options.map((option) => (
|
{model.options.map((option) => (
|
||||||
<option key={option.id} value={option.id}>{option.title} · {option.mode}</option>
|
<button
|
||||||
|
aria-selected={option.id === model.selectedOptionId}
|
||||||
|
className={`settings-account-picker-row${option.id === model.selectedOptionId ? " is-selected" : ""}`}
|
||||||
|
data-account-option-id={option.id}
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => actions.onOptionSelect(option.id)}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="settings-account-picker-service">
|
||||||
|
{option.icon ? <img alt="" aria-hidden="true" draggable={false} height="18" src={option.icon} width="18" /> : null}
|
||||||
|
<span>{option.title}</span>
|
||||||
|
</span>
|
||||||
|
<span>{option.functionLabel}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
{selectedOption ? (
|
{selectedOption ? (
|
||||||
<>
|
<>
|
||||||
<div className="settings-account-option-meta">
|
<div className="settings-account-option-summary">
|
||||||
<div>
|
<strong>Zugangsdaten für {selectedOption.title}</strong>
|
||||||
<strong>{selectedOption.title}</strong>
|
|
||||||
<span>{selectedOption.description}</span>
|
<span>{selectedOption.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<strong>{selectedOption.mode}</strong>
|
|
||||||
<span>{selectedOption.functionLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { cloneElement, type ChangeEvent, type ReactElement } from "react";
|
import { cloneElement, useEffect, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type ReactElement } from "react";
|
||||||
|
import { getSettingsSelectNavigationIndex } from "./settings-model";
|
||||||
import type {
|
import type {
|
||||||
SettingsFieldViewModel,
|
SettingsFieldViewModel,
|
||||||
SettingsFormViewModel,
|
SettingsFormViewModel,
|
||||||
|
SettingsSelectFieldViewModel,
|
||||||
SettingsTextFieldViewModel
|
SettingsTextFieldViewModel
|
||||||
} from "./settings-model";
|
} from "./settings-model";
|
||||||
|
|
||||||
@@ -73,27 +75,117 @@ function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; ac
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const selected = field.options.find((option) => option.value === field.value) ?? field.options[0];
|
||||||
|
const selectedIndex = Math.max(0, field.options.findIndex((option) => option.value === selected?.value));
|
||||||
|
|
||||||
|
const focusOption = (nextIndex: number): void => {
|
||||||
|
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (event: MouseEvent): void => {
|
||||||
|
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousedown", close);
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousedown", close);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIndex = getSettingsSelectNavigationIndex(selectedIndex, field.options.length, event.key);
|
||||||
|
setOpen(true);
|
||||||
|
focusOption(nextIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen((current) => !current);
|
||||||
|
if (!open) focusOption(selectedIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIndex = getSettingsSelectNavigationIndex(index, field.options.length, event.key);
|
||||||
|
focusOption(nextIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-field">
|
||||||
|
<label id={`${field.id}-label`}>{field.label}</label>
|
||||||
|
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
aria-controls={`${field.id}-options`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-labelledby={`${field.id}-label`}
|
||||||
|
className="settings-select-trigger"
|
||||||
|
disabled={field.disabled}
|
||||||
|
id={field.id}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
ref={triggerRef}
|
||||||
|
role="combobox"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{selected?.label ?? ""}</span>
|
||||||
|
<span aria-hidden="true" className="settings-select-chevron">⌄</span>
|
||||||
|
</button>
|
||||||
|
<div aria-hidden={!open} className="settings-select-options" id={`${field.id}-options`} role="listbox">
|
||||||
|
{field.options.map((option, index) => (
|
||||||
|
<button
|
||||||
|
aria-selected={field.value === option.value}
|
||||||
|
className={`settings-select-option${field.value === option.value ? " is-selected" : ""}`}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
actions.onChange(field.id, option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
||||||
|
ref={(element) => { optionRefs.current[index] = element; }}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>{option.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FieldHelp field={field} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||||
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
|
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
|
||||||
return <TextControl actions={actions} field={field} />;
|
return <TextControl actions={actions} field={field} />;
|
||||||
}
|
}
|
||||||
if (field.kind === "select") {
|
if (field.kind === "select") {
|
||||||
return (
|
return <SelectControl actions={actions} field={field} />;
|
||||||
<div className="settings-field">
|
|
||||||
<label htmlFor={field.id}>{field.label}</label>
|
|
||||||
<select
|
|
||||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
|
||||||
className="settings-control"
|
|
||||||
disabled={field.disabled}
|
|
||||||
id={field.id}
|
|
||||||
onChange={(event) => actions.onChange(field.id, event.target.value)}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<FieldHelp field={field} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (field.kind === "theme") {
|
if (field.kind === "theme") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AppSettings } from "../../../shared/types";
|
import type { AppSettings } from "../../../shared/types";
|
||||||
import type { AccountService } from "../../account-edit";
|
import type { AccountService } from "../../account-edit";
|
||||||
import { resolveAccountUsername } from "../../account-ui";
|
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
|
||||||
|
|
||||||
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
|
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
|
||||||
@@ -20,6 +19,7 @@ export const ACCOUNT_COLUMNS = [
|
|||||||
"Status",
|
"Status",
|
||||||
"Download-Traffic übrig",
|
"Download-Traffic übrig",
|
||||||
"Benutzername",
|
"Benutzername",
|
||||||
|
"E-Mail",
|
||||||
"Verfallsdatum",
|
"Verfallsdatum",
|
||||||
"Passwort/Zugang"
|
"Passwort/Zugang"
|
||||||
] as const;
|
] as const;
|
||||||
@@ -38,6 +38,42 @@ export function getSettingsSaveLabel(state: SettingsSaveState): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSettingsSelectNavigationIndex(currentIndex: number, optionCount: number, key: string): number {
|
||||||
|
if (optionCount <= 0) return -1;
|
||||||
|
const current = Math.max(0, Math.min(optionCount - 1, currentIndex));
|
||||||
|
if (key === "Home") return 0;
|
||||||
|
if (key === "End") return optionCount - 1;
|
||||||
|
if (key === "ArrowDown") return (current + 1) % optionCount;
|
||||||
|
if (key === "ArrowUp") return (current - 1 + optionCount) % optionCount;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHistoryRetentionSelection(
|
||||||
|
currentMode: AppSettings["historyRetentionMode"],
|
||||||
|
currentMaxEntries: number,
|
||||||
|
value: string
|
||||||
|
): Pick<AppSettings, "historyRetentionMode" | "historyMaxEntries"> {
|
||||||
|
const preset = /^permanent-(100|250)$/.exec(value);
|
||||||
|
if (preset) {
|
||||||
|
return {
|
||||||
|
historyRetentionMode: "permanent",
|
||||||
|
historyMaxEntries: Number(preset[1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value === "permanent") {
|
||||||
|
return {
|
||||||
|
historyRetentionMode: "permanent",
|
||||||
|
historyMaxEntries: currentMode === "permanent" && (currentMaxEntries === 100 || currentMaxEntries === 250)
|
||||||
|
? 500
|
||||||
|
: currentMaxEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
historyRetentionMode: value as AppSettings["historyRetentionMode"],
|
||||||
|
historyMaxEntries: currentMaxEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
|
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
|
||||||
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
|
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
|
||||||
|
|
||||||
@@ -75,6 +111,7 @@ export interface AccountRowViewModel {
|
|||||||
};
|
};
|
||||||
traffic: string;
|
traffic: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
email: string;
|
||||||
expires: string;
|
expires: string;
|
||||||
credential: string;
|
credential: string;
|
||||||
canCheck: boolean;
|
canCheck: boolean;
|
||||||
@@ -450,10 +487,14 @@ export function buildSettingsFormViewModel({
|
|||||||
id: "historyRetentionMode",
|
id: "historyRetentionMode",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: "Verlauf speichern",
|
label: "Verlauf speichern",
|
||||||
value: settings.historyRetentionMode,
|
value: settings.historyRetentionMode === "permanent" && (settings.historyMaxEntries === 100 || settings.historyMaxEntries === 250)
|
||||||
|
? `permanent-${settings.historyMaxEntries}`
|
||||||
|
: settings.historyRetentionMode,
|
||||||
options: [
|
options: [
|
||||||
{ value: "never", label: "Nie" },
|
{ value: "never", label: "Nie" },
|
||||||
{ value: "session", label: "Nur aktuelle Session" },
|
{ value: "session", label: "Nur aktuelle Session" },
|
||||||
|
{ value: "permanent-100", label: "Nur letzte 100 Einträge" },
|
||||||
|
{ value: "permanent-250", label: "Nur letzte 250 Einträge" },
|
||||||
{ value: "permanent", label: "Dauerhaft" }
|
{ value: "permanent", label: "Dauerhaft" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -553,6 +594,16 @@ function projectCredential(kind: AccountRowSource["credentialKind"]): string {
|
|||||||
return kind === "password" ? "••••••" : "Geschützter Zugang";
|
return kind === "password" ? "••••••" : "Geschützter Zugang";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectAccountIdentity(username: string, checkedEmail?: string): { username: string; email: string } {
|
||||||
|
const stored = username.trim();
|
||||||
|
const verifiedEmail = checkedEmail?.trim() || "";
|
||||||
|
const storedIsEmail = stored.includes("@");
|
||||||
|
return {
|
||||||
|
username: stored && !storedIsEmail ? stored : "—",
|
||||||
|
email: verifiedEmail || (storedIsEmail ? stored : "—")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function projectAccountRows(
|
export function projectAccountRows(
|
||||||
sources: readonly AccountRowSource[],
|
sources: readonly AccountRowSource[],
|
||||||
selectedIds: readonly string[],
|
selectedIds: readonly string[],
|
||||||
@@ -565,6 +616,7 @@ export function projectAccountRows(
|
|||||||
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
|
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
|
||||||
? source.status.premiumUntilMs
|
? source.status.premiumUntilMs
|
||||||
: null;
|
: null;
|
||||||
|
const identity = projectAccountIdentity(source.username, source.status.email);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
service: source.service,
|
service: source.service,
|
||||||
@@ -575,7 +627,8 @@ export function projectAccountRows(
|
|||||||
selected: selected.has(id),
|
selected: selected.has(id),
|
||||||
status,
|
status,
|
||||||
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
|
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
|
||||||
username: resolveAccountUsername(source.username, source.status.email),
|
username: identity.username,
|
||||||
|
email: identity.email,
|
||||||
expires: formatExpiry(source.status.premiumUntilMs),
|
expires: formatExpiry(source.status.premiumUntilMs),
|
||||||
credential: projectCredential(source.credentialKind),
|
credential: projectCredential(source.credentialKind),
|
||||||
canCheck: source.canCheck,
|
canCheck: source.canCheck,
|
||||||
|
|||||||
@@ -218,6 +218,91 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-select {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--ui-input);
|
||||||
|
color: var(--ui-text);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select-chevron {
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select-options {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 5px);
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: var(--md-layer-menu);
|
||||||
|
display: grid;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--ui-surface);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: max-height 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 150ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 180ms ease, border-color 180ms ease, visibility 0s linear 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select.is-open .settings-select-options {
|
||||||
|
max-height: 280px;
|
||||||
|
padding: 4px;
|
||||||
|
border-color: var(--ui-border);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select.is-open .settings-select-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select-option {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select-option:hover,
|
||||||
|
.settings-select-option.is-selected {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-control:focus-visible,
|
.settings-control:focus-visible,
|
||||||
.settings-button:focus-visible,
|
.settings-button:focus-visible,
|
||||||
.settings-switch:focus-visible,
|
.settings-switch:focus-visible,
|
||||||
@@ -467,8 +552,8 @@
|
|||||||
.settings-account-table-grid,
|
.settings-account-table-grid,
|
||||||
.settings-account-row {
|
.settings-account-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(190px, 1.15fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
|
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(145px, 0.9fr) minmax(190px, 1.1fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
|
||||||
min-width: 1110px;
|
min-width: 1260px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +707,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-email {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ui-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-action-button {
|
.settings-account-action-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -751,29 +843,90 @@
|
|||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-option-meta {
|
.settings-account-picker-table {
|
||||||
display: grid;
|
overflow: hidden;
|
||||||
grid-template-columns: minmax(0, 1fr) 150px;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-option-meta > div {
|
.settings-account-picker-header,
|
||||||
|
.settings-account-picker-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.8fr);
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-option-meta span {
|
.settings-account-picker-header {
|
||||||
overflow: hidden;
|
min-height: 32px;
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
background: var(--ui-surface-elevated, var(--ui-surface));
|
||||||
|
color: var(--ui-text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-header > span,
|
||||||
|
.settings-account-picker-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-list {
|
||||||
|
max-height: 190px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-row:hover,
|
||||||
|
.settings-account-picker-row.is-selected {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-row.is-selected {
|
||||||
|
box-shadow: inset 3px 0 0 var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-service {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-service img {
|
||||||
|
flex: 0 0 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-option-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding-top: 2px;
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-option-summary span {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-dialog-fields {
|
.settings-account-dialog-fields {
|
||||||
@@ -839,8 +992,8 @@
|
|||||||
|
|
||||||
.settings-account-table-grid,
|
.settings-account-table-grid,
|
||||||
.settings-account-row {
|
.settings-account-row {
|
||||||
grid-template-columns: 40px 160px 140px 180px 180px 120px 140px 42px;
|
grid-template-columns: 40px 160px 140px 180px 135px 170px 120px 140px 42px;
|
||||||
min-width: 1002px;
|
min-width: 1127px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,7 +1003,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-theme-options,
|
.settings-theme-options,
|
||||||
.settings-account-option-meta {
|
.settings-account-picker-header,
|
||||||
|
.settings-account-picker-row {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-header > span:last-child,
|
||||||
|
.settings-account-picker-row > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ export interface PackageEntry {
|
|||||||
priority?: PackagePriority;
|
priority?: PackagePriority;
|
||||||
postProcessLabel?: string;
|
postProcessLabel?: string;
|
||||||
audioStripSummary?: AudioStripSummary;
|
audioStripSummary?: AudioStripSummary;
|
||||||
|
cleanedCompletedItemCount?: number;
|
||||||
|
cleanedExtractedItemCount?: number;
|
||||||
|
cleanedDownloadedBytes?: number;
|
||||||
|
cleanedTotalBytes?: number;
|
||||||
|
cleanedUrls?: string[];
|
||||||
|
cleanedProviders?: DebridProvider[];
|
||||||
downloadStartedAt?: number;
|
downloadStartedAt?: number;
|
||||||
downloadCompletedAt?: number;
|
downloadCompletedAt?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
clampContextMenuPosition,
|
clampContextMenuPosition,
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
getContextSubmenuPosition
|
getContextSubmenuPosition
|
||||||
} from "../src/renderer/ui/ContextMenu";
|
} from "../src/renderer/ui/ContextMenu";
|
||||||
|
|
||||||
|
const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8");
|
||||||
|
const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
describe("ContextMenu", () => {
|
describe("ContextMenu", () => {
|
||||||
it("renders menu semantics and marks buttons as menu items", () => {
|
it("renders menu semantics and marks buttons as menu items", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
@@ -99,4 +103,13 @@ describe("ContextMenu", () => {
|
|||||||
{ width: 800, height: 600 }
|
{ width: 800, height: 600 }
|
||||||
)).toEqual({ x: 590, y: 450 });
|
)).toEqual({ x: 590, y: 450 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps submenus hidden until their viewport-safe position is ready", () => {
|
||||||
|
expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""');
|
||||||
|
expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")');
|
||||||
|
expect(stylesSource).toMatch(/\.ctx-menu:not\(\.is-positioned\)\s*\{[^}]*visibility:\s*hidden/s);
|
||||||
|
expect(stylesSource).not.toMatch(/\.ctx-menu-sub:hover\s+\.ctx-menu-sub-items\s*\{\s*display:\s*block/s);
|
||||||
|
expect(stylesSource).toMatch(/\.ctx-menu-sub:hover\s*>\s*\.ctx-menu-sub-items\.is-positioned/s);
|
||||||
|
expect(contextMenuSource).toContain('window.addEventListener("pointerdown", onOutside, true)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForT
|
|||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
|
import type { HistoryEntry } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -1146,7 +1147,6 @@ describe("download manager", () => {
|
|||||||
emptySession(),
|
emptySession(),
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
|
manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]);
|
||||||
await manager.start();
|
await manager.start();
|
||||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
@@ -7487,6 +7487,83 @@ describe("download manager", () => {
|
|||||||
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
|
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resets extraction state atomically for selected package items", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "reset-extraction-package";
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const itemIds = ["reset-a", "reset-b", "reset-c"];
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "reset-extraction",
|
||||||
|
outputDir: path.join(root, "downloads", "reset-extraction"),
|
||||||
|
extractDir: path.join(root, "extract", "reset-extraction"),
|
||||||
|
status: "extracting",
|
||||||
|
itemIds,
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
postProcessLabel: "release.part1.rar",
|
||||||
|
downloadCompletedAt: createdAt,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: `https://dummy/${itemId}`,
|
||||||
|
provider: "megadebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 1_000,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: `${itemId}.part1.rar`,
|
||||||
|
targetPath: path.join(root, "downloads", "reset-extraction", `${itemId}.part1.rar`),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "Unerwartetes Dateiende",
|
||||||
|
fullStatus: `Entpack-Fehler [${itemId}.part1.rar]: Unerwartetes Dateiende`,
|
||||||
|
onlineStatus: "online",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.resetItems(itemIds);
|
||||||
|
|
||||||
|
const snapshot = manager.getSnapshot().session;
|
||||||
|
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
|
||||||
|
status: "queued",
|
||||||
|
postProcessLabel: undefined,
|
||||||
|
downloadCompletedAt: 0
|
||||||
|
}));
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
expect(snapshot.items[itemId]).toEqual(expect.objectContaining({
|
||||||
|
status: "queued",
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
onlineStatus: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -8071,6 +8148,11 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
|
manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]);
|
||||||
const pkgId = manager.getSnapshot().session.packageOrder[0];
|
const pkgId = manager.getSnapshot().session.packageOrder[0];
|
||||||
|
const completedPostProcessLabels: Array<string | undefined> = [];
|
||||||
|
manager.on("state", (state) => {
|
||||||
|
const emittedPackage = state.session.packages[pkgId];
|
||||||
|
if (emittedPackage?.status === "completed") completedPostProcessLabels.push(emittedPackage.postProcessLabel);
|
||||||
|
});
|
||||||
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
|
const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || "";
|
||||||
expect(extractDir).toBeTruthy();
|
expect(extractDir).toBeTruthy();
|
||||||
expect(fs.existsSync(extractDir)).toBe(false);
|
expect(fs.existsSync(extractDir)).toBe(false);
|
||||||
@@ -8080,11 +8162,17 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(extractDir)).toBe(false);
|
expect(fs.existsSync(extractDir)).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
|
await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000);
|
||||||
|
await waitFor(() => {
|
||||||
|
const current = manager.getSnapshot().session.packages[pkgId];
|
||||||
|
return current?.status === "completed" && current.postProcessLabel === undefined;
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const item = Object.values(snapshot.session.items)[0];
|
const item = Object.values(snapshot.session.items)[0];
|
||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
|
||||||
|
expect(snapshot.session.packages[pkgId]?.postProcessLabel).toBeUndefined();
|
||||||
|
expect(completedPostProcessLabels.every((label) => label === undefined)).toBe(true);
|
||||||
expect(fs.existsSync(extractDir)).toBe(true);
|
expect(fs.existsSync(extractDir)).toBe(true);
|
||||||
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -8169,6 +8257,243 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves completed package progress when immediate cleanup removes a finished item", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "cleanup-progress-package";
|
||||||
|
const completedItemId = "cleanup-progress-completed";
|
||||||
|
const queuedItemId = "cleanup-progress-queued";
|
||||||
|
const createdAt = Date.now();
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "cleanup-progress",
|
||||||
|
outputDir: path.join(root, "downloads", "cleanup-progress"),
|
||||||
|
extractDir: path.join(root, "extract", "cleanup-progress"),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [completedItemId, queuedItemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[completedItemId] = {
|
||||||
|
id: completedItemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/completed",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 1_000,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "completed.rar",
|
||||||
|
targetPath: path.join(root, "downloads", "cleanup-progress", "completed.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Entpackt - Fertig",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[queuedItemId] = {
|
||||||
|
...session.items[completedItemId],
|
||||||
|
id: queuedItemId,
|
||||||
|
url: "https://dummy/queued",
|
||||||
|
status: "queued",
|
||||||
|
downloadedBytes: 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "queued.rar",
|
||||||
|
targetPath: path.join(root, "downloads", "cleanup-progress", "queued.rar"),
|
||||||
|
fullStatus: "Wartet"
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true,
|
||||||
|
completedCleanupPolicy: "immediate"
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
|
||||||
|
(manager as any).applyCompletedCleanupPolicy(packageId, completedItemId);
|
||||||
|
|
||||||
|
const packageEntry = manager.getSnapshot().session.packages[packageId];
|
||||||
|
expect(packageEntry.itemIds).toEqual([queuedItemId]);
|
||||||
|
expect(packageEntry.cleanedCompletedItemCount).toBe(1);
|
||||||
|
expect(packageEntry.cleanedExtractedItemCount).toBe(1);
|
||||||
|
expect(packageEntry.cleanedDownloadedBytes).toBe(1_000);
|
||||||
|
expect(packageEntry.cleanedTotalBytes).toBe(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes immediately cleaned items in the final package history entry", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "cleanup-history-package";
|
||||||
|
const firstItemId = "cleanup-history-first";
|
||||||
|
const secondItemId = "cleanup-history-second";
|
||||||
|
const createdAt = Date.now() - 5_000;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "cleanup-history",
|
||||||
|
outputDir: path.join(root, "downloads", "cleanup-history"),
|
||||||
|
extractDir: path.join(root, "extract", "cleanup-history"),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [firstItemId, secondItemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[firstItemId] = {
|
||||||
|
id: firstItemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/first",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 1_000,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "first.rar",
|
||||||
|
targetPath: path.join(root, "downloads", "cleanup-history", "first.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Entpackt - Fertig",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[secondItemId] = {
|
||||||
|
...session.items[firstItemId],
|
||||||
|
id: secondItemId,
|
||||||
|
url: "https://dummy/second",
|
||||||
|
provider: "megadebrid-api",
|
||||||
|
status: "queued",
|
||||||
|
downloadedBytes: 2_000,
|
||||||
|
totalBytes: 2_000,
|
||||||
|
fileName: "second.rar",
|
||||||
|
targetPath: path.join(root, "downloads", "cleanup-history", "second.rar"),
|
||||||
|
fullStatus: "Wartet"
|
||||||
|
};
|
||||||
|
const history: HistoryEntry[] = [];
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true,
|
||||||
|
completedCleanupPolicy: "immediate"
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state")),
|
||||||
|
{ onHistoryEntry: (entry) => history.push(entry) }
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).applyCompletedCleanupPolicy(packageId, firstItemId);
|
||||||
|
const pkg = (manager as any).session.packages[packageId];
|
||||||
|
(manager as any).session.items[secondItemId].status = "completed";
|
||||||
|
(manager as any).session.items[secondItemId].fullStatus = "Entpackt - Fertig";
|
||||||
|
pkg.status = "completed";
|
||||||
|
(manager as any).recordPackageHistory(packageId, pkg, [(manager as any).session.items[secondItemId]]);
|
||||||
|
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0]).toMatchObject({
|
||||||
|
totalBytes: 3_000,
|
||||||
|
downloadedBytes: 3_000,
|
||||||
|
fileCount: 2,
|
||||||
|
provider: null,
|
||||||
|
urls: ["https://dummy/first", "https://dummy/second"]
|
||||||
|
});
|
||||||
|
|
||||||
|
history.length = 0;
|
||||||
|
(manager as any).historyRecordedPackages.delete(packageId);
|
||||||
|
(manager as any).removePackageFromSession(packageId, [secondItemId], "deleted");
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0]).toMatchObject({
|
||||||
|
totalBytes: 3_000,
|
||||||
|
downloadedBytes: 3_000,
|
||||||
|
fileCount: 2,
|
||||||
|
provider: null,
|
||||||
|
status: "deleted",
|
||||||
|
urls: ["https://dummy/first", "https://dummy/second"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for aborted package post-processing before restarting reset items", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "reset-race-package";
|
||||||
|
const itemId = "reset-race-item";
|
||||||
|
const createdAt = Date.now() - 5_000;
|
||||||
|
session.running = true;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "reset-race",
|
||||||
|
outputDir: path.join(root, "downloads", "reset-race"),
|
||||||
|
extractDir: path.join(root, "extract", "reset-race"),
|
||||||
|
status: "failed",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/reset-race",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "failed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "reset-race.rar",
|
||||||
|
targetPath: "",
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "extract failed",
|
||||||
|
fullStatus: "Entpack-Fehler",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
||||||
|
let releaseTask = (): void => {};
|
||||||
|
const task = new Promise<void>((resolve) => { releaseTask = resolve; });
|
||||||
|
let releaseHybridTask = (): void => {};
|
||||||
|
const hybridTask = new Promise<void>((resolve) => { releaseHybridTask = resolve; });
|
||||||
|
const internal = manager as any;
|
||||||
|
internal.session.running = true;
|
||||||
|
internal.packagePostProcessTasks.set(packageId, task);
|
||||||
|
internal.packagePostProcessAbortControllers.set(packageId, new AbortController());
|
||||||
|
internal.packageHybridPostProcessTasks.set(packageId, new Set([hybridTask]));
|
||||||
|
internal.packageHybridPostProcessControllers.set(packageId, new Set([new AbortController()]));
|
||||||
|
internal.ensureScheduler = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const resetPromise = Promise.resolve(manager.resetItems([itemId]));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(internal.ensureScheduler).not.toHaveBeenCalled();
|
||||||
|
releaseTask();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(internal.ensureScheduler).not.toHaveBeenCalled();
|
||||||
|
releaseHybridTask();
|
||||||
|
await resetPromise;
|
||||||
|
expect(internal.ensureScheduler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes finished package when package_done cleanup policy is enabled", async () => {
|
it("removes finished package when package_done cleanup policy is enabled", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -9780,6 +10105,8 @@ describe("download manager", () => {
|
|||||||
itemIds: [itemId],
|
itemIds: [itemId],
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
downloadStartedAt: createdAt,
|
||||||
|
downloadCompletedAt: createdAt + 10_000,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt
|
updatedAt: createdAt
|
||||||
};
|
};
|
||||||
@@ -9841,13 +10168,16 @@ describe("download manager", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => renameStarted, 4000);
|
await waitFor(() => renameStarted, 4000);
|
||||||
manager.resetPackage(packageId);
|
const resetPromise = manager.resetPackage(packageId);
|
||||||
releaseRename();
|
releaseRename();
|
||||||
await deferredPromise;
|
await deferredPromise;
|
||||||
|
await resetPromise;
|
||||||
|
|
||||||
expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled();
|
expect(cleanupRemainingArchiveArtifacts).not.toHaveBeenCalled();
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||||
|
expect(snapshot.session.packages[packageId]?.downloadStartedAt).toBe(0);
|
||||||
|
expect(snapshot.session.packages[packageId]?.downloadCompletedAt).toBe(0);
|
||||||
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
expect(snapshot.session.items[itemId]?.status).toBe("queued");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+121
-34
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
|
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
|
||||||
import {
|
import {
|
||||||
buildDownloadSidebarCounts,
|
buildDownloadSidebarCounts,
|
||||||
@@ -32,7 +32,9 @@ import {
|
|||||||
arePackageCardPropsEqual,
|
arePackageCardPropsEqual,
|
||||||
compactDownloadStatus,
|
compactDownloadStatus,
|
||||||
downloadColumnDefinitions,
|
downloadColumnDefinitions,
|
||||||
getAvailabilitySummary
|
getAvailabilitySummary,
|
||||||
|
getPackageProgress,
|
||||||
|
getPackageSizeProgress
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
|
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
|
||||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||||
@@ -124,6 +126,23 @@ describe("Download-Gesamtgröße", () => {
|
|||||||
|
|
||||||
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
|
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves completed package bytes and progress after immediate cleanup", () => {
|
||||||
|
const active = item("active", "package-a", "downloading", {
|
||||||
|
downloadedBytes: 500,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: 50
|
||||||
|
});
|
||||||
|
const packageEntry = pkg("package-a", "Serie", ["active"]);
|
||||||
|
packageEntry.cleanedCompletedItemCount = 2;
|
||||||
|
packageEntry.cleanedExtractedItemCount = 2;
|
||||||
|
packageEntry.cleanedDownloadedBytes = 2_000;
|
||||||
|
packageEntry.cleanedTotalBytes = 2_000;
|
||||||
|
const row = { package: packageEntry, items: [active], allItems: [active], collapsed: true };
|
||||||
|
|
||||||
|
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_500, total: 3_000, value: 83 });
|
||||||
|
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 2, total: 3, value: 83 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("laufender Queue-Linkzähler", () => {
|
describe("laufender Queue-Linkzähler", () => {
|
||||||
@@ -156,15 +175,19 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
|||||||
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
|
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
|
||||||
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
|
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
|
||||||
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
|
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
|
||||||
|
expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
|
||||||
|
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
|
||||||
|
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler");
|
||||||
|
expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes duplicated access-mode wording from service labels", () => {
|
it("removes duplicated access-mode wording from service labels", () => {
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid (API)");
|
||||||
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
|
||||||
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid");
|
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,9 +241,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
|||||||
onClearAll: () => {},
|
onClearAll: () => {},
|
||||||
onToggleAllPackages: () => {},
|
onToggleAllPackages: () => {},
|
||||||
onShowAllPackages: () => {},
|
onShowAllPackages: () => {},
|
||||||
onPackageDragStart: () => {},
|
|
||||||
onPackageDrop: () => {},
|
|
||||||
onPackageDragEnd: () => {},
|
|
||||||
onSetVisibleSelection: () => {},
|
onSetVisibleSelection: () => {},
|
||||||
onToggleSelection: () => {},
|
onToggleSelection: () => {},
|
||||||
onSelectionMouseDown: () => {},
|
onSelectionMouseDown: () => {},
|
||||||
@@ -468,10 +488,12 @@ describe("downloads view", () => {
|
|||||||
|
|
||||||
it("shows only the package mode while the file mode remains hidden", () => {
|
it("shows only the package mode while the file mode remains hidden", () => {
|
||||||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
expect(html).toContain("Pakete");
|
expect(html).toContain("Pakete");
|
||||||
expect(html).not.toContain(">Dateien<");
|
expect(html).not.toContain(">Dateien<");
|
||||||
expect(html).not.toContain("downloads-mode-switch");
|
expect(html).not.toContain("downloads-mode-switch");
|
||||||
|
expect(css).toMatch(/\.downloads-mode-title\s*\{[^}]*justify-content:\s*center;[^}]*color:\s*#0a0f1a;[^}]*background:\s*#90cdf4;[^}]*text-align:\s*center;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
||||||
@@ -518,24 +540,24 @@ describe("downloads view", () => {
|
|||||||
expect(toolbar).not.toContain("downloads-search-input");
|
expect(toolbar).not.toContain("downloads-search-input");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards package drag lifecycle callbacks through the extracted downloads content", () => {
|
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
||||||
const calls: string[] = [];
|
const model = withRuntime(createInput());
|
||||||
const actions = createActions() as DownloadsViewActions & {
|
const component = PackageCardContent({
|
||||||
onPackageDragStart: (packageId: string) => void;
|
actions: createActions(),
|
||||||
onPackageDrop: (packageId: string) => void;
|
columnOrder: model.columnOrder,
|
||||||
onPackageDragEnd: () => void;
|
editing: false,
|
||||||
};
|
editingName: "",
|
||||||
actions.onPackageDragStart = (packageId) => calls.push(`start:${packageId}`);
|
gridTemplate: model.gridTemplate,
|
||||||
actions.onPackageDrop = (packageId) => calls.push(`drop:${packageId}`);
|
packageSpeedBps: 0,
|
||||||
actions.onPackageDragEnd = () => calls.push("end");
|
row: model.packageRows[0],
|
||||||
const content = DownloadsContent({ actions, model: withRuntime(createInput()) });
|
selectedIds: new Set<string>(),
|
||||||
const packageElement = findElement(content, (element) => element.props.row?.package.id === "package-a");
|
selectedVersion: 0
|
||||||
|
});
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
|
||||||
packageElement.props.onDragStart("package-a");
|
expect(component.props.draggable).toBeUndefined();
|
||||||
packageElement.props.onDrop("package-b");
|
component.props.onDragStart({ preventDefault });
|
||||||
packageElement.props.onDragEnd();
|
expect(preventDefault).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts with the local Start action and dispatches toolbar actions separately", () => {
|
it("starts with the local Start action and dispatches toolbar actions separately", () => {
|
||||||
@@ -670,6 +692,7 @@ describe("downloads view", () => {
|
|||||||
|
|
||||||
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
|
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
|
||||||
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-name-cell\s+\.downloads-rename-input\s*\{[^}]*flex:\s*1 1 auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
|
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
|
||||||
@@ -740,6 +763,30 @@ describe("download table row contracts", () => {
|
|||||||
])).toEqual({ online: 0, total: 1, state: "checking" });
|
])).toEqual({ online: 0, total: 1, state: "checking" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows reset package availability as one compact unchecked label", () => {
|
||||||
|
const resetItems = [
|
||||||
|
item("reset-a", "package-a", "queued", { onlineStatus: undefined }),
|
||||||
|
item("reset-b", "package-a", "queued", { onlineStatus: undefined }),
|
||||||
|
item("reset-c", "package-a", "queued", { onlineStatus: undefined })
|
||||||
|
];
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["availability"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "150px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: pkg("package-a", "Reset", resetItems.map((entry) => entry.id)), items: resetItems, allItems: resetItems, collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html).toContain(">Ungeprüft</span>");
|
||||||
|
expect(html).not.toContain(">0</span>");
|
||||||
|
expect(html).not.toContain(">3</span>");
|
||||||
|
expect(html).not.toContain(">online</span>");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders availability for package and file rows", () => {
|
it("renders availability for package and file rows", () => {
|
||||||
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
||||||
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
||||||
@@ -749,7 +796,7 @@ describe("download table row contracts", () => {
|
|||||||
editingName: "",
|
editingName: "",
|
||||||
gridTemplate: "110px",
|
gridTemplate: "110px",
|
||||||
packageSpeedBps: 0,
|
packageSpeedBps: 0,
|
||||||
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
|
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], allItems: [onlineItem], collapsed: true },
|
||||||
selectedIds: new Set<string>(),
|
selectedIds: new Set<string>(),
|
||||||
selectedVersion: 0
|
selectedVersion: 0
|
||||||
}));
|
}));
|
||||||
@@ -795,7 +842,7 @@ describe("download table row contracts", () => {
|
|||||||
editingName: "",
|
editingName: "",
|
||||||
gridTemplate: "80px",
|
gridTemplate: "80px",
|
||||||
packageSpeedBps: 0,
|
packageSpeedBps: 0,
|
||||||
row: { package: extractionPackage, items: [extractionItem], collapsed: true },
|
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
|
||||||
selectedIds: new Set<string>(),
|
selectedIds: new Set<string>(),
|
||||||
selectedVersion: 0
|
selectedVersion: 0
|
||||||
}));
|
}));
|
||||||
@@ -803,6 +850,29 @@ describe("download table row contracts", () => {
|
|||||||
expect(html).toContain(">70%</b>");
|
expect(html).toContain(">70%</b>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never exposes archive filenames as the visible package status", () => {
|
||||||
|
const extractionItem = item("archive-item", "archive-package", "completed", { fullStatus: "Entpacken - Ausstehend" });
|
||||||
|
const extractionPackage = {
|
||||||
|
...pkg("archive-package", "Archiv", [extractionItem.id]),
|
||||||
|
status: "extracting",
|
||||||
|
postProcessLabel: "release.part1.rar"
|
||||||
|
} as PackageEntry;
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "220px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html).toContain(">Entpacken - Ausstehend</span>");
|
||||||
|
expect(html).not.toContain(">release.part1.rar</span>");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders meter text in clipped track and fill layers", () => {
|
it("renders meter text in clipped track and fill layers", () => {
|
||||||
const html = renderToStaticMarkup(ItemRowContent({
|
const html = renderToStaticMarkup(ItemRowContent({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
@@ -836,8 +906,8 @@ describe("download table row contracts", () => {
|
|||||||
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||||||
expect(html).toContain('title="Download läuft (Mega-Debrid)"');
|
expect(html).toContain('title="Download läuft (Mega-Debrid)"');
|
||||||
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
||||||
expect(html).toContain('class="downloads-service-full">Mega-Debrid Web</span>');
|
expect(html).toContain('class="downloads-service-full">Mega-Debrid (Web)</span>');
|
||||||
expect(html).toContain('class="downloads-service-compact">Mega-Debrid</span>');
|
expect(html).toContain('class="downloads-service-compact">Mega-Debrid (Web)</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the whole visible selection atomically from the header checkbox", () => {
|
it("sets the whole visible selection atomically from the header checkbox", () => {
|
||||||
@@ -964,12 +1034,29 @@ describe("download table row contracts", () => {
|
|||||||
editingName: "",
|
editingName: "",
|
||||||
gridTemplate: "220px",
|
gridTemplate: "220px",
|
||||||
packageSpeedBps: 0,
|
packageSpeedBps: 0,
|
||||||
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
|
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], allItems: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
|
||||||
selectedIds: new Set<string>(),
|
selectedIds: new Set<string>(),
|
||||||
selectedVersion: 0
|
selectedVersion: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
|
||||||
|
const html = renderToStaticMarkup(ItemRowContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
gridTemplate: "220px",
|
||||||
|
item: item("extract-error", "package-a", "failed", {
|
||||||
|
fullStatus: "Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende",
|
||||||
|
lastError: "Mega-Debrid API: Kein Server verfügbar"
|
||||||
|
}),
|
||||||
|
selected: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html.match(/>Entpack-Fehler<\/span>/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain('title="Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende');
|
||||||
|
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only the operation in an actively downloading package status", () => {
|
it("shows only the operation in an actively downloading package status", () => {
|
||||||
@@ -981,7 +1068,7 @@ describe("download table row contracts", () => {
|
|||||||
editingName: "",
|
editingName: "",
|
||||||
gridTemplate: "220px",
|
gridTemplate: "220px",
|
||||||
packageSpeedBps: 1_000,
|
packageSpeedBps: 1_000,
|
||||||
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
|
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], allItems: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
|
||||||
selectedIds: new Set<string>(),
|
selectedIds: new Set<string>(),
|
||||||
selectedVersion: 0
|
selectedVersion: 0
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ import {
|
|||||||
buildTargetedAccountCheck,
|
buildTargetedAccountCheck,
|
||||||
filterAccountAddOptions,
|
filterAccountAddOptions,
|
||||||
getSettingsSaveLabel,
|
getSettingsSaveLabel,
|
||||||
|
getSettingsSelectNavigationIndex,
|
||||||
projectAccountRows,
|
projectAccountRows,
|
||||||
pruneAccountSelection,
|
pruneAccountSelection,
|
||||||
reconcileAccountAddDraft,
|
reconcileAccountAddDraft,
|
||||||
|
resolveHistoryRetentionSelection,
|
||||||
sortAccountRows,
|
sortAccountRows,
|
||||||
type AccountAddOption,
|
type AccountAddOption,
|
||||||
type AccountRowSource,
|
type AccountRowSource,
|
||||||
@@ -53,6 +55,10 @@ const accountWorkspaceSource = readFileSync(
|
|||||||
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
|
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
const settingsCss = readFileSync(
|
||||||
|
new URL("../src/renderer/views/settings/settings.css", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
function sourceBlock(source: string, start: string, end: string): string {
|
function sourceBlock(source: string, start: string, end: string): string {
|
||||||
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
|
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
|
||||||
@@ -105,7 +111,7 @@ function accountSources(): AccountRowSource[] {
|
|||||||
},
|
},
|
||||||
dailyLimitBytes: 10 * GIB,
|
dailyLimitBytes: 10 * GIB,
|
||||||
dailyUsageBytes: 4 * GIB,
|
dailyUsageBytes: 4 * GIB,
|
||||||
username: "stored@example.test",
|
username: "stored-user",
|
||||||
credentialKind: "password",
|
credentialKind: "password",
|
||||||
canCheck: true
|
canCheck: true
|
||||||
},
|
},
|
||||||
@@ -310,7 +316,8 @@ describe("settings model", () => {
|
|||||||
const rows = projectAccountRows(accountSources(), [], NOW);
|
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||||
|
|
||||||
expect(rows.map((row) => row.id)).toEqual(accountSources().map((source) => buildAccountRowId(source.service, source.mode, source.identityId)));
|
expect(rows.map((row) => row.id)).toEqual(accountSources().map((source) => buildAccountRowId(source.service, source.mode, source.identityId)));
|
||||||
expect(rows[0].username).toBe("verified@example.test");
|
expect(rows[0].username).toBe("stored-user");
|
||||||
|
expect(rows[0].email).toBe("verified@example.test");
|
||||||
expect(rows[0].credential).toBe("••••••");
|
expect(rows[0].credential).toBe("••••••");
|
||||||
expect(rows[1].credential).toBe("API-Key");
|
expect(rows[1].credential).toBe("API-Key");
|
||||||
expect(rows.map((row) => row.status.tone)).toEqual(["ok", "free", "invalid", "unknown", "disabled"]);
|
expect(rows.map((row) => row.status.tone)).toEqual(["ok", "free", "invalid", "unknown", "disabled"]);
|
||||||
@@ -420,7 +427,7 @@ describe("settings views", () => {
|
|||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers English and German as a live language setting", () => {
|
it("offers animated language and bounded history retention choices", () => {
|
||||||
const form = buildSettingsFormViewModel({
|
const form = buildSettingsFormViewModel({
|
||||||
settings: defaultSettings(),
|
settings: defaultSettings(),
|
||||||
section: "allgemein",
|
section: "allgemein",
|
||||||
@@ -439,6 +446,53 @@ describe("settings views", () => {
|
|||||||
{ value: "de", label: "Deutsch" }
|
{ value: "de", label: "Deutsch" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
const historyRetention = form.groups.flatMap((group) => group.fields).find((field) => field.id === "historyRetentionMode");
|
||||||
|
|
||||||
|
expect(historyRetention).toEqual({
|
||||||
|
id: "historyRetentionMode",
|
||||||
|
kind: "select",
|
||||||
|
label: "Verlauf speichern",
|
||||||
|
value: "permanent",
|
||||||
|
options: [
|
||||||
|
{ value: "never", label: "Nie" },
|
||||||
|
{ value: "session", label: "Nur aktuelle Session" },
|
||||||
|
{ value: "permanent-100", label: "Nur letzte 100 Einträge" },
|
||||||
|
{ value: "permanent-250", label: "Nur letzte 250 Einträge" },
|
||||||
|
{ value: "permanent", label: "Dauerhaft" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(<SettingsForm actions={{ onAction: () => {}, onChange: () => {} }} model={form} />);
|
||||||
|
expect(html).toContain("class=\"settings-select\"");
|
||||||
|
expect(html).toContain("role=\"combobox\"");
|
||||||
|
expect(html).toContain("role=\"listbox\"");
|
||||||
|
expect(settingsCss).toMatch(/\.settings-select-options\s*\{[^}]*opacity:\s*0[^}]*transform:\s*translateY\(-6px\)[^}]*transition:/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports keyboard navigation in animated settings selects", () => {
|
||||||
|
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
|
||||||
|
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
|
||||||
|
expect(getSettingsSelectNavigationIndex(0, 3, "ArrowUp")).toBe(2);
|
||||||
|
expect(getSettingsSelectNavigationIndex(1, 3, "Home")).toBe(0);
|
||||||
|
expect(getSettingsSelectNavigationIndex(1, 3, "End")).toBe(2);
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/renderer/views/settings/SettingsForm.tsx", import.meta.url), "utf8");
|
||||||
|
expect(source).toContain("optionRefs.current[nextIndex]?.focus()");
|
||||||
|
expect(source).toContain('event.key === "Home"');
|
||||||
|
expect(source).toContain('event.key === "End"');
|
||||||
|
expect(source).toContain("onBlur={onBlur}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a bounded history preset when permanent retention is selected", () => {
|
||||||
|
expect(resolveHistoryRetentionSelection("permanent", 100, "permanent")).toEqual({
|
||||||
|
historyRetentionMode: "permanent",
|
||||||
|
historyMaxEntries: 500
|
||||||
|
});
|
||||||
|
expect(resolveHistoryRetentionSelection("permanent", 250, "permanent-100")).toEqual({
|
||||||
|
historyRetentionMode: "permanent",
|
||||||
|
historyMaxEntries: 100
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders one real sidebar marker and all sections", () => {
|
it("renders one real sidebar marker and all sections", () => {
|
||||||
@@ -610,19 +664,22 @@ describe("account workspace", () => {
|
|||||||
|
|
||||||
expect(addHtml).toContain("Account hinzufügen");
|
expect(addHtml).toContain("Account hinzufügen");
|
||||||
expect(addHtml).toContain("Prüfen und speichern");
|
expect(addHtml).toContain("Prüfen und speichern");
|
||||||
expect(count(addHtml, "<select")).toBe(1);
|
expect(count(addHtml, "<select")).toBe(0);
|
||||||
expect(addHtml).toContain('aria-label="Dienst / Zugangstyp"');
|
expect(addHtml).toContain('aria-label="Dienst oder Zugangstyp suchen"');
|
||||||
expect(count(addHtml, "<option")).toBe(options.length);
|
expect(addHtml).toContain('role="listbox"');
|
||||||
options.forEach((option) => expect(addHtml).toContain(`value="${option.id}"`));
|
expect(addHtml).toContain('class="settings-account-picker-header"');
|
||||||
expect(addHtml).toContain('<option value="megadebrid-api" selected="">Mega-Debrid · API</option>');
|
expect(addHtml).toContain("Dienst");
|
||||||
|
expect(addHtml).toContain("Typ/Funktion");
|
||||||
|
options.forEach((option) => expect(addHtml).toContain(`data-account-option-id="${option.id}"`));
|
||||||
|
expect(addHtml).toContain('data-account-option-id="megadebrid-api"');
|
||||||
|
expect(addHtml).toContain('aria-selected="true"');
|
||||||
expect(addHtml).toContain("Weiteren Account hinzufügen");
|
expect(addHtml).toContain("Weiteren Account hinzufügen");
|
||||||
expect(addHtml).toContain("Login:Passwort");
|
expect(addHtml).toContain("Login:Passwort");
|
||||||
expect(addHtml).not.toContain('type="search"');
|
expect(addHtml).toContain('type="search"');
|
||||||
expect(addHtml).not.toContain("Account-Typ filtern");
|
expect(addHtml).toContain("settings-account-picker-row");
|
||||||
expect(addHtml).not.toContain("settings-account-picker-row");
|
|
||||||
expect(count(addHtml, 'class="settings-account-dialog-fields"')).toBe(1);
|
expect(count(addHtml, 'class="settings-account-dialog-fields"')).toBe(1);
|
||||||
expect(addHtml.indexOf('aria-label="Dienst / Zugangstyp"')).toBeLessThan(addHtml.indexOf("settings-account-option-meta"));
|
expect(addHtml.indexOf('aria-label="Dienst oder Zugangstyp suchen"')).toBeLessThan(addHtml.indexOf("settings-account-picker-table"));
|
||||||
expect(addHtml.indexOf("settings-account-option-meta")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
|
expect(addHtml.indexOf("settings-account-picker-table")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
|
||||||
expect(editHtml).toContain("Account bearbeiten");
|
expect(editHtml).toContain("Account bearbeiten");
|
||||||
expect(editHtml).toContain("member@example.test");
|
expect(editHtml).toContain("member@example.test");
|
||||||
expect(editHtml).toContain("Entfernen");
|
expect(editHtml).toContain("Entfernen");
|
||||||
@@ -631,7 +688,7 @@ describe("account workspace", () => {
|
|||||||
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects the account option through the single service selector", () => {
|
it("selects the account option through the compact service table", () => {
|
||||||
const selected: string[] = [];
|
const selected: string[] = [];
|
||||||
const tree = AccountAddDialog({
|
const tree = AccountAddDialog({
|
||||||
actions: {
|
actions: {
|
||||||
@@ -653,12 +710,24 @@ describe("account workspace", () => {
|
|||||||
busy: false
|
busy: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const selector = findElement(tree, (element) => element.type === "select" && element.props["aria-label"] === "Dienst / Zugangstyp");
|
const selector = findElement(tree, (element) => element.props["data-account-option-id"] === "debridlink-api");
|
||||||
|
|
||||||
selector.props.onChange({ target: { value: "debridlink-api" } });
|
selector.props.onClick();
|
||||||
|
|
||||||
expect(selected).toEqual(["debridlink-api"]);
|
expect(selected).toEqual(["debridlink-api"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps stored usernames separate from provider email addresses", () => {
|
||||||
|
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||||
|
|
||||||
|
expect(rows[0].username).toBe("stored-user");
|
||||||
|
expect(rows[0].email).toBe("verified@example.test");
|
||||||
|
expect(ACCOUNT_COLUMNS).toContain("E-Mail");
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||||
|
expect(html).toContain("stored-user");
|
||||||
|
expect(html).toContain("verified@example.test");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings App integration", () => {
|
describe("settings App integration", () => {
|
||||||
|
|||||||
+31
-1
@@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { AppSettings } from "../src/shared/types";
|
import { AppSettings } from "../src/shared/types";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -672,6 +672,36 @@ describe("settings storage", () => {
|
|||||||
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves cleaned package progress aggregates while normalizing a session", () => {
|
||||||
|
const session = emptySession();
|
||||||
|
session.packageOrder = ["pkg-progress"];
|
||||||
|
session.packages["pkg-progress"] = {
|
||||||
|
id: "pkg-progress",
|
||||||
|
name: "Progress",
|
||||||
|
outputDir: "C:\\Downloads\\Progress",
|
||||||
|
extractDir: "C:\\Downloads\\Progress\\Extracted",
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
cleanedCompletedItemCount: 3,
|
||||||
|
cleanedExtractedItemCount: 2,
|
||||||
|
cleanedDownloadedBytes: 3_000,
|
||||||
|
cleanedTotalBytes: 4_000,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeLoadedSession(session);
|
||||||
|
|
||||||
|
expect(normalized.packages["pkg-progress"]).toEqual(expect.objectContaining({
|
||||||
|
cleanedCompletedItemCount: 3,
|
||||||
|
cleanedExtractedItemCount: 2,
|
||||||
|
cleanedDownloadedBytes: 3_000,
|
||||||
|
cleanedTotalBytes: 4_000
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("returns empty session when session file contains invalid JSON", () => {
|
it("returns empty session when session file contains invalid JSON", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
Reference in New Issue
Block a user