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
This commit is contained in:
22
docker/jellyfin-xbox-proxy/Caddyfile
Normal file
22
docker/jellyfin-xbox-proxy/Caddyfile
Normal file
@@ -0,0 +1,22 @@
|
||||
:8096 {
|
||||
# Forward all requests to Jellyfin
|
||||
reverse_proxy jellyfin:8096 {
|
||||
# Pass through headers
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
|
||||
# Handle Xbox codec filtering via external handler
|
||||
@xbox_playback {
|
||||
path /Users/*/Items/*/PlaybackInfo
|
||||
method POST
|
||||
header User-Agent *Xbox*
|
||||
}
|
||||
|
||||
handle @xbox_playback {
|
||||
# Forward to Python filter service
|
||||
reverse_proxy http://xbox-filter:5000
|
||||
}
|
||||
}
|
||||
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"]
|
||||
57
docker/jellyfin-xbox-proxy/README.md
Normal file
57
docker/jellyfin-xbox-proxy/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Jellyfin Xbox Codec Filter Proxy
|
||||
|
||||
Transparenter Reverse Proxy der AV1 und Opus aus DeviceProfiles für Xbox-Clients filtert.
|
||||
|
||||
## Problem
|
||||
|
||||
Die Xbox UWP WebView2 App meldet fälschlicherweise Support für AV1 und Opus Codecs, kann diese aber nicht abspielen.
|
||||
|
||||
## Lösung
|
||||
|
||||
Dieser Proxy sitzt vor Jellyfin und filtert automatisch:
|
||||
- **AV1** aus Video-Codecs
|
||||
- **Opus** aus Audio-Codecs
|
||||
|
||||
...wenn der User-Agent "Xbox" enthält.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd docker/jellyfin-xbox-proxy
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
1. Passe `docker-compose.yml` an:
|
||||
- Volumes für Jellyfin
|
||||
- Port-Mappings
|
||||
|
||||
2. Der Proxy lauscht auf Port `8096` (Standard Jellyfin)
|
||||
3. Jellyfin läuft intern und ist nur via Proxy erreichbar
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
```
|
||||
Xbox Client → Port 8096 (Proxy) → Filtert Response → Jellyfin (intern)
|
||||
↓
|
||||
Entfernt AV1/Opus aus DeviceProfile
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
docker logs jellyfin-xbox-filter
|
||||
```
|
||||
|
||||
Zeigt wenn Xbox-Clients gefiltert werden:
|
||||
```
|
||||
INFO - Filtering Xbox DeviceProfile for User-Agent: Mozilla/5.0 ... WebView2 Xbox
|
||||
```
|
||||
|
||||
## Vorteile
|
||||
|
||||
- ✅ Kein Jellyfin-Code-Patch nötig
|
||||
- ✅ Funktioniert mit allen Jellyfin-Versionen
|
||||
- ✅ Einfach aktivieren/deaktivieren
|
||||
- ✅ Unabhängig von Jellyfin-Updates
|
||||
32
docker/jellyfin-xbox-proxy/docker-compose.yml
Normal file
32
docker/jellyfin-xbox-proxy/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
|
||||
xbox-filter:
|
||||
build: .
|
||||
container_name: jellyfin-xbox-filter
|
||||
depends_on:
|
||||
- jellyfin
|
||||
ports:
|
||||
- "8096:8096"
|
||||
environment:
|
||||
- JELLYFIN_URL=http://jellyfin:8096
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- jellyfin
|
||||
|
||||
networks:
|
||||
jellyfin:
|
||||
driver: bridge
|
||||
105
docker/jellyfin-xbox-proxy/xbox-filter.py
Normal file
105
docker/jellyfin-xbox-proxy/xbox-filter.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/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('/<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'
|
||||
|
||||
# Forward request to Jellyfin
|
||||
url = f"{JELLYFIN_URL}/{path}"
|
||||
headers = {key: value for key, value in request.headers if key.lower() != 'host'}
|
||||
|
||||
resp = requests.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False
|
||||
)
|
||||
|
||||
# Filter response for Xbox PlaybackInfo requests
|
||||
if is_xbox and is_playback_info and resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
|
||||
# Filter DeviceProfile in request body (if sent back)
|
||||
if "DeviceProfile" in data:
|
||||
logger.info(f"Filtering Xbox DeviceProfile for User-Agent: {user_agent}")
|
||||
data["DeviceProfile"] = filter_codecs(data["DeviceProfile"])
|
||||
|
||||
# Filter MediaSources DirectPlayProfiles (generated server-side)
|
||||
if "MediaSources" in data:
|
||||
for source in data["MediaSources"]:
|
||||
if "TranscodingUrl" in source:
|
||||
# Already filtered by Jellyfin
|
||||
pass
|
||||
|
||||
response_data = json.dumps(data)
|
||||
response = Response(response_data, status=resp.status_code)
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
except Exception as e:
|
||||
logger.error(f"Error filtering response: {e}")
|
||||
response = Response(resp.content, status=resp.status_code)
|
||||
else:
|
||||
response = Response(resp.content, 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)
|
||||
Reference in New Issue
Block a user