From 1cedac1aa944247a4e7e9e2a743ea43c9b7412d4 Mon Sep 17 00:00:00 2001 From: mani Date: Fri, 9 Jan 2026 00:03:04 +0100 Subject: [PATCH] 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 --- docker/jellyfin-xbox-proxy/Caddyfile | 22 ++++ docker/jellyfin-xbox-proxy/Dockerfile | 11 ++ docker/jellyfin-xbox-proxy/README.md | 57 ++++++++++ docker/jellyfin-xbox-proxy/docker-compose.yml | 32 ++++++ docker/jellyfin-xbox-proxy/xbox-filter.py | 105 ++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 docker/jellyfin-xbox-proxy/Caddyfile create mode 100644 docker/jellyfin-xbox-proxy/Dockerfile create mode 100644 docker/jellyfin-xbox-proxy/README.md create mode 100644 docker/jellyfin-xbox-proxy/docker-compose.yml create mode 100644 docker/jellyfin-xbox-proxy/xbox-filter.py diff --git a/docker/jellyfin-xbox-proxy/Caddyfile b/docker/jellyfin-xbox-proxy/Caddyfile new file mode 100644 index 0000000..47309e8 --- /dev/null +++ b/docker/jellyfin-xbox-proxy/Caddyfile @@ -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 + } +} diff --git a/docker/jellyfin-xbox-proxy/Dockerfile b/docker/jellyfin-xbox-proxy/Dockerfile new file mode 100644 index 0000000..f147dd2 --- /dev/null +++ b/docker/jellyfin-xbox-proxy/Dockerfile @@ -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"] diff --git a/docker/jellyfin-xbox-proxy/README.md b/docker/jellyfin-xbox-proxy/README.md new file mode 100644 index 0000000..a3aae8f --- /dev/null +++ b/docker/jellyfin-xbox-proxy/README.md @@ -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 diff --git a/docker/jellyfin-xbox-proxy/docker-compose.yml b/docker/jellyfin-xbox-proxy/docker-compose.yml new file mode 100644 index 0000000..0925e6d --- /dev/null +++ b/docker/jellyfin-xbox-proxy/docker-compose.yml @@ -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 diff --git a/docker/jellyfin-xbox-proxy/xbox-filter.py b/docker/jellyfin-xbox-proxy/xbox-filter.py new file mode 100644 index 0000000..29fcf6f --- /dev/null +++ b/docker/jellyfin-xbox-proxy/xbox-filter.py @@ -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('/', 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)