ytdlp.online

All fixes

On this page

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 pigff to 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 room 593229 in our test. If room_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 sign before submitting it to /lapi/live/getH5Play/{room_id} or the VOD endpoint. Both endpoints returned error: 0 in 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}),
            }),
        }
yt-dlp Tools
View all yt-dlp tools
yt-dlp Help & Docs