release: publish Multi-Debrid Downloader v2.0.14
Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [2.0.14] - 2026-08-10
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added immediate English/German language switching across the complete interface. New installations now start in English, while existing installations retain their previous language behavior until changed.
|
||||
- Added RapidGator file-size discovery before downloads start and accelerated large queue checks with bounded parallel requests.
|
||||
- Added package and file availability states with aligned online counters, clear colors, and automatic migration of existing column layouts.
|
||||
- Reworked the Downloads workspace for substantially denser queues, smoother interaction, clearer selection, and responsive column sizing.
|
||||
- Rebuilt the project README with current documentation and a sanitized screenshot of the application.
|
||||
|
||||
### Downloads workspace
|
||||
|
||||
- Reduced package rows to 40 pixels and file rows to 38 pixels so more queue entries fit on screen.
|
||||
- Removed the previous blue package-card treatment in favor of flat rows and restrained separators.
|
||||
- Changed progress fills to green and unified package/file value contrast.
|
||||
- Added stable minimum widths and proportional column distribution so labels and values remain readable at normal window sizes.
|
||||
- Centered all data columns while preserving left alignment for package and file names.
|
||||
- Increased the action column width and reserved scrollbar space to prevent clipping.
|
||||
- Normalized expand buttons to a fixed 30 × 30 pixel footprint.
|
||||
- Added a 300 ms measured expand/collapse animation for package files.
|
||||
- Added smooth, pointer-based full-column reordering. The dragged header and every visible cell move together while neighboring columns glide aside.
|
||||
- Added persistent column-order migration with the new Availability column.
|
||||
- Added RapidGator and DDownload hoster icons with full-name tooltips and text fallback for unknown hosters.
|
||||
|
||||
### Availability and metadata
|
||||
|
||||
- Added package-level online counters with separate online, partial, offline, and checking states.
|
||||
- Added file-level Online and Offline indicators.
|
||||
- Aligned availability symbols, counts, separators, and labels on fixed axes.
|
||||
- Read RapidGator file names and sizes from the public file page without creating a debrid download.
|
||||
- Replaced sequential link checks with bounded concurrency and duplicate-request sharing.
|
||||
- Improved retry behavior for transient RapidGator metadata failures.
|
||||
- Updated known file and package sizes while the queue is checked.
|
||||
- Corrected the sidebar Total value to represent the current queue instead of the historical all-time download counter.
|
||||
|
||||
### Selection and package controls
|
||||
|
||||
- Fixed global Select all and Deselect all synchronization for memoized package rows.
|
||||
- Increased selection checkboxes to 18 × 18 pixels.
|
||||
- Added a green selected-row background and a clear left selection edge.
|
||||
- Added Shift range selection across visible package and file rows.
|
||||
- Added Escape handling to clear the complete selection, including when a checkbox has focus.
|
||||
- Removed the redundant package activation checkbox; activation remains available through context actions.
|
||||
- Preserved additive Ctrl/Command selection and bulk actions.
|
||||
|
||||
### Navigation and animation
|
||||
|
||||
- Added a shared sliding selection indicator to the main navigation.
|
||||
- Added matching sliding indicators to Downloads filters, Settings sections, History ranges, and Statistics periods.
|
||||
- Fixed initial category selection so newly opened views appear selected immediately instead of fading in from an empty state.
|
||||
- Preserved intentional interface animations when the operating system requests reduced motion.
|
||||
- Added slide-in and slide-out transitions to the File, Settings, and Help menus.
|
||||
- Kept menu panels within the application window at narrow widths.
|
||||
- Added rolling number transitions for package, link, session, total-size, and hoster statistics.
|
||||
|
||||
### Interface and layout
|
||||
|
||||
- Added the application icon and a bordered product identity area to the main header.
|
||||
- Standardized sidebar geometry across Link collector, Settings, History, and Statistics.
|
||||
- Reworked Downloads sidebar actions as permanently recognizable bordered buttons.
|
||||
- Moved Monitor clipboard directly above the statistics panel, added the same button surface, and reduced the lower gap.
|
||||
- Made clipboard toggling optimistic so the checkbox responds immediately and safely rolls back on an IPC failure.
|
||||
- Hid the redundant Files display mode and retained a focused package workflow.
|
||||
- Improved header, menu, account-table, progress-value, and status text contrast.
|
||||
- Unified the speed indicator with the rest of the application shell.
|
||||
- Fixed duplicated Speed wording in the Downloads sidebar summary.
|
||||
|
||||
### Localization
|
||||
|
||||
- Added persistent `English` and `Deutsch` language settings.
|
||||
- Localized navigation, menus, settings, dialogs, notifications, tooltips, empty states, status labels, history entries, pagination, and update flows.
|
||||
- Fixed remaining German history statuses appearing while English was selected.
|
||||
- Kept existing installations compatible while setting English as the default for new installations.
|
||||
- Localized manual update checks and the GitHub release changelog display.
|
||||
|
||||
### History, settings, and accounts
|
||||
|
||||
- Matched History action buttons to the Downloads action-button design.
|
||||
- Centered History column headings except Package / File.
|
||||
- Changed removed history entries to red and completed entries to green.
|
||||
- Unified account-table header text with the primary high-contrast text color.
|
||||
- Kept Account Overview and Usage Rules within a stable viewport layout.
|
||||
- Aligned the update-check action to the right and kept Save settings visibly disabled when no changes are pending.
|
||||
|
||||
### Development and reliability
|
||||
|
||||
- Assigned the downloader development server a fixed port to prevent another Electron project from being opened accidentally.
|
||||
- Added strict development URL resolution for the correct Vite instance.
|
||||
- Packaged the application icon as an unpacked resource and resolved it from the correct development or installed path for window and tray use.
|
||||
- Improved settings migration for language and column-order versions.
|
||||
- Added regression coverage for localization, availability, column dragging, navigation indicators, queue totals, metadata checks, selection, clipboard behavior, and responsive layout contracts.
|
||||
|
||||
## [2.0.13] - 2026-08-10
|
||||
|
||||
- Rebuilt the primary desktop workspace across Downloads, Link collector, Settings, History, and Statistics.
|
||||
- Added compact navigation, contextual sidebars, dense data tables, account management improvements, and responsive layouts.
|
||||
- Improved update visibility, dialogs, context menus, notifications, keyboard focus, error recovery, and theme behavior.
|
||||
|
||||
## [2.0.12] - 2026-08-10
|
||||
|
||||
- Prevented static Settings text from being selected while keeping editable fields selectable.
|
||||
|
||||
## [2.0.11] - 2026-08-10
|
||||
|
||||
- Stabilized the account-management layout when switching between Overview and Usage Rules.
|
||||
@@ -1,367 +1,204 @@
|
||||
# Multi Debrid Downloader
|
||||
# Multi-Debrid Downloader
|
||||
|
||||
Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates.
|
||||
Fast package-based downloading for Windows with multiple debrid services, automatic fallback, link availability checks, extraction, queue recovery, history, statistics, and built-in updates.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
||||
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
||||
[](https://www.electronjs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
## Changelog
|
||||

|
||||
|
||||
### 2.0.12
|
||||
## Overview
|
||||
|
||||
- Prevent static headings, labels, hints, and card text from being selected throughout Settings.
|
||||
- Keep editable fields and text areas selectable for normal copy and paste.
|
||||
Multi-Debrid Downloader combines a desktop download queue with provider routing, package management, automatic extraction, and persistent recovery. Add direct hoster links, organize them into packages, choose your debrid accounts, and let the application handle retries, fallback, progress, cleanup, and history.
|
||||
|
||||
### 2.0.11
|
||||
The interface is available in English and German. New installations start in English, and the language can be changed immediately without restarting the application.
|
||||
|
||||
- Keep Account Overview and Usage Rules inside the same fixed layout frame.
|
||||
- Prevent sizing and scroll-mode changes when switching between the account tabs.
|
||||
## Download
|
||||
|
||||
### 2.0.10
|
||||
The latest Windows release is available on the [GitHub Releases page](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest).
|
||||
|
||||
- Add locally bundled provider logos throughout account management.
|
||||
- Reorder the account table to Hoster, Status, Download Traffic Remaining, Username, Expiry Date, and Password / Access.
|
||||
- Increase account-row and status text sizes while keeping the table compact.
|
||||
- Show full account usernames while continuing to protect passwords and API keys.
|
||||
- Replace the provider-wide edit form with a compact editor for the selected account or API key.
|
||||
- Preserve sibling accounts, usage counters, limits, and status metadata during account-specific edits.
|
||||
|
||||
### 2.0.9
|
||||
|
||||
- Rebuild the Accounts view as a viewport-filling compact table with flat rows and bottom-aligned actions.
|
||||
- Separate account usage rules from the overview so rotation and routing controls no longer reduce the visible account list.
|
||||
- Redesign the account dialog with one-line provider rows, an immediate default selection, compact credential fields, and split cancel/save actions.
|
||||
- Add a global account enable/disable control while preserving unrelated provider state.
|
||||
|
||||
### 2.0.8
|
||||
|
||||
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
|
||||
- Add account actions through right-click and the row action menu.
|
||||
- Support quick edit, status checks, enable/disable, and removal without expanding the full account form.
|
||||
|
||||
### 2.0.7
|
||||
|
||||
- Improve account filter readability in dark and light themes.
|
||||
|
||||
### 2.0.6
|
||||
|
||||
- Keeps the download speed history alive when switching between tabs.
|
||||
- Adds API and Web-Login filters to the account picker.
|
||||
- Uses clearer Web-Login account labels and validates supported credentials before saving.
|
||||
|
||||
## Why this tool?
|
||||
|
||||
- Package-based workflow with progress, extraction, history, and clean post-processing.
|
||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
||||
- Includes an in-app updater for releases published on GitHub.
|
||||
|
||||
## Supported providers
|
||||
|
||||
- AllDebrid API
|
||||
- AllDebrid Web via browser login
|
||||
- BestDebrid API
|
||||
- BestDebrid Web via cookie import
|
||||
- Debrid-Link with multi-key support
|
||||
- DDownload login
|
||||
- 1fichier API
|
||||
- LinkSnappy login
|
||||
- Mega-Debrid API
|
||||
- Mega-Debrid Web
|
||||
- Real-Debrid
|
||||
|
||||
## Core features
|
||||
|
||||
### Queue and package handling
|
||||
|
||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
||||
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
||||
- Ctrl+Click multi-select and bulk actions.
|
||||
- Queue backup import/export as JSON.
|
||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
||||
- Optional start scheduling for a specific time.
|
||||
- Session recovery after restart with optional auto-resume.
|
||||
- Optional auto-sorting by progress.
|
||||
|
||||
### Link collection
|
||||
|
||||
- Paste links directly into the collector.
|
||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
||||
- Clipboard watcher with automatic link detection.
|
||||
- `.dlc` import via file picker and drag-and-drop.
|
||||
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
||||
|
||||
### Provider routing and fallback
|
||||
|
||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
||||
- Optional automatic provider fallback on unrestrict/download failures.
|
||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
||||
- Providers can be disabled without deleting stored account data.
|
||||
- Daily traffic limits per provider.
|
||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
||||
|
||||
### Accounts and provider tools
|
||||
|
||||
- Central Accounts view with account type, status, info, access data, and actions.
|
||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
||||
|
||||
### Download engine
|
||||
|
||||
- Parallel downloads with resumable transfers when supported.
|
||||
- Reconnect handling with configurable wait time.
|
||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
||||
- Global speed limit or per-download speed limit mode.
|
||||
- Bandwidth schedules with time windows and speed caps.
|
||||
- Live bandwidth chart and session statistics.
|
||||
- Persistent all-time download counter.
|
||||
|
||||
### Extraction and post-processing
|
||||
|
||||
- Automatic extraction after download.
|
||||
- Extraction can continue even when the session is stopped or after app restart.
|
||||
- Hybrid download + extract workflow.
|
||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
||||
- Nested extraction for archives found inside extracted output.
|
||||
- Conflict handling: overwrite, skip, rename, or ask.
|
||||
- Disk-space validation before extraction.
|
||||
- Package-scoped password reuse for multi-archive sets.
|
||||
- Optional cleanup of downloaded archives after extraction.
|
||||
- Optional cleanup of link artifacts and sample files after extraction.
|
||||
- Optional flat MKV collection folder after package completion.
|
||||
|
||||
### Auto-rename and media cleanup
|
||||
|
||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
||||
- Multi-episode token parsing.
|
||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
||||
- Optional skip of already extracted packages on start.
|
||||
|
||||
### Integrity, history, and backup
|
||||
|
||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
||||
- Download history with package details, duration, size, provider, and target folder.
|
||||
- Backup export/import for restoring app state.
|
||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
||||
|
||||
### UI and desktop integration
|
||||
|
||||
- Downloads, history, statistics, and settings tabs.
|
||||
- Progress bars for packages and single items.
|
||||
- Hoster/provider display showing both source and effective debrid account.
|
||||
- Minimize-to-tray support.
|
||||
- Dark/light theme setting.
|
||||
- Long path support on Windows.
|
||||
- Default startup window size of `1920x1080`.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prebuilt releases
|
||||
|
||||
1. Download the latest installer or portable build from the releases page.
|
||||
2. Start the app.
|
||||
3. Add your provider credentials in `Settings > Accounts`.
|
||||
|
||||
Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
||||
|
||||
### Build from source
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js `20+`
|
||||
- npm
|
||||
- Windows `10/11`
|
||||
- Java Runtime `8+` for the optional JVM extraction backend
|
||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## NPM scripts
|
||||
|
||||
| Command | Description |
|
||||
| Package | Recommended for |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
||||
| `npm run build` | Builds main and renderer bundles |
|
||||
| `npm run start` | Starts the built app in production mode |
|
||||
| `npm test` | Runs Vitest unit tests |
|
||||
| `npm run self-check` | Runs integrated self-checks |
|
||||
| `npm run verify:release -- --verify-archives --seven-zip <path-to-7z.exe>` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads |
|
||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
||||
| `Real-Debrid-Downloader-Setup-<version>.exe` | Normal installation with Start menu and optional desktop shortcut |
|
||||
| `Real-Debrid-Downloader-<version>-portable.exe` | Portable use without installation |
|
||||
| `SHA256SUMS.txt` | Verifying downloaded files |
|
||||
|
||||
## Typical workflow
|
||||
Windows 10 or Windows 11 is required. Release executables are currently unsigned, so Windows may show a SmartScreen warning on first launch.
|
||||
|
||||
1. Add one or more provider accounts in `Settings > Accounts`.
|
||||
2. Configure provider order, fallback, and optional hoster routing.
|
||||
3. Paste links or import `.dlc` files.
|
||||
4. Adjust package names, target folders, extraction, and cleanup settings if needed.
|
||||
5. Start the queue and monitor downloads, extraction, and provider status.
|
||||
6. Review history and statistics after completion.
|
||||
## Highlights
|
||||
|
||||
- Dense package queue with expandable file rows, search, filters, bulk selection, Shift range selection, and keyboard clearing with Escape.
|
||||
- RapidGator availability and file-size discovery before a download starts.
|
||||
- Package totals calculated from known link sizes and updated while links are checked.
|
||||
- Multi-account provider routing with configurable priority, hoster overrides, traffic limits, and automatic fallback.
|
||||
- Resumable parallel downloads, retries, reconnect handling, schedules, bandwidth limits, and live speed history.
|
||||
- Automatic extraction with nested archives, password reuse, conflict handling, disk-space checks, and optional cleanup.
|
||||
- Persistent sessions, history, all-time statistics, queue backup import/export, and recovery after restart.
|
||||
- English and German interface with localized dialogs, statuses, menus, settings, and update information.
|
||||
- Built-in GitHub update checks with release changelogs and installer download support.
|
||||
|
||||
## Supported services
|
||||
|
||||
| Service | Access modes |
|
||||
| --- | --- |
|
||||
| Real-Debrid | API token |
|
||||
| AllDebrid | API token, browser login |
|
||||
| BestDebrid | API token, cookie import |
|
||||
| Debrid-Link | Multiple API keys |
|
||||
| Mega-Debrid | API, web login |
|
||||
| LinkSnappy | Account login |
|
||||
| DDownload | Account login |
|
||||
| 1fichier | API key |
|
||||
|
||||
Provider availability and supported hosters can change independently of the application. A valid account for at least one configured service is required for debrid downloads.
|
||||
|
||||
## Download queue
|
||||
|
||||
The Downloads workspace is optimized for large queues:
|
||||
|
||||
- Compact package and file rows with progress, downloaded size, total size, hoster, service, priority, status, speed, and availability.
|
||||
- RapidGator and DDownload hoster icons with full names in tooltips.
|
||||
- Online counters for packages and clear online, partial, checking, and offline states.
|
||||
- Drag-reorderable columns with a smooth full-column preview and persisted layout.
|
||||
- Green progress indicators, animated package expansion, and clear selected-row highlighting.
|
||||
- Context menus for package and file actions without redundant activation controls.
|
||||
- Start, pause, stop, schedule, reorder, rename, remove, enable, disable, reset, and retry actions.
|
||||
- Animated summary values for packages, links, session bytes, queue size, and hoster count.
|
||||
|
||||
## Link collector
|
||||
|
||||
- Paste one or many supported links into collections before adding them to the queue.
|
||||
- Import structured text exports, JSON queue backups, and DLC containers.
|
||||
- Drop plain links or supported files directly into the application.
|
||||
- Monitor the clipboard with an immediate, persistent toggle.
|
||||
- Preserve package and optional file names when exporting and re-importing selected links.
|
||||
|
||||
## Accounts and routing
|
||||
|
||||
- Manage several accounts or API keys for the same provider.
|
||||
- Enable or disable individual accounts without deleting their saved data.
|
||||
- Check account status, remaining traffic, username, expiry, and access type.
|
||||
- Configure primary, secondary, and tertiary provider fallback.
|
||||
- Route individual hosters through a specific provider.
|
||||
- Apply daily provider limits and per-key Debrid-Link limits.
|
||||
- Keep passwords, API keys, cookies, and tokens masked in the interface.
|
||||
|
||||
## Download engine
|
||||
|
||||
- Parallel downloads with configurable concurrency.
|
||||
- Resume support when the source and provider allow ranged requests.
|
||||
- Automatic retries with cooldown and reconnect handling.
|
||||
- Global or per-download speed limits and time-based bandwidth schedules.
|
||||
- Package speed, ETA, progress, and live bandwidth statistics.
|
||||
- Duplicate handling with keep, skip, or overwrite choices.
|
||||
- Optional scheduled queue start and automatic resume after restart.
|
||||
|
||||
## Extraction and cleanup
|
||||
|
||||
- Automatic RAR, ZIP, and 7z extraction after download.
|
||||
- Nested archive extraction and package-scoped password reuse.
|
||||
- Conflict policies for overwrite, skip, rename, or confirmation.
|
||||
- Disk-space validation before extraction.
|
||||
- Optional archive, link-artifact, and sample-file cleanup.
|
||||
- Optional flat MKV collection after a package completes.
|
||||
- Extraction recovery after restart and optional extraction while downloads are stopped.
|
||||
|
||||
## History and statistics
|
||||
|
||||
- Filterable history with completed, failed, removed, and active outcomes.
|
||||
- Package details including provider, target folder, size, duration, and completion state.
|
||||
- Session, daily, seven-day, 30-day, and all-time statistics.
|
||||
- Persistent all-time counters and bandwidth history.
|
||||
- Localized status colors and consistent table actions.
|
||||
|
||||
## Settings and updates
|
||||
|
||||
- Immediate English/German language switching.
|
||||
- Dark, light, and system theme options.
|
||||
- Configurable download folder, queue behavior, history retention, notifications, extraction, cleanup, and bandwidth.
|
||||
- GitHub update checks with localized release notes and download actions.
|
||||
- Backup export/import for settings and optional queue data.
|
||||
- Optional minimize-to-tray and desktop notifications.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Download the installer or portable executable from [Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest).
|
||||
2. Open **Settings → Accounts** and add at least one supported debrid account.
|
||||
3. Configure provider order and optional hoster routing under **Usage rules**.
|
||||
4. Add links from **Downloads** or prepare collections in **Link collector**.
|
||||
5. Review package names, destination, extraction, and cleanup settings.
|
||||
6. Start the queue.
|
||||
|
||||
## Link export format
|
||||
|
||||
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
|
||||
Selected packages or files can be exported as structured text and imported again without losing package grouping.
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
```text
|
||||
# rd-link-export: 1
|
||||
# package: Dave Staffel 1
|
||||
# file: Dave.S01E01.rar
|
||||
https://example.com/e01
|
||||
# file: Dave.S01E02.rar
|
||||
https://example.com/e02
|
||||
# package: Example Series S01
|
||||
# file: Example.Series.S01E01.part1.rar
|
||||
https://example.com/file-1
|
||||
# file: Example.Series.S01E01.part2.rar
|
||||
https://example.com/file-2
|
||||
```
|
||||
|
||||
Supported import sources:
|
||||
## Build from source
|
||||
|
||||
- collector text input
|
||||
- `Datei importieren`
|
||||
- drag-and-drop of `.txt` and `.json`
|
||||
Requirements:
|
||||
|
||||
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
|
||||
- Node.js 20 or newer
|
||||
- npm
|
||||
- Windows 10 or Windows 11 for release packaging
|
||||
- Optional Java Runtime 8 or newer for the JVM extraction backend
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm test
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Create the Windows installer and portable executable:
|
||||
|
||||
```powershell
|
||||
npm run release:win
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Start the main-process watcher, Vite renderer, and Electron |
|
||||
| `npm test` | Run client and backup API tests |
|
||||
| `npm run self-check` | Run the integrated application self-check |
|
||||
| `npm run build` | Build the main and renderer bundles |
|
||||
| `npm run release:win` | Build the Windows installer and portable executable |
|
||||
| `npm run verify:release` | Verify release metadata and packaged redistribution files |
|
||||
|
||||
## Project structure
|
||||
|
||||
- `src/main` - Electron main process, download engine, provider clients, updater, storage
|
||||
- `src/preload` - secure IPC bridge
|
||||
- `src/renderer` - React UI
|
||||
- `src/shared` - shared types and IPC contracts
|
||||
- `tests` - unit and integration-style tests
|
||||
- `resources/extractor-jvm` - optional JVM extraction runtime
|
||||
- `scripts` - release and build helpers
|
||||
|
||||
## Data and logs
|
||||
|
||||
Runtime files are stored in Electron's `userData` directory, including:
|
||||
|
||||
- `rd_downloader_config.json`
|
||||
- `rd_session_state.json`
|
||||
- `rd_history.json`
|
||||
- `rd_downloader.log`
|
||||
- `audit.log`
|
||||
- `rename.log`
|
||||
- `debug_support_manifest.json`
|
||||
- `trace.log`
|
||||
- `trace_config.json`
|
||||
- `session-logs/session_*.txt`
|
||||
- `package-logs/package_*.txt`
|
||||
- `item-logs/item_*.txt`
|
||||
|
||||
`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
|
||||
|
||||
### Remote debug server
|
||||
|
||||
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
|
||||
|
||||
Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`:
|
||||
|
||||
- `debug_token.txt`
|
||||
Example: a long random token such as `rd-debug-please-change-me`
|
||||
- `debug_port.txt`
|
||||
Example: `9868`
|
||||
- `debug_host.txt` (optional)
|
||||
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
|
||||
|
||||
After startup, the app also writes `debug_support_manifest.json` into the same runtime folder. This file lists all available endpoints, the authentication method, related runtime files, and the requirements for remote access.
|
||||
|
||||
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
|
||||
|
||||
The app menu under `Help` also includes a `Check debug setup` action. It verifies the current host/port/token/support-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
|
||||
|
||||
Available endpoints after restart:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /meta`
|
||||
- `GET /debug/setup`
|
||||
- `GET /self-check`
|
||||
- `GET /host/diagnostics`
|
||||
- `GET /status`
|
||||
- `GET /settings`
|
||||
- `GET /accounts`
|
||||
- `GET /stats`
|
||||
- `GET /history?limit=50&status=completed`
|
||||
- `GET /packages?package=Release&includeItems=1`
|
||||
- `GET /items?status=downloading&package=Release`
|
||||
- `GET /session?package=Release`
|
||||
- `GET /log?lines=100&grep=keyword`
|
||||
- `GET /logs/main?lines=100&grep=keyword`
|
||||
- `GET /logs/audit?lines=100&grep=keyword`
|
||||
- `GET /logs/rename?lines=100&grep=keyword`
|
||||
- `GET /logs/trace?lines=100&grep=keyword`
|
||||
- `GET /logs/session?lines=100&grep=keyword`
|
||||
- `GET /logs/package?package=Release&lines=100&grep=keyword`
|
||||
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
|
||||
- `GET /trace/config?enable=1¬e=support&durationMinutes=120`
|
||||
- `GET /support/bundle`
|
||||
- `GET /diagnostics?package=Release&lines=150`
|
||||
|
||||
Authentication works with either:
|
||||
|
||||
- header: `Authorization: Bearer <token>`
|
||||
- query param: `?token=<token>`
|
||||
|
||||
Example from PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
|
||||
Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
|
||||
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
|
||||
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"
|
||||
Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip"
|
||||
```text
|
||||
src/main Electron main process and download engine
|
||||
src/preload Secure IPC bridge
|
||||
src/renderer React interface
|
||||
src/shared Shared types and contracts
|
||||
resources/extractor-jvm Optional extraction runtime
|
||||
services/backup-api Backup API service
|
||||
scripts Build and release verification tools
|
||||
tests Unit and integration tests
|
||||
```
|
||||
|
||||
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely.
|
||||
## Data, privacy, and diagnostics
|
||||
|
||||
## Troubleshooting
|
||||
Configuration, credentials, queue state, history, and logs are stored locally in Electron's `userData` directory. Secrets are not included in public source or release archives. Provider credentials are only sent to the configured provider endpoints required for account and download operations.
|
||||
|
||||
- Provider does not work: verify credentials, enabled state, provider order, and daily limits.
|
||||
- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
|
||||
- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
|
||||
- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
|
||||
- Accounts table looks misaligned on one machine: use `Reset columns` in the Accounts view to clear the locally stored column widths.
|
||||
The application can generate support diagnostics and expose an optional authenticated local debug API. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token.
|
||||
|
||||
## Changelog
|
||||
## Updates and changelog
|
||||
|
||||
Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
|
||||
|
||||
### 2.0.5
|
||||
|
||||
- Move the default download, extraction, and video collection directories to the desktop under `Multi-Debrid-Downloader`.
|
||||
- Migrate untouched legacy default directories from `Downloads\RealDebrid` to the new desktop location.
|
||||
|
||||
### 2.0.4
|
||||
|
||||
- Rename the online backup action from "create" to "export" for clearer wording.
|
||||
|
||||
### 2.0.3
|
||||
|
||||
- Keep online backup keys on a single line in the export and import dialog.
|
||||
- Widen the online backup dialog on larger windows while keeping it responsive on smaller screens.
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the complete release history. The application also displays the current GitHub release notes when an update is available.
|
||||
|
||||
## License
|
||||
|
||||
The project is licensed under the MIT License. See `LICENSE`.
|
||||
|
||||
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
|
||||
|
||||
Bundled provider icon notices are available in `THIRD_PARTY_NOTICES.md`.
|
||||
Multi-Debrid Downloader is released under the [MIT License](LICENSE). Third-party notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
@@ -9,6 +9,7 @@ The provider names, logos, and favicons bundled with this application are tradem
|
||||
| BestDebrid | https://bestdebrid.com/en/files/assets/images/favicon.ico | 2026-08-09 | `11E54B1B0DCBBB8F5BCAA21D0EDC0CF182276C513ED733ACEEEBC35120CD9F33` |
|
||||
| AllDebrid | https://cdn.alldebrid.com/lib/images/default/favicon.png | 2026-08-09 | `AE296FBE43A6CCEABC85033E5C063BFCA01932EB3677FB679AF63C23535CD9EF` |
|
||||
| DDownload | https://ddownload.com/images/favicon.ico | 2026-08-09 | `E0413F73F8A2A5AD85BE8082B0897E82AE3B354FE2490884D7A992BB18F34DF3` |
|
||||
| RapidGator | https://rapidgator.net/favicon.ico | 2026-08-10 | `9CD57803D93FBD183DE40D1007606A3C30F90FC82A205C7ABF4C12D322B7C144` |
|
||||
| 1Fichier | https://img.1fichier.com/favicon.png | 2026-08-09 | `C3567E0299879FE67924C6119BF082BE128A5E8962FC0D61A0D8AAA0B1304CB5` |
|
||||
| Debrid-Link | https://cdn.debrid-link.com/img/fav/favicon.ico?i=2 | 2026-08-09 | `1AB89ADA0EB9CB665F5E05209F27B50869FCA1972620C90D57BF682E87F98FA4` |
|
||||
| LinkSnappy | https://linksnappy.com/templates/v3/img/favicon.png | 2026-08-09 | `D9DB0F5E4BDB2BF659ADD6D666F8464455196EC47201F27B126083754126497E` |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
|
||||
+7
-3
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.14",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:renderer": "vite",
|
||||
"dev:renderer": "vite --port 5180 --strictPort",
|
||||
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
||||
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
||||
"dev:electron": "wait-on tcp:5173 file:build/main/main/main.js && cross-env NODE_ENV=development electron .",
|
||||
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 electron .",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||
"build:renderer": "vite build",
|
||||
@@ -77,6 +77,10 @@
|
||||
{
|
||||
"from": "THIRD_PARTY_NOTICES.md",
|
||||
"to": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
{
|
||||
"from": "assets/app_icon.ico",
|
||||
"to": "assets/app_icon.ico"
|
||||
}
|
||||
],
|
||||
"asarUnpack": [
|
||||
|
||||
+22
-8
@@ -3,16 +3,30 @@ const { rcedit } = require("rcedit");
|
||||
|
||||
module.exports = async function afterPack(context) {
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
const productName = context.packager?.appInfo?.productName;
|
||||
const version = context.packager?.appInfo?.version;
|
||||
if (!productFilename) {
|
||||
console.warn(" • rcedit: skipped — productFilename not available");
|
||||
return;
|
||||
throw new Error("rcedit: productFilename not available");
|
||||
}
|
||||
if (!productName) {
|
||||
throw new Error("rcedit: productName not available");
|
||||
}
|
||||
if (!version) {
|
||||
throw new Error("rcedit: version not available");
|
||||
}
|
||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
||||
try {
|
||||
await rcedit(exePath, { icon: iconPath });
|
||||
} catch (error) {
|
||||
console.warn(` • rcedit: failed — ${String(error)}`);
|
||||
}
|
||||
console.log(` • rcedit: patching metadata and icon → ${exePath}`);
|
||||
await rcedit(exePath, {
|
||||
icon: iconPath,
|
||||
"file-version": version,
|
||||
"product-version": version,
|
||||
"version-string": {
|
||||
CompanyName: "Sucukdeluxe",
|
||||
FileDescription: "Multi-Debrid-Downloader",
|
||||
InternalFilename: productFilename,
|
||||
OriginalFilename: `${productFilename}.exe`,
|
||||
ProductName: productName,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,6 +23,10 @@ const EXPECTED_EXTRA_RESOURCE = Object.freeze({
|
||||
from: "LICENSE",
|
||||
to: "LICENSE"
|
||||
});
|
||||
const EXPECTED_ICON_RESOURCE = Object.freeze({
|
||||
from: "assets/app_icon.ico",
|
||||
to: "assets/app_icon.ico"
|
||||
});
|
||||
const REDISTRIBUTION_FILES = Object.freeze([
|
||||
Object.freeze({
|
||||
sourcePath: "LICENSE",
|
||||
@@ -32,7 +36,7 @@ const REDISTRIBUTION_FILES = Object.freeze([
|
||||
Object.freeze({
|
||||
sourcePath: "THIRD_PARTY_NOTICES.md",
|
||||
packagedPath: "resources/THIRD_PARTY_NOTICES.md",
|
||||
sha256: "b5d923900e9bf932fbf173191973d0892a091ac5afdc4907b8b9f4f846e8126c"
|
||||
sha256: "28f9d1f8692811758c41584e59d0db6cbf26654b4b93e335626921bef86bdf76"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
@@ -124,17 +128,31 @@ function verifyRedistributionFiles(baseDir, pathKey, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedExtraResource(extraResources) {
|
||||
function hasExtraResource(extraResources, expected) {
|
||||
const entries = Array.isArray(extraResources) ? extraResources : [extraResources];
|
||||
return entries.some((entry) => (
|
||||
entry
|
||||
&& typeof entry === "object"
|
||||
&& !Array.isArray(entry)
|
||||
&& entry.from === EXPECTED_EXTRA_RESOURCE.from
|
||||
&& entry.to === EXPECTED_EXTRA_RESOURCE.to
|
||||
&& entry.from === expected.from
|
||||
&& entry.to === expected.to
|
||||
));
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
}
|
||||
|
||||
function verifyPackagedIcon(sourceRoot, packagedRoot, label) {
|
||||
const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||
const packagedPath = path.join(packagedRoot, "resources", "assets", "app_icon.ico");
|
||||
requireNonEmptyFile(sourcePath, "source application icon");
|
||||
requireNonEmptyFile(packagedPath, `${label} application icon`);
|
||||
if (sha256File(sourcePath) !== sha256File(packagedPath)) {
|
||||
throw new Error(`${label} application icon content mismatch for resources/assets/app_icon.ico`);
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
@@ -195,7 +213,7 @@ function normalizeRelativePath(rootDir, filePath) {
|
||||
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function verifyArchiveRedistributionFiles(extractionRoot, archiveName) {
|
||||
function verifyArchiveRedistributionFiles(extractionRoot, archiveName, sourceRoot) {
|
||||
const extractedFiles = listFiles(extractionRoot);
|
||||
for (const file of REDISTRIBUTION_FILES) {
|
||||
const suffix = file.packagedPath.toLowerCase();
|
||||
@@ -214,6 +232,19 @@ function verifyArchiveRedistributionFiles(extractionRoot, archiveName) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const iconMatches = extractedFiles.filter((filePath) => normalizeRelativePath(extractionRoot, filePath).toLowerCase().endsWith("resources/assets/app_icon.ico"));
|
||||
if (iconMatches.length === 0) {
|
||||
throw new Error(`Missing application icon resources/assets/app_icon.ico in archive ${archiveName}`);
|
||||
}
|
||||
const sourceIconPath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||
requireNonEmptyFile(sourceIconPath, "source application icon");
|
||||
const sourceDigest = sha256File(sourceIconPath);
|
||||
for (const iconPath of iconMatches) {
|
||||
requireNonEmptyFile(iconPath, `${archiveName} application icon`);
|
||||
if (sha256File(iconPath) !== sourceDigest) {
|
||||
throw new Error(`Archive application icon content mismatch in ${archiveName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageVersion(rootDir) {
|
||||
@@ -260,9 +291,12 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
if (missingBuildFiles.length > 0) {
|
||||
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
||||
}
|
||||
if (!hasExpectedExtraResource(build.extraResources)) {
|
||||
if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) {
|
||||
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
||||
}
|
||||
if (!hasExtraResource(build.extraResources, EXPECTED_ICON_RESOURCE)) {
|
||||
throw new Error("package.json build.extraResources must copy assets/app_icon.ico to assets/app_icon.ico");
|
||||
}
|
||||
|
||||
const releaseDir = resolveReleaseDir(absoluteRoot);
|
||||
const latestFields = parseYamlDocument(
|
||||
@@ -310,6 +344,7 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
|
||||
verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
|
||||
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
||||
verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "win-unpacked");
|
||||
|
||||
return {
|
||||
publish: {
|
||||
@@ -340,7 +375,7 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
||||
try {
|
||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName);
|
||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
||||
verifiedArchives.push(archiveName);
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveAppIconPath(isPackaged: boolean, appPath: string, resourcesPath: string): string {
|
||||
const basePath = isPackaged ? resourcesPath : appPath;
|
||||
return path.join(basePath, "assets", "app_icon.ico");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api"
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
||||
return {
|
||||
language: "en",
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
@@ -120,7 +121,8 @@ export function defaultSettings(): AppSettings {
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
columnOrderVersion: 3,
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
|
||||
+16
-46
@@ -1697,13 +1697,23 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
||||
export interface RapidgatorCheckResult {
|
||||
online: boolean;
|
||||
fileName: string;
|
||||
fileSize: string | null;
|
||||
fileSizeBytes: number | null;
|
||||
}
|
||||
|
||||
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
|
||||
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
|
||||
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
|
||||
|
||||
export function parseRapidgatorFileSize(value: string | null | undefined): number | null {
|
||||
const match = String(value ?? "").trim().match(/^([\d.,]+)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i);
|
||||
if (!match) return null;
|
||||
const amount = Number(match[1].replace(/,/g, "."));
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
const powers: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
|
||||
const bytes = Math.round(amount * (1024 ** powers[match[2].toUpperCase()]));
|
||||
return Number.isSafeInteger(bytes) ? bytes : null;
|
||||
}
|
||||
|
||||
export async function checkRapidgatorOnline(
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
@@ -1723,46 +1733,6 @@ export async function checkRapidgatorOnline(
|
||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
|
||||
const response = await fetch(link, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
headers,
|
||||
signal: withTimeoutSignal(signal, 15000)
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const finalUrl = response.url || link;
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
const fileName = filenameFromRapidgatorUrlPath(link);
|
||||
return { online: true, fileName, fileSize: null };
|
||||
}
|
||||
|
||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||
break;
|
||||
}
|
||||
await sleepWithSignal(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
@@ -1776,7 +1746,7 @@ export async function checkRapidgatorOnline(
|
||||
|
||||
if (response.status === 404) {
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1791,20 +1761,20 @@ export async function checkRapidgatorOnline(
|
||||
const finalUrl = response.url || link;
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
|
||||
|
||||
if (RG_FILE_NOT_FOUND_RE.test(html)) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
|
||||
const sizeMatch = html.match(RG_FILESIZE_RE);
|
||||
const fileSize = sizeMatch ? sizeMatch[1].trim() : null;
|
||||
const fileSizeBytes = parseRapidgatorFileSize(sizeMatch?.[1]);
|
||||
|
||||
return { online: true, fileName, fileSize };
|
||||
return { online: true, fileName, fileSizeBytes };
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || "5180";
|
||||
export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
@@ -1703,6 +1703,19 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const item = items[nextIndex];
|
||||
nextIndex += 1;
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export class DownloadManager extends EventEmitter {
|
||||
private settings: AppSettings;
|
||||
|
||||
@@ -3278,31 +3291,28 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
const checkedUrls = new Map<string, Awaited<ReturnType<typeof checkRapidgatorOnline>>>();
|
||||
const checkedUrls = new Map<string, ReturnType<typeof checkRapidgatorOnline>>();
|
||||
|
||||
for (const { itemId, url } of itemsToCheck) {
|
||||
await runWithLimitedConcurrency(itemsToCheck, 8, async ({ itemId, url }) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
if (!item) return;
|
||||
|
||||
if (checkedUrls.has(url)) {
|
||||
const cached = checkedUrls.get(url);
|
||||
if (cached !== undefined) {
|
||||
this.applyRapidgatorCheckResult(item, cached);
|
||||
}
|
||||
this.emitState();
|
||||
continue;
|
||||
let check = checkedUrls.get(url);
|
||||
if (!check) {
|
||||
check = checkRapidgatorOnline(url);
|
||||
checkedUrls.set(url, check);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkRapidgatorOnline(url);
|
||||
checkedUrls.set(url, result);
|
||||
const result = await check;
|
||||
this.applyRapidgatorCheckResult(item, result);
|
||||
} catch (err) {
|
||||
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
|
||||
item.onlineStatus = undefined;
|
||||
}
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
}
|
||||
});
|
||||
|
||||
this.persistSoon();
|
||||
}
|
||||
@@ -3355,6 +3365,9 @@ export class DownloadManager extends EventEmitter {
|
||||
item.fileName = sanitizeFilename(result.fileName);
|
||||
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName));
|
||||
}
|
||||
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0) {
|
||||
item.totalBytes = result.fileSizeBytes;
|
||||
}
|
||||
item.onlineStatus = "online";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
@@ -3364,7 +3377,8 @@ export class DownloadManager extends EventEmitter {
|
||||
const uncheckedIds: string[] = [];
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (item.status !== "queued") continue;
|
||||
if (item.onlineStatus) continue;
|
||||
if (item.onlineStatus === "offline") continue;
|
||||
if (item.onlineStatus === "online" && item.totalBytes !== null && item.totalBytes > 0) continue;
|
||||
try {
|
||||
const host = new URL(item.url).hostname.toLowerCase();
|
||||
if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue;
|
||||
|
||||
+5
-3
@@ -11,6 +11,8 @@ import { APP_NAME } from "./constants";
|
||||
import { extractHttpLinksFromText } from "./utils";
|
||||
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
||||
import { revealHistoryEntry } from "./history-reveal";
|
||||
import { DEV_SERVER_URL } from "./dev-server-url";
|
||||
import { resolveAppIconPath } from "./app-icon";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -115,7 +117,7 @@ function createWindow(): BrowserWindow {
|
||||
minHeight: 760,
|
||||
backgroundColor: "#070b14",
|
||||
title: `${APP_NAME} - v${controller.getVersion()}`,
|
||||
icon: path.join(app.getAppPath(), "assets", "app_icon.ico"),
|
||||
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -140,7 +142,7 @@ function createWindow(): BrowserWindow {
|
||||
window.setAutoHideMenuBar(true);
|
||||
|
||||
if (isDevMode()) {
|
||||
void window.loadURL("http://localhost:5173");
|
||||
void window.loadURL(DEV_SERVER_URL);
|
||||
} else {
|
||||
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
|
||||
}
|
||||
@@ -212,7 +214,7 @@ function createTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
||||
const iconPath = resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath);
|
||||
try {
|
||||
tray = new Tray(iconPath);
|
||||
} catch (error) {
|
||||
|
||||
+13
-4
@@ -135,10 +135,10 @@ function migrateLegacyDefaultDirectories(settings: AppSettings, defaults: AppSet
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
||||
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
||||
|
||||
function normalizeColumnOrder(raw: unknown): string[] {
|
||||
function normalizeColumnOrder(raw: unknown, version: unknown): string[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return [...DEFAULT_COLUMN_ORDER];
|
||||
}
|
||||
@@ -154,6 +154,10 @@ function normalizeColumnOrder(raw: unknown): string[] {
|
||||
if (!seen.has("name")) {
|
||||
result.unshift("name");
|
||||
}
|
||||
if (version !== 3 && !seen.has("availability")) {
|
||||
const speedIndex = result.indexOf("speed");
|
||||
result.splice(speedIndex >= 0 ? speedIndex + 1 : result.length, 0, "availability");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -421,6 +425,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
);
|
||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||
const normalized: AppSettings = {
|
||||
language: settings.language === "de" ? "de" : "en",
|
||||
token: asText(settings.token),
|
||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||
megaLogin,
|
||||
@@ -506,7 +511,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
|
||||
columnOrderVersion: 3,
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
||||
@@ -677,9 +683,12 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
function readSettingsFile(filePath: string): AppSettings | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||
const merged = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
...migrateLegacyMegaEnableFlags(parsed)
|
||||
...migrateLegacyMegaEnableFlags(parsed),
|
||||
language: migratedLanguage,
|
||||
columnOrderVersion: parsed.columnOrderVersion
|
||||
});
|
||||
return sanitizeCredentialPersistence(merged);
|
||||
} catch (error) {
|
||||
|
||||
+114
-51
@@ -1,4 +1,5 @@
|
||||
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import type {
|
||||
@@ -36,13 +37,14 @@ import {
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||
import { pruneSelection } from "./selection";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit";
|
||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
||||
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
||||
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
||||
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
|
||||
import { AppShell } from "./shell/AppShell";
|
||||
@@ -88,8 +90,9 @@ import {
|
||||
StatisticsSidebarStatus,
|
||||
type StatisticsViewActions
|
||||
} from "./views/statistics/StatisticsView";
|
||||
import { buildDownloadsViewModel, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
||||
import {
|
||||
DownloadsContent,
|
||||
DownloadsFooter,
|
||||
@@ -1025,7 +1028,7 @@ const emptyStats = (): DownloadStats => ({
|
||||
|
||||
const emptySnapshot = (): UiSnapshot => ({
|
||||
settings: {
|
||||
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
||||
language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
||||
debridLinkDisabledKeyIds: [],
|
||||
archivePasswordList: "",
|
||||
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
||||
@@ -1041,7 +1044,8 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
columnOrderVersion: 3,
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {},
|
||||
@@ -1647,8 +1651,8 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record<str
|
||||
return totalSize > 0 ? totalDown / totalSize : 0;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
||||
|
||||
function sameStringArray(a: string[], b: string[]): boolean {
|
||||
@@ -1743,6 +1747,11 @@ export function App(): ReactElement {
|
||||
const updateCheckGenerationRef = useRef(0);
|
||||
const settingsDirtyRef = useRef(false);
|
||||
const settingsDraftRevisionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const localizer = createUiLocalizer(document, normalizeLanguage(settingsDraft.language));
|
||||
return () => localizer.disconnect();
|
||||
}, [settingsDraft.language]);
|
||||
const panelDirtyRevisionRef = useRef(0);
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
@@ -1831,8 +1840,13 @@ export function App(): ReactElement {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
|
||||
const [dragColId, setDragColId] = useState<string | null>(null);
|
||||
const [dropTargetCol, setDropTargetCol] = useState<string | null>(null);
|
||||
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
|
||||
const columnDragSettleTimerRef = useRef<number | null>(null);
|
||||
const suppressColumnSortRef = useRef(false);
|
||||
useEffect(() => () => {
|
||||
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
|
||||
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
||||
}, []);
|
||||
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
||||
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
@@ -4490,7 +4504,8 @@ export function App(): ReactElement {
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
|
||||
if (shouldClearDownloadSelectionOnEscape(target.tagName, inputType)) {
|
||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||
@@ -4505,7 +4520,7 @@ export function App(): ReactElement {
|
||||
};
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return;
|
||||
if (!shouldClearDownloadSelection(target)) return;
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -4896,6 +4911,34 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [askConfirmPrompt, performQuickAction]);
|
||||
|
||||
const setClipboardWatcherActive = useCallback((active: boolean): void => {
|
||||
const update = (current: UiSnapshot): UiSnapshot => ({
|
||||
...current,
|
||||
clipboardActive: active,
|
||||
settings: { ...current.settings, clipboardWatch: active }
|
||||
});
|
||||
const next = update(snapshotRef.current);
|
||||
snapshotRef.current = next;
|
||||
masterSnapshotRef.current = masterSnapshotRef.current ? update(masterSnapshotRef.current) : next;
|
||||
if (latestStateRef.current) {
|
||||
latestStateRef.current = update(latestStateRef.current);
|
||||
}
|
||||
setSnapshot(next);
|
||||
setSettingsDraft((current) => ({ ...current, clipboardWatch: active }));
|
||||
}, []);
|
||||
|
||||
const toggleClipboardWatcher = useCallback((): void => {
|
||||
const previous = snapshotRef.current.clipboardActive;
|
||||
const next = !previous;
|
||||
setClipboardWatcherActive(next);
|
||||
void window.rd.toggleClipboard()
|
||||
.then(setClipboardWatcherActive)
|
||||
.catch((error) => {
|
||||
setClipboardWatcherActive(previous);
|
||||
showToast(`Zwischenablage konnte nicht umgeschaltet werden: ${String(error)}`, 2600);
|
||||
});
|
||||
}, [setClipboardWatcherActive, showToast]);
|
||||
|
||||
const activateDownloadSchedule = useCallback((): void => {
|
||||
if (!scheduleTimeInput) return;
|
||||
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
||||
@@ -4918,6 +4961,7 @@ export function App(): ReactElement {
|
||||
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
||||
|
||||
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||
...downloadsViewCore,
|
||||
running: snapshot.session.running,
|
||||
@@ -4944,12 +4988,14 @@ export function App(): ReactElement {
|
||||
packages: snapshot.stats.totalPackages,
|
||||
links: Object.keys(snapshot.session.items).length,
|
||||
session: humanSize(snapshot.stats.totalDownloaded),
|
||||
total: humanSize(snapshot.stats.totalDownloadedAllTime),
|
||||
sessionBytes: snapshot.stats.totalDownloaded,
|
||||
total: humanSize(downloadQueueTotalBytes),
|
||||
totalBytes: downloadQueueTotalBytes,
|
||||
hosters: providerStats.length,
|
||||
speed: snapshot.speedText,
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalDownloadedAllTime, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const downloadsActions: DownloadsViewActions = {
|
||||
onDisplayModeChange: setDownloadDisplayMode,
|
||||
@@ -4988,7 +5034,7 @@ export function App(): ReactElement {
|
||||
if (entry) onPackageStartEdit(entry.id, entry.name);
|
||||
},
|
||||
onRemoveSelection: removeActionableDownloads,
|
||||
onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); },
|
||||
onToggleClipboardWatcher: toggleClipboardWatcher,
|
||||
onClearAll: clearDownloadQueue,
|
||||
onToggleAllPackages: () => {
|
||||
const targetState = !allPackagesCollapsed;
|
||||
@@ -5022,7 +5068,6 @@ export function App(): ReactElement {
|
||||
onToggleSelection: onSelectId,
|
||||
onSelectionMouseDown: onSelectMouseDown,
|
||||
onSelectionMouseEnter: onSelectMouseEnter,
|
||||
onTogglePackage: onPackageToggle,
|
||||
onTogglePackageCollapse: onPackageToggleCollapse,
|
||||
onStartPackageRename: onPackageStartEdit,
|
||||
onPackageRenameChange: setEditingName,
|
||||
@@ -5042,35 +5087,53 @@ export function App(): ReactElement {
|
||||
const item = snapshot.session.items[id];
|
||||
onPackageContextMenu(packageId ?? item?.packageId ?? id, item?.id, x, y);
|
||||
},
|
||||
onSortColumn: sortDownloadsByColumn,
|
||||
onColumnDragStart: (column, event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
setDragColId(column);
|
||||
onSortColumn: (column) => {
|
||||
if (suppressColumnSortRef.current) return;
|
||||
sortDownloadsByColumn(column);
|
||||
},
|
||||
onColumnDragOver: (column, event) => {
|
||||
if (dragColId && dragColId !== column) {
|
||||
event.preventDefault();
|
||||
setDropTargetCol(column);
|
||||
onColumnPointerDown: (column, event) => {
|
||||
if (columnDragSettleTimerRef.current !== null) {
|
||||
window.clearTimeout(columnDragSettleTimerRef.current);
|
||||
columnDragSettleTimerRef.current = null;
|
||||
}
|
||||
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
||||
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX);
|
||||
},
|
||||
onColumnDragLeave: () => setDropTargetCol(null),
|
||||
onColumnDrop: (column, event) => {
|
||||
event.preventDefault();
|
||||
setDropTargetCol(null);
|
||||
if (!dragColId || dragColId === column) return;
|
||||
const next = [...columnOrder];
|
||||
const fromIndex = next.indexOf(dragColId);
|
||||
const toIndex = next.indexOf(column);
|
||||
if (fromIndex < 0 || toIndex < 0) return;
|
||||
next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, dragColId);
|
||||
setColumnOrder(next);
|
||||
setDragColId(null);
|
||||
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
|
||||
onColumnPointerMove: (_column, event) => {
|
||||
const session = columnDragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
if (updateDownloadColumnDrag(session, event.clientX)) event.preventDefault();
|
||||
},
|
||||
onColumnDragEnd: () => {
|
||||
setDragColId(null);
|
||||
setDropTargetCol(null);
|
||||
onColumnPointerUp: (_column, event) => {
|
||||
const session = columnDragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
const next = settleDownloadColumnDrag(session, false);
|
||||
if (!next) {
|
||||
columnDragSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
suppressColumnSortRef.current = true;
|
||||
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
|
||||
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
|
||||
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
||||
if (changed) {
|
||||
flushSync(() => setColumnOrder(next));
|
||||
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
|
||||
}
|
||||
clearDownloadColumnDrag(session);
|
||||
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||
columnDragSettleTimerRef.current = null;
|
||||
}, 220);
|
||||
},
|
||||
onColumnPointerCancel: (_column, event) => {
|
||||
const session = columnDragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
settleDownloadColumnDrag(session, true);
|
||||
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
||||
clearDownloadColumnDrag(session);
|
||||
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||
columnDragSettleTimerRef.current = null;
|
||||
}, 220);
|
||||
},
|
||||
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
|
||||
};
|
||||
@@ -5617,14 +5680,15 @@ export function App(): ReactElement {
|
||||
<nav aria-label="Anwendungsmenü" className="md-application-menu-tree">
|
||||
<div className="menu-bar-item">
|
||||
<button
|
||||
aria-expanded={openMenu === "datei"}
|
||||
aria-haspopup="menu"
|
||||
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
|
||||
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
|
||||
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
|
||||
>
|
||||
Datei
|
||||
</button>
|
||||
{openMenu === "datei" && (
|
||||
<div className="menu-dropdown">
|
||||
<div aria-hidden={openMenu !== "datei"} className={`menu-dropdown${openMenu === "datei" ? " is-open" : ""}`}>
|
||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}>
|
||||
<span>Text mit Links analysieren</span>
|
||||
<span className="shortcut">Strg+L</span>
|
||||
@@ -5662,19 +5726,19 @@ export function App(): ReactElement {
|
||||
<span>Beenden</span>
|
||||
<span className="shortcut">Strg+Q</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-bar-item">
|
||||
<button
|
||||
aria-expanded={openMenu === "einstellungen"}
|
||||
aria-haspopup="menu"
|
||||
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
|
||||
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
|
||||
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
{openMenu === "einstellungen" && (
|
||||
<div className="menu-dropdown">
|
||||
<div aria-hidden={openMenu !== "einstellungen"} className={`menu-dropdown${openMenu === "einstellungen" ? " is-open" : ""}`}>
|
||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}>
|
||||
<span>Einstellungen</span>
|
||||
<span className="shortcut">Strg+P</span>
|
||||
@@ -5772,19 +5836,19 @@ export function App(): ReactElement {
|
||||
</div>
|
||||
<span className="menu-speed-unit">MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-bar-item">
|
||||
<button
|
||||
aria-expanded={openMenu === "hilfe"}
|
||||
aria-haspopup="menu"
|
||||
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
|
||||
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
|
||||
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
|
||||
>
|
||||
Hilfe
|
||||
</button>
|
||||
{openMenu === "hilfe" && (
|
||||
<div className="menu-dropdown">
|
||||
<div aria-hidden={openMenu !== "hilfe"} className={`menu-dropdown${openMenu === "hilfe" ? " is-open" : ""}`}>
|
||||
<div
|
||||
className="menu-submenu"
|
||||
onMouseEnter={() => setOpenSubmenu("hilfe-log")}
|
||||
@@ -5836,8 +5900,7 @@ export function App(): ReactElement {
|
||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
||||
<span>Suche Aktualisierungen</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="md-avatar-anchor">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AudioStripSummary, DebridProvider } from "../shared/types";
|
||||
import { hosterIconSources } from "./hoster-icons";
|
||||
|
||||
export const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
@@ -48,6 +49,13 @@ export function extractHoster(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } {
|
||||
const normalized = hoster.trim().toLowerCase();
|
||||
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
|
||||
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
|
||||
return { compact: hoster, title: hoster };
|
||||
}
|
||||
|
||||
export function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } {
|
||||
const parts: string[] = [];
|
||||
const ok = summary.remuxed + summary.keptSingle;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const hosterIconSources: Readonly<Record<string, string>> = {
|
||||
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
|
||||
ddownload: "./provider-icons/ddownload.ico"
|
||||
};
|
||||
@@ -0,0 +1,569 @@
|
||||
import type { AppLanguage } from "../shared/types";
|
||||
|
||||
const pairs = [
|
||||
["Downloads", "Downloads"], ["Linksammler", "Link collector"], ["Einstellungen", "Settings"], ["Verlauf", "History"], ["Statistiken", "Statistics"],
|
||||
["Datei", "File"], ["Hilfe", "Help"], ["Kontomenü", "Account menu"], ["Allgemein", "General"], ["Accounts", "Accounts"],
|
||||
["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"],
|
||||
["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"],
|
||||
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
|
||||
["Einstellungen speichern", "Save settings"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
|
||||
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord 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"],
|
||||
["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"],
|
||||
["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"],
|
||||
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
|
||||
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
|
||||
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
|
||||
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
|
||||
["Später", "Later"], ["Jetzt aktualisieren", "Update now"], ["Changelog anzeigen", "Show changelog"], ["Update wird vorbereitet...", "Preparing update..."], ["Update-Fortschritt", "Update progress"],
|
||||
["Pakete", "Packages"], ["Dateien", "Files"], ["Alle", "All"], ["Aktiv", "Active"], ["Wartend", "Queued"], ["Pausiert", "Paused"], ["Fertig", "Completed"], ["Fehler", "Errors"],
|
||||
["Downloadansicht", "Download view"], ["Downloadfilter", "Download filters"], ["Service filtern", "Filter service"], ["Downloads durchsuchen", "Search downloads"], ["Downloadstatus", "Download status"],
|
||||
["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"], ["Informationen", "Information"], ["Alle sichtbaren Downloads auswählen", "Select all visible downloads"],
|
||||
["Links hinzufügen", "Add links"], ["Start", "Start"], ["Pause", "Pause"], ["Stop", "Stop"], ["Zeitplan", "Schedule"], ["Nach oben", "Move up"], ["Nach unten", "Move down"],
|
||||
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
||||
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
|
||||
["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"],
|
||||
["Ü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"],
|
||||
["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"],
|
||||
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
|
||||
["Ablauf, Ziel, Tonspur, Ablageform und Leistung.", "Workflow, destination, audio track, layout and performance."], ["Ziel und Ablauf", "Destination and workflow"], ["Entpacken nach", "Extract to"],
|
||||
["Zielordner für entpackte Dateien.", "Destination folder for extracted files."], ["Benötigt ffmpeg.", "Requires ffmpeg."],
|
||||
["Wählen", "Choose"], ["Automatisch entpacken", "Extract automatically"], ["Bereits Entpacktes überspringen", "Skip already extracted files"], ["Entpackte Einträge ausblenden", "Hide extracted entries"],
|
||||
["Entpacken auch ohne laufende Sitzung", "Extract without an active session"], ["Deutsche Tonspur", "German audio track"], ["Nur deutsche Tonspur behalten", "Keep German audio track only"], ["Welche Tonspur behalten", "Audio track to keep"],
|
||||
["Ablageform", "Output layout"], ["Automatisch umbenennen", "Rename automatically"], ["In Paket-Unterordner ablegen", "Store in package subfolder"], ["Videos in Sammelordner verschieben", "Move videos to library folder"],
|
||||
["Video-Sammelordner", "Video library folder"], ["Leistung", "Performance"], ["Hybrid-Entpacken", "Hybrid extraction"], ["Gleichzeitige Entpackungen", "Concurrent extractions"], ["CPU-Priorität beim Entpacken", "CPU priority during extraction"],
|
||||
["Passwörter", "Passwords"], ["Passwortliste für Archive", "Archive password list"], ["Ein Passwort pro Zeile", "One password per line"], ["Hoch (80% CPU)", "High (80% CPU)"], ["Mittel (50% CPU)", "Medium (50% CPU)"], ["Niedrig (25% CPU)", "Low (25% CPU)"],
|
||||
["Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.", "Speed, reconnection and scheduled bandwidth rules."], ["Tempo-Begrenzung", "Speed limit"], ["Geschwindigkeit begrenzen", "Limit speed"],
|
||||
["Höchstgeschwindigkeit (MB/s)", "Maximum speed (MB/s)"], ["Limit gilt für", "Limit applies to"], ["Verbindung", "Connection"], ["Automatisch neu verbinden", "Reconnect automatically"], ["Wartezeit vor neuem Versuch (Sek.)", "Wait before retry (sec.)"],
|
||||
["Von (Stunde)", "From (hour)"], ["Bis (Stunde)", "To (hour)"], ["Limit (MB/s)", "Limit (MB/s)"], ["Zeitregel aktiviert", "Schedule rule enabled"], ["Zeitregel entfernen", "Remove schedule rule"],
|
||||
["Bandbreitenplanung", "Bandwidth schedule"], ["Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.", "Each rule sets a separate limit for a time window."], ["Weitere Zeitregel", "Additional schedule rule"], ["Zeitregel hinzufügen", "Add schedule rule"],
|
||||
["Integritätsprüfung und Aufräumen nach Downloads und Entpacken.", "Integrity checks and cleanup after downloading and extraction."], ["Prüfung", "Verification"], ["Dateien auf Fehler prüfen", "Check files for errors"],
|
||||
["Nach dem Entpacken", "After extraction"], ["Link-Dateien danach entfernen", "Remove link files afterward"], ["Vorschau-Dateien danach entfernen", "Remove sample files afterward"], ["Archive nach dem Entpacken", "Archives after extraction"],
|
||||
["Fertige Downloads und Konflikte", "Completed downloads and conflicts"], ["Fertige Downloads aus der Liste", "Completed downloads in the list"], ["Bei gleichnamigen Dateien", "When files have the same name"],
|
||||
["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], ["+ Hinzufügen", "+ Add"], ["− Entfernen", "− Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."],
|
||||
["Automatischer Fallback", "Automatic fallback"], ["Zugangsdaten lokal speichern", "Store credentials locally"], ["Hoster-Routing", "Hoster routing"], ["Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.", "Custom assignments override the default order for each hoster."],
|
||||
["Rotations-Verlauf", "Rotation history"], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"],
|
||||
["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"],
|
||||
["Dauer", "Duration"], ["Durchschnitt", "Average"], ["Zielordner", "Destination folder"], ["Verlaufsfilter", "History filters"], ["Verlauf leeren", "Clear history"], ["Verlaufsaktionen", "History actions"],
|
||||
["Einträge", "Entries"], ["Erneut hinzufügen", "Add again"], ["Im Ordner zeigen", "Show in folder"], ["Auswahl löschen", "Clear selection"], ["Verlauf durchsuchen", "Search history"], ["Name, Pfad, Hoster oder Provider", "Name, path, hoster or provider"],
|
||||
["Verlaufstabelle", "History table"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
|
||||
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
|
||||
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
||||
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
|
||||
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
|
||||
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
|
||||
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
|
||||
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
|
||||
["Bandbreitenverlauf", "Bandwidth history"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
|
||||
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
|
||||
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
|
||||
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
|
||||
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
|
||||
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Gesammelte Links", "Collected links"],
|
||||
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
|
||||
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
|
||||
["Link auswählen", "Select link"], ["Lokal", "Local"], ["Übernehmen", "Apply"], ["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
|
||||
["Die Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accounts werden geladen", "Loading accounts"], ["Die gespeicherten Accounts bleiben unverändert.", "Saved accounts remain unchanged."],
|
||||
["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."],
|
||||
["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."],
|
||||
["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"],
|
||||
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
||||
["Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.", "The selected Mega-Debrid account was not found."], ["Der ausgewählte Debrid-Link-Key wurde nicht gefunden.", "The selected Debrid-Link key was not found."],
|
||||
["Das Tageslimit muss eine positive Zahl oder 0 sein.", "The daily limit must be a positive number or 0."], ["Login und Passwort werden benötigt.", "Login and password are required."],
|
||||
["Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.", "The login must not contain a colon or line break."], ["Das Passwort darf keinen Zeilenumbruch enthalten.", "The password must not contain a line break."],
|
||||
["Dieser Mega-Debrid-Login ist bereits vorhanden.", "This Mega-Debrid login already exists."], ["Der API-Key wird benötigt.", "The API key is required."], ["Beim Bearbeiten ist genau ein API-Key erlaubt.", "Exactly one API key is allowed when editing."],
|
||||
["Dieser Debrid-Link-Key ist bereits vorhanden.", "This Debrid-Link key already exists."], ["Der Zugangstoken wird benötigt.", "The access token is required."], ["Die Prüfung hat keinen Account zurückgegeben.", "The check returned no account."],
|
||||
["Die Prüfung hat mehr als den ausgewählten Account zurückgegeben.", "The check returned more than the selected account."], ["Die Prüfung hat den falschen Account zurückgegeben.", "The check returned the wrong account."], ["Zugangsdaten ungültig", "Invalid credentials"],
|
||||
["Für diesen Zeitraum werden noch keine historischen Daten gespeichert.", "Historical data is not stored for this time range yet."], ["Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse", "No completed or failed results"],
|
||||
["Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.", "Today's data comes from the local provider usage counters for the current calendar day."],
|
||||
["Provider-Nutzung heute", "Provider usage today"], ["Dateianzahlen werden nicht tagesweise gespeichert", "File counts are not stored by day"], ["Ergebnisse werden nicht tagesweise gespeichert", "Results are not stored by day"],
|
||||
["Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert", "Average speed is not stored by day"], ["Fehler werden nicht tagesweise gespeichert", "Errors are not stored by day"],
|
||||
["Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.", "Totals come from permanently stored counters. Results and speeds are not stored historically."],
|
||||
["Gesamtzähler", "Total counter"], ["Ergebnisse werden nicht dauerhaft gespeichert", "Results are not stored permanently"], ["Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert", "Average speed is not stored permanently"],
|
||||
["Fehler werden nicht dauerhaft gespeichert", "Errors are not stored permanently"], ["Aktuelle Queue", "Current queue"], ["Letzter beendeter Lauf", "Last completed run"],
|
||||
["Während des laufenden Durchgangs nicht als Gesamtwert verfügbar", "Unavailable as a total while the run is active"], ["Kein beendeter Lauf mit Ergebnissen verfügbar", "No completed run with results available"],
|
||||
["Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt.", "Session counters and results from the last completed run are shown."], ["Nicht verfügbar", "Unavailable"],
|
||||
["Heute wurden noch keine Providerbytes erfasst.", "No provider bytes have been recorded today."], ["Noch keine gespeicherten Providerbytes vorhanden.", "No stored provider bytes available yet."],
|
||||
["In der aktuellen Queue sind noch keine Providerwerte vorhanden.", "No provider values are available in the current queue yet."], ["Statistik-Zeitraum", "Statistics time range"], ["Statistik-Dashboard", "Statistics dashboard"],
|
||||
["Statistiken zurücksetzen", "Reset statistics"], ["Erfolgsquote", "Success rate"], ["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
|
||||
["Kontextmenü", "Context menu"], ["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
|
||||
["Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.", "The interface was stopped to prevent data loss. Downloads running in the background are not affected. The error was written to the log."],
|
||||
["Oberfläche neu laden", "Reload interface"], ["Unbekannter Fehler", "Unknown error"],
|
||||
["Direkter Zugriff über API-Token.", "Direct access via API token."], ["Login über Browserfenster statt Token.", "Login through a browser window instead of a token."],
|
||||
["Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (API). Enter multiple accounts on separate lines for multi-account."],
|
||||
["Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (Web). Enter multiple accounts on separate lines for multi-account."],
|
||||
["Cookie-Import aus dem Browser statt API-Token.", "Import cookies from the browser instead of using an API token."], ["Direkter Zugriff über API-Key.", "Direct access via API key."],
|
||||
["Login über Browserfenster für reCAPTCHA.", "Login through a browser window for reCAPTCHA."], ["Direkter Login für ddownload.com und ddl.to.", "Direct login for ddownload.com and ddl.to."],
|
||||
["API-Key für 1fichier.com.", "API key for 1fichier.com."], ["API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.", "API key(s) for debrid-link.com. Enter multiple keys on separate lines for multi-account."],
|
||||
["Login für linksnappy.com mit Benutzername und Passwort.", "Login for linksnappy.com with username and password."], ["Login:Passwort (API)", "Login:password (API)"], ["Login:Passwort (Web)", "Login:password (Web)"],
|
||||
["Login + Passwort", "Login + password"], ["Login gespeichert", "Login saved"], ["API-Key gespeichert", "API key saved"], ["Zugang gespeichert", "Access saved"], ["Nicht hinterlegt", "Not stored"],
|
||||
["Bitte zuerst einen Account-Typ auswählen.", "Select an account type first."], ["Nicht angegeben", "Not specified"], ["Nur per API-Key sichtbar", "Visible only via API key"],
|
||||
["abgebrochen (fataler Fehler)", "cancelled (fatal error)"], ["übersprungen (bis zum Tagesreset gesperrt)", "skipped (blocked until daily reset)"], ["übersprungen (Cooldown aktiv)", "skipped (cooldown active)"],
|
||||
["übersprungen (deaktiviert)", "skipped (disabled)"], ["übersprungen (Tageslimit erreicht)", "skipped (daily limit reached)"], ["übersprungen (Host-Cooldown)", "skipped (host cooldown)"],
|
||||
["Provider-weiter Fehler, restliche Keys übersprungen", "Provider-wide error, remaining keys skipped"], ["Netzwerk-Kaskade, restliche Keys übersprungen", "Network cascade, remaining keys skipped"],
|
||||
["Key ist manuell deaktiviert.", "Key is manually disabled."], ["Lokales Tageslimit erreicht.", "Local daily limit reached."], ["Live-Status wird geladen.", "Loading live status."],
|
||||
["Der Hoster ist laut Debrid-Link aktuell offline.", "The hoster is currently offline according to Debrid-Link."], ["Key ist nutzbar.", "Key is usable."], ["Download starten für Statistiken", "Start a download for statistics"],
|
||||
["Download fertig | Prüfe Integrität...", "Download complete | Checking integrity..."], ["Starte Installer...", "Starting installer..."], ["Installer gestartet", "Installer started"],
|
||||
["Verlauf konnte nicht geladen werden", "History could not be loaded"], ["Debrid-Link ist nicht konfiguriert", "Debrid-Link is not configured"], ["Entpacken -", "Extraction -"],
|
||||
["Nur API aktiv. Kein Web-Fallback.", "API only. No web fallback."], ["Nur Web aktiv. Kein API-Fallback.", "Web only. No API fallback."], ["Login kann bei Bedarf direkt aus der Liste geöffnet werden.", "The login can be opened directly from the list when needed."],
|
||||
["Cookie-Import lässt sich direkt aus der Liste erneut starten.", "Cookie import can be started again directly from the list."], ["Lade Status", "Load status"], ["Rapidgator-Status wird aktualisiert.", "RapidGator status is being updated."],
|
||||
["Rapidgator-Status kann direkt aus der Liste geladen werden.", "RapidGator status can be loaded directly from the list."], ["Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.", "Status is based on the last saved AllDebrid data."],
|
||||
["Update wird vorbereitet", "Preparing update"], ["Stilles Update gestartet - App wird neu gestartet", "Silent update started - the app will restart"], ["Einstellungen gespeichert", "Settings saved"],
|
||||
["Real-Debrid Login-Fenster geöffnet", "Real-Debrid login window opened"], ["AllDebrid Login-Fenster geöffnet", "AllDebrid login window opened"], ["Keine Cookie-Datei ausgewählt", "No cookie file selected"],
|
||||
["Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", "No Mega-Debrid or Debrid-Link accounts configured for checking."], ["Account aktiviert", "Account enabled"], ["Account deaktiviert", "Account disabled"],
|
||||
["Account entfernen", "Remove account"], ["Account entfernt", "Account removed"], ["Key entfernen", "Remove key"], ["Key entfernt", "Key removed"], ["Accounts aktiviert", "Accounts enabled"], ["Accounts deaktiviert", "Accounts disabled"],
|
||||
["Für diesen Account ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this account."], ["Keine gespeicherten Links vorhanden", "No saved links available"], ["Keine Links hinzugefügt", "No links added"],
|
||||
["Fehler beim Hinzufügen", "Error while adding"], ["Verlaufseintrag entfernen", "Remove history entry"], ["Verlaufseinträge entfernen", "Remove history entries"], ["Diesen Eintrag aus dem Verlauf entfernen?", "Remove this entry from history?"],
|
||||
["Einige Verlaufseinträge konnten nicht entfernt werden", "Some history entries could not be removed"], ["Wirklich alle Einträge aus dem Verlauf entfernen?", "Remove all entries from history?"], ["Verlauf geleert", "History cleared"],
|
||||
["Verlauf konnte nicht geleert werden", "History could not be cleared"], ["Zielordner geöffnet", "Destination folder opened"], ["Verlaufseintrag wurde nicht gefunden", "History entry was not found"],
|
||||
["Der gespeicherte Zielordner ist ungültig", "The saved destination folder is invalid"], ["Der gespeicherte Zielordner existiert nicht mehr", "The saved destination folder no longer exists"],
|
||||
["Das gespeicherte Ziel ist kein Ordner", "The saved destination is not a folder"], ["Zielordner konnte nicht geöffnet werden", "Destination folder could not be opened"], ["Bitte zuerst mindestens einen Hoster-Account eintragen", "Add at least one hoster account first"],
|
||||
["Start abgebrochen", "Start cancelled"], ["Keine gültigen Links gefunden", "No valid links found"], ["Keine gültigen Links in den DLC-Dateien gefunden", "No valid links found in the DLC files"],
|
||||
["Keine gültigen Links in den Import-Dateien gefunden", "No valid links found in the import files"], ["Links per Drag-and-Drop eingefügt", "Links added by drag and drop"], ["Queue exportiert", "Queue exported"],
|
||||
["Keine gültigen Links in der Datei gefunden", "No valid links found in the file"], ["Sicherung exportiert", "Backup exported"], ["Online-Schlüssel erstellt", "Online key created"],
|
||||
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
|
||||
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
|
||||
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
|
||||
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
|
||||
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
|
||||
["Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.", "The current debug token will be replaced. External debug links using the old token will stop working."],
|
||||
["Token rotieren", "Rotate token"], ["Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", "Network mode requires at least one IP address or CIDR in the allowlist"],
|
||||
["Ferndiagnose aktiv", "Remote diagnostics active"], ["Ferndiagnose konfiguriert", "Remote diagnostics configured"], ["Ferndiagnose deaktiviert", "Remote diagnostics disabled"],
|
||||
["Neues Token - alter Verbindungscode ist ungueltig", "New token - old connection code is invalid"], ["Verbindungscode kopiert", "Connection code copied"], ["Kopieren fehlgeschlagen", "Copy failed"],
|
||||
["Queue löschen", "Delete queue"], ["Wirklich alle Einträge aus der Queue löschen?", "Delete all entries from the queue?"], ["Alles löschen", "Delete all"], ["Session-Statistik zurückgesetzt", "Session statistics reset"],
|
||||
["Gesamt-Downloadstatistik zurückgesetzt", "Total download statistics reset"], ["Hoster-Domain eingeben:", "Enter hoster domain:"], ["Test-Nachricht gesendet", "Test message sent"], ["Test fehlgeschlagen", "Test failed"],
|
||||
["Login / E-Mail", "Login / email"], ["Token / API-Key", "Token / API key"], ["Tageslimit (GB, optional)", "Daily limit (GB, optional)"], ["Kein Limit", "No limit"],
|
||||
["Der Zähler wird täglich um 00:00 Uhr zurückgesetzt.", "The counter resets every day at 00:00."], ["Account erfolgreich geprüft", "Account checked successfully"], ["Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this service."],
|
||||
["Paket- und Linkstatus werden laufend aktualisiert. Auswahl, Reihenfolge und aktive Filter bleiben beim Ansichtswechsel erhalten.", "Package and link status are updated continuously. Selection, order and active filters remain in place when switching views."],
|
||||
["Rohzeilen bleiben lokal in der gewählten Sammlung, bis sie über „An Downloads übergeben“ an die Queue gesendet werden. Strg+L öffnet den Linksammler, Strg+O lädt DLC-Dateien.", "Raw lines remain local in the selected collection until they are sent to the queue using “Send to downloads”. Ctrl+L opens the link collector, Ctrl+O loads DLC files."],
|
||||
["Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen.", "Completed and deleted packages remain searchable here. Details show the destination folder, provider and saved link addresses."],
|
||||
["Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden.", "Metrics remain empty for seven and 30 days while no historical buckets are stored."],
|
||||
["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"],
|
||||
["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"],
|
||||
["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"],
|
||||
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials. Keep it as secure as a password."],
|
||||
["Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."],
|
||||
["Online-Sicherung wird verschlüsselt und gespeichert …", "Online backup is being encrypted and saved …"], ["Online-Sicherungsschlüssel", "Online backup key"], ["Online-Sicherungsschlüssel eingeben", "Enter online backup key"], ["Wird geladen …", "Loading …"],
|
||||
["Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.", "Allows a trusted support contact protected read access to status, logs and errors. The connection code contains the access token and must be treated like a password."],
|
||||
["Oeffentliche Adresse (fuer den Verbindungscode)", "Public address (for the connection code)"], ["Allowlist - erlaubte IPs/CIDR (eine pro Zeile)", "Allowlist - permitted IPs/CIDRs (one per line)"],
|
||||
["Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.", "Required in network mode. Only these source IPs may connect (in addition to the token). Loopback is always allowed."],
|
||||
["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."],
|
||||
["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"],
|
||||
["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"],
|
||||
["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"],
|
||||
["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"],
|
||||
["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"],
|
||||
["API-Key Statistik", "API key statistics"], ["Status unbekannt", "Status unknown"], ["RG Links", "RG links"], ["Alle Namen kopiert", "All names copied"], ["Alle Namen kopieren", "Copy all names"],
|
||||
["Alle Links kopiert", "All links copied"], ["Alle Links kopieren", "Copy all links"],
|
||||
["nicht gesetzt", "not set"], ["Schätzwert", "Estimate"], ["ist verfügbar. Installierte Version:", "is available. Installed version:"],
|
||||
["Real-Debrid Web-Login", "Real-Debrid web login"], ["Mega-Debrid Web-Login", "Mega-Debrid web login"], ["BestDebrid Web-Login", "BestDebrid web login"], ["AllDebrid Web-Login", "AllDebrid web login"],
|
||||
["DDownload Login", "DDownload login"], ["Debrid-Link API", "Debrid-Link API"], ["LinkSnappy Web-Login", "LinkSnappy web login"],
|
||||
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], ["Mega-Debrid: Bitte Login und Passwort eintragen.", "Mega-Debrid: Enter a login and password."],
|
||||
["Dieser Mega-Debrid-Account ist bereits vorhanden.", "This Mega-Debrid account already exists."], ["Debrid-Link: Bitte genau einen API-Key eintragen.", "Debrid-Link: Enter exactly one API key."],
|
||||
["Die Prüfung hat nicht genau den neuen Account bestätigt.", "The check did not confirm exactly the new account."], ["Nur lokal gebunden", "Bound locally only"], ["(nur lokal)", "(local only)"], ["Nur lokal", "Local only"],
|
||||
["Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante.", "Binds only to 127.0.0.1. Remote access only through a tunnel (such as Tailscale/SSH) - the safest option."],
|
||||
["Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen.", "Binds to 0.0.0.0. Reachable on the network and requires an allowlist. Use only on trusted networks or VPNs."],
|
||||
["127.0.0.1 oder Tunnel-Adresse", "127.0.0.1 or tunnel address"], ["Server-IP oder DNS-Name", "Server IP or DNS name"], ["löschen ?", "delete?"], ["Link(s) verbleiben!", "link(s) remaining!"],
|
||||
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."],
|
||||
["überschreiben", "overwrite"], ["überspringen", "skip"], ["Priorität >", "Priority >"], ["Keys · Heute:", "Keys · Today:"], ["· Rapidgator-Quota wird geladen (", "· Loading RapidGator quota ("],
|
||||
["· API-Quota konnte nicht geladen werden", "· API quota could not be loaded"], ["Name kopiert", "Name copied"], ["Link kopiert", "Link copied"],
|
||||
["Download-Ziel", "Download destination"], ["Support-Logs:", "Support logs:"], ["Session-Logs", "Session logs"], ["Paket-Logs", "Package logs"], ["Support-Bundle:", "Support bundle:"], ["Warnungen:", "Warnings:"],
|
||||
["Web-Login", "Web login"], ["API-Token", "API token"], ["erfolgreich", "successful"], ["Unbekannt", "Unknown"], ["Abgebrochen", "Cancelled"], ["Passwort", "Password"],
|
||||
["Sicherung", "Backup"], ["Session-Log", "Session log"], ["Remote-Support", "Remote support"], ["Kopieren", "Copy"], ["Verbindungscode", "Connection code"], ["deaktiviert", "disabled"]
|
||||
] as const;
|
||||
|
||||
const deToEn = new Map<string, string>(pairs);
|
||||
const enToDe = new Map<string, string>(pairs.map(([de, en]) => [en, de]));
|
||||
const attributes = ["aria-label", "placeholder", "title"] as const;
|
||||
const prefixedPairs = [
|
||||
["Status: ", "Status: "], ["Debug-Server aktiv: ", "Debug server active: "], ["Runtime-Ordner: ", "Runtime folder: "], ["Token-Datei: ", "Token file: "], ["Support-Manifest: ", "Support manifest: "], ["Trace aktiv: ", "Trace active: "],
|
||||
["Unbekannter Account-Typ: ", "Unknown account type: "], ["Update-Download: ", "Update download: "], ["Update: ", "Update: "], ["Warnungen: ", "Warnings: "], ["Debug-Token rotiert: ", "Debug token rotated: "],
|
||||
["AllDebrid Status fehlgeschlagen: ", "AllDebrid status failed: "], ["Debrid-Link Quota fehlgeschlagen: ", "Debrid-Link quota failed: "], ["Snapshot konnte nicht geladen werden: ", "Snapshot could not be loaded: "],
|
||||
["Update-Check fehlgeschlagen: ", "Update check failed: "], ["Update-Fehler: ", "Update error: "], ["Auto-Update fehlgeschlagen: ", "Automatic update failed: "],
|
||||
["Einstellungen konnten nicht gespeichert werden: ", "Settings could not be saved: "], ["Real-Debrid Login fehlgeschlagen: ", "Real-Debrid login failed: "], ["AllDebrid Login fehlgeschlagen: ", "AllDebrid login failed: "],
|
||||
["BestDebrid Cookie-Import fehlgeschlagen: ", "BestDebrid cookie import failed: "], ["Account-Check fehlgeschlagen: ", "Account check failed: "], ["Prüfung fehlgeschlagen: ", "Check failed: "],
|
||||
["Account konnte nicht gespeichert werden: ", "Account could not be saved: "], ["Reset fehlgeschlagen: ", "Reset failed: "], ["Umschalten fehlgeschlagen: ", "Toggle failed: "],
|
||||
["Entfernen fehlgeschlagen: ", "Removal failed: "], ["Account konnte nicht entfernt werden: ", "Account could not be removed: "], ["Accounts konnten nicht umgeschaltet werden: ", "Accounts could not be toggled: "],
|
||||
["Fehler beim Hinzufügen: ", "Error while adding: "], ["Fehler beim DLC-Import: ", "DLC import error: "], ["Export fehlgeschlagen: ", "Export failed: "], ["Fehler bei Drag-and-Drop: ", "Drag-and-drop error: "],
|
||||
["Import fehlgeschlagen: ", "Import failed: "], ["Fehler: ", "Error: "], ["Sortierung fehlgeschlagen: ", "Sorting failed: "], ["Umbenennen fehlgeschlagen: ", "Rename failed: "],
|
||||
["Paket-Löschung fehlgeschlagen: ", "Package deletion failed: "], ["Paket-Umschalten fehlgeschlagen: ", "Package toggle failed: "], ["Sicherung fehlgeschlagen: ", "Backup failed: "],
|
||||
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
|
||||
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
|
||||
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
|
||||
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
|
||||
] as const;
|
||||
|
||||
export function normalizeLanguage(value: unknown): AppLanguage {
|
||||
return value === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function translateDynamic(value: string, language: AppLanguage): string {
|
||||
for (const [german, english] of prefixedPairs) {
|
||||
const source = language === "en" ? german : english;
|
||||
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
|
||||
}
|
||||
if (language === "en") {
|
||||
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
||||
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
|
||||
const pagination = value.match(/^(\d+\s*[–-]\s*\d+) von (\d+)$/);
|
||||
if (pagination) return `${pagination[1]} of ${pagination[2]}`;
|
||||
const schedule = value.match(/^Zeitregel (\d+)$/);
|
||||
if (schedule) return `Schedule rule ${schedule[1]}`;
|
||||
const scheduled = value.match(/^Geplant: (.+)$/);
|
||||
if (scheduled) return `Scheduled: ${scheduled[1].replace(/^Heute\b/, "Today")}`;
|
||||
const cancelled = value.match(/^(\d+) abgebrochen$/);
|
||||
if (cancelled) return `${cancelled[1]} cancelled`;
|
||||
const labelledCount = value.match(/^(Einträge|Sichtbar|Ausgewählt): (\d+)$/);
|
||||
if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`;
|
||||
const perPage = value.match(/^(\d+) pro Seite$/);
|
||||
if (perPage) return `${perPage[1]} per page`;
|
||||
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
|
||||
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
||||
const remaining = value.match(/^(.+) von (.+) übrig$/);
|
||||
if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`;
|
||||
const actionsFor = value.match(/^Aktionen für (.+)$/);
|
||||
if (actionsFor) return `Actions for ${actionsFor[1]}`;
|
||||
const assignment = value.match(/^(.+) Zuordnung entfernen$/);
|
||||
if (assignment) return `Remove ${assignment[1]} assignment`;
|
||||
const providerFor = value.match(/^Provider für (.+)$/);
|
||||
if (providerFor) return `Provider for ${providerFor[1]}`;
|
||||
const move = value.match(/^(.+) nach (oben|unten)$/);
|
||||
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
|
||||
const audio = value.match(/^Tonspur: (.+)$/);
|
||||
if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`;
|
||||
const result = value.match(/^(\d+\/\d+) fertig(.*)$/);
|
||||
if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`;
|
||||
const extracting = value.match(/^Entpacken (\d+%)$/);
|
||||
if (extracting) return `Extracting ${extracting[1]}`;
|
||||
const checkedUntil = value.match(/^Account geprüft — (.+) bis (.+)$/);
|
||||
if (checkedUntil) return `Account checked — ${checkedUntil[1]} until ${checkedUntil[2]}`;
|
||||
const checked = value.match(/^Account geprüft — (.+)$/);
|
||||
if (checked) return `Account checked — ${checked[1]}`;
|
||||
const invalid = value.match(/^Account ungültig — (.+)$/);
|
||||
if (invalid) return `Invalid account — ${invalid[1]}`;
|
||||
const queueLinks = value.match(/^(\d+) Link\(s\) zur Queue hinzugefügt$/);
|
||||
if (queueLinks) return `${queueLinks[1]} link(s) added to the queue`;
|
||||
const packagesAndLinks = value.match(/^(\d+) Paket\(e\), (\d+) Link\(s\) (hinzugefügt|importiert|exportiert)$/);
|
||||
if (packagesAndLinks) return `${packagesAndLinks[1]} package(s), ${packagesAndLinks[2]} link(s) ${{ hinzugefügt: "added", importiert: "imported", exportiert: "exported" }[packagesAndLinks[3] as "hinzugefügt" | "importiert" | "exportiert"]}`;
|
||||
const selected = value.match(/^(\d+) ausgewählt$/);
|
||||
if (selected) return `${selected[1]} selected`;
|
||||
const ago = value.match(/^vor (\d+) (Min|Std|Tag(?:en)?)$/);
|
||||
if (ago) return `${ago[1]} ${ago[2] === "Min" ? "min" : ago[2] === "Std" ? "hr" : ago[2].startsWith("Tag") ? "day" : ago[2]} ago`;
|
||||
const validation = value.match(/^(.+): (Mindestens einen Account hinzufügen|Bitte Zugangstoken eintragen|Bitte Login oder E-Mail eintragen|Bitte Passwort eintragen|Tageslimit muss eine Zahl >= 0 sein|Bitte mindestens einen gültigen API-Key eintragen)\.$/);
|
||||
if (validation) {
|
||||
const messages: Record<string, string> = {
|
||||
"Mindestens einen Account hinzufügen": "Add at least one account",
|
||||
"Bitte Zugangstoken eintragen": "Enter an access token",
|
||||
"Bitte Login oder E-Mail eintragen": "Enter a login or email address",
|
||||
"Bitte Passwort eintragen": "Enter a password",
|
||||
"Tageslimit muss eine Zahl >= 0 sein": "The daily limit must be a number >= 0",
|
||||
"Bitte mindestens einen gültigen API-Key eintragen": "Enter at least one valid API key"
|
||||
};
|
||||
return `${validation[1]}: ${messages[validation[2]]}.`;
|
||||
}
|
||||
const clipboard = value.match(/^Zwischenablage: (\d+) Link\(s\) erkannt$/);
|
||||
if (clipboard) return `Clipboard: ${clipboard[1]} link(s) detected`;
|
||||
const disabledKeys = value.match(/^(\d+\/\d+) API-Keys deaktiviert\.$/);
|
||||
if (disabledKeys) return `${disabledKeys[1]} API keys disabled.`;
|
||||
const accountCheck = value.match(/^Account-Check: (\d+\/\d+) Login gültig, (\d+) mit Premium\.$/);
|
||||
if (accountCheck) return `Account check: ${accountCheck[1]} logins valid, ${accountCheck[2]} with premium.`;
|
||||
const removeAccount = value.match(/^Soll (.+) wirklich aus der Accountliste entfernt werden\?$/);
|
||||
if (removeAccount) return `Remove ${removeAccount[1]} from the account list?`;
|
||||
const resolved = value.match(/^Konflikte gelöst: (\d+) überschrieben, (\d+) übersprungen$/);
|
||||
if (resolved) return `Conflicts resolved: ${resolved[1]} overwritten, ${resolved[2]} skipped`;
|
||||
const importedDlc = value.match(/^DLC importiert: (\d+) Paket\(e\), (\d+) Link\(s\)$/);
|
||||
if (importedDlc) return `DLC imported: ${importedDlc[1]} package(s), ${importedDlc[2]} link(s)`;
|
||||
const imported = value.match(/^(Importiert|Drag-and-Drop): (\d+) Paket\(e\), (\d+) Link\(s\)$/);
|
||||
if (imported) return `${imported[1] === "Importiert" ? "Imported" : "Drag and drop"}: ${imported[2]} package(s), ${imported[3]} link(s)`;
|
||||
const warnings = value.match(/^(\d+) Fehler, (\d+) Warnungen \(letzte (\d+)\)$/);
|
||||
if (warnings) return `${warnings[1]} errors, ${warnings[2]} warnings (latest ${warnings[3]})`;
|
||||
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
|
||||
if (capture) return `Capture links locally for ${capture[1]}.`;
|
||||
const copy = value.match(/^(.+) Klicken zum Kopieren$/);
|
||||
if (copy) return `Click to copy ${copy[1]}`;
|
||||
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
|
||||
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
|
||||
const quotaUnknown = value.match(/^(.+): unbekannt \((.+)\)$/);
|
||||
if (quotaUnknown) return `${quotaUnknown[1]}: unknown (${quotaUnknown[2]})`;
|
||||
const quota = value.match(/^(.+): (.+) frei von (.+) \((.+)% frei\) \| (.+)$/);
|
||||
if (quota) return `${quota[1]}: ${quota[2]} free of ${quota[3]} (${quota[4]}% free) | ${quota[5]}`;
|
||||
const namedLimit = value.match(/^(.+): (.+) Limit muss eine Zahl >= 0 sein\.$/);
|
||||
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} limit must be a number >= 0.`;
|
||||
const dailyBlocked = value.match(/^Tageslimit erreicht, bis zum Tagesreset gesperrt(.*)$/);
|
||||
if (dailyBlocked) return `Daily limit reached, blocked until the daily reset${dailyBlocked[1]}`;
|
||||
const failed = value.match(/^fehlgeschlagen(.*)$/);
|
||||
if (failed) return `failed${failed[1]}`;
|
||||
const timeout = value.match(/^Timeout\/Abbruch(.*) → nächster Account beim Retry$/);
|
||||
if (timeout) return `Timeout/cancellation${timeout[1]} → next account on retry`;
|
||||
const localLimit = value.match(/^Lokales Tageslimit erreicht \((.+) \/ (.+)\)\.$/);
|
||||
if (localLimit) return `Local daily limit reached (${localLimit[1]} / ${localLimit[2]}).`;
|
||||
const noUpdate = value.match(/^Kein Update verfügbar \(v(.+)\)$/);
|
||||
if (noUpdate) return `No update available (v${noUpdate[1]})`;
|
||||
const cookies = value.match(/^(\d+) BestDebrid-Cookies importiert$/);
|
||||
if (cookies) return `${cookies[1]} BestDebrid cookies imported`;
|
||||
const saved = value.match(/^(.+) gespeichert$/);
|
||||
if (saved) return `${saved[1]} saved`;
|
||||
const removeMega = value.match(/^Soll der Mega-Debrid-Account (.+) wirklich entfernt werden\?$/);
|
||||
if (removeMega) return `Remove the Mega-Debrid account ${removeMega[1]}?`;
|
||||
const removeKey = value.match(/^Soll der Debrid-Link-Key (.+) wirklich entfernt werden\?$/);
|
||||
if (removeKey) return `Remove the Debrid-Link key ${removeKey[1]}?`;
|
||||
const counterReset = value.match(/^(.+): Tageszähler zurückgesetzt$/);
|
||||
if (counterReset) return `${counterReset[1]}: Daily counter reset`;
|
||||
const actionFailed = value.match(/^(.+): (Reset|Umschalten|Aktion) fehlgeschlagen: (.+)$/);
|
||||
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Umschalten: "Toggle", Aktion: "Action" }[actionFailed[2] as "Reset" | "Umschalten" | "Aktion"]} failed: ${actionFailed[3]}`;
|
||||
const toggleFailed = value.match(/^(.+) konnte nicht umgeschaltet werden: (.+)$/);
|
||||
if (toggleFailed) return `${toggleFailed[1]} could not be toggled: ${toggleFailed[2]}`;
|
||||
const historyQuestion = value.match(/^(\d+) Einträge aus dem Verlauf entfernen\?$/);
|
||||
if (historyQuestion) return `Remove ${historyQuestion[1]} entries from history?`;
|
||||
const historyRemoved = value.match(/^(\d+) Verlaufseinträge entfernt$/);
|
||||
if (historyRemoved) return `${historyRemoved[1]} history entries removed`;
|
||||
const packageCount = value.match(/^(\d+) Paket\(e\)$/);
|
||||
if (packageCount) return `${packageCount[1]} package(s)`;
|
||||
const linkCount = value.match(/^(\d+) Link\(s\)$/);
|
||||
if (linkCount) return `${linkCount[1]} link(s)`;
|
||||
const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \((\d+)\)$/);
|
||||
if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${exportSelected[2]})`;
|
||||
const toggleAll = value.match(/^Alle (.+) umschalten$/);
|
||||
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
|
||||
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
|
||||
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
|
||||
const copied = value.match(/^(.+) kopiert$/);
|
||||
if (copied) return `${copied[1]} copied`;
|
||||
const suffixes: Array<[RegExp, string]> = [
|
||||
[/^(.+) auswählen$/, "Select $1"], [/^(.+) einklappen$/, "Collapse $1"], [/^(.+) ausklappen$/, "Expand $1"],
|
||||
[/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"]
|
||||
];
|
||||
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||
return value
|
||||
.replace(/Automatisch entpacken/g, "Extract automatically")
|
||||
.replace(/Wartet auf Wiederholung/g, "Waiting to retry")
|
||||
.replace(/Fehlgeschlagen nach (\d+) Versuchen/g, "Failed after $1 attempts")
|
||||
.replace(/(\d+) Fehler/g, "$1 errors");
|
||||
} else {
|
||||
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
|
||||
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
|
||||
const pagination = value.match(/^(\d+\s*[–-]\s*\d+) of (\d+)$/);
|
||||
if (pagination) return `${pagination[1]} von ${pagination[2]}`;
|
||||
const schedule = value.match(/^Schedule rule (\d+)$/);
|
||||
if (schedule) return `Zeitregel ${schedule[1]}`;
|
||||
const scheduled = value.match(/^Scheduled: (.+)$/);
|
||||
if (scheduled) return `Geplant: ${scheduled[1].replace(/^Today\b/, "Heute")}`;
|
||||
const cancelled = value.match(/^(\d+) cancelled$/);
|
||||
if (cancelled) return `${cancelled[1]} abgebrochen`;
|
||||
const labelledCount = value.match(/^(Entries|Visible|Selected): (\d+)$/);
|
||||
if (labelledCount) return `${({ Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as const)[labelledCount[1] as "Entries" | "Visible" | "Selected"]}: ${labelledCount[2]}`;
|
||||
const perPage = value.match(/^(\d+) per page$/);
|
||||
if (perPage) return `${perPage[1]} pro Seite`;
|
||||
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
|
||||
if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
||||
const remaining = value.match(/^(.+) of (.+) remaining$/);
|
||||
if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`;
|
||||
const actionsFor = value.match(/^Actions for (.+)$/);
|
||||
if (actionsFor) return `Aktionen für ${actionsFor[1]}`;
|
||||
const assignment = value.match(/^Remove (.+) assignment$/);
|
||||
if (assignment) return `${assignment[1]} Zuordnung entfernen`;
|
||||
const providerFor = value.match(/^Provider for (.+)$/);
|
||||
if (providerFor) return `Provider für ${providerFor[1]}`;
|
||||
const move = value.match(/^Move (.+) (up|down)$/);
|
||||
if (move) return `${move[1]} nach ${move[2] === "up" ? "oben" : "unten"}`;
|
||||
const audio = value.match(/^Audio track: (.+)$/);
|
||||
if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`;
|
||||
const result = value.match(/^(\d+\/\d+) completed(.*)$/);
|
||||
if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`;
|
||||
const extracting = value.match(/^Extracting (\d+%)$/);
|
||||
if (extracting) return `Entpacken ${extracting[1]}`;
|
||||
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
|
||||
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
|
||||
const checked = value.match(/^Account checked — (.+)$/);
|
||||
if (checked) return `Account geprüft — ${checked[1]}`;
|
||||
const invalid = value.match(/^Invalid account — (.+)$/);
|
||||
if (invalid) return `Account ungültig — ${invalid[1]}`;
|
||||
const queueLinks = value.match(/^(\d+) link\(s\) added to the queue$/);
|
||||
if (queueLinks) return `${queueLinks[1]} Link(s) zur Queue hinzugefügt`;
|
||||
const packagesAndLinks = value.match(/^(\d+) package\(s\), (\d+) link\(s\) (added|imported|exported)$/);
|
||||
if (packagesAndLinks) return `${packagesAndLinks[1]} Paket(e), ${packagesAndLinks[2]} Link(s) ${{ added: "hinzugefügt", imported: "importiert", exported: "exportiert" }[packagesAndLinks[3] as "added" | "imported" | "exported"]}`;
|
||||
const selected = value.match(/^(\d+) selected$/);
|
||||
if (selected) return `${selected[1]} ausgewählt`;
|
||||
const ago = value.match(/^(\d+) (min|hr|day) ago$/);
|
||||
if (ago) return `vor ${ago[1]} ${ago[2] === "min" ? "Min" : ago[2] === "hr" ? "Std" : "Tag"}`;
|
||||
const validation = value.match(/^(.+): (Add at least one account|Enter an access token|Enter a login or email address|Enter a password|The daily limit must be a number >= 0|Enter at least one valid API key)\.$/);
|
||||
if (validation) {
|
||||
const messages: Record<string, string> = {
|
||||
"Add at least one account": "Mindestens einen Account hinzufügen",
|
||||
"Enter an access token": "Bitte Zugangstoken eintragen",
|
||||
"Enter a login or email address": "Bitte Login oder E-Mail eintragen",
|
||||
"Enter a password": "Bitte Passwort eintragen",
|
||||
"The daily limit must be a number >= 0": "Tageslimit muss eine Zahl >= 0 sein",
|
||||
"Enter at least one valid API key": "Bitte mindestens einen gültigen API-Key eintragen"
|
||||
};
|
||||
return `${validation[1]}: ${messages[validation[2]]}.`;
|
||||
}
|
||||
const clipboard = value.match(/^Clipboard: (\d+) link\(s\) detected$/);
|
||||
if (clipboard) return `Zwischenablage: ${clipboard[1]} Link(s) erkannt`;
|
||||
const disabledKeys = value.match(/^(\d+\/\d+) API keys disabled\.$/);
|
||||
if (disabledKeys) return `${disabledKeys[1]} API-Keys deaktiviert.`;
|
||||
const accountCheck = value.match(/^Account check: (\d+\/\d+) logins valid, (\d+) with premium\.$/);
|
||||
if (accountCheck) return `Account-Check: ${accountCheck[1]} Login gültig, ${accountCheck[2]} mit Premium.`;
|
||||
const removeAccount = value.match(/^Remove (.+) from the account list\?$/);
|
||||
if (removeAccount) return `Soll ${removeAccount[1]} wirklich aus der Accountliste entfernt werden?`;
|
||||
const resolved = value.match(/^Conflicts resolved: (\d+) overwritten, (\d+) skipped$/);
|
||||
if (resolved) return `Konflikte gelöst: ${resolved[1]} überschrieben, ${resolved[2]} übersprungen`;
|
||||
const importedDlc = value.match(/^DLC imported: (\d+) package\(s\), (\d+) link\(s\)$/);
|
||||
if (importedDlc) return `DLC importiert: ${importedDlc[1]} Paket(e), ${importedDlc[2]} Link(s)`;
|
||||
const imported = value.match(/^(Imported|Drag and drop): (\d+) package\(s\), (\d+) link\(s\)$/);
|
||||
if (imported) return `${imported[1] === "Imported" ? "Importiert" : "Drag-and-Drop"}: ${imported[2]} Paket(e), ${imported[3]} Link(s)`;
|
||||
const warnings = value.match(/^(\d+) errors, (\d+) warnings \(latest (\d+)\)$/);
|
||||
if (warnings) return `${warnings[1]} Fehler, ${warnings[2]} Warnungen (letzte ${warnings[3]})`;
|
||||
const capture = value.match(/^Capture links locally for (.+)\.$/);
|
||||
if (capture) return `Links für ${capture[1]} lokal erfassen.`;
|
||||
const copy = value.match(/^Click to copy (.+)$/);
|
||||
if (copy) return `${copy[1]} Klicken zum Kopieren`;
|
||||
const cancelledPart = value.match(/^· (\d+) cancelled$/);
|
||||
if (cancelledPart) return `· ${cancelledPart[1]} abgebrochen`;
|
||||
const quotaUnknown = value.match(/^(.+): unknown \((.+)\)$/);
|
||||
if (quotaUnknown) return `${quotaUnknown[1]}: unbekannt (${quotaUnknown[2]})`;
|
||||
const quota = value.match(/^(.+): (.+) free of (.+) \((.+)% free\) \| (.+)$/);
|
||||
if (quota) return `${quota[1]}: ${quota[2]} frei von ${quota[3]} (${quota[4]}% frei) | ${quota[5]}`;
|
||||
const namedLimit = value.match(/^(.+): (.+) limit must be a number >= 0\.$/);
|
||||
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} Limit muss eine Zahl >= 0 sein.`;
|
||||
const dailyBlocked = value.match(/^Daily limit reached, blocked until the daily reset(.*)$/);
|
||||
if (dailyBlocked) return `Tageslimit erreicht, bis zum Tagesreset gesperrt${dailyBlocked[1]}`;
|
||||
const failed = value.match(/^failed(.*)$/);
|
||||
if (failed) return `fehlgeschlagen${failed[1]}`;
|
||||
const timeout = value.match(/^Timeout\/cancellation(.*) → next account on retry$/);
|
||||
if (timeout) return `Timeout/Abbruch${timeout[1]} → nächster Account beim Retry`;
|
||||
const localLimit = value.match(/^Local daily limit reached \((.+) \/ (.+)\)\.$/);
|
||||
if (localLimit) return `Lokales Tageslimit erreicht (${localLimit[1]} / ${localLimit[2]}).`;
|
||||
const noUpdate = value.match(/^No update available \(v(.+)\)$/);
|
||||
if (noUpdate) return `Kein Update verfügbar (v${noUpdate[1]})`;
|
||||
const cookies = value.match(/^(\d+) BestDebrid cookies imported$/);
|
||||
if (cookies) return `${cookies[1]} BestDebrid-Cookies importiert`;
|
||||
const saved = value.match(/^(.+) saved$/);
|
||||
if (saved) return `${saved[1]} gespeichert`;
|
||||
const removeMega = value.match(/^Remove the Mega-Debrid account (.+)\?$/);
|
||||
if (removeMega) return `Soll der Mega-Debrid-Account ${removeMega[1]} wirklich entfernt werden?`;
|
||||
const removeKey = value.match(/^Remove the Debrid-Link key (.+)\?$/);
|
||||
if (removeKey) return `Soll der Debrid-Link-Key ${removeKey[1]} wirklich entfernt werden?`;
|
||||
const counterReset = value.match(/^(.+): Daily counter reset$/);
|
||||
if (counterReset) return `${counterReset[1]}: Tageszähler zurückgesetzt`;
|
||||
const actionFailed = value.match(/^(.+): (Reset|Toggle|Action) failed: (.+)$/);
|
||||
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Toggle: "Umschalten", Action: "Aktion" }[actionFailed[2] as "Reset" | "Toggle" | "Action"]} fehlgeschlagen: ${actionFailed[3]}`;
|
||||
const toggleFailed = value.match(/^(.+) could not be toggled: (.+)$/);
|
||||
if (toggleFailed) return `${toggleFailed[1]} konnte nicht umgeschaltet werden: ${toggleFailed[2]}`;
|
||||
const historyQuestion = value.match(/^Remove (\d+) entries from history\?$/);
|
||||
if (historyQuestion) return `${historyQuestion[1]} Einträge aus dem Verlauf entfernen?`;
|
||||
const historyRemoved = value.match(/^(\d+) history entries removed$/);
|
||||
if (historyRemoved) return `${historyRemoved[1]} Verlaufseinträge entfernt`;
|
||||
const packageCount = value.match(/^(\d+) package\(s\)$/);
|
||||
if (packageCount) return `${packageCount[1]} Paket(e)`;
|
||||
const linkCount = value.match(/^(\d+) link\(s\)$/);
|
||||
if (linkCount) return `${linkCount[1]} Link(s)`;
|
||||
const exportSelected = value.match(/^Export selected (packages|files) \((\d+)\)$/);
|
||||
if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${exportSelected[2]})`;
|
||||
const toggleAll = value.match(/^Toggle all (.+)$/);
|
||||
if (toggleAll) return `Alle ${toggleAll[1]} umschalten`;
|
||||
const removeSelected = value.match(/^Remove selected \((\d+)\)$/);
|
||||
if (removeSelected) return `Ausgewählte entfernen (${removeSelected[1]})`;
|
||||
const copied = value.match(/^(.+) copied$/);
|
||||
if (copied) return `${copied[1]} kopiert`;
|
||||
const suffixes: Array<[RegExp, string]> = [
|
||||
[/^Select (.+)$/, "$1 auswählen"], [/^Collapse (.+)$/, "$1 einklappen"], [/^Expand (.+)$/, "$1 ausklappen"],
|
||||
[/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"]
|
||||
];
|
||||
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||
return value
|
||||
.replace(/Extract automatically/g, "Automatisch entpacken")
|
||||
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
|
||||
.replace(/Failed after (\d+) attempts/g, "Fehlgeschlagen nach $1 Versuchen")
|
||||
.replace(/(\d+) errors/g, "$1 Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
export function translateUiText(value: string, language: AppLanguage): string {
|
||||
const leading = value.match(/^\s*/)?.[0] ?? "";
|
||||
const trailing = value.match(/\s*$/)?.[0] ?? "";
|
||||
const core = value.slice(leading.length, value.length - trailing.length);
|
||||
if (!core) return value;
|
||||
const translated = (language === "en" ? deToEn : enToDe).get(core) ?? translateDynamic(core, language);
|
||||
return `${leading}${translated}${trailing}`;
|
||||
}
|
||||
|
||||
type Localizer = { setLanguage: (language: AppLanguage) => void; disconnect: () => void };
|
||||
|
||||
export function createUiLocalizer(documentRef: Document, initialLanguage: AppLanguage): Localizer {
|
||||
let language = initialLanguage;
|
||||
const sourceText = new WeakMap<Text, string>();
|
||||
const renderedText = new WeakMap<Text, string>();
|
||||
const sourceAttributes = new WeakMap<Element, Map<string, string>>();
|
||||
const renderedAttributes = new WeakMap<Element, Map<string, string>>();
|
||||
|
||||
const ignored = (node: Node): boolean => node.parentElement?.closest('[data-i18n-ignore="true"]') !== null;
|
||||
const localizeText = (node: Text): void => {
|
||||
if (ignored(node)) return;
|
||||
const current = node.data;
|
||||
const lastRendered = renderedText.get(node);
|
||||
if (!sourceText.has(node) || (lastRendered !== undefined && current !== lastRendered)) sourceText.set(node, current);
|
||||
const next = translateUiText(sourceText.get(node) ?? current, language);
|
||||
renderedText.set(node, next);
|
||||
if (current !== next) node.data = next;
|
||||
};
|
||||
const localizeElement = (element: Element): void => {
|
||||
if (element.closest('[data-i18n-ignore="true"]')) return;
|
||||
let sources = sourceAttributes.get(element);
|
||||
let rendered = renderedAttributes.get(element);
|
||||
if (!sources) { sources = new Map(); sourceAttributes.set(element, sources); }
|
||||
if (!rendered) { rendered = new Map(); renderedAttributes.set(element, rendered); }
|
||||
for (const attribute of attributes) {
|
||||
const current = element.getAttribute(attribute);
|
||||
if (current === null) continue;
|
||||
const lastRendered = rendered.get(attribute);
|
||||
if (!sources.has(attribute) || (lastRendered !== undefined && current !== lastRendered)) sources.set(attribute, current);
|
||||
const next = translateUiText(sources.get(attribute) ?? current, language);
|
||||
rendered.set(attribute, next);
|
||||
if (current !== next) element.setAttribute(attribute, next);
|
||||
}
|
||||
};
|
||||
const localizeTree = (root: Node): void => {
|
||||
if (root.nodeType === Node.TEXT_NODE) localizeText(root as Text);
|
||||
if (root.nodeType === Node.ELEMENT_NODE) localizeElement(root as Element);
|
||||
const walker = documentRef.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) localizeText(node as Text);
|
||||
else localizeElement(node as Element);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "characterData") localizeText(mutation.target as Text);
|
||||
else if (mutation.type === "attributes") localizeElement(mutation.target as Element);
|
||||
else for (const node of mutation.addedNodes) localizeTree(node);
|
||||
}
|
||||
});
|
||||
const start = (): void => {
|
||||
documentRef.documentElement.lang = language;
|
||||
localizeTree(documentRef.body);
|
||||
observer.observe(documentRef.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
||||
};
|
||||
start();
|
||||
return {
|
||||
setLanguage(nextLanguage) {
|
||||
language = nextLanguage;
|
||||
documentRef.documentElement.lang = language;
|
||||
localizeTree(documentRef.body);
|
||||
},
|
||||
disconnect() { observer.disconnect(); }
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { SessionState } from "../shared/types";
|
||||
|
||||
const DOWNLOAD_SELECTION_PRESERVE_SELECTOR = ".downloads-package-card, .downloads-item-row, .downloads-selection-cell, .package-card, .ctx-menu, .modal-backdrop, .modal-card";
|
||||
|
||||
export function shouldClearDownloadSelection(target: Pick<Element, "closest">): boolean {
|
||||
return !target.closest(DOWNLOAD_SELECTION_PRESERVE_SELECTOR);
|
||||
}
|
||||
|
||||
export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType = ""): boolean {
|
||||
const normalizedTag = tagName.toUpperCase();
|
||||
if (normalizedTag === "TEXTAREA") return false;
|
||||
if (normalizedTag !== "INPUT") return true;
|
||||
return ["checkbox", "radio", "button"].includes(inputType.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop selected ids whose package OR item no longer exists in the session.
|
||||
* The selection set mixes package and item ids; when entries vanish (delta
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||
import appIconUrl from "../../../assets/app_icon.png";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { buildMainNavigation, type MainView } from "./shell-model";
|
||||
|
||||
@@ -8,18 +9,50 @@ export interface AppHeaderProps {
|
||||
actions: ReactNode;
|
||||
}
|
||||
|
||||
const useHeaderLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
|
||||
const navigation = buildMainNavigation(activeView);
|
||||
const navigationRef = useRef<HTMLElement>(null);
|
||||
|
||||
useHeaderLayoutEffect(() => {
|
||||
const element = navigationRef.current;
|
||||
if (!element) return;
|
||||
let frame = 0;
|
||||
const sync = (): void => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const activeItem = element.querySelector<HTMLElement>(`.md-shell-navigation-item[data-main-view="${activeView}"]`);
|
||||
if (!activeItem) return;
|
||||
const navigationRect = element.getBoundingClientRect();
|
||||
const itemRect = activeItem.getBoundingClientRect();
|
||||
element.style.setProperty("--md-navigation-active-x", `${itemRect.left - navigationRect.left}px`);
|
||||
element.style.setProperty("--md-navigation-active-y", `${itemRect.top - navigationRect.top}px`);
|
||||
element.style.setProperty("--md-navigation-active-width", `${itemRect.width}px`);
|
||||
element.style.setProperty("--md-navigation-active-height", `${itemRect.height}px`);
|
||||
element.classList.add("has-active-indicator");
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(sync);
|
||||
observer.observe(element);
|
||||
element.querySelectorAll<HTMLElement>(".md-shell-navigation-item").forEach((item) => observer.observe(item));
|
||||
sync();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [activeView]);
|
||||
|
||||
return (
|
||||
<header className="md-shell-header" data-ui-region="header">
|
||||
<div className="md-shell-brand">Multi-Debrid-Downloader</div>
|
||||
<nav aria-label="Hauptnavigation" className="md-shell-navigation" role="tablist">
|
||||
<div className="md-shell-brand"><img alt="" className="md-shell-brand-mark" src={appIconUrl} /><span>Multi-Debrid Downloader</span></div>
|
||||
<nav aria-label="Hauptnavigation" className="md-shell-navigation" ref={navigationRef} role="tablist">
|
||||
{navigation.map((item) => (
|
||||
<button
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
aria-selected={item.active}
|
||||
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
|
||||
data-main-view={item.id}
|
||||
data-visual-active-view={item.active ? item.id : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function UpdateExperience({
|
||||
{releaseNotes.trim() ? (
|
||||
<details className="md-update-release-notes">
|
||||
<summary>Changelog anzeigen</summary>
|
||||
<pre>{releaseNotes}</pre>
|
||||
<pre data-i18n-ignore="true">{releaseNotes}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
|
||||
.md-shell-brand {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
color: var(--ui-text);
|
||||
font-size: 16px;
|
||||
@@ -56,8 +59,17 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.md-shell-brand-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.md-shell-navigation {
|
||||
display: flex;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
@@ -66,8 +78,30 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-shell-navigation::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--md-navigation-active-width, 0px);
|
||||
height: var(--md-navigation-active-height, 0px);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-active);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate3d(var(--md-navigation-active-x, 0px), var(--md-navigation-active-y, 0px), 0);
|
||||
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 120ms ease;
|
||||
}
|
||||
|
||||
.md-shell-navigation.has-active-indicator::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.md-shell-navigation-item {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
@@ -86,10 +120,14 @@
|
||||
}
|
||||
|
||||
.md-shell-navigation-item.is-active {
|
||||
background: var(--ui-active);
|
||||
background: transparent;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.md-shell-navigation-item.is-active:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.md-shell-navigation-item:focus-visible,
|
||||
.md-shell-header-actions button:focus-visible,
|
||||
.md-shell-sidebar-toggle:focus-visible,
|
||||
@@ -398,6 +436,24 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.md-shell-sidebar-scroll > :is(.collector-sidebar, .settings-sidebar, .history-sidebar, .statistics-sidebar) {
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.md-shell-sidebar-scroll > :is(.collector-sidebar, .settings-sidebar, .history-sidebar, .statistics-sidebar) > :is(.collector-sidebar-heading, .settings-sidebar-heading, .history-sidebar-heading, .statistics-sidebar-heading) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md-shell-sidebar-scroll .collector-sidebar-select {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.md-shell-sidebar-status {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
@@ -412,6 +468,13 @@
|
||||
.md-shell-sidebar.is-collapsed :where(.md-shell-sidebar-scroll, .md-shell-sidebar-status) {
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
.md-shell-sidebar-scroll,
|
||||
.md-shell-sidebar-status {
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.md-shell-sidebar-toggle {
|
||||
@@ -537,6 +600,13 @@
|
||||
|
||||
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
|
||||
z-index: var(--md-layer-menu);
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.md-application-menu-tree .menu-bar-item:last-child > .menu-dropdown {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.md-overlay-host {
|
||||
@@ -819,4 +889,19 @@
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.md-shell-navigation::before {
|
||||
transition-duration: 420ms, 420ms, 420ms, 120ms !important;
|
||||
}
|
||||
|
||||
.md-application-menu-tree .menu-dropdown {
|
||||
transition-property: opacity, transform, visibility !important;
|
||||
transition-duration: 150ms, 180ms, 0s !important;
|
||||
transition-timing-function: ease, cubic-bezier(0.2, 0.8, 0.2, 1), linear !important;
|
||||
transition-delay: 0s, 0s, 180ms !important;
|
||||
}
|
||||
|
||||
.md-application-menu-tree .menu-dropdown.is-open {
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-18
@@ -1,24 +1,24 @@
|
||||
:root {
|
||||
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||
--bg: #050810;
|
||||
--bg-glow: #1e1730;
|
||||
--surface: #0c1322;
|
||||
--card: #12192c;
|
||||
--field: #090f1c;
|
||||
--border: #283447;
|
||||
--text: #ece6dd;
|
||||
--muted: #a59c8e;
|
||||
--accent: #f2942d;
|
||||
--accent-2: #ff7a5c;
|
||||
--bg: #0f0f0f;
|
||||
--bg-glow: #181818;
|
||||
--surface: #232323;
|
||||
--card: #232323;
|
||||
--field: #1b1b1b;
|
||||
--border: #3d3d3d;
|
||||
--text: #ffffff;
|
||||
--muted: #a5a5a5;
|
||||
--accent: #4a4a4a;
|
||||
--accent-2: #636363;
|
||||
--danger: #f4564b;
|
||||
--button-bg: #0d1a2c;
|
||||
--button-bg-hover: #12243d;
|
||||
--tab-bg: #0b1321;
|
||||
--tab-active: #14253e;
|
||||
--toast-bg: #0f1f33;
|
||||
--toast-border: #2a4f78;
|
||||
--progress-track: #0b1628;
|
||||
--overlay: rgba(8, 13, 23, 0.85);
|
||||
--button-bg: #2b2b2b;
|
||||
--button-bg-hover: #373737;
|
||||
--tab-bg: #1b1b1b;
|
||||
--tab-active: #383838;
|
||||
--toast-bg: #262626;
|
||||
--toast-border: #4a4a4a;
|
||||
--progress-track: #1b1b1b;
|
||||
--overlay: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
@@ -52,6 +52,51 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-sliding-selection {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.ui-sliding-selection::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--ui-sliding-selection-width, 0px);
|
||||
height: var(--ui-sliding-selection-height, 0px);
|
||||
border: 1px solid var(--ui-sliding-selection-border, var(--ui-border));
|
||||
border-radius: var(--ui-sliding-selection-radius, 6px);
|
||||
background: var(--ui-sliding-selection-background, var(--ui-active));
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform: translate3d(var(--ui-sliding-selection-x, 0px), var(--ui-sliding-selection-y, 0px), 0);
|
||||
transition: transform var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), width var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), height var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1);
|
||||
}
|
||||
|
||||
.ui-sliding-selection > [data-sliding-selection-item="true"] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui-sliding-selection-horizontal::before {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: var(--ui-accent);
|
||||
transform: translate3d(var(--ui-sliding-selection-x, 0px), 0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-sliding-selection::before {
|
||||
transition-property: transform, width, height !important;
|
||||
transition-duration: var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms) !important;
|
||||
transition-timing-function: cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -112,9 +157,23 @@ body,
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform-origin: top;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s linear 180ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-dropdown.is-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.menu-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
--ui-text: #FFFFFF;
|
||||
--ui-text-secondary: #EAEDF3;
|
||||
--ui-text-muted: #919191;
|
||||
--ui-primary: #BAD0FC;
|
||||
--ui-primary-hover: #8AA5DC;
|
||||
--ui-accent: #3886FF;
|
||||
--ui-primary: #D6D6D6;
|
||||
--ui-primary-hover: #E6E6E6;
|
||||
--ui-accent: #4A4A4A;
|
||||
--ui-success: #4ADE80;
|
||||
--ui-warning: #F1C786;
|
||||
--ui-danger: #F06464;
|
||||
--ui-error-action-text: #181A1F;
|
||||
@@ -34,9 +35,10 @@
|
||||
--ui-text: #181A1F;
|
||||
--ui-text-secondary: #343842;
|
||||
--ui-text-muted: #667085;
|
||||
--ui-primary: #A9C2F3;
|
||||
--ui-primary-hover: #8AA5DC;
|
||||
--ui-accent: #256FDB;
|
||||
--ui-primary: #3A3A3A;
|
||||
--ui-primary-hover: #202020;
|
||||
--ui-accent: #5E5E5E;
|
||||
--ui-success: #1E9E55;
|
||||
--ui-warning: #E8B85D;
|
||||
--ui-danger: #D94747;
|
||||
--ui-error-action-text: #181A1F;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from "react";
|
||||
|
||||
export type RollingMetricDirection = "up" | "down" | "none";
|
||||
|
||||
interface RollingMetricValueProps {
|
||||
numericValue: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RollingMetricTransition {
|
||||
direction: Exclude<RollingMetricDirection, "none">;
|
||||
from: string;
|
||||
id: number;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const useMetricLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export function getRollingMetricDirection(previous: number, next: number): RollingMetricDirection {
|
||||
if (next > previous) return "up";
|
||||
if (next < previous) return "down";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
|
||||
const previousRef = useRef({ numericValue, value });
|
||||
const sequenceRef = useRef(0);
|
||||
const outgoingRef = useRef<HTMLSpanElement>(null);
|
||||
const incomingRef = useRef<HTMLSpanElement>(null);
|
||||
const [transition, setTransition] = useState<RollingMetricTransition | null>(null);
|
||||
|
||||
useMetricLayoutEffect(() => {
|
||||
const previous = previousRef.current;
|
||||
if (previous.value === value && previous.numericValue === numericValue) return;
|
||||
previousRef.current = { numericValue, value };
|
||||
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
|
||||
if (direction === "none") {
|
||||
setTransition(null);
|
||||
return;
|
||||
}
|
||||
sequenceRef.current += 1;
|
||||
setTransition({ direction, from: previous.value, id: sequenceRef.current, to: value });
|
||||
}, [numericValue, value]);
|
||||
|
||||
useMetricLayoutEffect(() => {
|
||||
if (!transition || !outgoingRef.current || !incomingRef.current) return;
|
||||
const distance = transition.direction === "up" ? -1 : 1;
|
||||
const options: KeyframeAnimationOptions = { duration: 320, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" };
|
||||
const outgoing = outgoingRef.current.animate([
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
{ opacity: 0, transform: `translateY(${distance * 115}%)` }
|
||||
], options);
|
||||
const incoming = incomingRef.current.animate([
|
||||
{ opacity: 0, transform: `translateY(${-distance * 115}%)` },
|
||||
{ opacity: 1, transform: "translateY(0)" }
|
||||
], options);
|
||||
let active = true;
|
||||
Promise.all([outgoing.finished, incoming.finished]).then(() => {
|
||||
if (active) setTransition((current) => current?.id === transition.id ? null : current);
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
outgoing.cancel();
|
||||
incoming.cancel();
|
||||
};
|
||||
}, [transition]);
|
||||
|
||||
return (
|
||||
<strong aria-label={value} className="downloads-rolling-value" data-direction={transition?.direction ?? "none"}>
|
||||
{transition
|
||||
? <><span aria-hidden="true" className="downloads-rolling-value-layer is-outgoing" ref={outgoingRef}>{transition.from}</span><span aria-hidden="true" className="downloads-rolling-value-layer is-incoming" ref={incomingRef}>{transition.to}</span></>
|
||||
: <span aria-hidden="true" className="downloads-rolling-value-layer">{value}</span>}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createElement, useEffect, useLayoutEffect, useRef, type HTMLAttributes, type ReactElement, type ReactNode } from "react";
|
||||
|
||||
const useSelectionLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export interface SlidingSelectionProps extends HTMLAttributes<HTMLElement> {
|
||||
activeKey: string;
|
||||
as?: "div" | "nav";
|
||||
axis: "horizontal" | "vertical";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function scheduleSelectionLayout(
|
||||
hasPosition: boolean,
|
||||
frame: number,
|
||||
apply: () => void,
|
||||
requestFrame: (callback: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
cancelFrame: (frame: number) => void = cancelAnimationFrame,
|
||||
enableTransitions: () => void = () => {}
|
||||
): number {
|
||||
if (!hasPosition) {
|
||||
apply();
|
||||
return requestFrame(enableTransitions);
|
||||
}
|
||||
cancelFrame(frame);
|
||||
return requestFrame(apply);
|
||||
}
|
||||
|
||||
export function SlidingSelection({ activeKey, as = "div", axis, children, className = "", ...attributes }: SlidingSelectionProps): ReactElement {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const hasPositionRef = useRef(false);
|
||||
|
||||
useSelectionLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
let frame = 0;
|
||||
let transitionFrame = 0;
|
||||
const applyLayout = (): void => {
|
||||
const active = element.querySelector<HTMLElement>('[data-sliding-selection-active="true"]');
|
||||
if (!active) {
|
||||
element.style.setProperty("--ui-sliding-selection-width", "0px");
|
||||
element.style.setProperty("--ui-sliding-selection-height", "0px");
|
||||
return;
|
||||
}
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
element.style.setProperty("--ui-sliding-selection-x", `${activeRect.left - containerRect.left + element.scrollLeft}px`);
|
||||
element.style.setProperty("--ui-sliding-selection-y", `${activeRect.top - containerRect.top + element.scrollTop}px`);
|
||||
element.style.setProperty("--ui-sliding-selection-width", `${activeRect.width}px`);
|
||||
element.style.setProperty("--ui-sliding-selection-height", `${activeRect.height}px`);
|
||||
hasPositionRef.current = true;
|
||||
};
|
||||
const sync = (): void => {
|
||||
if (!hasPositionRef.current) {
|
||||
transitionFrame = scheduleSelectionLayout(false, transitionFrame, applyLayout, requestAnimationFrame, cancelAnimationFrame, () => {
|
||||
if (hasPositionRef.current) {
|
||||
element.style.setProperty("--ui-sliding-selection-duration", "420ms");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
frame = scheduleSelectionLayout(hasPositionRef.current, frame, applyLayout);
|
||||
};
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
|
||||
observer?.observe(element);
|
||||
element.querySelectorAll<HTMLElement>('[data-sliding-selection-item="true"]').forEach((item) => observer?.observe(item));
|
||||
element.addEventListener("scroll", sync, { passive: true });
|
||||
sync();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
cancelAnimationFrame(transitionFrame);
|
||||
observer?.disconnect();
|
||||
element.removeEventListener("scroll", sync);
|
||||
};
|
||||
}, [activeKey]);
|
||||
|
||||
return createElement(as, {
|
||||
...attributes,
|
||||
className: `ui-sliding-selection ui-sliding-selection-${axis}${className ? ` ${className}` : ""}`,
|
||||
ref
|
||||
}, children);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { CollectorViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
@@ -48,9 +49,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
||||
<strong>Sammlungen</strong>
|
||||
<span>{model.tabs.length}</span>
|
||||
</div>
|
||||
<div className="collector-sidebar-list">
|
||||
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
|
||||
{model.tabs.map((tab) => (
|
||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
|
||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
|
||||
<button
|
||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
||||
className="collector-sidebar-select"
|
||||
@@ -70,7 +71,7 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SlidingSelection>
|
||||
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
|
||||
.collector-sidebar-item.is-active {
|
||||
background: var(--ui-active);
|
||||
border-color: var(--ui-border);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.collector-sidebar-select,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
|
||||
import type { DownloadItem } from "../../../shared/types";
|
||||
import {
|
||||
compactProviderLabels,
|
||||
extractHoster,
|
||||
formatAudioStripSummary,
|
||||
formatDateTime,
|
||||
formatHosterLabel,
|
||||
formatSpeedMbps,
|
||||
humanSize,
|
||||
providerLabels
|
||||
@@ -13,24 +14,77 @@ import type { DownloadPackageRow } from "./downloads-model";
|
||||
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
||||
|
||||
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
||||
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
||||
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
type HosterLabel = ReturnType<typeof formatHosterLabel>;
|
||||
|
||||
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
if (label.iconSrc && !iconFailed) return <img alt="" className="downloads-hoster-icon" data-hoster={label.title.toLowerCase()} onError={() => setIconFailed(true)} src={label.iconSrc} />;
|
||||
return <span>{label.compact}</span>;
|
||||
}
|
||||
|
||||
function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement {
|
||||
return (
|
||||
<span className="downloads-cell downloads-hoster-cell" title={labels.map((label) => label.title).join(", ")}>
|
||||
{labels.map((label) => <HosterLabelContent key={label.title} label={label} />)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadGridTemplate(gridTemplate: string): string {
|
||||
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
|
||||
}
|
||||
|
||||
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
||||
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
|
||||
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
|
||||
account: { label: "Service", width: "132px" },
|
||||
prio: { label: "Priorität", width: "70px" },
|
||||
status: { label: "Status", width: "160px" },
|
||||
speed: { label: "Geschwindigkeit", width: "90px" },
|
||||
added: { label: "Hinzugefügt am", width: "155px" }
|
||||
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
|
||||
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
|
||||
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
||||
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
|
||||
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
|
||||
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
|
||||
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
||||
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
|
||||
};
|
||||
|
||||
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
||||
|
||||
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
|
||||
const total = items.length;
|
||||
const online = items.filter((item) => item.onlineStatus === "online").length;
|
||||
const offline = items.filter((item) => item.onlineStatus === "offline").length;
|
||||
if (total > 0 && online === total) return { online, total, state: "online" };
|
||||
if (total > 0 && offline === total) return { online, total, state: "offline" };
|
||||
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
|
||||
return { online, total, state: "checking" };
|
||||
}
|
||||
|
||||
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
|
||||
const symbol = state === "online" ? "✓" : state === "offline" ? "×" : "●";
|
||||
const label = text ?? `${online}/${total} online`;
|
||||
if (text) {
|
||||
return <span aria-label={label} className={`downloads-cell downloads-availability is-${state}`} title={label}><span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span><span className="downloads-availability-label">{text}</span></span>;
|
||||
}
|
||||
return (
|
||||
<span aria-label={label} className={`downloads-cell downloads-availability has-counts is-${state}`} title={label}>
|
||||
<span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span>
|
||||
<span className="downloads-availability-count is-online-count">{online}</span>
|
||||
<span aria-hidden="true" className="downloads-availability-separator">/</span>
|
||||
<span className="downloads-availability-count is-total-count">{total}</span>
|
||||
<span className="downloads-availability-label">online</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DownloadsTableActions {
|
||||
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
|
||||
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
|
||||
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
|
||||
onSelectionMouseEnter: (id: string) => void;
|
||||
onTogglePackage: (packageId: string) => void;
|
||||
onTogglePackageCollapse: (packageId: string) => void;
|
||||
onStartPackageRename: (packageId: string, packageName: string) => void;
|
||||
onPackageRenameChange: (name: string) => void;
|
||||
@@ -41,11 +95,10 @@ export interface DownloadsTableActions {
|
||||
onMovePackageDown: (packageId: string) => void;
|
||||
onRemoveItem: (itemId: string) => void;
|
||||
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
||||
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragLeave: () => void;
|
||||
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragEnd: () => void;
|
||||
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
||||
onSortColumn: (column: DownloadSortColumn) => void;
|
||||
}
|
||||
@@ -85,12 +138,18 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
||||
}
|
||||
if (column === "hoster") {
|
||||
const hoster = extractHoster(item.url);
|
||||
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
|
||||
const label = formatHosterLabel(hoster);
|
||||
return <HosterLabels labels={[label]} />;
|
||||
}
|
||||
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
||||
if (column === "prio") return <span className="downloads-cell" />;
|
||||
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
|
||||
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
||||
if (column === "availability") {
|
||||
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
|
||||
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
|
||||
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
|
||||
}
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
|
||||
return null;
|
||||
}
|
||||
@@ -110,7 +169,7 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
|
||||
className={`downloads-item-row${selected ? " is-selected" : ""}`}
|
||||
data-download-row-id={item.id}
|
||||
role="row"
|
||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
||||
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
||||
@@ -126,8 +185,8 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
|
||||
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
|
||||
}}
|
||||
>
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(item.id, true, event.shiftKey); }} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
|
||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button">⋮</button></span>
|
||||
</div>
|
||||
);
|
||||
@@ -163,6 +222,71 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
|
||||
|
||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||
|
||||
interface PackageItemsTransitionProps {
|
||||
actions: DownloadsTableActions;
|
||||
collapsed: boolean;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
items: DownloadItem[];
|
||||
selectedIds: ReadonlySet<string>;
|
||||
sessionRunning: boolean;
|
||||
}
|
||||
|
||||
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
|
||||
const [renderItems, setRenderItems] = useState(!collapsed);
|
||||
const animationRef = useRef<Animation | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const initialRenderRef = useRef(true);
|
||||
|
||||
useRendererLayoutEffect(() => {
|
||||
if (initialRenderRef.current) {
|
||||
initialRenderRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!renderItems) {
|
||||
if (!collapsed) setRenderItems(true);
|
||||
return;
|
||||
}
|
||||
const container = containerRef.current;
|
||||
const inner = innerRef.current;
|
||||
if (!container || !inner) return;
|
||||
animationRef.current?.cancel();
|
||||
const targetHeight = inner.scrollHeight;
|
||||
const animation = collapsed
|
||||
? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" })
|
||||
: container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" });
|
||||
animationRef.current = animation;
|
||||
animation.onfinish = () => {
|
||||
if (animationRef.current !== animation) return;
|
||||
animationRef.current = null;
|
||||
if (collapsed) {
|
||||
setRenderItems(false);
|
||||
} else {
|
||||
animation.cancel();
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
animation.onfinish = null;
|
||||
animation.cancel();
|
||||
if (animationRef.current === animation) animationRef.current = null;
|
||||
};
|
||||
}, [collapsed, renderItems]);
|
||||
|
||||
if (!renderItems) return null;
|
||||
return (
|
||||
<div
|
||||
aria-hidden={collapsed}
|
||||
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="downloads-package-items-inner" ref={innerRef}>
|
||||
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
@@ -204,7 +328,6 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
return (
|
||||
<span className="downloads-cell downloads-name-cell">
|
||||
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
||||
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
|
||||
{editing
|
||||
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
@@ -228,8 +351,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
}
|
||||
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
|
||||
if (column === "hoster") {
|
||||
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
|
||||
return <span className="downloads-cell" title={value}>{value}</span>;
|
||||
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
|
||||
return <HosterLabels labels={labels} />;
|
||||
}
|
||||
if (column === "account") {
|
||||
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||
@@ -241,6 +364,10 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
|
||||
}
|
||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||
if (column === "availability") {
|
||||
const availability = getAvailabilitySummary(row.items);
|
||||
return <Availability {...availability} />;
|
||||
}
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||
return null;
|
||||
}
|
||||
@@ -272,7 +399,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className={`package-card 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}
|
||||
draggable={draggable}
|
||||
onContextMenu={(event) => {
|
||||
@@ -289,7 +416,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
||||
className="downloads-package-row"
|
||||
data-download-row-id={entry.id}
|
||||
role="row"
|
||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
||||
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
@@ -302,11 +429,11 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
||||
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
|
||||
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
|
||||
>
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(entry.id, true, event.shiftKey); }} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
||||
</div>
|
||||
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
||||
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -345,7 +472,7 @@ export interface DownloadsTableHeaderProps {
|
||||
|
||||
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
||||
return (
|
||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
|
||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
|
||||
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => {
|
||||
const definition = downloadColumnDefinitions[column];
|
||||
@@ -353,14 +480,20 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
|
||||
return (
|
||||
<div
|
||||
className="downloads-column-header"
|
||||
draggable
|
||||
data-download-column={column}
|
||||
key={column}
|
||||
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
|
||||
onDragEnd={actions.onColumnDragEnd}
|
||||
onDragLeave={actions.onColumnDragLeave}
|
||||
onDragOver={(event) => actions.onColumnDragOver(column, event)}
|
||||
onDragStart={(event) => actions.onColumnDragStart(column, event)}
|
||||
onDrop={(event) => actions.onColumnDrop(column, event)}
|
||||
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !event.isPrimary) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
actions.onColumnPointerDown(column, event);
|
||||
}}
|
||||
onPointerMove={(event) => actions.onColumnPointerMove(column, event)}
|
||||
onPointerUp={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
actions.onColumnPointerUp(column, event);
|
||||
}}
|
||||
role="columnheader"
|
||||
>
|
||||
{definition.sortable
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||
import {
|
||||
DownloadsTableHeader,
|
||||
@@ -13,7 +15,9 @@ export interface DownloadsStatusModel {
|
||||
packages: number;
|
||||
links: number;
|
||||
session: string;
|
||||
sessionBytes: number;
|
||||
total: string;
|
||||
totalBytes: number;
|
||||
hosters: number;
|
||||
speed: string;
|
||||
eta: string;
|
||||
@@ -81,35 +85,32 @@ const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
|
||||
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
|
||||
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
|
||||
</div>
|
||||
<nav aria-label="Downloadfilter">
|
||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
</nav>
|
||||
<div className="downloads-mode-title">Pakete</div>
|
||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
</SlidingSelection>
|
||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||
<div className="downloads-sidebar-actions">
|
||||
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
||||
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||
</div>
|
||||
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
|
||||
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
|
||||
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
|
||||
const entries = [
|
||||
["Pakete", String(model.status.packages)],
|
||||
["Links", String(model.status.links)],
|
||||
["Sitzung", model.status.session],
|
||||
["Gesamt", model.status.total],
|
||||
["Hoster", String(model.status.hosters)],
|
||||
["Geschwindigkeit", model.status.speed],
|
||||
["ETA", model.status.eta]
|
||||
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
|
||||
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
|
||||
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
|
||||
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
|
||||
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
|
||||
];
|
||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
|
||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
|
||||
}
|
||||
|
||||
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
export interface ColumnDragMeasurement {
|
||||
id: string;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface ColumnDragPreview {
|
||||
order: string[];
|
||||
offsets: Record<string, number>;
|
||||
settleOffsets: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DownloadColumnDragSession {
|
||||
active: boolean;
|
||||
draggedId: string;
|
||||
measurements: ColumnDragMeasurement[];
|
||||
pointerId: number;
|
||||
preview: ColumnDragPreview;
|
||||
root: HTMLElement;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
export function calculateColumnDragPreview(columns: readonly ColumnDragMeasurement[], draggedId: string, deltaX: number): ColumnDragPreview {
|
||||
const dragged = columns.find((column) => column.id === draggedId);
|
||||
if (!dragged) {
|
||||
const order = columns.map((column) => column.id);
|
||||
const offsets = Object.fromEntries(order.map((id) => [id, 0]));
|
||||
return { order, offsets, settleOffsets: { ...offsets } };
|
||||
}
|
||||
|
||||
const draggedCenter = dragged.left + dragged.width / 2 + deltaX;
|
||||
const remaining = columns.filter((column) => column.id !== draggedId);
|
||||
let targetIndex = remaining.findIndex((column) => draggedCenter < column.left + column.width / 2);
|
||||
if (targetIndex < 0) targetIndex = remaining.length;
|
||||
|
||||
const order = remaining.map((column) => column.id);
|
||||
order.splice(targetIndex, 0, draggedId);
|
||||
|
||||
const originalById = new Map(columns.map((column) => [column.id, column]));
|
||||
const targetLeftById = new Map<string, number>();
|
||||
let nextLeft = columns[0]?.left ?? 0;
|
||||
for (const id of order) {
|
||||
const column = originalById.get(id);
|
||||
if (!column) continue;
|
||||
targetLeftById.set(id, nextLeft);
|
||||
nextLeft += column.width;
|
||||
}
|
||||
|
||||
const settleOffsets = Object.fromEntries(columns.map((column) => [column.id, (targetLeftById.get(column.id) ?? column.left) - column.left]));
|
||||
const offsets = { ...settleOffsets, [draggedId]: deltaX };
|
||||
return { order, offsets, settleOffsets };
|
||||
}
|
||||
|
||||
function setColumnOffsets(root: HTMLElement, offsets: Record<string, number>, excludedId?: string): void {
|
||||
for (const [id, offset] of Object.entries(offsets)) {
|
||||
if (id === excludedId) continue;
|
||||
root.style.setProperty(`--downloads-column-drag-${id}`, `${offset}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggedColumn(root: HTMLElement, draggedId: string, dragging: boolean): void {
|
||||
if (dragging && root.dataset.columnDragging !== draggedId) {
|
||||
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||
root.querySelectorAll<HTMLElement>(`[data-download-column="${draggedId}"]`).forEach((element) => { element.dataset.columnDragging = "true"; });
|
||||
root.dataset.columnDragging = draggedId;
|
||||
} else if (!dragging && root.dataset.columnDragging) {
|
||||
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||
delete root.dataset.columnDragging;
|
||||
}
|
||||
}
|
||||
|
||||
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number): DownloadColumnDragSession | null {
|
||||
const root = element.closest<HTMLElement>(".downloads-table");
|
||||
const header = element.closest<HTMLElement>(".downloads-table-header");
|
||||
if (!root || !header) return null;
|
||||
const measurements = Array.from(header.querySelectorAll<HTMLElement>(".downloads-column-header[data-download-column]")).map((column) => {
|
||||
const rect = column.getBoundingClientRect();
|
||||
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
|
||||
}).filter((column) => column.id && column.width > 0);
|
||||
if (!measurements.some((column) => column.id === draggedId)) return null;
|
||||
return {
|
||||
active: false,
|
||||
draggedId,
|
||||
measurements,
|
||||
pointerId,
|
||||
preview: calculateColumnDragPreview(measurements, draggedId, 0),
|
||||
root,
|
||||
startX: clientX
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDownloadColumnDrag(session: DownloadColumnDragSession, clientX: number): boolean {
|
||||
const deltaX = clientX - session.startX;
|
||||
if (!session.active && Math.abs(deltaX) < 5) return false;
|
||||
if (!session.active) {
|
||||
session.active = true;
|
||||
session.root.classList.add("is-column-drag-active");
|
||||
setDraggedColumn(session.root, session.draggedId, true);
|
||||
}
|
||||
const nextPreview = calculateColumnDragPreview(session.measurements, session.draggedId, deltaX);
|
||||
session.root.style.setProperty("--downloads-active-drag-x", `${deltaX}px`);
|
||||
if (nextPreview.order.join("|") !== session.preview.order.join("|")) {
|
||||
setColumnOffsets(session.root, nextPreview.offsets, session.draggedId);
|
||||
}
|
||||
session.preview = nextPreview;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function settleDownloadColumnDrag(session: DownloadColumnDragSession, cancelled: boolean): string[] | null {
|
||||
if (!session.active) return null;
|
||||
const originalOrder = session.measurements.map((column) => column.id);
|
||||
const order = cancelled ? originalOrder : session.preview.order;
|
||||
const offsets = cancelled
|
||||
? Object.fromEntries(originalOrder.map((id) => [id, 0]))
|
||||
: session.preview.settleOffsets;
|
||||
setColumnOffsets(session.root, offsets);
|
||||
session.root.style.removeProperty("--downloads-active-drag-x");
|
||||
setDraggedColumn(session.root, session.draggedId, false);
|
||||
session.root.classList.add("is-column-drag-settling");
|
||||
return order;
|
||||
}
|
||||
|
||||
export function clearDownloadColumnDrag(session: DownloadColumnDragSession): void {
|
||||
session.root.classList.remove("is-column-drag-active", "is-column-drag-settling");
|
||||
session.root.style.removeProperty("--downloads-active-drag-x");
|
||||
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
|
||||
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||
delete session.root.dataset.columnDragging;
|
||||
}
|
||||
@@ -75,6 +75,14 @@ export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): Downl
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
total += item.totalBytes || item.downloadedBytes || 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function isExtracted(item: DownloadItem): boolean {
|
||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||
}
|
||||
|
||||
@@ -34,43 +34,51 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 14px 12px;
|
||||
padding: 14px 12px 6px;
|
||||
}
|
||||
|
||||
.downloads-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
.downloads-mode-title {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-active);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.downloads-mode-switch button,
|
||||
.downloads-sidebar nav button,
|
||||
.downloads-sidebar-actions button {
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--ui-text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-mode-switch button {
|
||||
text-align: center;
|
||||
.downloads-sidebar nav button:not(.is-active):hover,
|
||||
.downloads-sidebar-actions button:hover:not(:disabled),
|
||||
.downloads-clipboard-toggle:hover {
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.downloads-mode-switch button.is-active,
|
||||
.downloads-sidebar nav button.is-active {
|
||||
color: #ffffff;
|
||||
background: var(--ui-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.downloads-sidebar nav {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
--ui-sliding-selection-background: var(--ui-accent);
|
||||
--ui-sliding-selection-border: transparent;
|
||||
--ui-sliding-selection-radius: 4px;
|
||||
}
|
||||
|
||||
.downloads-sidebar nav button {
|
||||
@@ -103,15 +111,31 @@
|
||||
.downloads-sidebar-actions {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.downloads-sidebar-actions label {
|
||||
.downloads-sidebar-actions button {
|
||||
border: 1px solid var(--ui-border);
|
||||
background: var(--ui-input);
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.downloads-sidebar-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.downloads-clipboard-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-input);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloads-sidebar-status {
|
||||
@@ -135,6 +159,23 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.downloads-rolling-value {
|
||||
display: inline-grid;
|
||||
height: 1.35em;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
overflow: hidden;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.downloads-rolling-value-layer {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloads-main-column {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||
@@ -211,6 +252,14 @@
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.downloads-table-header,
|
||||
.downloads-table-body {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 1191px;
|
||||
}
|
||||
|
||||
.downloads-table-header {
|
||||
@@ -220,9 +269,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
min-width: max-content;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-table-header);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -230,54 +278,113 @@
|
||||
}
|
||||
|
||||
.downloads-table-body {
|
||||
min-width: max-content;
|
||||
overflow: visible;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.downloads-package-card {
|
||||
min-width: max-content;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 72%, transparent);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: var(--ui-canvas);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.downloads-package-card.is-disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.downloads-package-items {
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.downloads-package-items.is-collapsed {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.downloads-package-items-inner {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.downloads-view :where(button, input, select):focus-visible {
|
||||
outline: 1px solid #7B8492;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.downloads-item-row,
|
||||
.downloads-package-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
min-width: max-content;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-canvas);
|
||||
background: transparent;
|
||||
border-color: var(--ui-border);
|
||||
contain: layout paint style;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 40px;
|
||||
}
|
||||
|
||||
.downloads-item-row {
|
||||
height: 38px;
|
||||
contain-intrinsic-size: 38px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.downloads-item-row.is-selected,
|
||||
.downloads-package-card.is-selected > .downloads-package-row {
|
||||
background: var(--ui-active);
|
||||
background: color-mix(in srgb, var(--ui-success) 14%, var(--ui-canvas));
|
||||
box-shadow: inset 3px 0 0 var(--ui-success);
|
||||
}
|
||||
|
||||
.downloads-package-row:hover,
|
||||
.downloads-item-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.downloads-item-row.is-selected:hover,
|
||||
.downloads-package-card.is-selected > .downloads-package-row:hover {
|
||||
background: color-mix(in srgb, var(--ui-success) 18%, var(--ui-canvas));
|
||||
}
|
||||
|
||||
.downloads-cell-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-cell-slot > .downloads-cell {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloads-cell-slot[data-download-column="name"] > .downloads-name-cell {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-cell,
|
||||
.downloads-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
padding: 0 7px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -298,20 +405,117 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-action-cell button,
|
||||
.downloads-collapse-button {
|
||||
.downloads-hoster-cell {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.downloads-hoster-icon {
|
||||
display: block;
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.downloads-hoster-icon[data-hoster="rapidgator"] {
|
||||
transform: translateY(-4px) scale(2);
|
||||
}
|
||||
|
||||
.downloads-selection-cell input[type="checkbox"] {
|
||||
box-sizing: border-box;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--ui-success);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.downloads-action-cell button {
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.downloads-collapse-button {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.downloads-name-cell > input[type="checkbox"] {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.downloads-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.downloads-table.is-column-drag-active [data-download-column] {
|
||||
position: relative;
|
||||
transform: translate3d(var(--downloads-column-drag-x, 0px), 0, 0);
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.downloads-table [data-download-column="name"] { --downloads-column-drag-x: var(--downloads-column-drag-name, 0px); }
|
||||
.downloads-table [data-download-column="size"] { --downloads-column-drag-x: var(--downloads-column-drag-size, 0px); }
|
||||
.downloads-table [data-download-column="progress"] { --downloads-column-drag-x: var(--downloads-column-drag-progress, 0px); }
|
||||
.downloads-table [data-download-column="hoster"] { --downloads-column-drag-x: var(--downloads-column-drag-hoster, 0px); }
|
||||
.downloads-table [data-download-column="account"] { --downloads-column-drag-x: var(--downloads-column-drag-account, 0px); }
|
||||
.downloads-table [data-download-column="prio"] { --downloads-column-drag-x: var(--downloads-column-drag-prio, 0px); }
|
||||
.downloads-table [data-download-column="status"] { --downloads-column-drag-x: var(--downloads-column-drag-status, 0px); }
|
||||
.downloads-table [data-download-column="speed"] { --downloads-column-drag-x: var(--downloads-column-drag-speed, 0px); }
|
||||
.downloads-table [data-download-column="availability"] { --downloads-column-drag-x: var(--downloads-column-drag-availability, 0px); }
|
||||
.downloads-table [data-download-column="added"] { --downloads-column-drag-x: var(--downloads-column-drag-added, 0px); }
|
||||
|
||||
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
|
||||
--downloads-column-drag-x: var(--downloads-active-drag-x, 0px);
|
||||
z-index: 4;
|
||||
transition: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.downloads-table.is-column-drag-active .downloads-column-header[data-column-dragging="true"] {
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent), inset -2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent);
|
||||
}
|
||||
|
||||
.downloads-table.is-column-drag-settling [data-download-column] {
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.downloads-table.is-column-drag-active [data-download-column],
|
||||
.downloads-table.is-column-drag-settling [data-download-column] {
|
||||
transition-duration: 220ms !important;
|
||||
}
|
||||
|
||||
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
.downloads-column-header button {
|
||||
@@ -319,10 +523,19 @@
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
text-transform: inherit;
|
||||
}
|
||||
|
||||
.downloads-column-header[data-download-column="name"] {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-column-header[data-download-column="name"] button {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-link-state {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
@@ -343,11 +556,65 @@
|
||||
background: var(--ui-warning);
|
||||
}
|
||||
|
||||
.downloads-availability {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 7ch;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.downloads-availability.has-counts {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 4ch 1ch 4ch auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.downloads-availability-symbol {
|
||||
width: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloads-availability-count.is-online-count {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.downloads-availability-count.is-total-count {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-availability-separator {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloads-availability-label {
|
||||
padding-left: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-availability.is-online {
|
||||
color: var(--ui-success);
|
||||
}
|
||||
|
||||
.downloads-availability.is-partial {
|
||||
color: var(--ui-warning);
|
||||
}
|
||||
|
||||
.downloads-availability.is-offline {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.downloads-availability.is-checking {
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.downloads-meter {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
@@ -357,7 +624,7 @@
|
||||
.downloads-meter > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
|
||||
background: var(--ui-success);
|
||||
}
|
||||
|
||||
.downloads-meter > b {
|
||||
@@ -371,6 +638,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloads-item-row .downloads-meter > b {
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.downloads-empty-state,
|
||||
.downloads-table-message {
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
||||
import "./history.css";
|
||||
|
||||
@@ -60,11 +61,13 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
return (
|
||||
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
|
||||
<strong className="history-sidebar-heading">Verlauf</strong>
|
||||
<div className="history-filter-list">
|
||||
<SlidingSelection activeKey={model.filter} axis="vertical" className="history-filter-list">
|
||||
{filterItems.map((item) => (
|
||||
<button
|
||||
aria-current={model.filter === item.id ? "page" : undefined}
|
||||
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
|
||||
data-sliding-selection-active={model.filter === item.id}
|
||||
data-sliding-selection-item="true"
|
||||
key={item.id}
|
||||
onClick={() => actions.onFilterChange(item.id)}
|
||||
type="button"
|
||||
@@ -73,7 +76,7 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
<span>{model.counts[item.id]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SlidingSelection>
|
||||
<button
|
||||
className="history-sidebar-clear"
|
||||
disabled={model.totalCount === 0 || model.loading}
|
||||
@@ -194,11 +197,12 @@ export function HistoryContent({ model, actions }: HistoryViewProps): ReactEleme
|
||||
<button
|
||||
aria-label={`Aktionen für ${row.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
actions.onContextMenu(row.id, rect.right, rect.bottom);
|
||||
}}
|
||||
type="button"
|
||||
>•••</button>
|
||||
>⋮</button>
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <HistoryRowDetails row={row} /> : null}
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
}
|
||||
|
||||
.history-filter.is-active {
|
||||
background: var(--ui-active);
|
||||
border-color: var(--ui-border);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,14 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-table-header-row > span {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-table-header-row > span:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-table-header-row > span,
|
||||
.history-row > span {
|
||||
min-width: 0;
|
||||
@@ -213,7 +221,14 @@
|
||||
}
|
||||
|
||||
.history-row-action button {
|
||||
width: 36px;
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
color: var(--ui-text);
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.history-expand:hover,
|
||||
@@ -244,9 +259,9 @@
|
||||
}
|
||||
|
||||
.history-status-deleted {
|
||||
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
|
||||
color: var(--ui-warning);
|
||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.history-status-failed {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ReactElement,
|
||||
type UIEvent
|
||||
} from "react";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
@@ -430,10 +431,12 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
||||
</label>
|
||||
) : null}
|
||||
</header>
|
||||
<div aria-label="Accountverwaltung" className="settings-account-tabs" role="tablist">
|
||||
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" axis="horizontal" className="settings-account-tabs" role="tablist">
|
||||
<button
|
||||
aria-controls="settings-account-overview"
|
||||
aria-selected={model.activePanel === "overview"}
|
||||
data-sliding-selection-active={model.activePanel === "overview"}
|
||||
data-sliding-selection-item="true"
|
||||
id="settings-account-overview-tab"
|
||||
onClick={() => actions.onPanelChange("overview")}
|
||||
role="tab"
|
||||
@@ -442,12 +445,14 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
||||
<button
|
||||
aria-controls="settings-account-rules"
|
||||
aria-selected={model.activePanel === "rules"}
|
||||
data-sliding-selection-active={model.activePanel === "rules"}
|
||||
data-sliding-selection-item="true"
|
||||
id="settings-account-rules-tab"
|
||||
onClick={() => actions.onPanelChange("rules")}
|
||||
role="tab"
|
||||
type="button"
|
||||
>Verwendungsregeln</button>
|
||||
</div>
|
||||
</SlidingSelection>
|
||||
<div
|
||||
aria-labelledby="settings-account-overview-tab"
|
||||
className="settings-account-panel"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AccountWorkspaceViewModel
|
||||
} from "./AccountWorkspace";
|
||||
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import "./settings.css";
|
||||
|
||||
export type SettingsViewRegion = "all" | "sidebar" | "content";
|
||||
@@ -42,17 +43,19 @@ export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactEle
|
||||
<div className="settings-sidebar-heading">
|
||||
<strong>Einstellungen</strong>
|
||||
</div>
|
||||
<div className="settings-sidebar-list">
|
||||
<SlidingSelection activeKey={model.section} axis="vertical" className="settings-sidebar-list">
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<button
|
||||
aria-current={model.section === section.id ? "page" : undefined}
|
||||
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
|
||||
data-sliding-selection-active={model.section === section.id}
|
||||
data-sliding-selection-item="true"
|
||||
key={section.id}
|
||||
onClick={() => actions.onSectionChange(section.id)}
|
||||
type="button"
|
||||
>{section.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</SlidingSelection>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,6 +408,22 @@ export function buildSettingsFormViewModel({
|
||||
title: "Allgemein",
|
||||
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
|
||||
groups: [
|
||||
{
|
||||
id: "general-language",
|
||||
title: "Sprache",
|
||||
fields: [
|
||||
{
|
||||
id: "language",
|
||||
kind: "select",
|
||||
label: "Sprache",
|
||||
value: settings.language,
|
||||
options: [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "de", label: "Deutsch" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "general-storage",
|
||||
title: "Speicherort",
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
}
|
||||
|
||||
.settings-sidebar-item.is-active {
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-active);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
}
|
||||
|
||||
.settings-account-tabs button[aria-selected="true"] {
|
||||
border-bottom-color: var(--ui-accent);
|
||||
border-bottom-color: transparent;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
@@ -474,13 +474,17 @@
|
||||
|
||||
.settings-account-table-grid {
|
||||
height: 41px;
|
||||
color: var(--ui-text-muted);
|
||||
color: var(--ui-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-form-group:has(#update\:check) .settings-action-field {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.settings-account-table-grid > span,
|
||||
.settings-account-row > span {
|
||||
min-width: 0;
|
||||
@@ -604,7 +608,7 @@
|
||||
.settings-account-traffic,
|
||||
.settings-account-expires,
|
||||
.settings-account-credential {
|
||||
color: var(--ui-text-muted);
|
||||
color: var(--ui-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
StatisticsRange,
|
||||
StatisticsViewModel
|
||||
} from "./statistics-model";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import "./statistics.css";
|
||||
|
||||
export interface StatisticsViewActions {
|
||||
@@ -109,17 +110,19 @@ export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps,
|
||||
return (
|
||||
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
|
||||
<strong className="statistics-sidebar-heading">Zeitraum</strong>
|
||||
<div className="statistics-range-list">
|
||||
<SlidingSelection activeKey={model.range} axis="vertical" className="statistics-range-list">
|
||||
{rangeItems.map((item) => (
|
||||
<button
|
||||
aria-current={model.range === item.id ? "page" : undefined}
|
||||
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
|
||||
data-sliding-selection-active={model.range === item.id}
|
||||
data-sliding-selection-item="true"
|
||||
key={item.id}
|
||||
onClick={() => actions.onRangeChange(item.id)}
|
||||
type="button"
|
||||
>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</SlidingSelection>
|
||||
<p className="statistics-sidebar-message">{model.message}</p>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
|
||||
.statistics-range-active {
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-active);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export type DebridProvider =
|
||||
| "linksnappy";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
export type AppLanguage = "en" | "de";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||
@@ -67,6 +68,7 @@ export interface DebridAccountStatus {
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
language: AppLanguage;
|
||||
token: string;
|
||||
realDebridUseWebLogin: boolean;
|
||||
megaLogin: string;
|
||||
@@ -145,6 +147,7 @@ export interface AppSettings {
|
||||
totalRuntimeAllTimeMs: number;
|
||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||
columnOrder: string[];
|
||||
columnOrderVersion?: number;
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAppIconPath } from "../src/main/app-icon";
|
||||
|
||||
describe("application icon path", () => {
|
||||
it("uses the unpacked resource copy in packaged builds", () => {
|
||||
expect(resolveAppIconPath(true, "C:\\app\\resources\\app.asar", "C:\\app\\resources")).toBe(
|
||||
path.join("C:\\app\\resources", "assets", "app_icon.ico")
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the source asset in development", () => {
|
||||
expect(resolveAppIconPath(false, "C:\\repo", "C:\\app\\resources")).toBe(
|
||||
path.join("C:\\repo", "assets", "app_icon.ico")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("keeps application menus mounted for animated opening and closing", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(source.match(/menu-dropdown\$\{openMenu ===/g)).toHaveLength(3);
|
||||
expect(css).toMatch(/\.menu-dropdown\s*\{[^}]*opacity:\s*0;[^}]*transform:\s*translateY\(-8px\);[^}]*visibility:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.menu-dropdown\.is-open\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateY\(0\);[^}]*visibility:\s*visible;/s);
|
||||
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
|
||||
});
|
||||
|
||||
it("uses the product asset in the header brand", () => {
|
||||
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||
expect(html).toContain("Multi-Debrid Downloader");
|
||||
expect(html).toContain("app_icon.png");
|
||||
});
|
||||
|
||||
it("exposes all five views with exactly one active item", () => {
|
||||
const items = buildMainNavigation("downloads");
|
||||
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
|
||||
expect(items.filter((item) => item.active)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders one measured sliding indicator behind the active navigation item", () => {
|
||||
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||
const source = readFileSync(new URL("../src/renderer/shell/AppHeader.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
expect(html).toContain("data-main-view=\"downloads\"");
|
||||
expect(source).toContain("ResizeObserver");
|
||||
expect(css).toMatch(/\.md-shell-navigation::before\s*\{[^}]*transform:\s*translate3d\(var\(--md-navigation-active-x[^}]*transition:\s*transform 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\), width 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
|
||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-shell-navigation::before\s*\{[^}]*transition-duration:\s*420ms, 420ms, 420ms, 120ms !important;/);
|
||||
expect(css).toMatch(/\.md-shell-navigation-item\.is-active\s*\{[^}]*background:\s*transparent;/s);
|
||||
});
|
||||
|
||||
it("renders context regions without global placeholders", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell
|
||||
@@ -58,6 +89,14 @@ describe("desktop shell", () => {
|
||||
expect(html).not.toContain("data-ui-region=\"footer\"");
|
||||
});
|
||||
|
||||
it("keeps every view sidebar on the same content axis", () => {
|
||||
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\)\s*\{[^}]*gap:\s*8px;[^}]*padding:\s*0;[^}]*width:\s*100%;/s);
|
||||
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\) > :is\(\.collector-sidebar-heading, \.settings-sidebar-heading, \.history-sidebar-heading, \.statistics-sidebar-heading\)\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*28px;[^}]*padding:\s*0;/s);
|
||||
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll \.collector-sidebar-select\s*\{[^}]*padding:\s*0 10px;/s);
|
||||
});
|
||||
|
||||
it("renders the account popover only while open", () => {
|
||||
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
|
||||
const html = renderToStaticMarkup(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
CollectorInputDialog,
|
||||
CollectorContent,
|
||||
CollectorSidebar,
|
||||
CollectorToolbar,
|
||||
CollectorView,
|
||||
type CollectorViewActions
|
||||
@@ -141,6 +142,18 @@ describe("collector model", () => {
|
||||
});
|
||||
|
||||
describe("CollectorView", () => {
|
||||
it("marks collections for one measured vertical selection indicator", () => {
|
||||
const model = buildCollectorViewModel([
|
||||
{ id: "tab-a", name: "Sammlung A", text: "https://example.test/a" },
|
||||
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||
], "tab-b", "", false, []);
|
||||
const html = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps empty, busy and error states inside the same table body", () => {
|
||||
const empty = renderToStaticMarkup(
|
||||
<CollectorView
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { calculateColumnDragPreview, updateDownloadColumnDrag, type DownloadColumnDragSession } from "../src/renderer/views/downloads/column-drag";
|
||||
|
||||
const columns = [
|
||||
{ id: "name", left: 0, width: 300 },
|
||||
{ id: "size", left: 300, width: 150 },
|
||||
{ id: "status", left: 450, width: 100 }
|
||||
];
|
||||
|
||||
describe("animated download column drag", () => {
|
||||
it("moves a narrow right column left while adjacent columns make room", () => {
|
||||
expect(calculateColumnDragPreview(columns, "status", -420)).toEqual({
|
||||
order: ["status", "name", "size"],
|
||||
offsets: { name: 100, size: 100, status: -420 },
|
||||
settleOffsets: { name: 100, size: 100, status: -450 }
|
||||
});
|
||||
});
|
||||
|
||||
it("moves a wide left column right using the measured column widths", () => {
|
||||
expect(calculateColumnDragPreview(columns, "name", 500)).toEqual({
|
||||
order: ["size", "status", "name"],
|
||||
offsets: { name: 500, size: -300, status: -300 },
|
||||
settleOffsets: { name: 250, size: -300, status: -300 }
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the original order while the dragged center has not crossed a neighbor", () => {
|
||||
expect(calculateColumnDragPreview(columns, "size", 20).order).toEqual(["name", "size", "status"]);
|
||||
});
|
||||
|
||||
it("writes only the continuous active offset while the target slot stays unchanged", () => {
|
||||
const setProperty = vi.fn();
|
||||
const root = {
|
||||
classList: { add: vi.fn() },
|
||||
dataset: { columnDragging: "size" },
|
||||
querySelectorAll: vi.fn(() => []),
|
||||
style: { setProperty }
|
||||
} as unknown as HTMLElement;
|
||||
const session = {
|
||||
active: true,
|
||||
draggedId: "size",
|
||||
measurements: columns,
|
||||
pointerId: 1,
|
||||
preview: calculateColumnDragPreview(columns, "size", 20),
|
||||
root,
|
||||
startX: 0
|
||||
} as DownloadColumnDragSession;
|
||||
|
||||
updateDownloadColumnDrag(session, 25);
|
||||
|
||||
expect(setProperty).toHaveBeenCalledTimes(1);
|
||||
expect(setProperty).toHaveBeenCalledWith("--downloads-active-drag-x", "25px");
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -2749,6 +2749,38 @@ describe("normalizeResolvedFilename", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRapidgatorFileSize", () => {
|
||||
it("converts hoster size labels to bytes", () => {
|
||||
expect(parseRapidgatorFileSize("1.50 GB")).toBe(1_610_612_736);
|
||||
expect(parseRapidgatorFileSize("658,25 MB")).toBe(690_225_152);
|
||||
expect(parseRapidgatorFileSize("1024 B")).toBe(1024);
|
||||
});
|
||||
|
||||
it("rejects missing and malformed values", () => {
|
||||
expect(parseRapidgatorFileSize(null)).toBeNull();
|
||||
expect(parseRapidgatorFileSize("unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRapidgatorOnline", () => {
|
||||
it("loads metadata directly without waiting for a separate HEAD request", async () => {
|
||||
const methods: string[] = [];
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
methods.push(String(init?.method || "GET"));
|
||||
return new Response('<html><title>episode.part01.rar</title><div>File size: <strong>1.50 GB</strong></div></html>', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await checkRapidgatorOnline("https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
|
||||
expect(methods).toEqual(["GET"]);
|
||||
expect(result).toEqual({
|
||||
online: true,
|
||||
fileName: "episode.part01.rar",
|
||||
fileSizeBytes: 1_610_612_736
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filenameFromRapidgatorUrlPath", () => {
|
||||
it("extracts filename from standard rapidgator URL", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||
|
||||
@@ -393,7 +393,8 @@ describe("debug-server", () => {
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.status).toBe("ok");
|
||||
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
||||
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
||||
expect(payload.host).toBe("0.0.0.0");
|
||||
expect(payload.localOnly).toBe(false);
|
||||
@@ -420,7 +421,8 @@ describe("debug-server", () => {
|
||||
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
expect(payload.status).toBe("ok");
|
||||
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
||||
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEV_SERVER_PORT, DEV_SERVER_URL } from "../src/main/dev-server-url";
|
||||
|
||||
describe("development server URL", () => {
|
||||
it("uses the isolated downloader development port by default", () => {
|
||||
expect(DEV_SERVER_PORT).toBe("5180");
|
||||
expect(DEV_SERVER_URL).toBe("http://localhost:5180");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
@@ -22,6 +22,25 @@ import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe("runWithLimitedConcurrency", () => {
|
||||
it("processes the full batch without exceeding the configured worker count", async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const completed: number[] = [];
|
||||
|
||||
await runWithLimitedConcurrency([1, 2, 3, 4, 5, 6], 3, async (value) => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
completed.push(value);
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
expect(peak).toBe(3);
|
||||
expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||
it("detects archive names from extractor log variants", () => {
|
||||
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
|
||||
@@ -166,6 +185,28 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("download manager", () => {
|
||||
it("stores RapidGator metadata before a download starts", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "metadata", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const itemId = snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0];
|
||||
const internal = manager as unknown as {
|
||||
session: typeof snapshot.session;
|
||||
applyRapidgatorCheckResult: (item: typeof snapshot.session.items[string], result: { online: boolean; fileName: string; fileSizeBytes: number | null }) => void;
|
||||
};
|
||||
|
||||
internal.applyRapidgatorCheckResult(internal.session.items[itemId], {
|
||||
online: true,
|
||||
fileName: "episode.part01.rar",
|
||||
fileSizeBytes: 1_610_612_736
|
||||
});
|
||||
|
||||
expect(internal.session.items[itemId].totalBytes).toBe(1_610_612_736);
|
||||
expect(internal.session.items[itemId].status).toBe("queued");
|
||||
});
|
||||
|
||||
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
+213
-20
@@ -1,4 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
buildDownloadSidebarCounts,
|
||||
buildDownloadsViewModel,
|
||||
classifyDownloadStatus,
|
||||
getDownloadQueueTotalBytes,
|
||||
type DownloadSidebarFilter,
|
||||
type DownloadsModelInput
|
||||
} from "../src/renderer/views/downloads/downloads-model";
|
||||
@@ -21,13 +24,64 @@ import {
|
||||
} from "../src/renderer/views/downloads/DownloadsView";
|
||||
import {
|
||||
DownloadsTableHeader,
|
||||
ItemRowContent,
|
||||
PackageCardContent,
|
||||
areItemRowPropsEqual,
|
||||
arePackageCardPropsEqual
|
||||
arePackageCardPropsEqual,
|
||||
downloadColumnDefinitions,
|
||||
getAvailabilitySummary
|
||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
describe("Downloadtabellen-Spalten", () => {
|
||||
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
|
||||
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
|
||||
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
|
||||
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
|
||||
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
|
||||
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
|
||||
});
|
||||
|
||||
it("uses the normal text color for sortable and static column headers", () => {
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*color:\s*var\(--ui-text\);/s);
|
||||
});
|
||||
|
||||
it("moves complete columns through pointer capture and animated transforms", () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/DownloadsTable.tsx"), "utf8");
|
||||
const dragSource = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/column-drag.ts"), "utf8");
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(source).toContain("onColumnPointerDown");
|
||||
expect(source).toContain("setPointerCapture");
|
||||
expect(source).toContain("onPointerMove");
|
||||
expect(source).not.toMatch(/className="downloads-column-header"[\s\S]{0,180}\sdraggable/);
|
||||
expect(dragSource).toContain('root.style.setProperty(`--downloads-column-drag-${id}`');
|
||||
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-download-column\]\s*\{[^}]*transform:\s*translate3d\(var\(--downloads-column-drag-x, 0px\), 0, 0\);[^}]*transition:\s*transform 220ms/s);
|
||||
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*transition:\s*none;/s);
|
||||
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
|
||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollende Downloadkennzahlen", () => {
|
||||
it("moves increasing values up and decreasing values down", () => {
|
||||
expect(getRollingMetricDirection(300, 600)).toBe("up");
|
||||
expect(getRollingMetricDirection(600, 300)).toBe("down");
|
||||
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
||||
});
|
||||
|
||||
it("animates exactly the five stable sidebar metrics", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
||||
expect(html).toContain('data-status-metric="speed"');
|
||||
expect(html).toContain('data-status-metric="eta"');
|
||||
});
|
||||
});
|
||||
|
||||
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
@@ -57,10 +111,21 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
|
||||
return { id, name, itemIds, createdAt: now } as PackageEntry;
|
||||
}
|
||||
|
||||
describe("Download-Gesamtgröße", () => {
|
||||
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
|
||||
const items = [
|
||||
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
|
||||
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
|
||||
];
|
||||
|
||||
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
|
||||
});
|
||||
});
|
||||
|
||||
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
||||
const items = [
|
||||
item("active", "package-a", "downloading"),
|
||||
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
||||
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link", url: "https://ddownload.com/file/queued" }),
|
||||
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
|
||||
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
|
||||
];
|
||||
@@ -114,7 +179,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
||||
onToggleSelection: () => {},
|
||||
onSelectionMouseDown: () => {},
|
||||
onSelectionMouseEnter: () => {},
|
||||
onTogglePackage: () => {},
|
||||
onTogglePackageCollapse: () => {},
|
||||
onStartPackageRename: () => {},
|
||||
onPackageRenameChange: () => {},
|
||||
@@ -126,11 +190,10 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
||||
onRemoveItem: () => {},
|
||||
onOpenContextMenu: () => {},
|
||||
onSortColumn: () => {},
|
||||
onColumnDragStart: () => {},
|
||||
onColumnDragOver: () => {},
|
||||
onColumnDragLeave: () => {},
|
||||
onColumnDrop: () => {},
|
||||
onColumnDragEnd: () => {},
|
||||
onColumnPointerDown: () => {},
|
||||
onColumnPointerMove: () => {},
|
||||
onColumnPointerUp: () => {},
|
||||
onColumnPointerCancel: () => {},
|
||||
onColumnContextMenu: () => {},
|
||||
...overrides
|
||||
};
|
||||
@@ -190,7 +253,9 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
||||
packages: 2,
|
||||
links: 4,
|
||||
session: "3,00 GB",
|
||||
sessionBytes: 3_000_000_000,
|
||||
total: "10,00 GB",
|
||||
totalBytes: 10_000_000_000,
|
||||
hosters: 3,
|
||||
speed: "96,00 Mbit/s",
|
||||
eta: "00:05:00"
|
||||
@@ -325,6 +390,39 @@ describe("downloads model", () => {
|
||||
});
|
||||
|
||||
describe("downloads view", () => {
|
||||
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const actionsMarkup = html.match(/<div class="downloads-sidebar-actions">([\s\S]*?)<\/div>/)?.[1] ?? "";
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(html).toContain('class="downloads-clipboard-toggle"');
|
||||
expect(actionsMarkup).not.toContain("Zwischenablage überwachen");
|
||||
expect(css).toMatch(/\.downloads-sidebar\s*\{[^}]*height:\s*100%;[^}]*padding:\s*14px 12px 6px;/s);
|
||||
expect(css).toMatch(/\.downloads-clipboard-toggle\s*\{[^}]*margin-top:\s*auto;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);/s);
|
||||
});
|
||||
|
||||
it("shows the sidebar actions as permanently recognizable buttons", () => {
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.downloads-sidebar-actions button\s*\{[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);[^}]*padding:\s*0 10px;/s);
|
||||
});
|
||||
|
||||
it("marks the download filters for one measured vertical selection indicator", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows only the package mode while the file mode remains hidden", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||
|
||||
expect(html).toContain("Pakete");
|
||||
expect(html).not.toContain(">Dateien<");
|
||||
expect(html).not.toContain("downloads-mode-switch");
|
||||
});
|
||||
|
||||
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
||||
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||||
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
|
||||
@@ -474,15 +572,36 @@ describe("downloads view", () => {
|
||||
|
||||
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
|
||||
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
|
||||
expect(html).toContain('class="downloads-package-card ');
|
||||
expect(html).not.toContain('class="package-card');
|
||||
expect(html).toContain('class="downloads-hoster-icon" data-hoster="rapidgator" src="data:image/x-icon;base64,');
|
||||
expect(html).toContain('class="downloads-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
|
||||
expect(html).not.toContain('title="RapidGator">RG<');
|
||||
expect(html).toContain('data-download-column="name"');
|
||||
expect(html).toContain('data-download-column="hoster"');
|
||||
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
|
||||
expect(html).toContain('class="downloads-package-items is-expanded"');
|
||||
expect(html).toContain('class="downloads-package-items-inner"');
|
||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
||||
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
|
||||
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/s);
|
||||
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
|
||||
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
|
||||
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
||||
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
||||
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
||||
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
|
||||
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
|
||||
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
|
||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
|
||||
expect(css).not.toMatch(/gradient|nth-child/i);
|
||||
});
|
||||
|
||||
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
|
||||
@@ -492,6 +611,17 @@ describe("downloads view", () => {
|
||||
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
||||
});
|
||||
|
||||
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*box-shadow:\s*inset 3px 0 0 var\(--ui-success\);/s);
|
||||
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
|
||||
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
|
||||
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
|
||||
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||
|
||||
@@ -511,6 +641,14 @@ describe("downloads view", () => {
|
||||
});
|
||||
|
||||
describe("downloads App integration", () => {
|
||||
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
expect(source).toMatch(/const toggleClipboardWatcher = useCallback\(\(\): void => \{[\s\S]*setClipboardWatcherActive\(next\);[\s\S]*window\.rd\.toggleClipboard\(\)/);
|
||||
expect(source).toContain("onToggleClipboardWatcher: toggleClipboardWatcher");
|
||||
expect(source).not.toContain("onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }");
|
||||
});
|
||||
|
||||
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
@@ -523,6 +661,63 @@ describe("downloads App integration", () => {
|
||||
});
|
||||
|
||||
describe("download table row contracts", () => {
|
||||
it("summarizes full, partial, offline and unchecked package availability", () => {
|
||||
expect(getAvailabilitySummary([
|
||||
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
|
||||
item("online-b", "package-a", "queued", { onlineStatus: "online" })
|
||||
])).toEqual({ online: 2, total: 2, state: "online" });
|
||||
expect(getAvailabilitySummary([
|
||||
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
|
||||
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
|
||||
])).toEqual({ online: 1, total: 2, state: "partial" });
|
||||
expect(getAvailabilitySummary([
|
||||
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
|
||||
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
|
||||
])).toEqual({ online: 0, total: 2, state: "offline" });
|
||||
expect(getAvailabilitySummary([
|
||||
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
|
||||
])).toEqual({ online: 0, total: 1, state: "checking" });
|
||||
});
|
||||
|
||||
it("renders availability for package and file rows", () => {
|
||||
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
||||
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["availability"],
|
||||
editing: false,
|
||||
editingName: "",
|
||||
gridTemplate: "110px",
|
||||
packageSpeedBps: 0,
|
||||
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
const itemHtml = renderToStaticMarkup(ItemRowContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["availability"],
|
||||
gridTemplate: "110px",
|
||||
item: onlineItem,
|
||||
selected: false
|
||||
}));
|
||||
|
||||
expect(packageHtml).toContain("1/1 online");
|
||||
expect(packageHtml).toContain("downloads-availability has-counts is-online");
|
||||
expect(packageHtml).toContain("downloads-availability-count is-online-count");
|
||||
expect(packageHtml).toContain("downloads-availability-separator");
|
||||
expect(packageHtml).toContain("downloads-availability-count is-total-count");
|
||||
expect(packageHtml).toContain("downloads-availability-label");
|
||||
expect(itemHtml).toContain("Online");
|
||||
expect(itemHtml).toContain("downloads-availability is-online");
|
||||
});
|
||||
|
||||
it("aligns availability symbols, split counts and labels on fixed axes", () => {
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||
|
||||
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
||||
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
||||
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
||||
});
|
||||
|
||||
it("preserves the package download and extraction phase split", () => {
|
||||
const extractionPackage = {
|
||||
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
|
||||
@@ -646,14 +841,13 @@ describe("download table row contracts", () => {
|
||||
expect(commits).toEqual(["package-a:Neuer Name"]);
|
||||
});
|
||||
|
||||
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
|
||||
it("removes the redundant package activation checkbox and preserves context actions", () => {
|
||||
const calls: Array<unknown> = [];
|
||||
const model = withRuntime(createInput());
|
||||
const row = model.packageRows[0];
|
||||
const component = PackageCardContent({
|
||||
actions: createActions({
|
||||
onToggleSelection: (id) => calls.push(["select", id]),
|
||||
onTogglePackage: (id) => calls.push(["toggle", id]),
|
||||
onToggleSelection: (id, ctrl, shift) => calls.push(["select", id, ctrl, shift]),
|
||||
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||
}),
|
||||
columnOrder: model.columnOrder,
|
||||
@@ -666,13 +860,12 @@ describe("download table row contracts", () => {
|
||||
selectedVersion: 1
|
||||
});
|
||||
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
|
||||
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
|
||||
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
|
||||
|
||||
selection.props.onChange();
|
||||
activation.props.onChange();
|
||||
selection.props.onClick({ stopPropagation: () => {}, ctrlKey: false, metaKey: false, shiftKey: true });
|
||||
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
|
||||
|
||||
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
|
||||
expect(calls).toEqual([["select", "package-a", true, true], ["context", "package-a", 30, 50]]);
|
||||
expect(() => findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren")).toThrow("Element not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../src/renderer/views/history/history-model";
|
||||
import {
|
||||
HistoryContent,
|
||||
HistorySidebar,
|
||||
HistoryToolbar,
|
||||
HistoryView,
|
||||
type HistoryViewActions
|
||||
@@ -204,6 +205,15 @@ describe("history model", () => {
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("marks history filters for one measured vertical selection indicator", () => {
|
||||
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
|
||||
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(7);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
@@ -277,6 +287,20 @@ describe("HistoryView", () => {
|
||||
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
|
||||
});
|
||||
|
||||
it("matches the download action control and centers every header except package and file", () => {
|
||||
const content = HistoryContent({
|
||||
actions: createActions(),
|
||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
||||
});
|
||||
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
|
||||
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
|
||||
expect(actionCell.props.children.props.children).toBe("⋮");
|
||||
expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s);
|
||||
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s);
|
||||
});
|
||||
|
||||
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HistoryView
|
||||
@@ -393,6 +417,18 @@ describe("HistoryView", () => {
|
||||
});
|
||||
|
||||
describe("visual history states", () => {
|
||||
it("keeps the selected category visible immediately while only its geometry glides", () => {
|
||||
const styles = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
const selection = readFileSync(new URL("../src/renderer/ui/SlidingSelection.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
const selectionStyles = styles.slice(styles.indexOf(".ui-sliding-selection"), styles.indexOf("html,"));
|
||||
|
||||
expect(selectionStyles).toMatch(/\.ui-sliding-selection::before\s*\{[^}]*opacity:\s*1;/s);
|
||||
expect(selectionStyles).not.toContain(".ui-sliding-selection.has-sliding-selection::before");
|
||||
expect(selectionStyles).not.toMatch(/transition(?:-property)?:[^;]*opacity/);
|
||||
expect(selection).not.toContain("classList.add(\"has-sliding-selection\")");
|
||||
expect(selection).not.toContain("classList.remove(\"has-sliding-selection\")");
|
||||
});
|
||||
|
||||
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||
const firstRequest = source.indexOf("window.rd.getVersion()");
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeLanguage, translateUiText } from "../src/renderer/i18n";
|
||||
|
||||
describe("renderer localization", () => {
|
||||
it("falls back to English", () => {
|
||||
expect(normalizeLanguage(undefined)).toBe("en");
|
||||
expect(normalizeLanguage("fr")).toBe("en");
|
||||
expect(normalizeLanguage("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("translates exact interface labels in both directions", () => {
|
||||
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
||||
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
||||
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
||||
});
|
||||
|
||||
it("translates dynamic update and pagination text", () => {
|
||||
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
||||
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||
expect(translateUiText("1–46 von 46", "en")).toBe("1–46 of 46");
|
||||
});
|
||||
|
||||
it("translates the complete history surface including status values", () => {
|
||||
const translations = new Map([
|
||||
["Alle Einträge", "All entries"],
|
||||
["Heute", "Today"],
|
||||
["Letzte 7 Tage", "Last 7 days"],
|
||||
["Älter", "Older"],
|
||||
["Gelöscht", "Deleted"],
|
||||
["Fehlgeschlagen", "Failed"],
|
||||
["Verlauf leeren", "Clear history"],
|
||||
["Erneut hinzufügen", "Add again"],
|
||||
["Im Ordner zeigen", "Show in folder"],
|
||||
["Auswahl löschen", "Clear selection"],
|
||||
["Noch kein Verlauf", "No history yet"],
|
||||
["Keine passenden Einträge", "No matching entries"]
|
||||
]);
|
||||
|
||||
for (const [german, english] of translations) {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
}
|
||||
});
|
||||
|
||||
it("translates dynamic queue and availability values", () => {
|
||||
expect(translateUiText("26/26 online", "en")).toBe("26/26 online");
|
||||
expect(translateUiText("3 abgebrochen", "en")).toBe("3 cancelled");
|
||||
expect(translateUiText("Geplant: 18:30", "en")).toBe("Scheduled: 18:30");
|
||||
expect(translateUiText("Einträge: 2", "en")).toBe("Entries: 2");
|
||||
expect(translateUiText("Sichtbar: 2", "en")).toBe("Visible: 2");
|
||||
expect(translateUiText("Ausgewählt: 0", "en")).toBe("Selected: 0");
|
||||
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
|
||||
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
|
||||
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Verfügbarkeit", "Availability"],
|
||||
["Hinzugefügt am", "Added on"],
|
||||
["Ungeprüft", "Unchecked"],
|
||||
["Paket gestoppt", "Package stopped"],
|
||||
["Noch keine Downloads", "No downloads yet"],
|
||||
["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."],
|
||||
["Neue Sammlung", "New collection"],
|
||||
["Links erfassen", "Capture links"],
|
||||
["Datei importieren", "Import file"],
|
||||
["Sammlung verarbeiten", "Process collection"],
|
||||
["An Downloads übergeben", "Send to downloads"],
|
||||
["Keine passenden Links", "No matching links"],
|
||||
["Noch keine Links", "No links yet"],
|
||||
["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
|
||||
["Prüfen und speichern", "Check and save"],
|
||||
["Accounts durchsuchen", "Search accounts"],
|
||||
["Account-Typ filtern", "Filter account type"],
|
||||
["Keine passenden Account-Typen.", "No matching account types."],
|
||||
["Geschützter Zugang", "Protected access"],
|
||||
["Immer erste Tonspur", "Always first audio track"],
|
||||
["Keine Archive löschen", "Do not delete archives"],
|
||||
["Sieben Tage", "Seven days"],
|
||||
["Zeitraum", "Period"],
|
||||
["Nicht verfügbar", "Unavailable"],
|
||||
["Datenmenge", "Data volume"],
|
||||
["Erfolgsquote", "Success rate"],
|
||||
["Durchschnitt", "Average"],
|
||||
["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
|
||||
["Kontextmenü", "Context menu"],
|
||||
["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
|
||||
["Oberfläche neu laden", "Reload interface"],
|
||||
["Unbekannter Fehler", "Unknown error"],
|
||||
["Details anzeigen", "Show details"],
|
||||
["Details ausblenden", "Hide details"]
|
||||
])("translates renderer text %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Film.mkv auswählen", "Select Film.mkv"],
|
||||
["Aktionen für Film.mkv", "Actions for Film.mkv"],
|
||||
["RapidGator Zuordnung entfernen", "Remove RapidGator assignment"],
|
||||
["RapidGator nach oben", "Move RapidGator up"],
|
||||
["Geplant: Heute 22:15", "Scheduled: Today 22:15"],
|
||||
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
|
||||
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
|
||||
["Entpacken 52%", "Extracting 52%"],
|
||||
["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"],
|
||||
["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"],
|
||||
["Account geprüft — Premium bis 2027-01-01", "Account checked — Premium until 2027-01-01"],
|
||||
["7 Link(s) zur Queue hinzugefügt", "7 link(s) added to the queue"],
|
||||
["2 Paket(e), 5 Link(s) importiert", "2 package(s), 5 link(s) imported"],
|
||||
["3 ausgewählt", "3 selected"],
|
||||
["vor 4 Std", "4 hr ago"],
|
||||
["Zeitregel 4", "Schedule rule 4"],
|
||||
["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"]
|
||||
])("translates composed renderer text %s without changing its payload", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://rapidgator.net/file/abc?token=secret",
|
||||
"C:\\Downloads\\Film [1080p]\\video.mkv",
|
||||
"video.GERMAN.DL.1080p.mkv",
|
||||
"ECONNRESET at api.example.test:443",
|
||||
"RapidGator API: quota=0"
|
||||
])("keeps free technical content unchanged: %s", (value) => {
|
||||
expect(translateUiText(value, "en")).toBe(value);
|
||||
expect(translateUiText(value, "de")).toBe(value);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nicht gesetzt", "not set"],
|
||||
["Schätzwert", "Estimate"],
|
||||
["ist verfügbar. Installierte Version:", "is available. Installed version:"],
|
||||
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."],
|
||||
["Nur lokal", "Local only"],
|
||||
["Name kopiert", "Name copied"],
|
||||
["Link kopiert", "Link copied"],
|
||||
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."]
|
||||
])("translates remaining renderer inventory text %s", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Debug-Server aktiv: 127.0.0.1:9345", "Debug server active: 127.0.0.1:9345"],
|
||||
["Unbekannter Account-Typ: custom-provider", "Unknown account type: custom-provider"],
|
||||
["RapidGator: Bitte Passwort eintragen.", "RapidGator: Enter a password."],
|
||||
["Update-Download: 42% (42 MB / 100 MB)", "Update download: 42% (42 MB / 100 MB)"],
|
||||
["Zwischenablage: 3 Link(s) erkannt", "Clipboard: 3 link(s) detected"],
|
||||
["2/4 API-Keys deaktiviert.", "2/4 API keys disabled."],
|
||||
["Account-Check: 3/4 Login gültig, 2 mit Premium.", "Account check: 3/4 logins valid, 2 with premium."],
|
||||
["Soll RapidGator wirklich aus der Accountliste entfernt werden?", "Remove RapidGator from the account list?"],
|
||||
["Konflikte gelöst: 2 überschrieben, 3 übersprungen", "Conflicts resolved: 2 overwritten, 3 skipped"],
|
||||
["DLC importiert: 2 Paket(e), 5 Link(s)", "DLC imported: 2 package(s), 5 link(s)"],
|
||||
["3 Fehler, 2 Warnungen (letzte 5)", "3 errors, 2 warnings (latest 5)"],
|
||||
["Links für Sammlung 1 lokal erfassen.", "Capture links locally for Sammlung 1."],
|
||||
["Film.mkv Klicken zum Kopieren", "Click to copy Film.mkv"]
|
||||
])("translates additional composed renderer text %s", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,9 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||
: `resources/app.asar.unpacked/${relativePath}`;
|
||||
writeFile(outputDir, targetPath, content);
|
||||
}
|
||||
if (omittedName !== "app_icon.ico") {
|
||||
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "") {
|
||||
@@ -133,6 +136,10 @@ function createReleaseFixture(): string {
|
||||
{
|
||||
from: "THIRD_PARTY_NOTICES.md",
|
||||
to: "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
{
|
||||
from: "assets/app_icon.ico",
|
||||
to: "assets/app_icon.ico"
|
||||
}
|
||||
],
|
||||
nsis: {
|
||||
@@ -162,6 +169,8 @@ function createReleaseFixture(): string {
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||
writeRedistributionFiles(rootDir);
|
||||
writeRedistributionFiles(rootDir, true);
|
||||
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||
writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon");
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
@@ -336,6 +345,13 @@ describe("public release metadata", () => {
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects a packaged application without its window and tray icon", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/app_icon|icon/i);
|
||||
});
|
||||
|
||||
it("rejects incomplete third-party redistribution notices", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(
|
||||
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pruneSelection } from "../src/renderer/selection";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "../src/renderer/selection";
|
||||
import type { SessionState } from "../src/shared/types";
|
||||
|
||||
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
||||
@@ -42,3 +42,23 @@ describe("pruneSelection", () => {
|
||||
expect(next).toBe(sel); // unchanged → same instance
|
||||
});
|
||||
});
|
||||
|
||||
describe("download selection clearing", () => {
|
||||
const target = (...classes: string[]): Pick<Element, "closest"> => ({
|
||||
closest: (selector: string) => selector.split(",").some((entry: string) => classes.includes(entry.trim().slice(1))) ? {} as Element : null
|
||||
});
|
||||
|
||||
it("preserves selection while header, package and file selection controls handle their own click", () => {
|
||||
expect(shouldClearDownloadSelection(target("downloads-selection-cell"))).toBe(false);
|
||||
expect(shouldClearDownloadSelection(target("downloads-package-card"))).toBe(false);
|
||||
expect(shouldClearDownloadSelection(target("downloads-item-row"))).toBe(false);
|
||||
expect(shouldClearDownloadSelection(target("unrelated-surface"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clears checkbox-focused selection on Escape but preserves text editing", () => {
|
||||
expect(shouldClearDownloadSelectionOnEscape("INPUT", "checkbox")).toBe(true);
|
||||
expect(shouldClearDownloadSelectionOnEscape("DIV")).toBe(true);
|
||||
expect(shouldClearDownloadSelectionOnEscape("INPUT", "text")).toBe(false);
|
||||
expect(shouldClearDownloadSelectionOnEscape("TEXTAREA")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
SETTINGS_SECTIONS,
|
||||
buildSettingsFormViewModel,
|
||||
buildAccountRowId,
|
||||
buildTargetedAccountCheck,
|
||||
filterAccountAddOptions,
|
||||
@@ -411,6 +412,35 @@ describe("settings model", () => {
|
||||
});
|
||||
|
||||
describe("settings views", () => {
|
||||
it("marks settings sections for one measured vertical selection indicator", () => {
|
||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(SETTINGS_SECTIONS.length);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers English and German as a live language setting", () => {
|
||||
const form = buildSettingsFormViewModel({
|
||||
settings: defaultSettings(),
|
||||
section: "allgemein",
|
||||
speedLimitInput: "0",
|
||||
scheduleSpeedInputs: {}
|
||||
});
|
||||
const language = form.groups.flatMap((group) => group.fields).find((field) => field.id === "language");
|
||||
|
||||
expect(language).toEqual({
|
||||
id: "language",
|
||||
kind: "select",
|
||||
label: "Sprache",
|
||||
value: "en",
|
||||
options: [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "de", label: "Deutsch" }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("renders one real sidebar marker and all sections", () => {
|
||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
|
||||
@@ -459,6 +489,14 @@ describe("settings views", () => {
|
||||
});
|
||||
|
||||
describe("account workspace", () => {
|
||||
it("marks account panels for one measured horizontal selection indicator", () => {
|
||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-horizontal");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
||||
@@ -621,6 +659,7 @@ describe("settings geometry", () => {
|
||||
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
||||
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
||||
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
||||
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("sliding selection scheduling", () => {
|
||||
it("applies the first mounted selection before scheduling later movements", async () => {
|
||||
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
|
||||
const schedule = Reflect.get(selectionModule, "scheduleSelectionLayout");
|
||||
|
||||
expect(schedule).toBeTypeOf("function");
|
||||
|
||||
const events: string[] = [];
|
||||
const pending: { callback?: FrameRequestCallback } = {};
|
||||
const requestFrame = (callback: FrameRequestCallback): number => {
|
||||
events.push("request");
|
||||
pending.callback = callback;
|
||||
return 9;
|
||||
};
|
||||
const cancelFrame = (frame: number): void => {
|
||||
events.push(`cancel:${frame}`);
|
||||
};
|
||||
const apply = (): void => {
|
||||
events.push("apply");
|
||||
};
|
||||
const enableTransitions = (): void => {
|
||||
events.push("enable");
|
||||
};
|
||||
|
||||
expect(schedule(false, 0, apply, requestFrame, cancelFrame, enableTransitions)).toBe(9);
|
||||
expect(events).toEqual(["apply", "request"]);
|
||||
|
||||
pending.callback?.(0);
|
||||
expect(events).toEqual(["apply", "request", "enable"]);
|
||||
|
||||
events.length = 0;
|
||||
expect(schedule(true, 4, apply, requestFrame, cancelFrame)).toBe(9);
|
||||
expect(events).toEqual(["cancel:4", "request"]);
|
||||
|
||||
pending.callback?.(0);
|
||||
expect(events).toEqual(["cancel:4", "request", "apply"]);
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,15 @@ describe("statistics model", () => {
|
||||
});
|
||||
|
||||
describe("statistics view", () => {
|
||||
it("marks statistic ranges for one measured vertical selection indicator", () => {
|
||||
const model = buildStatisticsViewModel(createSnapshot(), "today", now);
|
||||
const html = renderToStaticMarkup(<StatisticsSidebar actions={createActions()} model={model} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(5);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.totalDownloaded = 2_048;
|
||||
|
||||
@@ -17,6 +17,51 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("settings storage", () => {
|
||||
it("repairs a persisted version-2 column order that lost availability during default merging", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify({
|
||||
...defaultSettings(),
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
columnOrderVersion: 2
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
|
||||
expect(loaded.columnOrder).toEqual(["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"]);
|
||||
expect(loaded.columnOrderVersion).toBe(3);
|
||||
});
|
||||
|
||||
it("adds availability beside speed once for legacy column settings", () => {
|
||||
const legacy = { ...defaultSettings(), columnOrder: ["name", "status", "speed"] } as Partial<AppSettings>;
|
||||
delete legacy.columnOrderVersion;
|
||||
|
||||
const normalized = normalizeSettings(legacy as AppSettings);
|
||||
|
||||
expect(normalized.columnOrder).toEqual(["name", "status", "speed", "availability"]);
|
||||
expect(normalized.columnOrderVersion).toBe(3);
|
||||
expect(normalizeSettings({ ...normalized, columnOrder: ["name", "speed"] }).columnOrder).toEqual(["name", "speed"]);
|
||||
});
|
||||
|
||||
it("uses English for new installations and preserves only supported languages", () => {
|
||||
expect(defaultSettings().language).toBe("en");
|
||||
expect(normalizeSettings({ ...defaultSettings(), language: "de" }).language).toBe("de");
|
||||
expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en");
|
||||
});
|
||||
|
||||
it("keeps German for existing settings files created before language selection existed", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
|
||||
delete legacy.language;
|
||||
fs.mkdirSync(path.dirname(paths.configFile), { recursive: true });
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify(legacy), "utf8");
|
||||
|
||||
expect(loadSettings(paths).language).toBe("de");
|
||||
});
|
||||
|
||||
it("defaults download directories to the desktop project folder", () => {
|
||||
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
||||
const defaults = defaultSettings();
|
||||
|
||||
@@ -58,6 +58,7 @@ function createSettings(): AppSettings {
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin,
|
||||
megaPassword: "visual-password",
|
||||
language: "de",
|
||||
megaCredentials: `${megaLogin}:visual-password`,
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: true,
|
||||
@@ -139,7 +140,8 @@ function createSettings(): AppSettings {
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
columnOrderVersion: 3,
|
||||
extractCpuPriority: "middle",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: ["onefichier"],
|
||||
|
||||
Reference in New Issue
Block a user