Compare commits
32 Commits
b611a83c8e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1e61f50c | ||
|
|
7fa3cf423a | ||
|
|
7ec56cbcaa | ||
|
|
1774339f88 | ||
|
|
f0ae5ced4f | ||
|
|
413579151a | ||
|
|
ad655ae4c4 | ||
|
|
63f6738e11 | ||
|
|
0eaee053f5 | ||
|
|
a0097cae2d | ||
|
|
9e6b9fc1db | ||
|
|
c9ffefed70 | ||
|
|
485b809a63 | ||
|
|
6efda6b0bd | ||
|
|
1cedac1aa9 | ||
|
|
f517a97ca0 | ||
|
|
0bd96b4a89 | ||
|
|
ceda15a9f4 | ||
|
|
33b8d104a1 | ||
|
|
8ba181add6 | ||
|
|
97208b62ba | ||
|
|
bc2cf0f4b8 | ||
|
|
38683358b8 | ||
|
|
5a277e94a2 | ||
|
|
94f5e64ab0 | ||
|
|
766a58195d | ||
|
|
23e93b584e | ||
|
|
0b3d627074 | ||
|
|
c0dd7c9897 | ||
|
|
4cf5571355 | ||
|
|
730624f9f4 | ||
|
|
99accb8df2 |
62
build.bak
Normal file
62
build.bak
Normal 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 "=================================================="
|
||||
51
docker/jellyfin-xbox-proxy/Caddyfile
Normal file
51
docker/jellyfin-xbox-proxy/Caddyfile
Normal 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
|
||||
}
|
||||
11
docker/jellyfin-xbox-proxy/Dockerfile
Normal file
11
docker/jellyfin-xbox-proxy/Dockerfile
Normal 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"]
|
||||
108
docker/jellyfin-xbox-proxy/README.md
Normal file
108
docker/jellyfin-xbox-proxy/README.md
Normal 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"?
|
||||
52
docker/jellyfin-xbox-proxy/docker-compose.yml
Normal file
52
docker/jellyfin-xbox-proxy/docker-compose.yml
Normal 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:
|
||||
122
docker/jellyfin-xbox-proxy/xbox-filter.py
Normal file
122
docker/jellyfin-xbox-proxy/xbox-filter.py
Normal 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)
|
||||
Submodule jellyfin-server updated: 16e62d50f6...0bc8b92b6a
Submodule jellyfin-web updated: c7942c7d8b...c66db4e18d
@@ -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
|
||||
931
patches/jellyfin-web-download-dialog-v10.11.5.patch
Normal file
931
patches/jellyfin-web-download-dialog-v10.11.5.patch
Normal 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);
|
||||
});
|
||||
}
|
||||
BIN
playlist [playlist].mp4.part-Frag36.part
Normal file
BIN
playlist [playlist].mp4.part-Frag36.part
Normal file
Binary file not shown.
1
playlist [playlist].mp4.ytdl
Normal file
1
playlist [playlist].mp4.ytdl
Normal file
@@ -0,0 +1 @@
|
||||
{"downloader": {"current_fragment": {"index": 35}, "extra_state": {}}}
|
||||
Reference in New Issue
Block a user