fix: update innertube clients and fix HLS/DASH quality switching
All checks were successful
CI / test (push) Successful in 53s
All checks were successful
CI / test (push) Successful in 53s
- Update innertube client versions to match yt-dlp (android 21.02.35, ios 21.02.3, web 2.20260114.08.00, android_vr 1.65.10) - Remove obsolete clients (android-test-suite, ios_vr) - Replace tv_embedded with TVHTML5_SIMPLY (cn 75) - Add new clients: web_embedded, mweb, tv - Fix HLS freeze on quality switch: use nextLevel instead of currentLevel, handle bufferStalledError, stream proxy segments instead of buffering in memory - Populate DASH quality selector with actual sources (no Auto) - Render quality-select empty in template, let JS populate per mode
This commit is contained in:
72
tests/test_watch_formats.py
Normal file
72
tests/test_watch_formats.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
from youtube import watch_formats
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodecName:
|
||||||
|
def test_avc_returns_h264(self):
|
||||||
|
assert watch_formats.codec_name('avc1.64001F') == 'h264'
|
||||||
|
|
||||||
|
def test_av01_returns_av1(self):
|
||||||
|
assert watch_formats.codec_name('av01.0.05M.08') == 'av1'
|
||||||
|
|
||||||
|
def test_vp9_returns_vp(self):
|
||||||
|
assert watch_formats.codec_name('vp9') == 'vp'
|
||||||
|
|
||||||
|
def test_unknown_returns_unknown(self):
|
||||||
|
assert watch_formats.codec_name('unknown_codec') == 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoQualityString:
|
||||||
|
def test_with_vcodec(self):
|
||||||
|
fmt = {'vcodec': 'avc1', 'width': 1920, 'height': 1080, 'fps': 30}
|
||||||
|
assert watch_formats.video_quality_string(fmt) == '1920x1080 30fps'
|
||||||
|
|
||||||
|
def test_with_vcodec_no_fps(self):
|
||||||
|
fmt = {'vcodec': 'avc1', 'width': 1280, 'height': 720}
|
||||||
|
assert watch_formats.video_quality_string(fmt) == '1280x720'
|
||||||
|
|
||||||
|
def test_with_acodec_only(self):
|
||||||
|
fmt = {'acodec': 'mp4a.40.2'}
|
||||||
|
assert watch_formats.video_quality_string(fmt) == 'audio only'
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
fmt = {}
|
||||||
|
assert watch_formats.video_quality_string(fmt) == '?'
|
||||||
|
|
||||||
|
|
||||||
|
class TestShortVideoQualityString:
|
||||||
|
def test_with_fps(self):
|
||||||
|
fmt = {'quality': 1080, 'fps': 60, 'vcodec': 'av01.0.05M.08'}
|
||||||
|
assert watch_formats.short_video_quality_string(fmt) == '1080p60 AV1'
|
||||||
|
|
||||||
|
def test_h264(self):
|
||||||
|
fmt = {'quality': 720, 'fps': 30, 'vcodec': 'avc1.64001E'}
|
||||||
|
assert watch_formats.short_video_quality_string(fmt) == '720p30 h264'
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioQualityString:
|
||||||
|
def test_with_bitrate(self):
|
||||||
|
fmt = {'acodec': 'mp4a.40.2', 'audio_bitrate': 128}
|
||||||
|
assert watch_formats.audio_quality_string(fmt) == '128k'
|
||||||
|
|
||||||
|
def test_with_sample_rate(self):
|
||||||
|
fmt = {'acodec': 'mp4a.40.2', 'audio_bitrate': 128, 'audio_sample_rate': 44100}
|
||||||
|
assert watch_formats.audio_quality_string(fmt) == '128k 44.1kHz'
|
||||||
|
|
||||||
|
def test_video_only(self):
|
||||||
|
fmt = {'vcodec': 'avc1'}
|
||||||
|
assert watch_formats.audio_quality_string(fmt) == 'video only'
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatBytes:
|
||||||
|
def test_none(self):
|
||||||
|
assert watch_formats.format_bytes(None) == 'N/A'
|
||||||
|
|
||||||
|
def test_bytes(self):
|
||||||
|
assert watch_formats.format_bytes(512) == '512.00B'
|
||||||
|
|
||||||
|
def test_kibibytes(self):
|
||||||
|
assert watch_formats.format_bytes(1024) == '1.00KiB'
|
||||||
|
|
||||||
|
def test_mebibytes(self):
|
||||||
|
assert watch_formats.format_bytes(1048576) == '1.00MiB'
|
||||||
190
youtube/constants.py
Normal file
190
youtube/constants.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Constants used across yt-local application."""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
|
||||||
|
YOUTUBE_DOMAINS = ('youtube.com', 'youtu.be', 'youtube-nocookie.com')
|
||||||
|
|
||||||
|
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)'
|
||||||
|
MOBILE_USER_AGENT = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36'
|
||||||
|
|
||||||
|
REPLACEMENT_MAP = collections.OrderedDict([
|
||||||
|
('<', '_'),
|
||||||
|
('>', '_'),
|
||||||
|
(': ', ' - '),
|
||||||
|
(':', '-'),
|
||||||
|
('"', "'"),
|
||||||
|
('/', '_'),
|
||||||
|
('\\', '_'),
|
||||||
|
('|', '-'),
|
||||||
|
('?', ''),
|
||||||
|
('*', '_'),
|
||||||
|
('\t', ' '),
|
||||||
|
])
|
||||||
|
|
||||||
|
DOS_RESERVED_NAMES = frozenset({
|
||||||
|
'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3',
|
||||||
|
'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt0',
|
||||||
|
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7',
|
||||||
|
'lpt8', 'lpt9'
|
||||||
|
})
|
||||||
|
|
||||||
|
INNERTUBE_CLIENTS = {
|
||||||
|
'android': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'ANDROID',
|
||||||
|
'clientVersion': '21.02.35',
|
||||||
|
'osName': 'Android',
|
||||||
|
'osVersion': '11',
|
||||||
|
'androidSdkVersion': 30,
|
||||||
|
'platform': 'MOBILE',
|
||||||
|
'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
|
||||||
|
'REQUIRE_JS_PLAYER': False,
|
||||||
|
},
|
||||||
|
|
||||||
|
'ios': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'IOS',
|
||||||
|
'clientVersion': '21.02.3',
|
||||||
|
'deviceMake': 'Apple',
|
||||||
|
'deviceModel': 'iPhone16,2',
|
||||||
|
'osName': 'iPhone',
|
||||||
|
'osVersion': '18.3.2.22D82',
|
||||||
|
'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
|
||||||
|
'REQUIRE_JS_PLAYER': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'tv_embedded': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'TVHTML5_SIMPLY',
|
||||||
|
'clientVersion': '1.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 75,
|
||||||
|
'REQUIRE_JS_PLAYER': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'web': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'clientName': 'WEB',
|
||||||
|
'clientVersion': '2.20260114.08.00',
|
||||||
|
'userAgent': DEFAULT_USER_AGENT,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 1
|
||||||
|
},
|
||||||
|
|
||||||
|
'web_embedded': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'WEB_EMBEDDED_PLAYER',
|
||||||
|
'clientVersion': '1.20260115.01.00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 56,
|
||||||
|
'REQUIRE_JS_PLAYER': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'mweb': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'MWEB',
|
||||||
|
'clientVersion': '2.20260115.01.00',
|
||||||
|
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 2,
|
||||||
|
'REQUIRE_JS_PLAYER': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'tv': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'hl': 'en',
|
||||||
|
'gl': 'US',
|
||||||
|
'clientName': 'TVHTML5',
|
||||||
|
'clientVersion': '7.20260114.12.00',
|
||||||
|
'userAgent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/25.lts.30.1034943-gold (unlike Gecko), Unknown_TV_Unknown_0/Unknown (Unknown, Unknown)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
|
||||||
|
'REQUIRE_JS_PLAYER': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'android_vr': {
|
||||||
|
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
||||||
|
'INNERTUBE_CONTEXT': {
|
||||||
|
'client': {
|
||||||
|
'clientName': 'ANDROID_VR',
|
||||||
|
'clientVersion': '1.65.10',
|
||||||
|
'deviceMake': 'Oculus',
|
||||||
|
'deviceModel': 'Quest 3',
|
||||||
|
'androidSdkVersion': 32,
|
||||||
|
'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
|
||||||
|
'osName': 'Android',
|
||||||
|
'osVersion': '12L',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
|
||||||
|
'REQUIRE_JS_PLAYER': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
THEME_NAMES = {
|
||||||
|
0: 'light_theme',
|
||||||
|
1: 'gray_theme',
|
||||||
|
2: 'dark_theme',
|
||||||
|
}
|
||||||
|
|
||||||
|
FONT_CHOICES = {
|
||||||
|
0: 'initial',
|
||||||
|
1: '"liberation serif", "times new roman", calibri, carlito, serif',
|
||||||
|
2: 'arial, "liberation sans", sans-serif',
|
||||||
|
3: 'verdana, sans-serif',
|
||||||
|
4: 'tahoma, sans-serif',
|
||||||
|
}
|
||||||
|
|
||||||
|
URL_ORIGIN = "/https://www.youtube.com"
|
||||||
|
|
||||||
|
MAX_RETRIES = 5
|
||||||
|
BASE_DELAY = 1.0
|
||||||
|
|
||||||
|
TOR_DEFAULT_PORT = 9050
|
||||||
|
TOR_CONTROL_DEFAULT_PORT = 9151
|
||||||
|
|
||||||
|
DEFAULT_PORT = 9010
|
||||||
|
|
||||||
|
# Backward compatibility aliases (matching existing code names)
|
||||||
|
desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0'
|
||||||
|
desktop_ua = (('User-Agent', desktop_user_agent),)
|
||||||
|
mobile_ua = (('User-Agent', MOBILE_USER_AGENT),)
|
||||||
|
json_header = (('Content-Type', 'application/json'),)
|
||||||
|
|
||||||
|
# Re-export for convenience
|
||||||
|
url_origin = URL_ORIGIN
|
||||||
@@ -31,9 +31,34 @@ if (data.using_pair_sources) {
|
|||||||
avMerge = new AVMerge(video, srcPair, 0);
|
avMerge = new AVMerge(video, srcPair, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quality selector
|
// Quality selector — populate with available sources
|
||||||
const qs = document.getElementById('quality-select');
|
const qs = document.getElementById('quality-select');
|
||||||
if (qs) {
|
if (qs) {
|
||||||
|
// Clear the HLS-oriented "Auto" default; DASH has discrete sources
|
||||||
|
qs.innerHTML = '';
|
||||||
|
|
||||||
|
// Add pair_sources (video+audio, used by AVMerge)
|
||||||
|
if (data['pair_sources'] && data['pair_sources'].length) {
|
||||||
|
data['pair_sources'].forEach(function(src, i) {
|
||||||
|
let opt = document.createElement('option');
|
||||||
|
opt.value = JSON.stringify({type: 'pair', index: i});
|
||||||
|
opt.textContent = src.quality_string;
|
||||||
|
if (i === data['pair_idx']) opt.selected = true;
|
||||||
|
qs.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uni_sources (integrated video+audio, single file)
|
||||||
|
if (data['uni_sources'] && data['uni_sources'].length) {
|
||||||
|
data['uni_sources'].forEach(function(src, i) {
|
||||||
|
let opt = document.createElement('option');
|
||||||
|
opt.value = JSON.stringify({type: 'uni', index: i});
|
||||||
|
opt.textContent = src.quality_string;
|
||||||
|
if (!data['pair_sources'].length && i === data['uni_idx']) opt.selected = true;
|
||||||
|
qs.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
qs.addEventListener('change', function(e) {
|
qs.addEventListener('change', function(e) {
|
||||||
changeQuality(JSON.parse(this.value))
|
changeQuality(JSON.parse(this.value))
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,17 @@ function initHLSNative(manifestUrl) {
|
|||||||
lowLatencyMode: false,
|
lowLatencyMode: false,
|
||||||
maxBufferLength: 30,
|
maxBufferLength: 30,
|
||||||
maxMaxBufferLength: 60,
|
maxMaxBufferLength: 60,
|
||||||
|
maxBufferHole: 0.5,
|
||||||
startLevel: -1,
|
startLevel: -1,
|
||||||
|
// Prevent stalls on quality switch: nudge playback past small gaps
|
||||||
|
nudgeMaxRetry: 5,
|
||||||
|
// Allow more time for segments coming through our proxy
|
||||||
|
fragLoadingTimeOut: 30000,
|
||||||
|
fragLoadingMaxRetry: 5,
|
||||||
|
fragLoadingRetryDelay: 1000,
|
||||||
|
levelLoadingTimeOut: 15000,
|
||||||
|
levelLoadingMaxRetry: 4,
|
||||||
|
levelLoadingRetryDelay: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.hls = hls;
|
window.hls = hls;
|
||||||
@@ -89,15 +99,26 @@ function initHLSNative(manifestUrl) {
|
|||||||
console.error('HLS fatal error:', data.type, data.details);
|
console.error('HLS fatal error:', data.type, data.details);
|
||||||
switch(data.type) {
|
switch(data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.warn('HLS network error, attempting recovery...');
|
||||||
hls.startLoad();
|
hls.startLoad();
|
||||||
break;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.warn('HLS media error, attempting recovery...');
|
||||||
hls.recoverMediaError();
|
hls.recoverMediaError();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Non-fatal errors can still cause stalls, especially
|
||||||
|
// bufferStalledError after a quality switch through our proxy
|
||||||
|
console.warn('HLS non-fatal error:', data.type, data.details);
|
||||||
|
if (data.details === 'bufferStalledError') {
|
||||||
|
// Buffer ran dry — HLS.js is waiting for data.
|
||||||
|
// Nudge it to retry loading the current fragment.
|
||||||
|
hls.startLoad();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,13 +143,36 @@ function initPlayer() {
|
|||||||
initHLSNative(hls_manifest_url);
|
initHLSNative(hls_manifest_url);
|
||||||
|
|
||||||
const qualitySelect = document.getElementById('quality-select');
|
const qualitySelect = document.getElementById('quality-select');
|
||||||
|
// Set initial Auto option while manifest loads
|
||||||
|
if (qualitySelect) {
|
||||||
|
qualitySelect.innerHTML = '<option value="-1" selected>Auto</option>';
|
||||||
|
}
|
||||||
if (qualitySelect) {
|
if (qualitySelect) {
|
||||||
qualitySelect.addEventListener('change', function () {
|
qualitySelect.addEventListener('change', function () {
|
||||||
const level = parseInt(this.value);
|
const level = parseInt(this.value);
|
||||||
|
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.currentLevel = level;
|
const currentTime = video.currentTime;
|
||||||
console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p');
|
const wasPaused = video.paused;
|
||||||
|
|
||||||
|
// Use nextLevel for smoother transition: it waits for the
|
||||||
|
// current segment to finish before switching, avoiding an
|
||||||
|
// abrupt buffer flush that starves the player.
|
||||||
|
if (level === -1) {
|
||||||
|
// Back to auto — re-enable ABR
|
||||||
|
hls.currentLevel = -1;
|
||||||
|
console.log('Quality: Auto (ABR)');
|
||||||
|
} else {
|
||||||
|
hls.nextLevel = level;
|
||||||
|
console.log('Quality: switching to',
|
||||||
|
hls.levels[level]?.height + 'p');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the video was already stalled, kick the loader
|
||||||
|
// so it starts fetching the new level immediately.
|
||||||
|
if (video.readyState < 3) {
|
||||||
|
hls.startLoad(currentTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,14 +75,11 @@
|
|||||||
<div class="external-player-controls">
|
<div class="external-player-controls">
|
||||||
<input class="speed" id="speed-control" type="text" title="Video speed">
|
<input class="speed" id="speed-control" type="text" title="Video speed">
|
||||||
{% if settings.use_video_player < 2 %}
|
{% if settings.use_video_player < 2 %}
|
||||||
<!-- Native player quality selector -->
|
<!-- Quality selector (populated by JS: HLS adds Auto+levels, DASH adds discrete sources) -->
|
||||||
<select id="quality-select" autocomplete="off">
|
<select id="quality-select" autocomplete="off">
|
||||||
<option value="-1" selected>Auto</option>
|
|
||||||
<!-- Quality options will be populated by HLS -->
|
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<select id="quality-select" autocomplete="off" style="display: none;">
|
<select id="quality-select" autocomplete="off" style="display: none;">
|
||||||
<!-- Quality options will be populated by HLS -->
|
|
||||||
</select>
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if settings.use_video_player != 2 %}
|
{% if settings.use_video_player != 2 %}
|
||||||
|
|||||||
183
youtube/util.py
183
youtube/util.py
@@ -23,6 +23,9 @@ import stem
|
|||||||
import stem.control
|
import stem.control
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from youtube.yt_data_extract.common import concat_or_none
|
||||||
|
from youtube import constants
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# The trouble with the requests library: It ships its own certificate bundle via certifi
|
# The trouble with the requests library: It ships its own certificate bundle via certifi
|
||||||
@@ -468,11 +471,11 @@ def head(url, use_tor=False, report_text=None, max_redirects=10):
|
|||||||
print(f'{report_text} Latency: {round(time.monotonic() - start_time, 3)}')
|
print(f'{report_text} Latency: {round(time.monotonic() - start_time, 3)}')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
mobile_user_agent = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36'
|
mobile_user_agent = constants.MOBILE_USER_AGENT
|
||||||
mobile_ua = (('User-Agent', mobile_user_agent),)
|
mobile_ua = constants.mobile_ua
|
||||||
desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0'
|
desktop_user_agent = constants.desktop_user_agent
|
||||||
desktop_ua = (('User-Agent', desktop_user_agent),)
|
desktop_ua = constants.desktop_ua
|
||||||
json_header = (('Content-Type', 'application/json'),)
|
json_header = constants.json_header
|
||||||
desktop_xhr_headers = (
|
desktop_xhr_headers = (
|
||||||
('Accept', '*/*'),
|
('Accept', '*/*'),
|
||||||
('Accept-Language', 'en-US,en;q=0.5'),
|
('Accept-Language', 'en-US,en;q=0.5'),
|
||||||
@@ -641,7 +644,7 @@ def update_query_string(query_string, items):
|
|||||||
return urllib.parse.urlencode(parameters, doseq=True)
|
return urllib.parse.urlencode(parameters, doseq=True)
|
||||||
|
|
||||||
|
|
||||||
YOUTUBE_DOMAINS = ('youtube.com', 'youtu.be', 'youtube-nocookie.com')
|
YOUTUBE_DOMAINS = constants.YOUTUBE_DOMAINS
|
||||||
YOUTUBE_URL_RE_STR = r'https?://(?:[a-zA-Z0-9_-]*\.)?(?:'
|
YOUTUBE_URL_RE_STR = r'https?://(?:[a-zA-Z0-9_-]*\.)?(?:'
|
||||||
YOUTUBE_URL_RE_STR += r'|'.join(map(re.escape, YOUTUBE_DOMAINS))
|
YOUTUBE_URL_RE_STR += r'|'.join(map(re.escape, YOUTUBE_DOMAINS))
|
||||||
YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?'
|
YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?'
|
||||||
@@ -668,16 +671,6 @@ def left_remove(string, substring):
|
|||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
def concat_or_none(*strings):
|
|
||||||
'''Concatenates strings. Returns None if any of the arguments are None'''
|
|
||||||
result = ''
|
|
||||||
for string in strings:
|
|
||||||
if string is None:
|
|
||||||
return None
|
|
||||||
result += string
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def prefix_urls(item):
|
def prefix_urls(item):
|
||||||
if settings.proxy_images:
|
if settings.proxy_images:
|
||||||
try:
|
try:
|
||||||
@@ -726,24 +719,8 @@ def check_gevent_exceptions(*tasks):
|
|||||||
|
|
||||||
|
|
||||||
# https://stackoverflow.com/a/62888
|
# https://stackoverflow.com/a/62888
|
||||||
replacement_map = collections.OrderedDict([
|
replacement_map = constants.REPLACEMENT_MAP
|
||||||
('<', '_'),
|
DOS_names = constants.DOS_RESERVED_NAMES
|
||||||
('>', '_'),
|
|
||||||
(': ', ' - '),
|
|
||||||
(':', '-'),
|
|
||||||
('"', "'"),
|
|
||||||
('/', '_'),
|
|
||||||
('\\', '_'),
|
|
||||||
('|', '-'),
|
|
||||||
('?', ''),
|
|
||||||
('*', '_'),
|
|
||||||
('\t', ' '),
|
|
||||||
])
|
|
||||||
|
|
||||||
DOS_names = {'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3',
|
|
||||||
'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt0',
|
|
||||||
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7',
|
|
||||||
'lpt8', 'lpt9'}
|
|
||||||
|
|
||||||
|
|
||||||
def to_valid_filename(name):
|
def to_valid_filename(name):
|
||||||
@@ -785,143 +762,7 @@ def to_valid_filename(name):
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L72
|
INNERTUBE_CLIENTS = constants.INNERTUBE_CLIENTS
|
||||||
INNERTUBE_CLIENTS = {
|
|
||||||
'android': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'hl': 'en',
|
|
||||||
'gl': 'US',
|
|
||||||
'clientName': 'ANDROID',
|
|
||||||
'clientVersion': '19.09.36',
|
|
||||||
'osName': 'Android',
|
|
||||||
'osVersion': '12',
|
|
||||||
'androidSdkVersion': 31,
|
|
||||||
'platform': 'MOBILE',
|
|
||||||
'userAgent': 'com.google.android.youtube/19.09.36 (Linux; U; Android 12; US) gzip'
|
|
||||||
},
|
|
||||||
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
|
|
||||||
#'thirdParty': {
|
|
||||||
# 'embedUrl': 'https://google.com', # Can be any valid URL
|
|
||||||
#}
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
|
|
||||||
'REQUIRE_JS_PLAYER': False,
|
|
||||||
},
|
|
||||||
|
|
||||||
'android-test-suite': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'hl': 'en',
|
|
||||||
'gl': 'US',
|
|
||||||
'clientName': 'ANDROID_TESTSUITE',
|
|
||||||
'clientVersion': '1.9',
|
|
||||||
'osName': 'Android',
|
|
||||||
'osVersion': '12',
|
|
||||||
'androidSdkVersion': 31,
|
|
||||||
'platform': 'MOBILE',
|
|
||||||
'userAgent': 'com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip'
|
|
||||||
},
|
|
||||||
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
|
|
||||||
#'thirdParty': {
|
|
||||||
# 'embedUrl': 'https://google.com', # Can be any valid URL
|
|
||||||
#}
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
|
|
||||||
'REQUIRE_JS_PLAYER': False,
|
|
||||||
},
|
|
||||||
|
|
||||||
'ios': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'hl': 'en',
|
|
||||||
'gl': 'US',
|
|
||||||
'clientName': 'IOS',
|
|
||||||
'clientVersion': '21.03.2',
|
|
||||||
'deviceMake': 'Apple',
|
|
||||||
'deviceModel': 'iPhone16,2',
|
|
||||||
'osName': 'iPhone',
|
|
||||||
'osVersion': '18.7.2.22H124',
|
|
||||||
'userAgent': 'com.google.ios.youtube/21.03.2 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
|
|
||||||
'REQUIRE_JS_PLAYER': False
|
|
||||||
},
|
|
||||||
|
|
||||||
# This client can access age restricted videos (unless the uploader has disabled the 'allow embedding' option)
|
|
||||||
# See: https://github.com/zerodytrash/YouTube-Internal-Clients
|
|
||||||
'tv_embedded': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'hl': 'en',
|
|
||||||
'gl': 'US',
|
|
||||||
'clientName': 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
|
|
||||||
'clientVersion': '2.0',
|
|
||||||
'clientScreen': 'EMBED',
|
|
||||||
},
|
|
||||||
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
|
|
||||||
'thirdParty': {
|
|
||||||
'embedUrl': 'https://google.com', # Can be any valid URL
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 85,
|
|
||||||
'REQUIRE_JS_PLAYER': True,
|
|
||||||
},
|
|
||||||
|
|
||||||
'web': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'clientName': 'WEB',
|
|
||||||
'clientVersion': '2.20220801.00.00',
|
|
||||||
'userAgent': desktop_user_agent,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 1
|
|
||||||
},
|
|
||||||
'android_vr': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'clientName': 'ANDROID_VR',
|
|
||||||
'clientVersion': '1.60.19',
|
|
||||||
'deviceMake': 'Oculus',
|
|
||||||
'deviceModel': 'Quest 3',
|
|
||||||
'androidSdkVersion': 32,
|
|
||||||
'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
|
|
||||||
'osName': 'Android',
|
|
||||||
'osVersion': '12L',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
|
|
||||||
'REQUIRE_JS_PLAYER': False,
|
|
||||||
},
|
|
||||||
|
|
||||||
'ios_vr': {
|
|
||||||
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
|
||||||
'INNERTUBE_CONTEXT': {
|
|
||||||
'client': {
|
|
||||||
'hl': 'en',
|
|
||||||
'gl': 'US',
|
|
||||||
'clientName': 'IOS_VR',
|
|
||||||
'clientVersion': '1.0',
|
|
||||||
'deviceMake': 'Apple',
|
|
||||||
'deviceModel': 'iPhone16,2',
|
|
||||||
'osName': 'iPhone',
|
|
||||||
'osVersion': '18.7.2.22H124',
|
|
||||||
'userAgent': 'com.google.ios.youtube/1.0 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
|
|
||||||
'REQUIRE_JS_PLAYER': False
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_visitor_data():
|
def get_visitor_data():
|
||||||
visitor_data = None
|
visitor_data = None
|
||||||
|
|||||||
103
youtube/watch.py
103
youtube/watch.py
@@ -17,8 +17,16 @@ from flask import request
|
|||||||
import youtube
|
import youtube
|
||||||
from youtube import yt_app
|
from youtube import yt_app
|
||||||
from youtube import util, comments, local_playlist, yt_data_extract
|
from youtube import util, comments, local_playlist, yt_data_extract
|
||||||
|
from youtube import watch_formats
|
||||||
import settings
|
import settings
|
||||||
|
|
||||||
|
# Backward compatibility aliases
|
||||||
|
codec_name = watch_formats.codec_name
|
||||||
|
video_quality_string = watch_formats.video_quality_string
|
||||||
|
short_video_quality_string = watch_formats.short_video_quality_string
|
||||||
|
audio_quality_string = watch_formats.audio_quality_string
|
||||||
|
format_bytes = watch_formats.format_bytes
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,15 +37,7 @@ except FileNotFoundError:
|
|||||||
decrypt_cache = {}
|
decrypt_cache = {}
|
||||||
|
|
||||||
|
|
||||||
def codec_name(vcodec):
|
# codec_name imported from watch_formats
|
||||||
if vcodec.startswith('avc'):
|
|
||||||
return 'h264'
|
|
||||||
elif vcodec.startswith('av01'):
|
|
||||||
return 'av1'
|
|
||||||
elif vcodec.startswith('vp'):
|
|
||||||
return 'vp'
|
|
||||||
else:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def get_video_sources(info, target_resolution):
|
def get_video_sources(info, target_resolution):
|
||||||
@@ -446,7 +446,7 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
|
|||||||
info['hls_audio_tracks'] = {}
|
info['hls_audio_tracks'] = {}
|
||||||
hls_data = None
|
hls_data = None
|
||||||
hls_client_used = None
|
hls_client_used = None
|
||||||
for hls_client in ('ios', 'ios_vr', 'android'):
|
for hls_client in ('ios', 'android'):
|
||||||
try:
|
try:
|
||||||
resp = fetch_player_response(hls_client, video_id) or {}
|
resp = fetch_player_response(hls_client, video_id) or {}
|
||||||
hls_data = json.loads(resp) if isinstance(resp, str) else resp
|
hls_data = json.loads(resp) if isinstance(resp, str) else resp
|
||||||
@@ -621,55 +621,10 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
def video_quality_string(format):
|
# video_quality_string imported from watch_formats
|
||||||
if format['vcodec']:
|
# short_video_quality_string imported from watch_formats
|
||||||
result = f"{format['width'] or '?'}x{format['height'] or '?'}"
|
# audio_quality_string imported from watch_formats
|
||||||
if format['fps']:
|
# format_bytes imported from watch_formats
|
||||||
result += f" {format['fps']}fps"
|
|
||||||
return result
|
|
||||||
elif format['acodec']:
|
|
||||||
return 'audio only'
|
|
||||||
|
|
||||||
return '?'
|
|
||||||
|
|
||||||
|
|
||||||
def short_video_quality_string(fmt):
|
|
||||||
result = f"{fmt['quality'] or '?'}p"
|
|
||||||
if fmt['fps']:
|
|
||||||
result += str(fmt['fps'])
|
|
||||||
if fmt['vcodec'].startswith('av01'):
|
|
||||||
result += ' AV1'
|
|
||||||
elif fmt['vcodec'].startswith('avc'):
|
|
||||||
result += ' h264'
|
|
||||||
else:
|
|
||||||
result += f" {fmt['vcodec']}"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def audio_quality_string(fmt):
|
|
||||||
if fmt['acodec']:
|
|
||||||
if fmt['audio_bitrate']:
|
|
||||||
result = f"{fmt['audio_bitrate']}k"
|
|
||||||
else:
|
|
||||||
result = '?k'
|
|
||||||
if fmt['audio_sample_rate']:
|
|
||||||
result += f" {'%.3G' % (fmt['audio_sample_rate']/1000)}kHz"
|
|
||||||
return result
|
|
||||||
elif fmt['vcodec']:
|
|
||||||
return 'video only'
|
|
||||||
return '?'
|
|
||||||
|
|
||||||
|
|
||||||
# from https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/utils.py
|
|
||||||
def format_bytes(bytes):
|
|
||||||
if bytes is None:
|
|
||||||
return 'N/A'
|
|
||||||
if type(bytes) is str:
|
|
||||||
bytes = float(bytes)
|
|
||||||
if bytes == 0.0:
|
|
||||||
exponent = 0
|
|
||||||
else:
|
|
||||||
exponent = int(math.log(bytes, 1024.0))
|
|
||||||
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
|
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
|
||||||
converted = float(bytes) / float(1024 ** exponent)
|
converted = float(bytes) / float(1024 ** exponent)
|
||||||
return '%.2f%s' % (converted, suffix)
|
return '%.2f%s' % (converted, suffix)
|
||||||
@@ -832,14 +787,12 @@ def get_audio_track():
|
|||||||
|
|
||||||
# This is an actual segment - fetch and serve it
|
# This is an actual segment - fetch and serve it
|
||||||
try:
|
try:
|
||||||
headers = (
|
headers_dict = {
|
||||||
('User-Agent', 'Mozilla/5.0'),
|
'User-Agent': 'Mozilla/5.0',
|
||||||
('Accept', '*/*'),
|
'Accept': '*/*',
|
||||||
)
|
}
|
||||||
content = util.fetch_url(seg_url, headers=headers,
|
|
||||||
debug_name='hls_seg', report_text=None)
|
|
||||||
|
|
||||||
# Determine content type based on URL or content
|
# Determine content type based on URL
|
||||||
# HLS segments are usually MPEG-TS (.ts) but can be MP4 (.mp4, .m4s)
|
# HLS segments are usually MPEG-TS (.ts) but can be MP4 (.mp4, .m4s)
|
||||||
if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'):
|
if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'):
|
||||||
content_type = 'video/mp4'
|
content_type = 'video/mp4'
|
||||||
@@ -849,7 +802,23 @@ def get_audio_track():
|
|||||||
# Default to MPEG-TS for HLS
|
# Default to MPEG-TS for HLS
|
||||||
content_type = 'video/mp2t'
|
content_type = 'video/mp2t'
|
||||||
|
|
||||||
return flask.Response(content, mimetype=content_type,
|
response, cleanup_func = util.fetch_url_response(
|
||||||
|
seg_url, headers=tuple(headers_dict.items()),
|
||||||
|
timeout=30, use_tor=settings.route_tor)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = response.read(64 * 1024) # 64 KB chunks
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
cleanup_func(response)
|
||||||
|
|
||||||
|
return flask.Response(
|
||||||
|
flask.stream_with_context(generate()),
|
||||||
|
mimetype=content_type,
|
||||||
headers={
|
headers={
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
|||||||
82
youtube/watch_formats.py
Normal file
82
youtube/watch_formats.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Video format helpers for yt-local."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def codec_name(vcodec: str) -> str:
|
||||||
|
"""Extract codec short name from codec string."""
|
||||||
|
if vcodec.startswith('avc'):
|
||||||
|
return 'h264'
|
||||||
|
elif vcodec.startswith('av01'):
|
||||||
|
return 'av1'
|
||||||
|
elif vcodec.startswith('vp'):
|
||||||
|
return 'vp'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def video_quality_string(fmt: Dict[str, Any]) -> str:
|
||||||
|
"""Return video quality string (e.g., '1920x1080 30fps')."""
|
||||||
|
if fmt.get('vcodec'):
|
||||||
|
result = f"{fmt.get('width') or '?'}x{fmt.get('height') or '?'}"
|
||||||
|
if fmt.get('fps'):
|
||||||
|
result += f" {fmt['fps']}fps"
|
||||||
|
return result
|
||||||
|
elif fmt.get('acodec'):
|
||||||
|
return 'audio only'
|
||||||
|
return '?'
|
||||||
|
|
||||||
|
|
||||||
|
def short_video_quality_string(fmt: Dict[str, Any]) -> str:
|
||||||
|
"""Return short video quality string (e.g., '1080p60 AV1')."""
|
||||||
|
result = f"{fmt.get('quality') or '?'}p"
|
||||||
|
if fmt.get('fps'):
|
||||||
|
result += str(fmt['fps'])
|
||||||
|
vcodec = fmt.get('vcodec', '')
|
||||||
|
if vcodec.startswith('av01'):
|
||||||
|
result += ' AV1'
|
||||||
|
elif vcodec.startswith('avc'):
|
||||||
|
result += ' h264'
|
||||||
|
else:
|
||||||
|
result += f" {vcodec}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def audio_quality_string(fmt: Dict[str, Any]) -> str:
|
||||||
|
"""Return audio quality string (e.g., '128k 44.1kHz')."""
|
||||||
|
if fmt.get('acodec'):
|
||||||
|
if fmt.get('audio_bitrate'):
|
||||||
|
result = f"{fmt['audio_bitrate']}k"
|
||||||
|
else:
|
||||||
|
result = '?k'
|
||||||
|
if fmt.get('audio_sample_rate'):
|
||||||
|
result += f" {'%.3G' % (fmt['audio_sample_rate']/1000)}kHz"
|
||||||
|
return result
|
||||||
|
elif fmt.get('vcodec'):
|
||||||
|
return 'video only'
|
||||||
|
return '?'
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(bytes_val: Optional[float]) -> str:
|
||||||
|
"""Convert bytes to human-readable string (e.g., '1.5 MiB')."""
|
||||||
|
if bytes_val is None:
|
||||||
|
return 'N/A'
|
||||||
|
if type(bytes_val) is str:
|
||||||
|
bytes_val = float(bytes_val)
|
||||||
|
if bytes_val == 0.0:
|
||||||
|
exponent = 0
|
||||||
|
else:
|
||||||
|
exponent = int(math.log(bytes_val, 1024.0))
|
||||||
|
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
|
||||||
|
converted = float(bytes_val) / float(1024 ** exponent)
|
||||||
|
return '%.2f%s' % (converted, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'codec_name',
|
||||||
|
'video_quality_string',
|
||||||
|
'short_video_quality_string',
|
||||||
|
'audio_quality_string',
|
||||||
|
'format_bytes',
|
||||||
|
]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from .common import (get, multi_get, deep_get, multi_deep_get,
|
from .common import (get, multi_get, deep_get, multi_deep_get,
|
||||||
liberal_update, conservative_update, remove_redirect, normalize_url,
|
liberal_update, conservative_update, remove_redirect, normalize_url,
|
||||||
extract_str, extract_formatted_text, extract_int, extract_approx_int,
|
extract_str, extract_formatted_text, extract_int, extract_approx_int,
|
||||||
extract_date, extract_item_info, extract_items, extract_response)
|
extract_date, extract_item_info, extract_items, extract_response,
|
||||||
|
concat_or_none)
|
||||||
|
|
||||||
from .everything_else import (extract_channel_info, extract_search_info,
|
from .everything_else import (extract_channel_info, extract_search_info,
|
||||||
extract_playlist_metadata, extract_playlist_info, extract_comments_info)
|
extract_playlist_metadata, extract_playlist_info, extract_comments_info)
|
||||||
|
|||||||
Reference in New Issue
Block a user