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:
mani
2026-01-09 00:03:04 +01:00
parent f517a97ca0
commit 1cedac1aa9
5 changed files with 227 additions and 0 deletions

View 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
}
}

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,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

View 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

View 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)