Compare commits

...

32 Commits

Author SHA1 Message Date
mani
6b1e61f50c Update jellyfin-server and jellyfin-web to v10.11.6
Some checks failed
Unstable Build / Debian (amd64, bookworm) (push) Has been cancelled
Unstable Build / Debian (amd64, bullseye) (push) Has been cancelled
Unstable Build / Debian (amd64, trixie) (push) Has been cancelled
Unstable Build / Debian (arm64, bookworm) (push) Has been cancelled
Unstable Build / Debian (arm64, bullseye) (push) Has been cancelled
Unstable Build / Debian (arm64, trixie) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, focal) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, jammy) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, noble) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, focal) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, jammy) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, noble) (push) Has been cancelled
Unstable Build / Linux (amd64) (push) Has been cancelled
Unstable Build / Docker (push) Has been cancelled
Unstable Build / Linux (amd64-musl) (push) Has been cancelled
Unstable Build / Linux (arm64) (push) Has been cancelled
Unstable Build / Linux (arm64-musl) (push) Has been cancelled
Unstable Build / Windows (amd64) (push) Has been cancelled
Unstable Build / Windows (arm64) (push) Has been cancelled
Unstable Build / MacOS (amd64) (push) Has been cancelled
Unstable Build / MacOS (arm64) (push) Has been cancelled
Unstable Build / Portable (push) Has been cancelled
Unstable Build / Nuget (push) Has been cancelled
Unstable Build / WindowsInstaller (amd64) (push) Has been cancelled
Unstable Build / WindowsInstallerUpload (amd64) (push) Has been cancelled
2026-01-20 14:20:40 +01:00
mani
7fa3cf423a Update jellyfin-web: fix Xbox UWP codec detection
Some checks failed
Unstable Build / Docker (push) Has been cancelled
Unstable Build / Debian (amd64, bookworm) (push) Has been cancelled
Unstable Build / Debian (amd64, bullseye) (push) Has been cancelled
Unstable Build / Debian (amd64, trixie) (push) Has been cancelled
Unstable Build / Debian (arm64, bookworm) (push) Has been cancelled
Unstable Build / Debian (arm64, bullseye) (push) Has been cancelled
Unstable Build / Debian (arm64, trixie) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, focal) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, jammy) (push) Has been cancelled
Unstable Build / Ubuntu (amd64, noble) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, focal) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, jammy) (push) Has been cancelled
Unstable Build / Ubuntu (arm64, noble) (push) Has been cancelled
Unstable Build / Linux (amd64) (push) Has been cancelled
Unstable Build / Linux (amd64-musl) (push) Has been cancelled
Unstable Build / Linux (arm64) (push) Has been cancelled
Unstable Build / Linux (arm64-musl) (push) Has been cancelled
Unstable Build / Windows (amd64) (push) Has been cancelled
Unstable Build / Windows (arm64) (push) Has been cancelled
Unstable Build / Nuget (push) Has been cancelled
Unstable Build / MacOS (amd64) (push) Has been cancelled
Unstable Build / MacOS (arm64) (push) Has been cancelled
Unstable Build / Portable (push) Has been cancelled
Unstable Build / WindowsInstaller (amd64) (push) Has been cancelled
Unstable Build / WindowsInstallerUpload (amd64) (push) Has been cancelled
2026-01-09 00:50:19 +01:00
mani
7ec56cbcaa Revert jellyfin-server: remove server-side Xbox filter 2026-01-09 00:44:15 +01:00
mani
1774339f88 Update jellyfin-server: fix Xbox playback on GET endpoint 2026-01-09 00:38:46 +01:00
mani
f0ae5ced4f Update jellyfin-server: Add logging to both PlaybackInfo endpoints 2026-01-09 00:36:17 +01:00
mani
413579151a Update jellyfin-server: Add Xbox filter debug logging 2026-01-09 00:28:06 +01:00
mani
ad655ae4c4 Debug: Add test response for PlaybackInfo matching
Testing if path_regexp works by returning debug response
instead of proxying. This will show:
1. If the path is matched correctly
2. What User-Agent header is received

Once confirmed working, uncomment reverse_proxy lines.
2026-01-09 00:24:51 +01:00
mani
63f6738e11 Major fix: Caddy-based intelligent routing for Xbox filter
Complete architecture overhaul:
- Caddy is now the main proxy (port 8096)
- Only Xbox PlaybackInfo requests go to Python filter
- Everything else (WebSocket, streaming, API) goes directly to Jellyfin

Changes:
- Add Caddy service to docker-compose
- Use path_regexp for proper PlaybackInfo matching
- Remove method POST constraint (handled by path)
- Direct routing for non-Xbox requests
- WebSocket support for /socket endpoint

This fixes:
- 503 errors on normal requests
- 400 errors on WebSocket connections
- Performance issues from proxying everything

Architecture:
Client → Caddy → Check (path + User-Agent)
              ↓ Xbox PlaybackInfo → Python Filter → Jellyfin
              ↓ Everything else → Jellyfin
2026-01-09 00:21:57 +01:00
mani
0eaee053f5 Fix Caddyfile: Proper reverse proxy to Python filter
Caddyfile now:
- Runs on port 8097 (external)
- Proxies to xbox-filter:8096 (Python filter)
- Python filter handles all Xbox detection and codec filtering
- Python filter then proxies to jellyfin:8096 (internal)

Architecture:
Client :8097 → Caddy → :8096 Python Filter → :8096 Jellyfin

Use Caddy if you need additional features like:
- SSL/TLS termination
- Rate limiting
- Access control
- Advanced routing

Or use Python filter directly on port 8096 for simpler setup.
2026-01-09 00:19:14 +01:00
mani
a0097cae2d Revert "Clean up Xbox proxy: Remove obsolete Caddyfile and improve docs"
This reverts commit 9e6b9fc1db.
2026-01-09 00:18:43 +01:00
mani
9e6b9fc1db Clean up Xbox proxy: Remove obsolete Caddyfile and improve docs
- Remove Caddyfile (Python proxy handles everything)
- Clarify that REQUEST body is filtered, not response
- Add better log examples showing actual output
- Add docker-compose comments about port exposure
- Explain filtering flow: User-Agent detection → JSON parsing → Codec filtering
2026-01-09 00:17:47 +01:00
mani
c9ffefed70 Fix Xbox proxy: Filter REQUEST body instead of response
BREAKING FIX: DeviceProfile is sent FROM client TO server in request body,
not returned in response. This was the bug preventing the filter from working.

Changes:
- Filter DeviceProfile in POST request body before sending to Jellyfin
- Add extensive debug logging to track filtering process
- Remove unnecessary response filtering (DeviceProfile not in response)
- Simplify response handling (always stream)

Now logs:
- 'Xbox PlaybackInfo request detected' when Xbox client detected
- 'Filtering Xbox DeviceProfile in REQUEST' when filtering
- 'No DeviceProfile found' if profile missing (debug info)
2026-01-09 00:15:08 +01:00
mani
485b809a63 Fix Xbox proxy: Add proper header forwarding and streaming support
- Add query parameter forwarding (?api_key=... etc.)
- Add X-Forwarded-* headers for client IP tracking
- Exclude problematic headers (content-length, transfer-encoding, etc.)
- Add streaming support for large files (8KB chunks)
- Add root route handler
- Properly forward all HTTP methods
2026-01-09 00:12:50 +01:00
mani
6efda6b0bd Update jellyfin-server: User-Agent based Xbox codec detection
- Changed from DeviceId to User-Agent for Xbox detection
- Xbox UWP app sends 'WebView2 Xbox' in User-Agent
- Fixes issue where DeviceId contains random GUID instead of 'xbox'
- Keeps FFmpeg log level, remote source transcoding, and other fixes
2026-01-09 00:03:45 +01:00
mani
1cedac1aa9 Add Xbox codec filter proxy as alternative to code patches
- Transparent reverse proxy that filters AV1/Opus for Xbox clients
- Detects Xbox via User-Agent header
- Docker-based solution, no Jellyfin code modifications needed
- Can be easily enabled/disabled without affecting Jellyfin updates
2026-01-09 00:03:04 +01:00
mani
f517a97ca0 Remove download/transcoding features to patches - keep core fixes
Moved to patches/:
- jellyfin-progressive-download-improvements-v10.11.5.patch (server)
- jellyfin-web-download-dialog-v10.11.5.patch (web UI)

Kept in code:
- Xbox UWP codec detection (AV1/Opus fix)
- FFmpeg log level configuration
- Remote source forced transcoding
- Quality text UI fix (web)

Both jellyfin-server and jellyfin-web reset to v10.11.5 base
2026-01-08 23:37:37 +01:00
mani
0bd96b4a89 Update jellyfin-server: fix Xbox playback 2026-01-08 17:13:39 +01:00
mani
ceda15a9f4 Update jellyfin-server: add filename debug info 2026-01-08 17:05:09 +01:00
mani
33b8d104a1 Update jellyfin-server: clean downloads folder code 2026-01-08 16:50:31 +01:00
mani
8ba181add6 Update jellyfin-server: add debug logging 2026-01-08 16:45:35 +01:00
mani
97208b62ba Update jellyfin-server: add /downloads/ folder for full-file downloads 2026-01-08 16:38:15 +01:00
mani
bc2cf0f4b8 Remove all timestamp fixes - revert to original Jellyfin 2026-01-08 16:34:41 +01:00
mani
38683358b8 Reset to working fix: Progressive-only timestamp normalization 2026-01-08 15:50:12 +01:00
mani
5a277e94a2 Apply timestamp normalization only for full file downloads
- Force CopyTimestamps=false when SegmentContainer is null (full downloads)
- Preserve client's copyTimestamps setting for streaming (HLS)
- Fixes negative timestamps only in downloaded files, not streaming
2026-01-08 15:47:52 +01:00
mani
94f5e64ab0 Update jellyfin-server: always apply timestamp normalization 2026-01-08 15:44:33 +01:00
mani
766a58195d Update jellyfin-server: remove restrictive Progressive-only conditions 2026-01-08 15:23:30 +01:00
mani
23e93b584e Update jellyfin-server: add debug logging to trace code path 2026-01-08 13:38:18 +01:00
mani
0b3d627074 Update jellyfin-server: use Console.Error for debug output 2026-01-08 13:37:05 +01:00
mani
c0dd7c9897 Update jellyfin-server: add file-based debug logging 2026-01-08 13:32:06 +01:00
mani
4cf5571355 Update jellyfin-server: fix missing using directive 2026-01-08 13:26:42 +01:00
mani
730624f9f4 Update jellyfin-server: use ILogger for debug output 2026-01-08 13:22:13 +01:00
mani
99accb8df2 Update jellyfin-server: file-based debug logging 2026-01-08 13:19:27 +01:00
12 changed files with 1449 additions and 2 deletions

62
build.bak Normal file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Build Jellyfin Docker image on TrueNAS
# Usage: ./build_truenas.sh <truenas-host> [tag]
# Example: ./build_truenas.sh 192.168.79.249 10.11.5-custom
set -e
TRUENAS_HOST="${1}"
IMAGE_TAG="${2:-latest}"
BUILD_DIR="/tmp/jellyfin-build-$$"
if [ -z "$TRUENAS_HOST" ]; then
echo "Usage: $0 <truenas-host> [tag]"
echo "Example: $0 192.168.79.249 10.11.5-custom"
exit 1
fi
echo "=================================================="
echo "Building Jellyfin on TrueNAS"
echo "=================================================="
echo "Host: $TRUENAS_HOST"
echo "Tag: $IMAGE_TAG"
echo "=================================================="
# Create temp build directory on TrueNAS
echo "Creating build directory on TrueNAS..."
ssh root@${TRUENAS_HOST} "mkdir -p ${BUILD_DIR}"
# Sync repository to TrueNAS (excluding .git for speed)
echo "Syncing repository to TrueNAS..."
rsync -avz --progress \
--exclude='.git' \
--exclude='node_modules' \
--exclude='bin' \
--exclude='obj' \
./ root@${TRUENAS_HOST}:${BUILD_DIR}/
# Initialize and sync submodules on TrueNAS
echo "Initializing submodules on TrueNAS..."
ssh root@${TRUENAS_HOST} "cd ${BUILD_DIR} && git submodule update --init --recursive"
# Build Docker image on TrueNAS
echo "Building Docker image on TrueNAS..."
ssh root@${TRUENAS_HOST} "cd ${BUILD_DIR} && docker build -t jellyfin:${IMAGE_TAG} --file docker/Dockerfile ."
# Tag image
echo "Tagging image..."
ssh root@${TRUENAS_HOST} "docker tag jellyfin:${IMAGE_TAG} jellyfin:latest"
# Cleanup
echo "Cleaning up build directory..."
ssh root@${TRUENAS_HOST} "rm -rf ${BUILD_DIR}"
echo "=================================================="
echo "✓ Build complete!"
echo "=================================================="
echo "Image: jellyfin:${IMAGE_TAG}"
echo ""
echo "To run the container:"
echo " ssh root@${TRUENAS_HOST}"
echo " docker run -d jellyfin:${IMAGE_TAG}"
echo "=================================================="

View File

@@ -0,0 +1,51 @@
# Caddyfile for Jellyfin Xbox Codec Filter Proxy
# Routes Xbox PlaybackInfo to Python filter, everything else to Jellyfin directly
:8096 {
# Debug: Log all requests
log {
output stdout
format json
level DEBUG
}
# Xbox PlaybackInfo requests with Xbox in User-Agent go to Python filter
@xbox_playback {
path_regexp ^/Users/.*/Items/.*/PlaybackInfo$
header User-Agent *Xbox*
}
# Test: Route ALL PlaybackInfo requests to filter first (ignore User-Agent)
@all_playback {
path_regexp ^/Users/.*/Items/.*/PlaybackInfo$
}
handle @all_playback {
respond "Matched PlaybackInfo - User-Agent: {http.request.header.User-Agent}" 200
# Uncomment when working:
# reverse_proxy xbox-filter:8096 {
# header_up Host {host}
# header_up X-Real-IP {remote_host}
# header_up X-Forwarded-For {remote_host}
# header_up X-Forwarded-Proto {scheme}
# }
}
# All other requests go directly to Jellyfin
handle {
reverse_proxy jellyfin:8096 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Enable WebSocket support
transport http {
versions h2c 1.1
}
}
}
# Enable compression
encode gzip
}

View File

@@ -0,0 +1,11 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests
COPY xbox-filter.py .
EXPOSE 8096
CMD ["python", "xbox-filter.py"]

View File

@@ -0,0 +1,108 @@
# Jellyfin Xbox Codec Filter Proxy
Intelligenter Caddy-basierter Reverse Proxy mit Python-Filter für Xbox-Clients.
## Problem
Die Xbox UWP WebView2 App meldet fälschlicherweise Support für AV1 und Opus Codecs, kann diese aber nicht abspielen.
## Lösung
Dieses Setup verwendet **Caddy als Hauptproxy** mit einem spezialisierten Python-Filter:
- **Caddy** routet intelligently:
- Xbox PlaybackInfo Requests → Python Filter (codec filtering)
- Alle anderen Requests → direkt zu Jellyfin (WebSockets, Streaming, etc.)
- **Python Filter** filtert nur:
- **AV1** aus Video-Codecs
- **Opus** aus Audio-Codecs
## Installation
```bash
cd docker/jellyfin-xbox-proxy
docker-compose up -d
```
## Konfiguration
1. Passe `docker-compose.yml` an:
- Volumes für Jellyfin
- PublishedServerUrl
2. Der Stack läuft auf Port `8096` (Standard Jellyfin)
## Architektur
```
Xbox Client → Port 8096 (Caddy) → Check User-Agent
↓ ↓
User-Agent enthält "Xbox"?
↓ ↓
JA: Python Filter NEIN: Direkt zu Jellyfin
↓ ↓
Filtert DeviceProfile (WebSocket, Streaming, etc.)
Jellyfin
```
## Routing-Regeln
**Zu Python-Filter:**
- `POST /Users/*/Items/*/PlaybackInfo`
- Header: `User-Agent: *Xbox*`
**Direkt zu Jellyfin:**
- Alle anderen Requests
- WebSocket-Verbindungen (`/socket`)
- Video-Streaming
- API-Calls
- Web-UI
## Logs
**Caddy Logs:**
```bash
docker logs -f jellyfin-caddy
```
Zeigt Routing-Entscheidungen:
```
INFO - Routing to xbox-filter (PlaybackInfo + Xbox User-Agent)
INFO - Routing to jellyfin (all other requests)
```
**Xbox Filter Logs:**
```bash
docker logs -f jellyfin-xbox-filter
```
Zeigt Codec-Filterung:
```
INFO - Xbox PlaybackInfo request detected
INFO - Filtering Xbox DeviceProfile in REQUEST
INFO - DeviceProfile filtered successfully
```
## Vorteile
-**Intelligent Routing** - Nur Xbox PlaybackInfo zum Filter
-**Keine Latenz** - Normaler Traffic direkt zu Jellyfin
-**WebSocket Support** - `/socket` funktioniert einwandfrei
-**Production-Ready** - Caddy ist ein echter Production Proxy
-**Flexibel** - Caddy kann SSL, Rate-Limiting, etc. hinzufügen
-**Kein Jellyfin-Code-Patch** - funktioniert mit allen Versionen
## Troubleshooting
**503 Errors bei normalen Requests:**
- Python-Filter läuft nicht → `docker-compose up -d xbox-filter`
**400 Errors bei WebSocket:**
- Sollte nicht mehr passieren (direkt zu Jellyfin)
**Xbox spielt noch AV1:**
- Check Caddy-Logs: Wird Request zum Filter geroutet?
- Check Filter-Logs: Wird DeviceProfile gefiltert?
- User-Agent enthält "Xbox"?

View File

@@ -0,0 +1,52 @@
version: '3.8'
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
volumes:
- /path/to/config:/config
- /path/to/cache:/cache
- /path/to/media:/media
environment:
- JELLYFIN_PublishedServerUrl=http://your-server:8096
restart: unless-stopped
networks:
- jellyfin
# IMPORTANT: No ports exposed! Only accessible via Caddy
xbox-filter:
build: .
container_name: jellyfin-xbox-filter
depends_on:
- jellyfin
# No ports exposed - only Caddy talks to this
environment:
- JELLYFIN_URL=http://jellyfin:8096
restart: unless-stopped
networks:
- jellyfin
caddy:
image: caddy:2-alpine
container_name: jellyfin-caddy
depends_on:
- jellyfin
- xbox-filter
ports:
- "8096:8096" # Public-facing port
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
networks:
- jellyfin
networks:
jellyfin:
driver: bridge
volumes:
caddy_data:
caddy_config:

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Jellyfin Xbox Codec Filter Proxy
Filters AV1 and Opus from DeviceProfile responses for Xbox clients
"""
import json
import logging
from flask import Flask, request, Response
import requests
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
JELLYFIN_URL = "http://jellyfin:8096"
def filter_codecs(profile):
"""Remove AV1 from video codecs and Opus from audio codecs"""
if not profile:
return profile
# Filter DirectPlayProfiles
if profile.get("DirectPlayProfiles"):
for direct_play in profile["DirectPlayProfiles"]:
if "VideoCodec" in direct_play and direct_play["VideoCodec"]:
codecs = [c.strip() for c in direct_play["VideoCodec"].split(",")]
codecs = [c for c in codecs if c.lower() != "av1"]
direct_play["VideoCodec"] = ",".join(codecs)
if "AudioCodec" in direct_play and direct_play["AudioCodec"]:
codecs = [c.strip() for c in direct_play["AudioCodec"].split(",")]
codecs = [c for c in codecs if c.lower() != "opus"]
direct_play["AudioCodec"] = ",".join(codecs)
# Filter TranscodingProfiles
if profile.get("TranscodingProfiles"):
for transcoding in profile["TranscodingProfiles"]:
if "VideoCodec" in transcoding and transcoding["VideoCodec"]:
codecs = [c.strip() for c in transcoding["VideoCodec"].split(",")]
codecs = [c for c in codecs if c.lower() != "av1"]
transcoding["VideoCodec"] = ",".join(codecs)
if "AudioCodec" in transcoding and transcoding["AudioCodec"]:
codecs = [c.strip() for c in transcoding["AudioCodec"].split(",")]
codecs = [c for c in codecs if c.lower() != "opus"]
transcoding["AudioCodec"] = ",".join(codecs)
return profile
@app.route('/', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
def proxy(path):
"""Proxy all requests to Jellyfin, filter Xbox responses"""
user_agent = request.headers.get('User-Agent', '')
is_xbox = 'xbox' in user_agent.lower()
is_playback_info = 'PlaybackInfo' in path and request.method == 'POST'
# Build full URL with query parameters
url = f"{JELLYFIN_URL}/{path}"
if request.query_string:
url += f"?{request.query_string.decode('utf-8')}"
# Copy headers, exclude problematic ones
headers = {}
excluded = ['host', 'connection', 'content-length', 'content-encoding', 'transfer-encoding']
for key, value in request.headers:
if key.lower() not in excluded:
headers[key] = value
# Add X-Forwarded-* headers for proper client tracking
headers['X-Forwarded-For'] = request.remote_addr
headers['X-Forwarded-Proto'] = request.scheme
headers['X-Forwarded-Host'] = request.host
headers['X-Real-IP'] = request.remote_addr
# Get request body
request_data = request.get_data()
# Filter REQUEST body for Xbox PlaybackInfo (DeviceProfile is sent TO server)
if is_xbox and is_playback_info:
logger.info(f"Xbox PlaybackInfo request detected: {path}, User-Agent: {user_agent}")
if request_data:
try:
data = json.loads(request_data)
if "DeviceProfile" in data:
logger.info(f"Filtering Xbox DeviceProfile in REQUEST")
original_profile = data["DeviceProfile"]
filtered_profile = filter_codecs(data["DeviceProfile"])
data["DeviceProfile"] = filtered_profile
request_data = json.dumps(data).encode('utf-8')
headers['Content-Length'] = str(len(request_data))
logger.info("DeviceProfile filtered successfully in REQUEST")
else:
logger.warning("No DeviceProfile found in REQUEST body")
except Exception as e:
logger.error(f"Error filtering request: {e}", exc_info=True)
else:
logger.warning("No request body found for PlaybackInfo")
resp = requests.request(
method=request.method,
url=url,
headers=headers,
data=request_data,
cookies=request.cookies,
allow_redirects=False,
stream=True # Support large file streaming
)
# Stream all responses (no need to filter response, DeviceProfile only in request)
response = Response(resp.iter_content(chunk_size=8192), status=resp.status_code)
# Copy headers from Jellyfin response
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
for key, value in resp.headers.items():
if key.lower() not in excluded_headers:
response.headers[key] = value
return response
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8096)

View File

@@ -0,0 +1,109 @@
From: Jellyfin Packaging <noreply@jellyfin.org>
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

View File

@@ -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 = '<option value="0">Original Quality</option>';
+ 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 `<option value="${option.bitrate}"${selected}>${option.name} - ${mbps} Mbps</option>`;
+ }).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 `<option value="${stream.Index}" data-channels="${stream.Channels || 2}"${selected}>${displayTitle}</option>`;
+ }).join('');
+
+ if (audioStreams.length === 0) {
+ selectAudioTrack.innerHTML = '<option value="-1" data-channels="2">No Audio</option>';
+ }
+}
+
+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 = '<option value="none" selected>None</option>';
+
+ options += subtitleStreams.map(stream => {
+ const language = stream.Language || 'Unknown';
+ const codec = stream.Codec ? stream.Codec.toUpperCase() : '';
+ const displayTitle = stream.DisplayTitle || `${language} (${codec})`;
+
+ return `<option value="${stream.Index}">${displayTitle}</option>`;
+ }).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 @@
+<div class="formDialogHeader">
+ <button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
+ <span class="material-icons arrow_back"></span>
+ </button>
+ <h3 class="formDialogHeaderTitle">Download Options</h3>
+</div>
+
+<form>
+<div class="formDialogContent smoothScrollY" style="padding: 1.5em 1.5em 1.5em 1.5em; max-height: 60vh; overflow-y: auto;">
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Video Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectResolution">Resolution</label>
+ <select is="emby-select" id="selectResolution" class="emby-select-withcolor emby-select">
+ <option value="original">Original (Source)</option>
+ <option value="3840">4K (3840x2160)</option>
+ <option value="2560">1440p (2560x1440)</option>
+ <option value="1920" selected>1080p (1920x1080)</option>
+ <option value="1280">720p (1280x720)</option>
+ <option value="640">480p (640x480)</option>
+ </select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectQuality">Quality</label>
+ <select is="emby-select" id="selectQuality" class="emby-select-withcolor emby-select"></select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectVideoCodec">${LabelVideoCodec}</label>
+ <select is="emby-select" id="selectVideoCodec" class="emby-select-withcolor emby-select">
+ <option value="copy">Copy Original (No Transcode)</option>
+ <option value="h264">H.264 (Most Compatible)</option>
+ <option value="hevc">HEVC/H.265</option>
+ <option value="vp9">VP9</option>
+ <option value="av1" selected>AV1</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Audio Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioTrack">Audio Track</label>
+ <select is="emby-select" id="selectAudioTrack" class="emby-select-withcolor emby-select"></select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioCodec">${LabelAudioCodec}</label>
+ <select is="emby-select" id="selectAudioCodec" class="emby-select-withcolor emby-select">
+ <option value="aac">AAC (Recommended)</option>
+ <option value="ac3">AC3 (Dolby Digital)</option>
+ <option value="eac3">E-AC3 (Dolby Digital Plus)</option>
+ <option value="mp3">MP3</option>
+ <option value="opus">Opus (WebM)</option>
+ <option value="copy">Copy (No Transcode)</option>
+ </select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioBitrate">${LabelAudioBitrate}</label>
+ <select is="emby-select" id="selectAudioBitrate" class="emby-select-withcolor emby-select">
+ <option value="1536000">1536 kbps</option>
+ <option value="1024000">1024 kbps</option>
+ <option value="768000">768 kbps</option>
+ <option value="640000">640 kbps</option>
+ <option value="512000">512 kbps</option>
+ <option value="510000">510 kbps</option>
+ <option value="450000">450 kbps</option>
+ <option value="448000">448 kbps</option>
+ <option value="384000">384 kbps</option>
+ <option value="320000">320 kbps</option>
+ <option value="256000">256 kbps</option>
+ <option value="224000">224 kbps</option>
+ <option value="192000" selected>192 kbps</option>
+ <option value="160000">160 kbps</option>
+ <option value="128000">128 kbps</option>
+ <option value="96000">96 kbps</option>
+ <option value="64000">64 kbps</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Subtitle Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectSubtitle">Subtitles</label>
+ <select is="emby-select" id="selectSubtitle" class="emby-select-withcolor emby-select">
+ <option value="none" selected>None</option>
+ </select>
+ </div>
+
+ <div class="selectContainer" id="subtitleMethodContainer" style="display: none;">
+ <label class="selectLabel" for="selectSubtitleMethod">Subtitle Method</label>
+ <select is="emby-select" id="selectSubtitleMethod" class="emby-select-withcolor emby-select">
+ <option value="Encode">Burn-in (Hardcoded)</option>
+ <option value="Embed" selected>Embed (Soft Subtitle)</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Container Format</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectContainer">Container</label>
+ <select is="emby-select" id="selectContainer" class="emby-select-withcolor emby-select">
+ <option value="mp4">MP4</option>
+ <option value="mkv" selected>MKV</option>
+ <option value="webm">WebM</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <div class="downloadInfo" style="padding: 1em; background: rgba(255,255,255,0.05); border-radius: 4px;">
+ <div id="estimatedSize" style="font-size: 1.1em; margin-bottom: 0.5em;">
+ <strong>Estimated Size:</strong> <span id="sizeValue">Calculating...</span>
+ </div>
+ <div id="downloadDuration" style="color: rgba(255,255,255,0.7);">
+ <strong>Duration:</strong> <span id="durationValue">-</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+<div class="formDialogFooter">
+ <button is="emby-button" type="button" class="raised button-cancel block btnCancel">
+ <span>${ButtonCancel}</span>
+ </button>
+ <button is="emby-button" type="submit" class="raised button-submit block">
+ <span>Download</span>
+ </button>
+</div>
+</form>
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);
});
}

Binary file not shown.

View File

@@ -0,0 +1 @@
{"downloader": {"current_fragment": {"index": 35}, "extra_state": {}}}