diff --git a/jellyfin-server b/jellyfin-server index a67b2c1..43ded9a 160000 --- a/jellyfin-server +++ b/jellyfin-server @@ -1 +1 @@ -Subproject commit a67b2c12120474ebd1f5ed89e543ded9f1dc1c35 +Subproject commit 43ded9a017f193b3b8b4fa0b4a80e024057d02df diff --git a/jellyfin-web b/jellyfin-web index c7942c7..445fe22 160000 --- a/jellyfin-web +++ b/jellyfin-web @@ -1 +1 @@ -Subproject commit c7942c7d8bf70793372535fd9e9485ceeb776930 +Subproject commit 445fe22f296a43d51c1e50bd821a3497ac58ce9f diff --git a/patches/jellyfin-progressive-download-improvements-v10.11.5.patch b/patches/jellyfin-progressive-download-improvements-v10.11.5.patch new file mode 100644 index 0000000..a96110a --- /dev/null +++ b/patches/jellyfin-progressive-download-improvements-v10.11.5.patch @@ -0,0 +1,109 @@ +From: Jellyfin Packaging +Date: Wed, 8 Jan 2025 00:00:00 +0000 +Subject: [PATCH] Progressive download improvements for v10.11.5+ + +This patch includes all progressive download and transcoding improvements: +- Use /downloads/ folder for full-file progressive downloads +- Fix download transcoding not stopping when client disconnects +- Improve MP4 movflags for better seeking compatibility (mpv/iina/infuse) +- Fix timestamp seeking for progressive downloads +- Use separate transcode paths for HLS vs progressive downloads + +--- + Jellyfin.Api/Controllers/VideosController.cs | 3 ++- + Jellyfin.Api/Helpers/StreamingHelpers.cs | 15 +++++++++++++++ + .../MediaEncoding/EncodingHelper.cs | 22 ++++++++++++++++++++++ + 3 files changed, 39 insertions(+), 1 deletion(-) + +diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs +index 97f3239bbc..6f9fbadddb 100644 +--- a/Jellyfin.Api/Controllers/VideosController.cs ++++ b/Jellyfin.Api/Controllers/VideosController.cs +@@ -369,7 +369,8 @@ public class VideosController : BaseJellyfinApiController + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. +- var cancellationTokenSource = new CancellationTokenSource(); ++ // Link to HttpContext.RequestAborted so transcode stops when client disconnects ++ var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted); + var streamingRequest = new VideoRequestDto + { + Id = itemId, +diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs +index b3f5b9a801..f366fd7a23 100644 +--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs ++++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs +@@ -378,8 +378,23 @@ public static class StreamingHelpers + + var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var ext = outputFileExtension.ToLowerInvariant(); ++ ++ // Use different transcode paths for HLS vs Progressive downloads ++ // HLS segments: use RAM-based transcode path (fast, auto-cleanup) ++ // Progressive downloads: use disk-based path (large files OK, slower cleanup) + var folder = serverConfigurationManager.GetTranscodePath(); + ++ // Check if this is a full file download (no segments) vs HLS streaming ++ var streamingRequest = state.BaseRequest as StreamingRequestDto; ++ ++ if (streamingRequest?.SegmentContainer is null) ++ { ++ // Full file download - use disk-based transcode path ++ var diskTranscodePath = Path.Combine(folder, "downloads"); ++ Directory.CreateDirectory(diskTranscodePath); ++ folder = diskTranscodePath; ++ } ++ + return Path.Combine(folder, filename + ext); + } + +diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +index e088cd358d..a31a735bed 100644 +--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs ++++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +@@ -20,6 +20,7 @@ using Jellyfin.Extensions; + using MediaBrowser.Common.Configuration; + using MediaBrowser.Controller.Extensions; + using MediaBrowser.Controller.IO; ++using MediaBrowser.Controller.Streaming; + using MediaBrowser.Model.Configuration; + using MediaBrowser.Model.Dlna; + using MediaBrowser.Model.Dto; +@@ -7475,8 +7476,20 @@ namespace MediaBrowser.Controller.MediaEncoding + if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase) + && state.BaseRequest.Context == EncodingContext.Streaming) + { +- // Comparison: https://github.com/jansmolders86/mediacenterjs/blob/master/lib/transcoding/desktop.js +- format = " -f mp4 -movflags frag_keyframe+empty_moov+delay_moov"; ++ // Use fragmented MP4 for adaptive streaming (HLS/DASH with segments) ++ // Use faststart for progressive downloads (better seeking and metadata) ++ var streamingRequest = state.BaseRequest as StreamingRequestDto; ++ if (streamingRequest?.SegmentContainer is not null) ++ { ++ // Fragmented MP4 for HLS/DASH ++ format = " -f mp4 -movflags frag_keyframe+empty_moov+delay_moov"; ++ } ++ else ++ { ++ // Progressive download - use faststart for proper seeking and duration ++ // Use frag_keyframe for better seeking compatibility with mpv ++ format = " -f mp4 -movflags frag_keyframe+faststart+default_base_moof"; ++ } + } + + var threads = GetNumberOfThreads(state, encodingOptions, videoCodec); +@@ -7582,6 +7595,15 @@ namespace MediaBrowser.Controller.MediaEncoding + args += " -start_at_zero"; + } + } ++ else if (state.TranscodingType == TranscodingJobType.Progressive && !state.BaseRequest.CopyTimestamps) ++ { ++ // For progressive downloads without copyTimestamps, ensure timestamps start at 0 ++ // This fixes seeking issues in strict players like mpv, iina, and infuse ++ args += " -avoid_negative_ts make_zero -start_at_zero"; ++ } + + var qualityParam = GetVideoQualityParam(state, videoCodec, encodingOptions, defaultPreset); + +-- +2.43.0 diff --git a/patches/jellyfin-web-download-dialog-v10.11.5.patch b/patches/jellyfin-web-download-dialog-v10.11.5.patch new file mode 100644 index 0000000..6e4def5 --- /dev/null +++ b/patches/jellyfin-web-download-dialog-v10.11.5.patch @@ -0,0 +1,931 @@ +diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.js b/src/components/downloadOptionsDialog/downloadOptionsDialog.js +new file mode 100644 +index 0000000000..a240907c1f +--- /dev/null ++++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.js +@@ -0,0 +1,576 @@ ++import dialogHelper from '../dialogHelper/dialogHelper'; ++import layoutManager from '../layoutManager'; ++import globalize from '../../lib/globalize'; ++import loading from '../loading/loading'; ++import toast from '../toast/toast'; ++import { getVideoQualityOptions } from '../qualityOptions'; ++import template from './downloadOptionsDialog.template.html'; ++import '../../elements/emby-select/emby-select'; ++import '../../elements/emby-button/emby-button'; ++import './downloadOptionsDialog.scss'; ++ ++let currentItem; ++let currentApiClient; ++ ++function getBitrateOptionsForResolutionAndCodec(resolution, codec) { ++ // Codec efficiency factors (Jellyfin defaults) ++ const codecFactors = { ++ 'h264': 1.0, ++ 'hevc': 0.6, // 40% more efficient ++ 'vp9': 0.6, // 40% more efficient ++ 'av1': 0.5 // 50% more efficient ++ }; ++ ++ // Base bitrate ranges for H.264 (updated for 2025 standards) ++ const baseRanges = { ++ '3840': { min: 25000000, max: 80000000, label: '4K' }, // 25-80 Mbps ++ '2560': { min: 12000000, max: 35000000, label: '1440p' }, // 12-35 Mbps ++ '1920': { min: 5000000, max: 16000000, label: '1080p' }, // 5-16 Mbps ++ '1280': { min: 2500000, max: 8000000, label: '720p' }, // 2.5-8 Mbps ++ '640': { min: 1000000, max: 2500000, label: '480p' }, // 1-2.5 Mbps ++ 'original': { min: 50000000, max: 120000000, label: 'Original' } ++ }; ++ ++ if (resolution === 'original') { ++ return [ ++ { bitrate: 120000000, name: 'Extreme Quality' }, ++ { bitrate: 100000000, name: 'Maximum' }, ++ { bitrate: 80000000, name: 'High' }, ++ { bitrate: 60000000, name: 'Medium' }, ++ { bitrate: 50000000, name: 'Standard' } ++ ]; ++ } ++ ++ const range = baseRanges[resolution] || baseRanges['1920']; ++ const factor = codecFactors[codec] || 1.0; ++ ++ const min = Math.round(range.min * factor); ++ const max = Math.round(range.max * factor); ++ const step = (max - min) / 5; ++ ++ // Generate 6 quality levels ++ return [ ++ { bitrate: max, name: `Maximum (${range.label})` }, ++ { bitrate: Math.round(max - step), name: `Very High (${range.label})` }, ++ { bitrate: Math.round(max - step * 2), name: `High (${range.label})` }, ++ { bitrate: Math.round(max - step * 3), name: `Medium (${range.label})` }, ++ { bitrate: Math.round(max - step * 4), name: `Low (${range.label})` }, ++ { bitrate: min, name: `Minimum (${range.label})` } ++ ]; ++} ++ ++function populateQualityOptions(dlg, item) { ++ const selectQuality = dlg.querySelector('#selectQuality'); ++ const selectResolution = dlg.querySelector('#selectResolution'); ++ const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); ++ ++ const resolution = selectResolution?.value || '1920'; ++ const codec = selectVideoCodec?.value || 'h264'; ++ ++ if (codec === 'copy') { ++ selectQuality.innerHTML = ''; ++ return; ++ } ++ ++ const options = getBitrateOptionsForResolutionAndCodec(resolution, codec); ++ ++ selectQuality.innerHTML = options.map((option, index) => { ++ const selected = index === 2 ? ' selected' : ''; // Select "High" (3rd option) ++ const mbps = (option.bitrate / 1000000).toFixed(1); ++ return ``; ++ }).join(''); ++} ++ ++function populateAudioTracks(dlg, item) { ++ const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); ++ const audioStreams = item.MediaSources?.[0]?.MediaStreams?.filter(s => s.Type === 'Audio') || []; ++ ++ selectAudioTrack.innerHTML = audioStreams.map((stream, index) => { ++ const language = stream.Language || 'Unknown'; ++ const codec = stream.Codec ? stream.Codec.toUpperCase() : ''; ++ const channels = stream.Channels ? `${stream.Channels}.0` : ''; ++ const displayTitle = stream.DisplayTitle || `Track ${index + 1}`; ++ const selected = stream.IsDefault ? ' selected' : ''; ++ ++ return ``; ++ }).join(''); ++ ++ if (audioStreams.length === 0) { ++ selectAudioTrack.innerHTML = ''; ++ } ++} ++ ++function getRecommendedAudioBitrate(codec, channels) { ++ // Recommended bitrates based on codec and channel count (2025 standards) ++ const bitrates = { ++ 'aac': { ++ 2: 192000, // Stereo - transparent quality ++ 6: 256000, // 5.1 - Netflix/streaming standard ++ 8: 384000 // 7.1 - high quality ++ }, ++ 'ac3': { ++ 2: 192000, // Stereo ++ 6: 448000, // 5.1 - Dolby Digital standard (fixed at 448k) ++ 8: 640000 // 7.1 - Dolby Digital spec maximum ++ }, ++ 'eac3': { ++ 2: 224000, // Stereo - DD+ improved quality ++ 6: 384000, // 5.1 - DD+ standard ++ 8: 768000 // 7.1 - DD+ maximum ++ }, ++ 'opus': { ++ 2: 128000, // Stereo - excellent quality (Opus is very efficient) ++ 6: 256000, // 5.1 - high quality ++ 8: 384000 // 7.1 - very high quality ++ }, ++ 'mp3': { ++ 2: 192000, // Stereo - "very high quality" (320k is overkill) ++ 6: 192000, // Downmix to stereo (MP3 doesn't support multichannel) ++ 8: 192000 // Downmix to stereo ++ } ++ }; ++ ++ return bitrates[codec]?.[channels] || bitrates[codec]?.[2] || 192000; ++} ++ ++function getMaxAudioBitrate(codec) { ++ // Maximum sensible bitrates per codec ++ const maxBitrates = { ++ 'aac': 512000, // AAC practical max for 7.1 ++ 'ac3': 640000, // AC3 spec maximum ++ 'eac3': 1536000, // E-AC3 spec maximum (Atmos capable) ++ 'opus': 510000, // Opus practical max (very efficient) ++ 'mp3': 320000 // MP3 spec maximum ++ }; ++ ++ return maxBitrates[codec] || 640000; ++} ++ ++function updateAudioBitrateOptions(dlg) { ++ const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); ++ const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); ++ const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); ++ const audioBitrateContainer = dlg.querySelector('#selectAudioBitrate').closest('.selectContainer'); ++ ++ const codec = selectAudioCodec.value; ++ const selectedTrack = selectAudioTrack.options[selectAudioTrack.selectedIndex]; ++ const channels = parseInt(selectedTrack?.getAttribute('data-channels') || '2'); ++ ++ // Hide bitrate selector for "copy" ++ if (codec === 'copy') { ++ audioBitrateContainer.style.display = 'none'; ++ return; ++ } else { ++ audioBitrateContainer.style.display = 'block'; ++ } ++ ++ const maxBitrate = getMaxAudioBitrate(codec); ++ const recommendedBitrate = getRecommendedAudioBitrate(codec, channels); ++ ++ // Filter and update bitrate options based on codec ++ const options = selectAudioBitrate.querySelectorAll('option'); ++ let needsReset = false; ++ const currentBitrate = parseInt(selectAudioBitrate.value); ++ ++ options.forEach(option => { ++ const bitrate = parseInt(option.value); ++ const text = option.textContent.replace(' (Recommended)', ''); ++ ++ // Hide options exceeding codec maximum ++ if (bitrate > maxBitrate) { ++ option.style.display = 'none'; ++ option.disabled = true; ++ if (bitrate === currentBitrate) { ++ needsReset = true; ++ } ++ } else { ++ option.style.display = ''; ++ option.disabled = false; ++ } ++ ++ // Mark recommended bitrate ++ if (bitrate === recommendedBitrate && bitrate <= maxBitrate) { ++ option.textContent = text + ' (Recommended)'; ++ if (needsReset || currentBitrate > maxBitrate) { ++ option.selected = true; ++ } ++ } else { ++ option.textContent = text; ++ } ++ }); ++ ++ // If current selection exceeds max, select recommended ++ if (needsReset || currentBitrate > maxBitrate) { ++ selectAudioBitrate.value = recommendedBitrate.toString(); ++ } ++} ++ ++function populateSubtitles(dlg, item) { ++ const selectSubtitle = dlg.querySelector('#selectSubtitle'); ++ const subtitleStreams = item.MediaSources?.[0]?.MediaStreams?.filter(s => s.Type === 'Subtitle') || []; ++ ++ let options = ''; ++ ++ options += subtitleStreams.map(stream => { ++ const language = stream.Language || 'Unknown'; ++ const codec = stream.Codec ? stream.Codec.toUpperCase() : ''; ++ const displayTitle = stream.DisplayTitle || `${language} (${codec})`; ++ ++ return ``; ++ }).join(''); ++ ++ selectSubtitle.innerHTML = options; ++} ++ ++function updateSubtitleMethod(dlg) { ++ const selectSubtitle = dlg.querySelector('#selectSubtitle'); ++ const subtitleMethodContainer = dlg.querySelector('#subtitleMethodContainer'); ++ ++ if (selectSubtitle.value === 'none') { ++ subtitleMethodContainer.style.display = 'none'; ++ } else { ++ subtitleMethodContainer.style.display = 'block'; ++ } ++} ++ ++function updateEstimatedSize(dlg, item) { ++ const selectQuality = dlg.querySelector('#selectQuality'); ++ const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); ++ const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); ++ ++ const videoBitrate = parseInt(selectQuality.value) || 0; ++ const audioBitrate = parseInt(selectAudioBitrate.value) || 0; ++ const duration = item.RunTimeTicks ? item.RunTimeTicks / 10000000 : 0; // Convert to seconds ++ ++ // For "Copy Original" mode, use original file size if available ++ if (selectVideoCodec.value === 'copy') { ++ const originalSizeBytes = item.MediaSources?.[0]?.Size; ++ if (originalSizeBytes) { ++ const sizeGB = originalSizeBytes / 1024 / 1024 / 1024; ++ dlg.querySelector('#sizeValue').textContent = `~${sizeGB.toFixed(2)} GB`; ++ } else { ++ dlg.querySelector('#sizeValue').textContent = 'Unknown'; ++ } ++ } else if (videoBitrate > 0 && duration > 0) { ++ // Calculate size: (video + audio bitrate) * duration / 8 / 1024 / 1024 / 1024 = GB ++ const totalBitrate = videoBitrate + audioBitrate; ++ const sizeGB = (totalBitrate * duration) / 8 / 1024 / 1024 / 1024; ++ dlg.querySelector('#sizeValue').textContent = `~${sizeGB.toFixed(2)} GB`; ++ } else { ++ dlg.querySelector('#sizeValue').textContent = 'Unknown'; ++ } ++ ++ // Duration ++ if (duration > 0) { ++ const hours = Math.floor(duration / 3600); ++ const minutes = Math.floor((duration % 3600) / 60); ++ dlg.querySelector('#durationValue').textContent = `${hours}h ${minutes}m`; ++ } ++} ++ ++function updateCodecCompatibility(dlg) { ++ const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); ++ const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); ++ const selectContainer = dlg.querySelector('#selectContainer'); ++ const resolutionContainer = dlg.querySelector('#selectResolution').closest('.selectContainer'); ++ const qualityContainer = dlg.querySelector('#selectQuality').closest('.selectContainer'); ++ const audioCodecContainer = dlg.querySelector('#selectAudioCodec').closest('.selectContainer'); ++ const audioBitrateContainer = dlg.querySelector('#selectAudioBitrate').closest('.selectContainer'); ++ const containerContainer = dlg.querySelector('#selectContainer').closest('.selectContainer'); ++ ++ const videoCodec = selectVideoCodec.value; ++ const container = selectContainer.value; ++ const currentAudioCodec = selectAudioCodec.value; ++ ++ // If "Copy Original" is selected, hide all transcode options ++ if (videoCodec === 'copy') { ++ resolutionContainer.style.display = 'none'; ++ qualityContainer.style.display = 'none'; ++ audioCodecContainer.style.display = 'none'; ++ audioBitrateContainer.style.display = 'none'; ++ containerContainer.style.display = 'none'; ++ return; ++ } else { ++ resolutionContainer.style.display = 'block'; ++ qualityContainer.style.display = 'block'; ++ audioCodecContainer.style.display = 'block'; ++ audioBitrateContainer.style.display = 'block'; ++ containerContainer.style.display = 'block'; ++ } ++ ++ // Filter container options based on video codec ++ const containerOptions = selectContainer.querySelectorAll('option'); ++ let needsContainerReset = false; ++ ++ containerOptions.forEach(option => { ++ const cont = option.value; ++ let compatible = true; ++ ++ // VP9 is WebM-optimized (not compatible with MP4) ++ if (videoCodec === 'vp9' && cont === 'mp4') { ++ compatible = false; ++ } ++ // HEVC not compatible with WebM ++ if (videoCodec === 'hevc' && cont === 'webm') { ++ compatible = false; ++ } ++ // H.264 not typically used with WebM ++ if (videoCodec === 'h264' && cont === 'webm') { ++ compatible = false; ++ } ++ // AV1 and MKV support all containers ++ ++ if (compatible) { ++ option.style.display = ''; ++ option.disabled = false; ++ } else { ++ option.style.display = 'none'; ++ option.disabled = true; ++ if (cont === container) { ++ needsContainerReset = true; ++ } ++ } ++ }); ++ ++ // Reset container if current selection is incompatible ++ if (needsContainerReset) { ++ if (videoCodec === 'vp9') { ++ selectContainer.value = 'webm'; ++ } else if (videoCodec === 'hevc' || videoCodec === 'h264') { ++ selectContainer.value = 'mkv'; ++ } else if (videoCodec === 'av1') { ++ selectContainer.value = 'mkv'; ++ } ++ } ++ ++ // Filter audio codec options based on container ++ const audioOptions = selectAudioCodec.querySelectorAll('option'); ++ let needsAudioReset = false; ++ ++ audioOptions.forEach(option => { ++ const codec = option.value; ++ let compatible = true; ++ ++ if (container === 'webm') { ++ // WebM only supports Opus ++ compatible = (codec === 'opus' || codec === 'copy'); ++ } else if (container === 'mp4') { ++ // MP4 doesn't support Opus ++ compatible = (codec !== 'opus'); ++ } ++ // MKV supports everything ++ ++ if (compatible) { ++ option.style.display = ''; ++ option.disabled = false; ++ } else { ++ option.style.display = 'none'; ++ option.disabled = true; ++ if (codec === currentAudioCodec) { ++ needsAudioReset = true; ++ } ++ } ++ }); ++ ++ // Reset audio codec if current selection is incompatible ++ if (needsAudioReset) { ++ if (container === 'webm') { ++ selectAudioCodec.value = 'opus'; ++ } else if (container === 'mp4') { ++ selectAudioCodec.value = 'aac'; ++ } ++ } ++} ++ ++function buildDownloadUrl(apiClient, item, options) { ++ const params = new URLSearchParams({ ++ videoCodec: options.videoCodec, ++ audioCodec: options.audioCodec, ++ container: options.container, ++ videoBitRate: options.videoBitRate, ++ audioBitRate: options.audioBitRate, ++ audioStreamIndex: options.audioStreamIndex, ++ playSessionId: new Date().getTime().toString() // Unique ID to prevent caching ++ }); ++ ++ // Force transcoding - disable stream copy ++ // User can select "Copy Original" if they want to copy instead ++ params.set('enableAutoStreamCopy', 'false'); ++ params.set('allowVideoStreamCopy', 'false'); ++ params.set('allowAudioStreamCopy', 'false'); ++ ++ // Add resolution if not original ++ if (options.maxWidth && options.maxWidth !== 'original') { ++ params.set('maxWidth', options.maxWidth); ++ // Calculate height based on 16:9 aspect ratio ++ const maxHeight = Math.round(parseInt(options.maxWidth) * 9 / 16); ++ params.set('maxHeight', maxHeight.toString()); ++ } ++ ++ // Subtitles ++ if (options.subtitleStreamIndex !== null && options.subtitleStreamIndex !== 'none') { ++ params.set('subtitleStreamIndex', options.subtitleStreamIndex); ++ params.set('subtitleMethod', options.subtitleMethod); ++ } ++ ++ // Ensure proper seeking and duration metadata (copyTimestamps=false uses re-encoded timestamps) ++ params.set('copyTimestamps', 'false'); ++ ++ const url = `${apiClient.getUrl(`/Videos/${item.Id}/stream?${params.toString()}`)}`; ++ console.log('Download URL:', url); ++ return url; ++} ++ ++function onSubmit(e) { ++ e.preventDefault(); ++ ++ const dlg = e.target; ++ const selectResolution = dlg.querySelector('#selectResolution'); ++ const selectQuality = dlg.querySelector('#selectQuality'); ++ const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); ++ const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); ++ const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); ++ const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); ++ const selectSubtitle = dlg.querySelector('#selectSubtitle'); ++ const selectSubtitleMethod = dlg.querySelector('#selectSubtitleMethod'); ++ const selectContainer = dlg.querySelector('#selectContainer'); ++ ++ // Create download ++ import('../../scripts/fileDownloader').then((fileDownloader) => { ++ let downloadUrl; ++ let filename; ++ let title; ++ ++ // Check if user wants original file ++ if (selectVideoCodec.value === 'copy') { ++ downloadUrl = currentApiClient.getItemDownloadUrl(currentItem.Id); ++ filename = currentItem.Path ? currentItem.Path.replace(/^.*[\\/]/, '') : `${currentItem.Name}.${currentItem.Container || 'mkv'}`; ++ title = `${currentItem.Name} (Original)`; ++ } else { ++ const options = { ++ maxWidth: selectResolution.value, ++ videoBitRate: parseInt(selectQuality.value), ++ videoCodec: selectVideoCodec.value, ++ audioStreamIndex: parseInt(selectAudioTrack.value), ++ audioCodec: selectAudioCodec.value, ++ audioBitRate: parseInt(selectAudioBitrate.value), ++ subtitleStreamIndex: selectSubtitle.value === 'none' ? null : parseInt(selectSubtitle.value), ++ subtitleMethod: selectSubtitleMethod.value, ++ container: selectContainer.value ++ }; ++ ++ downloadUrl = buildDownloadUrl(currentApiClient, currentItem, options); ++ ++ // Build filename in dotted notation ++ const resolutionName = selectResolution.options[selectResolution.selectedIndex].text.split(' ')[0]; ++ const codecName = selectVideoCodec.value.toUpperCase(); ++ const audioBitrateKbps = Math.round(options.audioBitRate / 1000); ++ const audioCodecName = selectAudioCodec.value.toUpperCase(); ++ ++ // Clean item name (replace spaces with dots, remove problematic characters) ++ const cleanName = currentItem.Name.replace(/\s+/g, '.').replace(/[^\w\.\-]/g, ''); ++ ++ // Format: Name.1080p.AV1.192kbps.AAC.mkv ++ filename = `${cleanName}.${resolutionName}.${codecName}.${audioBitrateKbps}kbps.${audioCodecName}.${options.container}`; ++ title = `${currentItem.Name} (${resolutionName} ${codecName})`; ++ } ++ ++ fileDownloader.download([{ ++ url: downloadUrl, ++ item: currentItem, ++ itemId: currentItem.Id, ++ serverId: currentApiClient.serverId(), ++ title: title, ++ filename: filename ++ }]); ++ ++ toast('Download started'); ++ dialogHelper.close(e.target.closest('.dialog')); ++ }); ++ ++ return false; ++} ++ ++export function show(item, apiClient) { ++ currentItem = item; ++ currentApiClient = apiClient; ++ ++ return new Promise((resolve) => { ++ const dlg = dialogHelper.createDialog({ ++ removeOnClose: true, ++ size: 'medium' ++ }); ++ ++ dlg.classList.add('formDialog'); ++ ++ let html = globalize.translateHtml(template, 'core'); ++ dlg.innerHTML = html; ++ ++ // Populate dropdowns ++ populateQualityOptions(dlg, item); ++ populateAudioTracks(dlg, item); ++ populateSubtitles(dlg, item); ++ updateEstimatedSize(dlg, item); ++ updateCodecCompatibility(dlg); // Set initial state ++ updateAudioBitrateOptions(dlg); // Set recommended bitrates ++ ++ // Event listeners ++ dlg.querySelector('#selectResolution').addEventListener('change', () => { ++ populateQualityOptions(dlg, item); ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectQuality').addEventListener('change', () => { ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectVideoCodec').addEventListener('change', () => { ++ updateCodecCompatibility(dlg); ++ populateQualityOptions(dlg, item); ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectAudioTrack').addEventListener('change', () => { ++ updateAudioBitrateOptions(dlg); ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectAudioCodec').addEventListener('change', () => { ++ updateCodecCompatibility(dlg); ++ updateAudioBitrateOptions(dlg); ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectAudioBitrate').addEventListener('change', () => { ++ updateEstimatedSize(dlg, item); ++ }); ++ ++ dlg.querySelector('#selectContainer').addEventListener('change', () => { ++ updateCodecCompatibility(dlg); ++ }); ++ ++ dlg.querySelector('#selectSubtitle').addEventListener('change', () => { ++ updateSubtitleMethod(dlg); ++ }); ++ ++ dlg.querySelector('form').addEventListener('submit', onSubmit); ++ ++ dlg.querySelector('.btnCancel').addEventListener('click', () => { ++ dialogHelper.close(dlg); ++ }); ++ ++ if (layoutManager.tv) { ++ import('../autoFocuser').then(({ default: autoFocuser }) => { ++ autoFocuser.autoFocus(dlg); ++ }); ++ } ++ ++ dialogHelper.open(dlg).then(() => { ++ resolve(); ++ }); ++ }); ++} ++ ++export default { ++ show: show ++}; +diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.scss b/src/components/downloadOptionsDialog/downloadOptionsDialog.scss +new file mode 100644 +index 0000000000..6d168e75c2 +--- /dev/null ++++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.scss +@@ -0,0 +1,65 @@ ++.verticalSection { ++ margin-bottom: 1.5em; ++ ++ &:last-child { ++ margin-bottom: 0; ++ } ++} ++ ++.sectionTitle { ++ margin: 0 0 0.75em 0; ++ font-size: 1.1em; ++ font-weight: 500; ++ color: rgba(255, 255, 255, 0.87); ++} ++ ++.selectContainer { ++ margin-bottom: 1em; ++ ++ &:last-child { ++ margin-bottom: 0; ++ } ++} ++ ++.selectLabel { ++ display: block; ++ margin-bottom: 0.5em; ++ font-size: 0.95em; ++ color: rgba(255, 255, 255, 0.7); ++} ++ ++.emby-select { ++ width: 100%; ++} ++ ++.downloadInfo { ++ background: rgba(var(--primary-rgb), 0.1); ++ border-left: 3px solid rgba(var(--primary-rgb), 0.5); ++ padding: 1em; ++ border-radius: 4px; ++ margin-bottom: 8em; ++} ++ ++#estimatedSize, ++#downloadDuration { ++ font-size: 0.95em; ++} ++ ++#sizeValue, ++#durationValue { ++ color: rgba(var(--primary-rgb), 1); ++ font-weight: 500; ++} ++ ++.formDialogFooter { ++ position: sticky; ++ bottom: 0; ++ background: var(--dialog-background, #202020); ++ padding: 1em 1.5em; ++ margin: 0 -1.5em; ++ border-top: 1px solid rgba(255, 255, 255, 0.1); ++ display: flex; ++ gap: 1em; ++ justify-content: flex-end; ++ z-index: 1000; ++} +diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html b/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html +new file mode 100644 +index 0000000000..be024f63c1 +--- /dev/null ++++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html +@@ -0,0 +1,138 @@ ++
++ ++

Download Options

++
++ ++
++
++
++

Video Settings

++ ++
++ ++ ++
++ ++
++ ++ ++
++ ++
++ ++ ++
++
++ ++
++

Audio Settings

++ ++
++ ++ ++
++ ++
++ ++ ++
++ ++
++ ++ ++
++
++ ++
++

Subtitle Settings

++ ++
++ ++ ++
++ ++ ++
++ ++
++

Container Format

++ ++
++ ++ ++
++
++ ++
++
++
++ Estimated Size: Calculating... ++
++
++ Duration: - ++
++
++
++
++ ++
++ ++ ++
++
+diff --git a/src/components/itemContextMenu.js b/src/components/itemContextMenu.js +index 7f07c203e4..1e1a86f9e7 100644 +--- a/src/components/itemContextMenu.js ++++ b/src/components/itemContextMenu.js +@@ -13,6 +13,7 @@ import { playbackManager } from './playback/playbackmanager'; + import toast from './toast/toast'; + import * as userSettings from '../scripts/settings/userSettings'; + import { AppFeature } from 'constants/appFeature'; ++import downloadOptionsDialog from './downloadOptionsDialog/downloadOptionsDialog'; + + /** Item types that support downloading all children. */ + const DOWNLOAD_ALL_TYPES = [ +@@ -410,18 +411,26 @@ function executeCommand(item, id, options) { + }); + break; + case 'download': +- import('../scripts/fileDownloader').then((fileDownloader) => { +- const downloadHref = apiClient.getItemDownloadUrl(itemId); +- fileDownloader.download([{ +- url: downloadHref, +- item, +- itemId, +- serverId, +- title: item.Name, +- filename: item.Path.replace(/^.*[\\/]/, '') +- }]); +- getResolveFunction(getResolveFunction(resolve, id), id)(); +- }); ++ // Show download options dialog for videos, direct download for others ++ if (item.Type === 'Movie' || item.Type === 'Episode' || item.Type === 'Video') { ++ downloadOptionsDialog.show(item, apiClient).then(() => { ++ getResolveFunction(getResolveFunction(resolve, id), id)(); ++ }); ++ } else { ++ // Direct download for non-video items (books, music, etc.) ++ import('../scripts/fileDownloader').then((fileDownloader) => { ++ const downloadHref = apiClient.getItemDownloadUrl(itemId); ++ fileDownloader.download([{ ++ url: downloadHref, ++ item, ++ itemId, ++ serverId, ++ title: item.Name, ++ filename: item.Path.replace(/^.*[\\/]/, '') ++ }]); ++ getResolveFunction(getResolveFunction(resolve, id), id)(); ++ }); ++ } + break; + case 'downloadall': { + const downloadItems = items => { +diff --git a/src/components/playback/playersettingsmenu.js b/src/components/playback/playersettingsmenu.js +index 5b0aca301f..12b2488fc3 100644 +--- a/src/components/playback/playersettingsmenu.js ++++ b/src/components/playback/playersettingsmenu.js +@@ -93,12 +93,16 @@ function getQualitySecondaryText(player) { + return stream.Type === 'Video'; + })[0]; + ++ const videoCodec = videoStream ? videoStream.Codec : null; ++ const videoBitRate = videoStream ? videoStream.BitRate : null; + const videoWidth = videoStream ? videoStream.Width : null; + const videoHeight = videoStream ? videoStream.Height : null; + + const options = qualityoptions.getVideoQualityOptions({ + currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player), + isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player), ++ videoCodec, ++ videoBitRate, + videoWidth: videoWidth, + videoHeight: videoHeight, + enableAuto: true +diff --git a/src/scripts/fileDownloader.js b/src/scripts/fileDownloader.js +index 24f1631017..dde5c18ae6 100644 +--- a/src/scripts/fileDownloader.js ++++ b/src/scripts/fileDownloader.js +@@ -3,8 +3,7 @@ import shell from './shell'; + + export function download(items) { + if (!shell.downloadFiles(items)) { +- multiDownload(items.map(function (item) { +- return item.url; +- })); ++ // Pass full item objects (with url and filename) to multiDownload ++ multiDownload(items); + } + } +diff --git a/src/scripts/multiDownload.js b/src/scripts/multiDownload.js +index c9f40f56a4..cef4c8a3bf 100644 +--- a/src/scripts/multiDownload.js ++++ b/src/scripts/multiDownload.js +@@ -27,25 +27,35 @@ function fallback(urls) { + })(); + } + +-function download(url) { ++function download(url, filename) { + const a = document.createElement('a'); +- a.download = ''; ++ a.download = filename || ''; + a.href = url; + a.click(); + } + +-export default function (urls) { +- if (!urls) { +- throw new Error('`urls` required'); ++export default function (items) { ++ if (!items) { ++ throw new Error('`items` required'); + } + ++ // Support both old format (array of URLs) and new format (array of objects with url+filename) ++ const isOldFormat = typeof items[0] === 'string'; ++ ++ console.log('multiDownload received items:', items); ++ console.log('isOldFormat:', isOldFormat); ++ + if (typeof document.createElement('a').download === 'undefined' || browser.iOS) { ++ const urls = isOldFormat ? items : items.map(item => item.url || item); + return fallback(urls); + } + + let delay = 0; + +- urls.forEach(function (url) { +- setTimeout(download.bind(null, url), 100 * ++delay); ++ items.forEach(function (item) { ++ const url = isOldFormat ? item : (item.url || item); ++ const filename = isOldFormat ? '' : (item.filename || ''); ++ console.log('Downloading:', { url, filename }); ++ setTimeout(download.bind(null, url, filename), 100 * ++delay); + }); + }