yt-dlp Douyu fix: PhantomJS not found and room ID errors
Published
Download fix plugin douyu_signing.py
If yt-dlp reports PhantomJS not found when downloading Douyu videos or Unable to extract room id when recording a livestream, load the compatibility plugin below. It fixes room ID extraction on the new livestream pages, runs request signing with Deno, and corrects stream URL joining.
To skip setting up Deno and a plugin directory locally, paste your Douyu link into the downloader on our homepage and use the online tool.
While investigating Douyu livestream and video-on-demand failures, we encountered three successive problems. The pages had moved their data to Next.js blocks, breaking the room ID regex. Once that was fixed, the upstream signing code still called the deprecated PhantomJS runtime directly. After signing worked and formats were listed, the assembled FLV stream URL was missing its /live/ directory. Start with the setup steps, then read the breakdown of each failure.
Symptoms and where they occur
| Error or symptom | Failing stage |
|---|---|
Unable to extract room id |
The new livestream page uses a Next.js data stream, so the old $ROOM.room_id match fails |
PhantomJS not found |
The upstream douyutv.py calls PhantomJSwrapper directly; installing Deno alone does not change this |
Formats are listed (-F works), but the download exits with an error |
The API's rtmp_url lacks a trailing slash, so urljoin drops the /live/ directory |
The channel is not currently live |
The room is offline; stopping extraction is expected |
The channel is auto-playing VODs |
The room is replaying VOD content (videoLoop: 1); the plugin stops extraction |
Quick fix steps
1. Prepare the tools and plugin file
Make sure yt-dlp, deno, and ffmpeg are available in your terminal. Our test environment used yt-dlp nightly 2026.10.29.232701, Deno 2.9.8, and FFmpeg 7.1.5. The plugin depends on yt-dlp's built-in _search_nextjs_v13_data method, so update yt-dlp first if your version is too old.
Create this directory structure in your working directory and save the full plugin code at the end as douyu_signing.py:
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
Run the following commands from the directory containing plugins. The --plugin-dirs ./plugins option loads the plugin without editing the source files in your yt-dlp installation.
2. Check the livestream format list
This example uses a public livestream room. Replace the URL with the room you want to test:
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
The [DouyuTV+DenoSigning] label confirms that the plugin is active. If the room is live, yt-dlp requests the getH5Play endpoint and prints the available quality formats. If it reports The channel is not currently live, try a room that is currently broadcasting.
3. Record 30 seconds to check the media stream
A format list only confirms that the signing request returned error: 0. You still need to read the media stream to verify that the CDN URL was assembled correctly:
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"
The -f worst option selects the lowest bitrate to keep the test file small. The -t 30 option limits recording to 30 seconds, and the command sets a 15-second network read/write timeout.
4. Test VOD and inspect the file (optional)
The same plugin also covers the Douyu VOD extractor, DouyuShowIE. When testing a VOD, look for [DouyuShow+DenoSigning] in the log:
# 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"
Use ffprobe and a full decoding check to inspect the recorded .flv or .mp4 for damaged frames or missing audio and video tracks:
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 -
The three failures explained
1. Next.js data blocks replace the room ID assignment
The upstream extractor originally read the room ID from a $ROOM.room_id assignment in the page. A related failure is reported in issue #16230. On the new Douyu livestream pages, including the alias pigff and the numeric room URL 593229, room metadata has moved into Next.js App Router script data blocks (__next_f.push).
A broad regex that searches the whole page for room_id can pick up a recommended room from the sidebar or footer. The plugin instead parses the data stream with _search_nextjs_v13_data and reads only the current room's roomInfo.room dictionary:
- It resolves the alias
pigffto the actual room ID,24422. - It checks the room's status before requesting a signature. If
show_status == 2, it reports that the room is offline, as with room593229in our test. Ifroom_biz_all.videoLoop == 1, it reports that the room is replaying videos, avoiding unnecessary signing work. - If the page has no Next.js data blocks, it falls back to the upstream
super()._real_extract(url)method for the older page structure.
2. The upstream code calls PhantomJSwrapper directly
After obtaining the real room_id, the extractor must call Douyu's ub98484234 signing function to calculate v, did, tt, and sign. Even if Deno, the runtime recommended by yt-dlp, is installed, _calc_sign in the upstream douyutv.py still instantiates PhantomJSwrapper directly. This causes PhantomJS not found for both livestreams and VODs.
The plugin overrides _calc_sign while retaining the upstream logic for fetching the signing script and the bundled CryptoJS MD5 code. It passes room_id for livestreams and the video ID read from the ROOM.point_id field in window.$DATA for VODs. Python safely encodes the arguments with json.dumps, then sends the script through standard input to a Deno subprocess with no permissions granted:
- The command explicitly uses
--no-prompt,--no-config, and--no-remote, without granting file, environment variable, or network access. Deno's permission model restricts JavaScript API access; it does not replace an operating-system sandbox. - The process has a 15-second timeout. The plugin checks that the returned query string contains a nonempty
signbefore submitting it to/lapi/live/getH5Play/{room_id}or the VOD endpoint. Both endpoints returnederror: 0in our tests.
3. urljoin drops the /live/ directory
After room ID extraction and signing were fixed, -F listed multiple quality formats. The first FFmpeg recording attempt still failed with ffmpeg exited with code 8.
The getH5Play JSON response splits the stream URL into a directory prefix, rtmp_url, and a filename, rtmp_live. In our test, rtmp_url ended in /live without a trailing slash. When the upstream code joined the parts with urllib.parse.urljoin, it treated live as the final filename and replaced it. Issue #11998 also reports CDN 403 errors caused by a missing /live/ path:
urljoin('https://cdn.example/live', '24422.flv?sign=example')
# Incorrect: https://cdn.example/24422.flv?sign=example
urljoin('https://cdn.example/live/', '24422.flv?sign=example')
# Correct: https://cdn.example/live/24422.flv?sign=example
The plugin normalizes rtmp_url with data['rtmp_url'].rstrip('/') + '/' in _extract_stream_formats before passing it to the upstream parser. The stream filename and signing query parameters stay intact. With the fixes in place, livestream recording and VOD fragment downloading both completed, and the resulting files passed decoding checks.
Full plugin code
Save this code as 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}),
}),
}