feat: add quality selection for Plex transcoded downloads#1418
Conversation
Pick a resolution/bitrate when downloading from Plex. Non-original picks transcode the download server-side through the universal transcoder (download=1, HTTP/MKV) and save it offline; the transcode-decision flow falls back to the original file when transcoding isn't available. The chosen quality is persisted on the download row and on sync rules, so retries, resumes, and auto-synced episodes rebuild the same quality. Adds a Default download quality setting plus a per-download picker (Plex only; Jellyfin and collection/playlist downloads keep originals). A live transcode reports no Content-Length, so its download is non-resumable and shows an indeterminate Downloading state, while held items read as Queued everywhere until their task actually starts.
|
No background downloads I assume? |
There was a problem hiding this comment.
Pull request overview
This PR adds per-download quality selection for Plex offline downloads by reusing the universal transcode decision flow and persisting the chosen quality in the DB so retries/resumes/sync rules keep consistent behavior. It also refines download UI state handling for held/enqueued tasks and for transcoded downloads that lack measurable byte progress.
Changes:
- Persist a
downloadQualitypreset onDownloadedMediaandSyncRules(schema v17) and thread it through queueing, sync execution, and retries. - Add Plex
download=1universal-transcode start URL generation (MKV container) with fallback to original when transcoding is unavailable. - Update UI + progress models to distinguish “held/enqueued” vs “actively transferring” via a
runningflag and to render indeterminate progress for sizeless transcodes.
Reviewed changes
Copilot reviewed 33 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/widgets/download_tree_view_test.dart | Adds unit coverage for running propagation and measurable-progress behavior. |
| test/services/sync_rule_executor_test.dart | Updates executor tests for the new quality parameter and adds propagation tests. |
| test/services/plex_playback_data_request_test.dart | Verifies download=1 is appended only to the transcode start path. |
| test/services/download_manager_service_test.dart | Adds tests for running flag updates and kill-recovery behavior for transcodes. |
| test/providers/download_provider_test.dart | Tests quality derivation for partial resume and cross-server guards. |
| test/models/download_models_test.dart | Adds tests for determinateProgress and displayStatus. |
| test/database/app_database_test.dart | Bumps schema version test to v17 and verifies downloadQuality round-trip. |
| lib/widgets/media_context_menu.dart | Switches size estimate to MediaVersion.totalSizeBytes and removes old helper. |
| lib/widgets/episode_card.dart | Uses displayStatus/determinateProgress for consistent queued/downloading display. |
| lib/widgets/download_tree_view.dart | Adds running/measurable-progress logic and adjusts UI rendering for transcodes. |
| lib/utils/quality_preset_labels.dart | Adds initialPreset support for quality picker dialogs. |
| lib/utils/download_utils.dart | Adds Plex-only quality picker and threads preset into sync rule + queue paths. |
| lib/utils/dialogs.dart | Adds selected-row rendering support to the option picker dialog. |
| lib/services/sync_rule_executor.dart | Threads TranscodeQualityPreset through sync rule queueing. |
| lib/services/settings_service.dart | Adds persisted “Default download quality” setting. |
| lib/services/plex_client.dart | Adds forDownload start-path mode and resolves transcoded download URLs (MKV). |
| lib/services/jellyfin_client/parts/images_downloads.dart | Accepts preset param but intentionally ignores it (Jellyfin stays original). |
| lib/services/jellyfin_client.dart | Imports preset type for use by Jellyfin part files. |
| lib/services/download_manager_service.dart | Persists quality on rows, uses container for extension, disables native pause/retry for transcodes, and emits running. |
| lib/screens/settings/settings_screen.dart | Adds “Default Download Quality” selection UI. |
| lib/screens/media_detail/action_buttons.dart | Preserves selected quality across delete + retry and uses displayStatus/determinateProgress. |
| lib/providers/download_provider.dart | Threads preset through queue paths, persists on sync rules, and derives quality for partial resumes. |
| lib/models/transcode_quality_preset.dart | Adds storageValue (null for original) for DB persistence. |
| lib/models/download_models.freezed.dart | Regenerates Freezed output to include the new running field. |
| lib/models/download_models.dart | Adds running, determinateProgress, and displayStatus to DownloadProgress. |
| lib/media/media_version.dart | Adds totalSizeBytes for aggregated part sizing. |
| lib/media/media_server_client.dart | Extends resolveDownload API to accept a transcode preset. |
| lib/media/download_resolution.dart | Adds container to drive correct saved-file extension (e.g. MKV). |
| lib/i18n/strings.g.dart | Updates generated string counts. |
| lib/i18n/strings_en.g.dart | Adds new localized strings for download quality and “Downloading…” label. |
| lib/i18n/en.i18n.json | Adds new English strings for download quality and “Downloading…”. |
| lib/database/tables.dart | Adds nullable downloadQuality columns to DownloadedMedia and SyncRules. |
| lib/database/download_operations.dart | Threads downloadQuality into download insert operations. |
| lib/database/app_database.g.dart | Regenerates Drift code for the new columns/fields. |
| lib/database/app_database.dart | Bumps schema to v17, adds migration for downloadQuality, and persists it on sync rule upsert. |
Files not reviewed (2)
- lib/database/app_database.g.dart: Generated file
- lib/models/download_models.freezed.dart: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (_pausingKeys.contains(globalKey) || _cancellingKeys.contains(globalKey)) break; | ||
| await _onDownloadCanceled(globalKey, update.task.taskId); | ||
| case TaskStatus.paused: | ||
| _runningKeys.remove(globalKey); | ||
| appLogger.d('Download paused by system for $globalKey'); |
|
Downloads still runs in the background as long as it is not killed by OS (especially in Android). I didn't touch this app-wide feature. Curious to learn if plex handles resume of transcoded downloads and how, haven't got to that part yet! |
|
Jellyfin behavior needs to be fixed, either don't prompt for quality or implement transcoded downloads. Have you tested this with a non-Plex Pass server? |
|
Jellyfin can be added easily, but I didn't test the downloads without plex pass server, that is a real gap. Let me check both of these and get back at you! |
Until now Plezy could only download the original file from Plex. This adds a per-download quality picker, the same idea as the official apps: the server transcodes the download to the resolution/bitrate you pick and saves the smaller copy offline. "Original" still does exactly what it did before, so nothing changes unless you opt in.
How it works
A non-original pick reuses the player's existing universal-transcode path. It runs the transcode-decision call, then downloads a
download=1HTTP/MKV stream. (download=1is Plex's offline mode: the server produces a complete file as fast as it can instead of throttling to playback speed.) If the server can't transcode the item it falls back to the original file, so picking a quality is never fatal.The chosen quality is stored on the
DownloadedMediaandSyncRulesrows, so retries, resumes, kill recovery, and auto-synced episodes all rebuild the same quality. A partial show resume reads it back from a sibling episode that's already downloaded, scoped to the same server and profile so it can't pick up a colliding rating key from another server.What changed
downloadQualitycolumn onDownloadedMediaandSyncRules(schema v17).DownloadResolution.containerdecides the.mkvextension.resolveDownloadbuilds thedownload=1transcode URL (withcontainer: mkv) through the decision flow, and falls back to the direct file (logged) when the server won't transcode.download=1only goes on the start path, so the decision request stays byte-identical to playback..mkvon both the normal path and SAF recovery. Transcoded tasks turn off native pause and Range-retry and send failures to the app-level retry instead. Resume, pause, and recovery all branch on the actual result (container != null), not on what was requested.runningflag feeds a singleDownloadProgress.displayStatusrule, so a held item shows "Queued" everywhere (the tree, the episode card, the action button) instead of "Downloading".Pause, resume, interruptions
A live transcode isn't a stable, seekable file. It sends no
Content-LengthorAccept-Ranges, and the server regenerates the stream on every request, so a Range-based resume would splice mismatched bytes and corrupt the file. Because of that, transcoded downloads don't resume. They restart cleanly instead.A transient error after some bytes have arrived auto-retries up to 3 times, 30s apart, each time with a fresh session. A hard network error (host won't resolve, connection refused, network unreachable) stops and waits for a manual retry rather than hammering the server. Either way you get a complete file or a clean failure, never a corrupt partial.
Limitations
Content-Length, so the row just shows an indeterminate "Downloading…" until it finishes. Original downloads are unaffected.Checks
dart formatclean,flutter analyzereports 0 errors and 0 warnings,flutter testpasses (2506 tests), codegen is committed (slang+build_runner), anddart_code_linterfinds no unused code or files.download=1URL placement; sync-rule quality propagation for shows and collections; sibling-quality derivation and the cross-server guard; therunning-flag flip and the kill-recovery re-queue/adopt split; and thedeterminateProgress/displayStatusrendering.resolveDownloaddecision-to-container mapping, since covering it would need a test seam in shared playback code. The manual run below exercises it.download=1gave a complete, seekable MKV faster than playback speed, the chosen quality was honoured, the fallback worked, and original downloads behaved as before.