yt-dlp und Douyu: PhantomJS not found und Raum-ID-Fehler beheben
Veröffentlicht am
Fix-Plugin herunterladen douyu_signing.py
Wenn yt-dlp beim Herunterladen von Douyu-Videos PhantomJS not found oder beim Aufzeichnen eines Livestreams Unable to extract room id meldet, kannst du das Kompatibilitätsplugin aus diesem Artikel laden. Es behebt drei Probleme: das Auslesen der Raum-ID aus den neuen Livestream-Seiten, die Berechnung der Signatur mit Deno und das Zusammensetzen der Stream-URL.
Wenn du Deno und das Pluginverzeichnis nicht lokal einrichten möchtest, füge den Douyu-Link in den Downloader auf unserer Startseite ein und nutze das Online-Werkzeug.
Bei der Fehlersuche für Douyu-Livestreams und Videos auf Abruf stießen wir nacheinander auf drei Hindernisse. Die neuen Seiten verwenden Next.js-Datenblöcke, wodurch der bisherige reguläre Ausdruck keine Raum-ID mehr findet. Nach dieser Korrektur rief der ursprüngliche Extraktor weiterhin das veraltete PhantomJS zur Signaturberechnung auf. Sobald die Signatur funktionierte und die verfügbaren Auflösungen angezeigt wurden, fehlte in der zusammengesetzten FLV-Stream-URL noch das Verzeichnis /live/. Hier findest du zuerst die Schritte zur Behebung und anschließend die Ursachen im Detail.
Fehlermeldungen und betroffene Schritte
| Fehlermeldung oder Verhalten | Ursache |
|---|---|
Unable to extract room id |
Die neue Livestream-Seite verwendet einen Next.js-Datenstrom; die bisherige Suche nach $ROOM.room_id findet keinen Treffer |
PhantomJS not found |
In der ursprünglichen douyutv.py ist der Aufruf von PhantomJSwrapper fest eingebaut; Deno zu installieren reicht deshalb nicht aus |
Die Formate werden angezeigt (-F funktioniert), aber der Download bricht mit einem Fehler ab |
Dem von der API gelieferten rtmp_url fehlt der abschließende Schrägstrich; urljoin entfernt beim Zusammensetzen das Verzeichnis /live/ |
The channel is not currently live |
Der Raum ist offline; die Verarbeitung wird entsprechend dem Raumstatus regulär beendet |
The channel is auto-playing VODs |
Der Raum spielt Videos auf Abruf in einer Schleife ab (videoLoop: 1); das Plugin beendet die Extraktion |
Schritte zur schnellen Fehlerbehebung
1. Umgebung und Plugindatei vorbereiten
Stelle sicher, dass yt-dlp, deno und ffmpeg im Terminal verfügbar sind. Für die Tests in diesem Artikel wurden yt-dlp nightly 2026.10.29.232701, Deno 2.9.8 und FFmpeg 7.1.5 verwendet. Das Plugin benötigt die in yt-dlp enthaltene Methode _search_nextjs_v13_data. Aktualisiere yt-dlp zuerst, wenn deine lokale Version zu alt ist.
Erstelle in deinem Arbeitsverzeichnis die folgende Ordnerstruktur und speichere den vollständigen Plugincode am Ende des Artikels als douyu_signing.py:
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
Führe alle folgenden Befehle im Verzeichnis aus, das den Ordner plugins enthält. Das Plugin wird mit --plugin-dirs ./plugins geladen. Den Quellcode im Installationsverzeichnis von yt-dlp musst du nicht ändern.
2. Die Formate des Livestreams prüfen
Das folgende Beispiel verwendet einen öffentlichen Livestream-Raum. Ersetze die URL durch die des gewünschten Raums:
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
Die Kennung [DouyuTV+DenoSigning] im Protokoll zeigt, dass das Plugin aktiv ist. Wenn der Raum live sendet, wird die Schnittstelle getH5Play abgefragt und die Liste der verfügbaren Auflösungen ausgegeben. Bei The channel is not currently live ist der Raum gerade offline. Verwende dann für den Test einen Raum mit laufender Übertragung.
3. Den Medienstrom mit einer 30-Sekunden-Aufnahme prüfen
Eine erfolgreiche Formatliste zeigt zunächst nur, dass die API-Anfrage mit Signatur error: 0 zurückgegeben hat. Ob die CDN-Adresse korrekt zusammengesetzt wurde, lässt sich erst durch das tatsächliche Abrufen des Streams bestätigen:
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir --downloader ffmpeg --downloader-args "ffmpeg_i:-rw_timeout 15000000" --downloader-args "ffmpeg_o:-t 30" -f worst -o "douyu-live.%(ext)s" "https://www.douyu.com/pigff"
Mit -f worst wird die niedrigste Bitrate gewählt, damit die Testdatei klein bleibt. -t 30 begrenzt die Aufnahme auf 30 Sekunden; für Lese- und Schreibzugriffe über das Netzwerk gilt ein Timeout von 15 Sekunden.
4. Videos auf Abruf und Dateien prüfen (optional)
Dasselbe Plugin deckt auch den Douyu-Extraktor DouyuShowIE für Videos auf Abruf (VOD) ab. Bei einem VOD-Test erscheint [DouyuShow+DenoSigning] im Protokoll:
# List VOD formats
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -F "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"
# Download the VOD sample
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -f worst -o "douyu-video.%(ext)s" "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"
Um eine aufgezeichnete .flv- oder .mp4-Datei auf beschädigte Frames und vollständige Audio- und Videospuren zu prüfen, kannst du ffprobe ausführen und die Datei vollständig dekodieren lassen:
ffprobe -v error -show_entries stream=codec_name,codec_type,width,height:format=duration,size -of json "douyu-live.flv"
ffmpeg -nostdin -v error -xerror -i "douyu-live.flv" -map 0:v:0 -map "0:a:0?" -f null -
Die drei Fehler im Detail
Erster Fehler: Next.js-Datenblöcke ersetzen die bisherige Raum-ID-Zuweisung
Der ursprüngliche Extraktor las die Raum-ID aus einer Zuweisung an $ROOM.room_id im Seitenquelltext aus. Ein entsprechender Fehler ist in Issue #16230 des yt-dlp-Projekts dokumentiert. Auf den neuen Douyu-Livestream-Seiten, beispielsweise beim Alias pigff oder der numerischen Raumadresse 593229, liegen die Raummetadaten inzwischen in den Skriptdatenblöcken des Next.js App Routers (__next_f.push).
Eine zu allgemeine Suche nach room_id im gesamten HTML kann versehentlich die ID eines empfohlenen Raums aus der Seitenleiste oder dem unteren Seitenbereich liefern. Deshalb liest das Plugin den Datenstrom mit _search_nextjs_v13_data ein und wählt ausschließlich das Dictionary roomInfo.room des aktuellen Raums aus:
- Der Alias
pigffwird korrekt zur tatsächlichen Raum-ID24422aufgelöst. - Der Raumstatus wird vor der Signaturanfrage geprüft. Bei
show_status == 2wird sofort die Offline-Meldung ausgegeben, etwa beim nicht sendenden Raum593229. Beiroom_biz_all.videoLoop == 1wird gemeldet, dass der Raum Videos in einer Schleife wiedergibt. Dadurch entfällt eine unnötige Signaturberechnung. - Enthält die Seite keine Next.js-Datenblöcke, verwendet sie also die ältere Struktur, übernimmt automatisch die ursprüngliche Verarbeitung über
super()._real_extract(url).
Zweiter Fehler: Der direkte Aufruf von PhantomJSwrapper
Nach dem Ermitteln der tatsächlichen room_id muss der Extraktor Douyus Signaturfunktion ub98484234 ausführen, um die vier Parameter v, did, tt und sign zu berechnen. Auch wenn das von yt-dlp empfohlene Deno bereits installiert ist, erstellt _calc_sign in der ursprünglichen douyutv.py weiterhin direkt eine Instanz von PhantomJSwrapper. Das führt zu PhantomJS not found und betrifft sowohl Livestreams als auch VODs.
Das Plugin überschreibt _calc_sign, behält aber die ursprüngliche Logik zum Abrufen des Signaturskripts und der eingebundenen CryptoJS-MD5-Implementierung bei. Für Livestreams wird room_id übergeben, für VODs die Video-ID aus dem Feld ROOM.point_id von window.$DATA. Die Argumente werden mit json.dumps sicher kodiert und über die Standardeingabe an einen Deno-Unterprozess ohne erteilte Zugriffsrechte übergeben:
--no-prompt,--no-configund--no-remotewerden ausdrücklich gesetzt. Datei-, Umgebungsvariablen- oder Netzwerkzugriffsrechte werden nicht erteilt. Das Berechtigungsmodell von Deno beschränkt den Zugriff über JavaScript-APIs; es ersetzt keine Sandbox auf Betriebssystemebene.- Für den Prozess gilt ein Timeout von 15 Sekunden. Bevor die zurückgegebenen Parameter an
/lapi/live/getH5Play/{room_id}oder die VOD-Schnittstelle gesendet werden, prüft das Plugin, ob der Querystring ein nicht leeres Feldsignenthält. In den Tests gaben beide Schnittstellenerror: 0zurück.
Dritter Fehler: urljoin entfernt das Verzeichnis /live/
Nach der Korrektur von Raum-ID und Signatur zeigte -F bereits Formate mit mehreren Bitraten an. Der erste Aufnahmeversuch mit FFmpeg scheiterte dennoch mit Exitcode 8 (ffmpeg exited with code 8).
Die JSON-Antwort von getH5Play liefert die Stream-Adresse in zwei Teilen: rtmp_url enthält das Verzeichnispräfix, rtmp_live den Dateinamen. Dabei endet rtmp_url auf /live, ohne abschließenden Schrägstrich. Beim direkten Zusammensetzen mit urllib.parse.urljoin wird live deshalb als letzter Dateiname behandelt und ersetzt. Auch Issue #11998 dokumentiert CDN-Antworten mit Status 403 aufgrund eines fehlenden /live/-Verzeichnisses:
urljoin('https://cdn.example/live', '24422.flv?sign=example')
# Incorrect result: https://cdn.example/24422.flv?sign=example
urljoin('https://cdn.example/live/', '24422.flv?sign=example')
# Correct result: https://cdn.example/live/24422.flv?sign=example
Das Plugin ergänzt in _extract_stream_formats zuerst den abschließenden Schrägstrich von rtmp_url mit data['rtmp_url'].rstrip('/') + '/' und übergibt die Daten anschließend an die ursprüngliche Formatauswertung. Der Stream-Dateiname und die signierten Queryparameter bleiben unverändert. Nach dieser Korrektur konnten sowohl Livestream-Aufnahmen als auch fragmentierte VOD-Downloads vollständig abgerufen und erfolgreich dekodiert werden.
Vollständiger Plugincode
Speichere den folgenden Code als plugins/douyu/yt_dlp_plugins/extractor/douyu_signing.py:
"""Run Douyu signing scripts in Deno without file, environment, or network access."""
import json
import shutil
import subprocess
import time
import urllib.parse
import uuid
from yt_dlp.extractor.douyutv import DouyuShowIE as UpstreamDouyuShowIE, DouyuTVIE as UpstreamDouyuTVIE
from yt_dlp.utils import (
ExtractorError,
UserNotLive,
int_or_none,
str_or_none,
traverse_obj,
unescapeHTML,
url_or_none,
urlencode_postdata,
)
class _DenoSigning:
def _calc_sign(self, sign_func, video_id, room_id):
deno = shutil.which('deno')
if not deno:
raise ExtractorError('Deno is required for Douyu signing', expected=True)
script = ('globalThis.window=globalThis;' + self._get_cryptojs_md5(video_id) + ';' + sign_func
+ ';console.log(ub98484234(' + ','.join(json.dumps(value) for value in
(str(room_id), uuid.uuid4().hex, str(round(time.time())))) + '))')
try:
result = subprocess.run([deno, 'run', '--no-prompt', '--no-config', '--no-remote', '-'],
input=script, text=True, capture_output=True, timeout=15)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ExtractorError('Douyu signing runtime failed', cause=exc) from exc
lines = result.stdout.strip().splitlines()
fields = urllib.parse.parse_qs(lines[-1]) if lines and result.returncode == 0 else {}
if not fields.get('sign'):
raise ExtractorError('Unable to sign Douyu request with Deno')
return {key: values[0] for key, values in fields.items()}
class DouyuShowIE(UpstreamDouyuShowIE, plugin_name='DenoSigning'):
_calc_sign = _DenoSigning._calc_sign
class DouyuTVIE(UpstreamDouyuTVIE, plugin_name='DenoSigning'):
_calc_sign = _DenoSigning._calc_sign
def _extract_stream_formats(self, stream_formats):
normalized = []
for response in stream_formats:
data = response.get('data')
if isinstance(data, dict) and isinstance(data.get('rtmp_url'), str):
# Treat /live as a directory so urljoin preserves it.
response = {**response, 'data': {
**data, 'rtmp_url': data['rtmp_url'].rstrip('/') + '/',
}}
normalized.append(response)
return super()._extract_stream_formats(normalized)
def _real_extract(self, url):
video_id = self._match_id(url)
webpage = self._download_webpage(url, video_id)
room = traverse_obj(
self._search_nextjs_v13_data(webpage, video_id, fatal=False),
(..., 'roomInfo', 'room', {dict}), get_all=False)
if not room:
# Let upstream handle older pages without guessing IDs from recommendations or URLs.
return super()._real_extract(url)
room_id = str_or_none(room.get('room_id'))
if not room_id or not room_id.isascii() or not room_id.isdecimal() or int(room_id) <= 0:
raise ExtractorError('Unable to extract room id')
if traverse_obj(room, ('room_biz_all', 'videoLoop', {int_or_none})) == 1:
raise UserNotLive('The channel is auto-playing VODs', video_id=video_id)
if int_or_none(room.get('show_status')) == 2:
raise UserNotLive(video_id=video_id)
# New pages omit the signing function; reuse upstream script fetching and format parsing.
sign_func = self._get_sign_func(room_id, video_id)
form_data = {'rate': 0, **self._calc_sign(sign_func, video_id, room_id)}
stream_formats = [self._download_json(
f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
note='Downloading livestream format', data=urlencode_postdata(form_data))]
for rate_id in traverse_obj(stream_formats[0], ('data', 'multirates', ..., 'rate')):
if rate_id != traverse_obj(stream_formats[0], ('data', 'rate')):
form_data['rate'] = rate_id
stream_formats.append(self._download_json(
f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
note=f'Downloading livestream format {rate_id}', data=urlencode_postdata(form_data)))
return {
'id': room_id,
'display_id': video_id,
'formats': self._extract_stream_formats(stream_formats),
'is_live': True,
**traverse_obj(room, {
'title': ('room_name', {str}, {unescapeHTML}),
'description': ('show_details', {str}),
'uploader': ('nickname', {str}),
'thumbnail': ('coverSrc', {url_or_none}),
}),
}