Skip to content

feat: add quality selection for Plex transcoded downloads#1418

Draft
huzama wants to merge 1 commit into
edde746:mainfrom
huzama:feat/plex-download-transcode-quality
Draft

feat: add quality selection for Plex transcoded downloads#1418
huzama wants to merge 1 commit into
edde746:mainfrom
huzama:feat/plex-download-transcode-quality

Conversation

@huzama

@huzama huzama commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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=1 HTTP/MKV stream. (download=1 is 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 DownloadedMedia and SyncRules rows, 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

  • DB: new downloadQuality column on DownloadedMedia and SyncRules (schema v17). DownloadResolution.container decides the .mkv extension.
  • Plex: resolveDownload builds the download=1 transcode URL (with container: mkv) through the decision flow, and falls back to the direct file (logged) when the server won't transcode. download=1 only goes on the start path, so the decision request stays byte-identical to playback.
  • Download manager: the quality flows from queue to row to enqueue and is re-read on retry. The saved file gets .mkv on 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.
  • Provider / sync: the quality is threaded through every queue path and saved on sync rules, and a manual retry keeps the quality you picked.
  • UI: a "Default download quality" setting plus a per-download picker, shown only when the backend is Plex and supports transcoding. A running flag feeds a single DownloadProgress.displayStatus rule, so a held item shows "Queued" everywhere (the tree, the episode card, the action button) instead of "Downloading".
  • Jellyfin: takes the preset and ignores it, so it keeps downloading the original.

Pause, resume, interruptions

A live transcode isn't a stable, seekable file. It sends no Content-Length or Accept-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.

Situation Original Transcoded
Native retries 5, with Range-resume 0, goes to the app-level retry
Pause native pause/resume cancels, and resume re-downloads from 0%
Network error Range-resume from the last offset restarts with a fresh session, old partial deleted
App kill adopts the surviving task re-queued from scratch

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

  • Plex only. Jellyfin downloads the original.
  • No progress percentage, speed, or ETA. A live transcode sends no Content-Length, so the row just shows an indeterminate "Downloading…" until it finishes. Original downloads are unaffected.
  • Not resumable. Any interruption restarts it cleanly (see above).
  • Collections and playlists stay on Original. There's no picker on the bulk path yet. The sync executor already honours a stored quality, so wiring it up later needs no data change.
  • Size estimates show for single items only. A whole show or season shows the quality labels without sizes.
  • Default audio track, and subtitles saved as sidecars. Embedded subtitle tracks aren't muxed in.
  • Transcode speed is the server's call (hardware-accel setting, transcode preset, source codec), not the client's.

Checks

  • dart format clean, flutter analyze reports 0 errors and 0 warnings, flutter test passes (2506 tests), codegen is committed (slang + build_runner), and dart_code_linter finds no unused code or files.
  • Tests added or extended: schema v17 round-trip; download=1 URL placement; sync-rule quality propagation for shows and collections; sibling-quality derivation and the cross-server guard; the running-flag flip and the kill-recovery re-queue/adopt split; and the determinateProgress / displayStatus rendering.
  • One thing isn't unit-tested: the resolveDownload decision-to-container mapping, since covering it would need a test seam in shared playback code. The manual run below exercises it.
  • Manual: on a real Plex server, download=1 gave a complete, seekable MKV faster than playback speed, the chosen quality was honoured, the fallback worked, and original downloads behaved as before.

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.
Copilot AI review requested due to automatic review settings June 26, 2026 07:55
@edde746

edde746 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

No background downloads I assume?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 downloadQuality preset on DownloadedMedia and SyncRules (schema v17) and thread it through queueing, sync execution, and retries.
  • Add Plex download=1 universal-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 running flag 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.

Comment on lines 1571 to 1575
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');
@huzama

huzama commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

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!

@edde746

edde746 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

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?

@huzama

huzama commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

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!

@huzama huzama marked this pull request as draft July 3, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants