Add HLS support to multi-audio

This commit is contained in:
2026-04-05 14:56:51 -05:00
parent 62a028968e
commit f0649be5de
19 changed files with 2256 additions and 164 deletions

View File

@@ -217,6 +217,12 @@ def site_dispatch(env, start_response):
start_response('302 Found', [('Location', '/https://youtube.com')]) start_response('302 Found', [('Location', '/https://youtube.com')])
return return
# Handle local API endpoints directly (e.g., /ytl-api/...)
if path.startswith('/ytl-api/'):
env['SERVER_NAME'] = 'youtube.com'
yield from yt_app(env, start_response)
return
try: try:
env['SERVER_NAME'], env['PATH_INFO'] = split_url(path[1:]) env['SERVER_NAME'], env['PATH_INFO'] = split_url(path[1:])
except ValueError: except ValueError:

View File

@@ -159,18 +159,32 @@ For security reasons, enabling this is not recommended.''',
}), }),
('default_resolution', { ('default_resolution', {
'type': int, 'type': str,
'default': 720, 'default': 'auto',
'comment': '', 'comment': '',
'options': [ 'options': [
(144, '144p'), ('auto', 'Auto'),
(240, '240p'), ('144', '144p'),
(360, '360p'), ('240', '240p'),
(480, '480p'), ('360', '360p'),
(720, '720p'), ('480', '480p'),
(1080, '1080p'), ('720', '720p'),
(1440, '1440p'), ('1080', '1080p'),
(2160, '2160p'), ('1440', '1440p'),
('2160', '2160p'),
],
'category': 'playback',
}),
('playback_mode', {
'type': str,
'default': 'auto',
'label': 'Playback mode',
'comment': 'HLS uses hls.js (multi-audio). DASH uses av-merge (single audio).',
'options': [
('auto', 'Auto (HLS preferred)'),
('hls', 'Force HLS'),
('dash', 'Force DASH'),
], ],
'category': 'playback', 'category': 'playback',
}), }),
@@ -217,7 +231,8 @@ For security reasons, enabling this is not recommended.''',
(2, 'Always'), (2, 'Always'),
], ],
'category': 'playback', 'category': 'playback',
'description': 'If set to Prefer or Always and the default resolution is set to 360p or 720p, uses the unified (integrated) video files which contain audio and video, with buffering managed by the browser. If set to prefer not, uses the separate audio and video files through custom buffer management in av-merge via MediaSource unless they are unavailable.', 'hidden': True,
'description': 'Deprecated: HLS is now used exclusively for all playback.',
}), }),
('use_video_player', { ('use_video_player', {
@@ -522,7 +537,7 @@ else:
globals().update(current_settings_dict) globals().update(current_settings_dict)
if route_tor: if globals().get('route_tor', False):
print("Tor routing is ON") print("Tor routing is ON")
else: else:
print("Tor routing is OFF - your YouTube activity is NOT anonymous") print("Tor routing is OFF - your YouTube activity is NOT anonymous")
@@ -542,7 +557,7 @@ def add_setting_changed_hook(setting, func):
def set_img_prefix(old_value=None, value=None): def set_img_prefix(old_value=None, value=None):
global img_prefix global img_prefix
if value is None: if value is None:
value = proxy_images value = globals().get('proxy_images', False)
if value: if value:
img_prefix = '/' img_prefix = '/'
else: else:

23
youtube/hls_cache.py Normal file
View File

@@ -0,0 +1,23 @@
"""Multi-audio track support via HLS streaming.
Instead of downloading all segments, we proxy the HLS playlist and
let the browser stream the audio directly. Zero local storage needed.
"""
_tracks = {} # cache_key -> {'hls_url': str, ...}
def register_track(cache_key, hls_playlist_url, content_length=0,
video_id=None, track_id=None):
print(f'[audio-track-cache] Registering track: {cache_key} -> {hls_playlist_url[:80]}...')
_tracks[cache_key] = {'hls_url': hls_playlist_url}
print(f'[audio-track-cache] Available tracks: {list(_tracks.keys())}')
def get_hls_url(cache_key):
entry = _tracks.get(cache_key)
if entry:
print(f'[audio-track-cache] Found track: {cache_key}')
else:
print(f'[audio-track-cache] Track not found: {cache_key}')
return entry['hls_url'] if entry else None

2
youtube/static/js/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,10 +13,12 @@
let qualityOptions = []; let qualityOptions = [];
let qualityDefault; let qualityDefault;
// Collect uni sources (integrated)
for (let src of data.uni_sources) { for (let src of data.uni_sources) {
qualityOptions.push(src.quality_string); qualityOptions.push(src.quality_string);
} }
// Collect pair sources (av-merge)
for (let src of data.pair_sources) { for (let src of data.pair_sources) {
qualityOptions.push(src.quality_string); qualityOptions.push(src.quality_string);
} }
@@ -29,6 +31,37 @@
qualityDefault = 'None'; qualityDefault = 'None';
} }
// Current av-merge instance
let avMerge = null;
// Change quality: handles both uni (integrated) and pair (av-merge)
function changeQuality(selection) {
let currentVideoTime = video.currentTime;
let videoPaused = video.paused;
let videoSpeed = video.playbackRate;
let srcInfo;
// Close previous av-merge if any
if (avMerge && typeof avMerge.close === 'function') {
avMerge.close();
}
if (selection.type == 'uni') {
srcInfo = data.uni_sources[selection.index];
video.src = srcInfo.url;
avMerge = null;
} else {
srcInfo = data.pair_sources[selection.index];
avMerge = new AVMerge(video, srcInfo, currentVideoTime);
}
video.currentTime = currentVideoTime;
if (!videoPaused) {
video.play();
}
video.playbackRate = videoSpeed;
}
// Fix plyr refusing to work with qualities that are strings // Fix plyr refusing to work with qualities that are strings
Object.defineProperty(Plyr.prototype, 'quality', { Object.defineProperty(Plyr.prototype, 'quality', {
set: function (input) { set: function (input) {
@@ -59,7 +92,6 @@
}); });
const playerOptions = { const playerOptions = {
// Learning about autoplay permission https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/autoplay#syntax
autoplay: autoplayActive, autoplay: autoplayActive,
disableContextMenu: false, disableContextMenu: false,
captions: { captions: {
@@ -92,6 +124,7 @@
if (quality == 'None') { if (quality == 'None') {
return; return;
} }
// Check if it's a uni source (integrated)
if (quality.includes('(integrated)')) { if (quality.includes('(integrated)')) {
for (let i = 0; i < data.uni_sources.length; i++) { for (let i = 0; i < data.uni_sources.length; i++) {
if (data.uni_sources[i].quality_string == quality) { if (data.uni_sources[i].quality_string == quality) {
@@ -100,6 +133,7 @@
} }
} }
} else { } else {
// It's a pair source (av-merge)
for (let i = 0; i < data.pair_sources.length; i++) { for (let i = 0; i < data.pair_sources.length; i++) {
if (data.pair_sources[i].quality_string == quality) { if (data.pair_sources[i].quality_string == quality) {
changeQuality({ type: 'pair', index: i }); changeQuality({ type: 'pair', index: i });
@@ -117,20 +151,30 @@
tooltips: { tooltips: {
controls: true, controls: true,
}, },
} };
const player = new Plyr(document.getElementById('js-video-player'), playerOptions); const video = document.getElementById('js-video-player');
const player = new Plyr(video, playerOptions);
// Hide audio track selector (DASH doesn't support multi-audio)
const audioContainer = document.getElementById('plyr-audio-container');
if (audioContainer) audioContainer.style.display = 'none';
// disable double click to fullscreen // disable double click to fullscreen
// https://github.com/sampotts/plyr/issues/1370#issuecomment-528966795
player.eventListeners.forEach(function(eventListener) { player.eventListeners.forEach(function(eventListener) {
if(eventListener.type === 'dblclick') { if(eventListener.type === 'dblclick') {
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options); eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
} }
}); });
// Add .started property, true after the playback has been started // Add .started property
// Needed so controls won't be hidden before playback has started
player.started = false; player.started = false;
player.once('playing', function(){this.started = true}); player.once('playing', function(){ this.started = true; });
// Set initial time
if (data.time_start != 0) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
})(); })();

View File

@@ -0,0 +1,536 @@
(function main() {
'use strict';
console.log('Plyr start script loaded');
// Captions
let captionsActive = false;
if (typeof data !== 'undefined' && (data.settings.subtitles_mode === 2 || (data.settings.subtitles_mode === 1 && data.has_manual_captions))) {
captionsActive = true;
}
// AutoPlay
let autoplayActive = typeof data !== 'undefined' && data.settings.autoplay_videos || false;
// Quality map: label -> hls level index
window.hlsQualityMap = {};
let plyrInstance = null;
let currentQuality = 'auto';
let hls = null;
window.hls = null;
/**
* Get start level from settings (highest quality <= target)
*/
function getStartLevel(levels) {
if (typeof data === 'undefined' || !data.settings) return -1;
const defaultRes = data.settings.default_resolution;
if (defaultRes === 'auto' || !defaultRes) return -1;
const target = parseInt(defaultRes);
// Find the level with the highest height that is still <= target
let bestLevel = -1;
let bestHeight = 0;
for (let i = 0; i < levels.length; i++) {
const h = levels[i].height;
if (h <= target && h > bestHeight) {
bestHeight = h;
bestLevel = i;
}
}
return bestLevel;
}
/**
* Initialize HLS
*/
function initHLS(manifestUrl) {
return new Promise((resolve, reject) => {
if (!manifestUrl) {
reject('No HLS manifest URL provided');
return;
}
console.log('Initializing HLS for Plyr:', manifestUrl);
if (hls) {
hls.destroy();
hls = null;
}
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
window.hls = hls;
const video = document.getElementById('js-video-player');
if (!video) {
reject('Video element not found');
return;
}
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
console.log('HLS manifest parsed, levels:', hls.levels?.length);
// Set initial quality from settings
const startLevel = getStartLevel(hls.levels);
if (startLevel !== -1) {
hls.currentLevel = startLevel;
const level = hls.levels[startLevel];
currentQuality = level.height + 'p';
console.log('Starting at resolution:', currentQuality);
}
resolve(hls);
});
hls.on(Hls.Events.ERROR, function(_, data) {
if (data.fatal) {
console.error('HLS fatal error:', data.type, data.details);
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
reject(data);
break;
}
}
});
});
}
/**
* Change HLS quality
*/
function changeHLSQuality(quality) {
if (!hls) {
console.error('HLS not available');
return;
}
console.log('Changing HLS quality to:', quality);
if (quality === 'auto') {
hls.currentLevel = -1;
currentQuality = 'auto';
console.log('HLS quality set to Auto');
const qualityBtnText = document.getElementById('plyr-quality-text');
if (qualityBtnText) {
qualityBtnText.textContent = 'Auto';
}
} else {
const levelIndex = window.hlsQualityMap[quality];
if (levelIndex !== undefined) {
hls.currentLevel = levelIndex;
currentQuality = quality;
console.log('HLS quality set to:', quality);
const qualityBtnText = document.getElementById('plyr-quality-text');
if (qualityBtnText) {
qualityBtnText.textContent = quality;
}
}
}
}
/**
* Create custom quality control in Plyr controls
*/
function addCustomQualityControl(player, qualityLabels) {
player.on('ready', () => {
console.log('Adding custom quality control...');
const controls = player.elements.container.querySelector('.plyr__controls');
if (!controls) {
console.error('Controls not found');
return;
}
if (document.getElementById('plyr-quality-container')) {
console.log('Quality control already exists');
return;
}
const qualityContainer = document.createElement('div');
qualityContainer.id = 'plyr-quality-container';
qualityContainer.className = 'plyr__control plyr__control--custom';
const qualityButton = document.createElement('button');
qualityButton.type = 'button';
qualityButton.className = 'plyr__control';
qualityButton.setAttribute('data-plyr', 'quality-custom');
qualityButton.setAttribute('aria-label', 'Quality');
qualityButton.innerHTML = `
<svg class="plyr__icon hls_quality_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect>
<line x1="8" y1="12" x2="16" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="16"></line>
</svg>
<span id="plyr-quality-text">${currentQuality === 'auto' ? 'Auto' : currentQuality}</span>
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
const dropdown = document.createElement('div');
dropdown.className = 'plyr-quality-dropdown';
qualityLabels.forEach(label => {
const option = document.createElement('div');
option.className = 'plyr-quality-option';
option.textContent = label === 'auto' ? 'Auto' : label;
if (label === currentQuality) {
option.setAttribute('data-active', 'true');
}
option.addEventListener('click', (e) => {
e.stopPropagation();
changeHLSQuality(label);
dropdown.querySelectorAll('.plyr-quality-option').forEach(opt => {
opt.removeAttribute('data-active');
});
option.setAttribute('data-active', 'true');
dropdown.style.display = 'none';
});
dropdown.appendChild(option);
});
qualityButton.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = dropdown.style.display === 'block';
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
d.style.display = 'none';
});
dropdown.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!qualityContainer.contains(e.target)) {
dropdown.style.display = 'none';
}
});
qualityContainer.appendChild(qualityButton);
qualityContainer.appendChild(dropdown);
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
if (settingsBtn) {
settingsBtn.insertAdjacentElement('beforebegin', qualityContainer);
} else {
controls.appendChild(qualityContainer);
}
console.log('Custom quality control added');
});
}
/**
* Create custom audio tracks control in Plyr controls
*/
function addCustomAudioTracksControl(player, hlsInstance) {
player.on('ready', () => {
console.log('Adding custom audio tracks control...');
const controls = player.elements.container.querySelector('.plyr__controls');
if (!controls) {
console.error('Controls not found');
return;
}
if (document.getElementById('plyr-audio-container')) {
console.log('Audio tracks control already exists');
return;
}
const audioContainer = document.createElement('div');
audioContainer.id = 'plyr-audio-container';
audioContainer.className = 'plyr__control plyr__control--custom';
const audioButton = document.createElement('button');
audioButton.type = 'button';
audioButton.className = 'plyr__control';
audioButton.setAttribute('data-plyr', 'audio-custom');
audioButton.setAttribute('aria-label', 'Audio Track');
audioButton.innerHTML = `
<svg class="plyr__icon hls_audio_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z"></path>
<path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>
</svg>
<span id="plyr-audio-text">Audio</span>
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
const audioDropdown = document.createElement('div');
audioDropdown.className = 'plyr-audio-dropdown';
function updateAudioDropdown() {
if (!hlsInstance || !hlsInstance.audioTracks) return;
audioDropdown.innerHTML = '';
if (hlsInstance.audioTracks.length === 0) {
const noTrackMsg = document.createElement('div');
noTrackMsg.className = 'plyr-audio-no-tracks';
noTrackMsg.textContent = 'No audio tracks';
audioDropdown.appendChild(noTrackMsg);
return;
}
hlsInstance.audioTracks.forEach((track, idx) => {
const option = document.createElement('div');
option.className = 'plyr-audio-option';
option.textContent = track.name || track.lang || `Track ${idx + 1}`;
if (hlsInstance.audioTrack === idx) {
option.setAttribute('data-active', 'true');
}
option.addEventListener('click', (e) => {
e.stopPropagation();
hlsInstance.audioTrack = idx;
console.log('Audio track changed to:', track.name || track.lang || idx);
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = track.name || track.lang || `Track ${idx + 1}`;
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
audioDropdown.querySelectorAll('.plyr-audio-option').forEach(opt => {
opt.removeAttribute('data-active');
});
option.setAttribute('data-active', 'true');
audioDropdown.style.display = 'none';
});
audioDropdown.appendChild(option);
});
}
audioButton.addEventListener('click', (e) => {
e.stopPropagation();
updateAudioDropdown();
const isVisible = audioDropdown.style.display === 'block';
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
d.style.display = 'none';
});
audioDropdown.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!audioContainer.contains(e.target)) {
audioDropdown.style.display = 'none';
}
});
audioContainer.appendChild(audioButton);
audioContainer.appendChild(audioDropdown);
const qualityContainer = document.getElementById('plyr-quality-container');
if (qualityContainer) {
qualityContainer.insertAdjacentElement('beforebegin', audioContainer);
} else {
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
if (settingsBtn) {
settingsBtn.insertAdjacentElement('beforebegin', audioContainer);
} else {
controls.appendChild(audioContainer);
}
}
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
// Prefer "original" audio track
const originalIdx = hlsInstance.audioTracks.findIndex(t =>
(t.name || '').toLowerCase().includes('original')
);
if (originalIdx !== -1) {
hlsInstance.audioTrack = originalIdx;
console.log('Selected original audio track:', hlsInstance.audioTracks[originalIdx].name);
}
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
if (currentTrack) {
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
}
}
hlsInstance.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
console.log('Audio tracks updated, count:', hlsInstance.audioTracks?.length);
if (hlsInstance.audioTracks?.length > 0) {
updateAudioDropdown();
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
if (currentTrack) {
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
}
}
});
console.log('Custom audio tracks control added');
});
}
/**
* Initialize Plyr with HLS quality options
*/
function initPlyrWithQuality(hlsInstance) {
const video = document.getElementById('js-video-player');
if (!hlsInstance || !hlsInstance.levels || hlsInstance.levels.length === 0) {
console.error('HLS not ready');
return;
}
if (!video) {
console.error('Video element not found');
return;
}
console.log('HLS levels available:', hlsInstance.levels.length);
const sortedLevels = [...hlsInstance.levels].sort((a, b) => b.height - a.height);
const seenHeights = new Set();
const uniqueLevels = [];
sortedLevels.forEach((level) => {
if (!seenHeights.has(level.height)) {
seenHeights.add(level.height);
uniqueLevels.push(level);
}
});
const qualityLabels = ['auto'];
uniqueLevels.forEach((level) => {
const originalIndex = hlsInstance.levels.indexOf(level);
const label = level.height + 'p';
if (!window.hlsQualityMap[label]) {
qualityLabels.push(label);
window.hlsQualityMap[label] = originalIndex;
}
});
console.log('Quality labels:', qualityLabels);
const playerOptions = {
autoplay: autoplayActive,
disableContextMenu: false,
captions: {
active: captionsActive,
language: typeof data !== 'undefined' ? data.settings.subtitles_language : 'en',
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen',
],
iconUrl: '/youtube.com/static/modules/plyr/plyr.svg',
blankVideo: '/youtube.com/static/modules/plyr/blank.webm',
debug: false,
storage: { enabled: false },
previewThumbnails: {
enabled: typeof storyboard_url !== 'undefined' && storyboard_url !== null,
src: typeof storyboard_url !== 'undefined' && storyboard_url !== null ? [storyboard_url] : [],
},
settings: ['captions', 'speed', 'loop'],
tooltips: {
controls: true,
},
};
console.log('Creating Plyr...');
try {
plyrInstance = new Plyr(video, playerOptions);
console.log('Plyr instance created');
window.plyrInstance = plyrInstance;
addCustomQualityControl(plyrInstance, qualityLabels);
addCustomAudioTracksControl(plyrInstance, hlsInstance);
if (plyrInstance.eventListeners) {
plyrInstance.eventListeners.forEach(function(eventListener) {
if(eventListener.type === 'dblclick') {
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
}
});
}
plyrInstance.started = false;
plyrInstance.once('playing', function(){this.started = true});
if (typeof data !== 'undefined' && data.time_start != 0) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
console.log('Plyr init complete');
} catch (e) {
console.error('Failed to initialize Plyr:', e);
}
}
/**
* Main initialization
*/
async function start() {
console.log('Starting Plyr with HLS...');
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
console.error('No HLS manifest URL available');
return;
}
try {
const hlsInstance = await initHLS(hls_manifest_url);
initPlyrWithQuality(hlsInstance);
} catch (error) {
console.error('Failed to initialize:', error);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();

View File

@@ -0,0 +1,375 @@
/**
* YouTube Storyboard Preview Thumbnails
* Shows preview thumbnails when hovering over the progress bar
* Works with native HTML5 video player
*
* Fetches the proxied WebVTT storyboard from backend and extracts image URLs
*/
(function() {
'use strict';
console.log('Storyboard Preview Thumbnails loaded');
// Storyboard configuration
let storyboardImages = []; // Array of {time, imageUrl, x, y, width, height}
let previewElement = null;
let tooltipElement = null;
let video = null;
let progressBarRect = null;
/**
* Fetch and parse the storyboard VTT file
* The backend generates a VTT with proxied image URLs
*/
function fetchStoryboardVTT(vttUrl) {
return fetch(vttUrl)
.then(response => {
if (!response.ok) throw new Error('Failed to fetch storyboard VTT');
return response.text();
})
.then(vttText => {
console.log('Fetched storyboard VTT, length:', vttText.length);
const lines = vttText.split('\n');
const images = [];
let currentEntry = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Parse timestamp line: 00:00:00.000 --> 00:00:10.000
if (line.includes('-->')) {
const timeMatch = line.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const ms = parseInt(timeMatch[4]);
currentEntry = {
time: hours * 3600 + minutes * 60 + seconds + ms / 1000
};
}
}
// Parse image URL with crop parameters: /url#xywh=x,y,w,h
else if (line.includes('#xywh=') && currentEntry) {
const [urlPart, paramsPart] = line.split('#xywh=');
const [x, y, width, height] = paramsPart.split(',').map(Number);
currentEntry.imageUrl = urlPart;
currentEntry.x = x;
currentEntry.y = y;
currentEntry.width = width;
currentEntry.height = height;
images.push(currentEntry);
currentEntry = null;
}
}
console.log('Parsed', images.length, 'storyboard frames');
return images;
});
}
/**
* Format time as MM:SS or H:MM:SS
*/
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
* Find the closest storyboard frame for a given time
*/
function findFrameAtTime(time) {
if (!storyboardImages.length) return null;
// Binary search for efficiency
let left = 0;
let right = storyboardImages.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const frame = storyboardImages[mid];
if (time >= frame.time && time < (storyboardImages[mid + 1]?.time || Infinity)) {
return frame;
} else if (time < frame.time) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Return closest frame
return storyboardImages[Math.min(left, storyboardImages.length - 1)];
}
/**
* Detect browser
*/
function getBrowser() {
const ua = navigator.userAgent;
if (ua.indexOf('Firefox') > -1) return 'firefox';
if (ua.indexOf('Chrome') > -1) return 'chrome';
if (ua.indexOf('Safari') > -1) return 'safari';
return 'other';
}
/**
* Detect the progress bar position in native video element
* Different browsers have different control layouts
*/
function detectProgressBar() {
if (!video) return null;
const rect = video.getBoundingClientRect();
const browser = getBrowser();
let progressBarArea;
switch(browser) {
case 'firefox':
// Firefox: La barra de progreso está en la parte inferior pero más delgada
// Normalmente ocupa solo unos 20-25px de altura y está centrada
progressBarArea = {
top: rect.bottom - 30, // Área más pequeña para Firefox
bottom: rect.bottom - 5, // Dejamos espacio para otros controles
left: rect.left + 60, // Firefox tiene botones a la izquierda (play, volumen)
right: rect.right - 10, // Y a la derecha (fullscreen, etc)
height: 25
};
break;
case 'chrome':
default:
// Chrome: La barra de progreso ocupa un área más grande
progressBarArea = {
top: rect.bottom - 50,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
height: 50
};
break;
}
return progressBarArea;
}
/**
* Check if mouse is over the progress bar area
*/
function isOverProgressBar(mouseX, mouseY) {
if (!progressBarRect) return false;
return mouseX >= progressBarRect.left &&
mouseX <= progressBarRect.right &&
mouseY >= progressBarRect.top &&
mouseY <= progressBarRect.bottom;
}
/**
* Initialize preview elements
*/
function initPreviewElements() {
video = document.getElementById('js-video-player');
if (!video) {
console.error('Video element not found');
return;
}
console.log('Video element found, browser:', getBrowser());
// Create preview element
previewElement = document.createElement('div');
previewElement.className = 'storyboard-preview';
previewElement.style.cssText = `
position: fixed;
display: none;
pointer-events: none;
z-index: 10000;
background: #000;
border: 2px solid #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
`;
// Create tooltip element
tooltipElement = document.createElement('div');
tooltipElement.className = 'storyboard-tooltip';
tooltipElement.style.cssText = `
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #fff;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-family: Arial, sans-serif;
white-space: nowrap;
pointer-events: none;
`;
previewElement.appendChild(tooltipElement);
document.body.appendChild(previewElement);
// Update progress bar position on mouse move
video.addEventListener('mousemove', updateProgressBarPosition);
}
/**
* Update progress bar position detection
*/
function updateProgressBarPosition() {
progressBarRect = detectProgressBar();
}
/**
* Handle mouse move - only show preview when over progress bar area
*/
function handleMouseMove(e) {
if (!video || !storyboardImages.length) return;
// Update progress bar position on each move
progressBarRect = detectProgressBar();
// Only show preview if mouse is over the progress bar area
if (!isOverProgressBar(e.clientX, e.clientY)) {
if (previewElement) previewElement.style.display = 'none';
return;
}
// Calculate position within the progress bar
const progressBarWidth = progressBarRect.right - progressBarRect.left;
let xInProgressBar = e.clientX - progressBarRect.left;
// Adjust for Firefox's left offset
const browser = getBrowser();
if (browser === 'firefox') {
// Ajustar el rango para que coincida mejor con la barra real
xInProgressBar = Math.max(0, Math.min(xInProgressBar, progressBarWidth));
}
const percentage = Math.max(0, Math.min(1, xInProgressBar / progressBarWidth));
const time = percentage * video.duration;
const frame = findFrameAtTime(time);
if (!frame) return;
// Preview dimensions
const previewWidth = 160;
const previewHeight = 90;
const offsetFromCursor = 10;
// Position above the cursor
let previewTop = e.clientY - previewHeight - offsetFromCursor;
// If preview would go above the video, position below the cursor
const videoRect = video.getBoundingClientRect();
if (previewTop < videoRect.top) {
previewTop = e.clientY + offsetFromCursor;
}
// Keep preview within horizontal bounds
let left = e.clientX - (previewWidth / 2);
// Ajustes específicos para Firefox
if (browser === 'firefox') {
// En Firefox, la barra no llega hasta los extremos
const minLeft = progressBarRect.left + 10;
const maxLeft = progressBarRect.right - previewWidth - 10;
left = Math.max(minLeft, Math.min(left, maxLeft));
} else {
left = Math.max(videoRect.left, Math.min(left, videoRect.right - previewWidth));
}
// Apply all styles
previewElement.style.cssText = `
display: block;
position: fixed;
left: ${left}px;
top: ${previewTop}px;
width: ${previewWidth}px;
height: ${previewHeight}px;
background-image: url('${frame.imageUrl}');
background-position: -${frame.x}px -${frame.y}px;
background-size: auto;
background-repeat: no-repeat;
border: 2px solid #fff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 10000;
pointer-events: none;
`;
tooltipElement.textContent = formatTime(time);
}
/**
* Handle mouse leave video
*/
function handleMouseLeave() {
if (previewElement) {
previewElement.style.display = 'none';
}
}
/**
* Initialize storyboard preview
*/
function init() {
console.log('Initializing storyboard preview...');
// Check if storyboard URL is available
if (typeof storyboard_url === 'undefined' || !storyboard_url) {
console.log('No storyboard URL available');
return;
}
console.log('Storyboard URL:', storyboard_url);
// Fetch the proxied VTT file from backend
fetchStoryboardVTT(storyboard_url)
.then(images => {
storyboardImages = images;
console.log('Loaded', images.length, 'storyboard images');
if (images.length === 0) {
console.log('No storyboard images parsed');
return;
}
initPreviewElements();
// Add event listeners to video
video.addEventListener('mousemove', handleMouseMove);
video.addEventListener('mouseleave', handleMouseLeave);
console.log('Storyboard preview initialized for', getBrowser());
})
.catch(err => {
console.error('Failed to load storyboard:', err);
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -95,7 +95,11 @@ if (data.playlist && data.playlist['id'] !== null) {
// Autoplay // Autoplay
if (data.settings.related_videos_mode !== 0 || data.playlist !== null) { (function() {
if (data.settings.related_videos_mode === 0 && data.playlist === null) {
return;
}
let playability_error = !!data.playability_error; let playability_error = !!data.playability_error;
let isPlaylist = false; let isPlaylist = false;
if (data.playlist !== null && data.playlist['current_index'] !== null) if (data.playlist !== null && data.playlist['current_index'] !== null)
@@ -155,7 +159,10 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
if(!playability_error){ if(!playability_error){
// play the video if autoplay is on // play the video if autoplay is on
if(autoplayEnabled){ if(autoplayEnabled){
video.play(); video.play().catch(function(e) {
// Autoplay blocked by browser - ignore silently
console.log('Autoplay blocked:', e.message);
});
} }
} }
@@ -197,4 +204,4 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
window.setTimeout(nextVideo, nextVideoDelay); window.setTimeout(nextVideo, nextVideoDelay);
} }
} }
} })();

View File

@@ -0,0 +1,323 @@
const video = document.getElementById('js-video-player');
window.hls = null;
let hls = null;
// ===========
// HLS NATIVE
// ===========
function initHLSNative(manifestUrl) {
if (!manifestUrl) {
console.error('No HLS manifest URL provided');
return;
}
console.log('Initializing native HLS player with manifest:', manifestUrl);
if (hls) {
window.hls = null;
hls.destroy();
hls = null;
}
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
window.hls = hls;
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
console.log('Native manifest parsed');
console.log('Levels:', data.levels.length);
const qualitySelect = document.getElementById('quality-select');
if (qualitySelect && data.levels?.length) {
qualitySelect.innerHTML = '<option value="-1">Auto</option>';
const sorted = [...data.levels].sort((a, b) => b.height - a.height);
const seen = new Set();
sorted.forEach(level => {
if (!seen.has(level.height)) {
seen.add(level.height);
const i = data.levels.indexOf(level);
const opt = document.createElement('option');
opt.value = i;
opt.textContent = level.height + 'p';
qualitySelect.appendChild(opt);
}
});
// Set initial quality from settings
if (typeof window.data !== 'undefined' && window.data.settings) {
const defaultRes = window.data.settings.default_resolution;
if (defaultRes !== 'auto' && defaultRes) {
const target = parseInt(defaultRes);
let bestLevel = -1;
let bestHeight = 0;
for (let i = 0; i < hls.levels.length; i++) {
const h = hls.levels[i].height;
if (h <= target && h > bestHeight) {
bestHeight = h;
bestLevel = i;
}
}
if (bestLevel !== -1) {
hls.currentLevel = bestLevel;
qualitySelect.value = bestLevel;
console.log('Starting at resolution:', bestHeight + 'p');
}
}
}
}
});
hls.on(Hls.Events.ERROR, function(_, data) {
if (data.fatal) {
console.error('HLS fatal error:', data.type, data.details);
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = manifestUrl;
} else {
console.error('HLS not supported');
}
}
// ======
// INIT
// ======
function initPlayer() {
console.log('Init native player');
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
console.error('No manifest URL');
return;
}
initHLSNative(hls_manifest_url);
const qualitySelect = document.getElementById('quality-select');
if (qualitySelect) {
qualitySelect.addEventListener('change', function () {
const level = parseInt(this.value);
if (hls) {
hls.currentLevel = level;
console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p');
}
});
}
}
// DOM READY
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPlayer);
} else {
initPlayer();
}
// =============
// AUDIO TRACKS
// =============
document.addEventListener('DOMContentLoaded', function() {
const audioTrackSelect = document.getElementById('audio-track-select');
if (audioTrackSelect) {
audioTrackSelect.addEventListener('change', function() {
const trackId = this.value;
if (hls && hls.audioTracks) {
const index = hls.audioTracks.findIndex(t =>
t.lang === trackId || t.name === trackId
);
if (index !== -1) {
hls.audioTrack = index;
console.log('Audio track changed to:', index);
}
}
});
}
if (hls) {
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_, data) => {
console.log('Audio tracks:', data.audioTracks);
// Populate audio track select if needed
if (audioTrackSelect && data.audioTracks.length > 0) {
audioTrackSelect.innerHTML = '<option value="">Select audio track</option>';
data.audioTracks.forEach(track => {
const option = document.createElement('option');
option.value = track.lang || track.name;
option.textContent = track.name || track.lang || `Track ${track.id}`;
audioTrackSelect.appendChild(option);
});
audioTrackSelect.disabled = false;
}
});
}
});
// ============
// START TIME
// ============
if (typeof data !== 'undefined' && data.time_start != 0 && video) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
// ==============
// SPEED CONTROL
// ==============
let speedInput = document.getElementById('speed-control');
if (speedInput) {
speedInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') {
let speed = parseFloat(speedInput.value);
if(!isNaN(speed)){
video.playbackRate = speed;
}
}
});
}
// =========
// Autoplay
// =========
(function() {
if (typeof data === 'undefined' || (data.settings.related_videos_mode === 0 && data.playlist === null)) {
return;
}
let playability_error = !!data.playability_error;
let isPlaylist = false;
if (data.playlist !== null && data.playlist['current_index'] !== null)
isPlaylist = true;
// read cookies on whether to autoplay
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
let cookieValue;
let playlist_id;
if (isPlaylist) {
// from https://stackoverflow.com/a/6969486
function escapeRegExp(string) {
// $& means the whole matched string
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
playlist_id = data.playlist['id'];
playlist_id = escapeRegExp(playlist_id);
cookieValue = document.cookie.replace(new RegExp(
'(?:(?:^|.*;\\s*)autoplay_'
+ playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$'
), '$1');
} else {
cookieValue = document.cookie.replace(new RegExp(
'(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$'
),'$1');
}
let autoplayEnabled = 0;
if(cookieValue.length === 0){
autoplayEnabled = 0;
} else {
autoplayEnabled = Number(cookieValue);
}
// check the checkbox if autoplay is on
let checkbox = document.querySelector('.autoplay-toggle');
if(autoplayEnabled){
checkbox.checked = true;
}
// listen for checkbox to turn autoplay on and off
let cookie = 'autoplay'
if (isPlaylist)
cookie += '_' + playlist_id;
checkbox.addEventListener( 'change', function() {
if(this.checked) {
autoplayEnabled = 1;
document.cookie = cookie + '=1; SameSite=Strict';
} else {
autoplayEnabled = 0;
document.cookie = cookie + '=0; SameSite=Strict';
}
});
if(!playability_error){
// play the video if autoplay is on
if(autoplayEnabled){
video.play().catch(function(e) {
// Autoplay blocked by browser - ignore silently
console.log('Autoplay blocked:', e.message);
});
}
}
// determine next video url
let nextVideoUrl;
if (isPlaylist) {
let currentIndex = data.playlist['current_index'];
if (data.playlist['current_index']+1 == data.playlist['items'].length)
nextVideoUrl = null;
else
nextVideoUrl = data.playlist['items'][data.playlist['current_index']+1]['url'];
// scroll playlist to proper position
// item height + gap == 100
let pl = document.querySelector('.playlist-videos');
pl.scrollTop = 100*currentIndex;
} else {
if (data.related.length === 0)
nextVideoUrl = null;
else
nextVideoUrl = data.related[0]['url'];
}
let nextVideoDelay = 1000;
// go to next video when video ends
// https://stackoverflow.com/a/2880950
if (nextVideoUrl) {
if(playability_error){
videoEnded();
} else {
video.addEventListener('ended', videoEnded, false);
}
function nextVideo(){
if(autoplayEnabled){
window.location.href = nextVideoUrl;
}
}
function videoEnded(e) {
window.setTimeout(nextVideo, nextVideoDelay);
}
}
})();

View File

@@ -44,13 +44,14 @@ e.g. Firefox playback speed options */
.plyr__controls { .plyr__controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-bottom: 0px;
} }
.plyr__progress__container { .plyr__progress__container {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
margin-bottom: -10px; margin-bottom: -5px;
} }
.plyr__controls .plyr__controls__item:first-child { .plyr__controls .plyr__controls__item:first-child {
@@ -72,6 +73,120 @@ e.g. Firefox playback speed options */
margin-bottom: 50px; margin-bottom: 50px;
} }
/*
* Plyr Custom Controls
*/
.plyr__control svg.hls_audio_icon,
.plyr__control svg.hls_quality_icon {
fill: none;
}
.plyr__control[data-plyr="quality-custom"],
.plyr__control[data-plyr="audio-custom"] {
cursor: pointer;
}
.plyr__control[data-plyr="quality-custom"]:hover,
.plyr__control[data-plyr="audio-custom"]:hover {
background: rgba(255, 255, 255, 0.2);
}
/*
* Custom styles for dropdown controls
*/
.plyr__control--custom {
padding: 0;
}
/* Quality and Audio containers */
#plyr-quality-container,
#plyr-audio-container {
position: relative;
display: inline-flex;
align-items: center;
}
/* Quality and Audio buttons */
#plyr-quality-container .plyr__control,
#plyr-audio-container .plyr__control {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Text labels */
#plyr-quality-text,
#plyr-audio-text {
font-size: 12px;
margin-left: 2px;
}
/* Dropdowns */
.plyr-quality-dropdown,
.plyr-audio-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: #E6E6E6;
color: #23282f;
border-radius: 4px;
padding: 4px 6px;
min-width: 90px;
display: none;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(0, 0, 0, 0.08);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
max-height: 320px;
overflow-y: auto;
}
/* Audio dropdown needs slightly wider */
.plyr-audio-dropdown {
min-width: 120px;
}
/* Dropdown options */
.plyr-quality-option,
.plyr-audio-option {
padding: 6px 16px;
margin-bottom: 2px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
color: #23282f;
white-space: nowrap;
text-align: left;
}
/* Active/selected option */
.plyr-quality-option[data-active="true"],
.plyr-audio-option[data-active="true"] {
background: #00b3ff;
color: #FFF;
font-weight: 500;
border-radius: 4px;
}
/* Hover state */
.plyr-quality-option:hover,
.plyr-audio-option:hover {
background: #00b3ff;
color: #FFF;
font-weight: 500;
border-radius: 4px;
}
/* No audio tracks message */
.plyr-audio-no-tracks {
padding: 6px 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
}
/* /*
* End custom styles * End custom styles
*/ */

View File

@@ -8,7 +8,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob: {{ app_url }}/* data: https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}"> <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; media-src 'self' blob: {{ app_url }}/* data: https://*.googlevideo.com; img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com; connect-src 'self' https://*.googlevideo.com; font-src 'self' data:; worker-src 'self' blob:;">
<title>{{ page_title }}</title> <title>{{ page_title }}</title>
<link title="YT Local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml"> <link title="YT Local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon"> <link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">

View File

@@ -3,13 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; media-src 'self' https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}"> <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob: https://*.googlevideo.com; img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com; connect-src 'self' https://*.googlevideo.com; font-src 'self' data:;">
<title>{{ title }}</title> <title>{{ title }}</title>
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon"> <link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
{% if settings.use_video_player == 2 %} {% if settings.use_video_player == 2 %}
<!-- plyr --> <!-- plyr -->
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet"> <link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
<!--/ plyr --> <!-- /plyr -->
{% endif %} {% endif %}
<style> <style>
body { body {
@@ -37,9 +37,6 @@
<body> <body>
<video id="js-video-player" controls autofocus onmouseleave="{{ title }}" <video id="js-video-player" controls autofocus onmouseleave="{{ title }}"
oncontextmenu="{{ title }}" onmouseenter="{{ title }}" title="{{ title }}"> oncontextmenu="{{ title }}" onmouseenter="{{ title }}" title="{{ title }}">
{% if uni_sources %}
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
{% endif %}
{% for source in subtitle_sources %} {% for source in subtitle_sources %}
{% if source['on'] %} {% if source['on'] %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default> <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
@@ -47,28 +44,66 @@
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}"> <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if uni_sources %}
{% for source in uni_sources %}
<source src="{{ source['url'] }}" type="{{ source['type'] }}" title="{{ source['quality_string'] }}">
{% endfor %}
{% endif %}
</video> </video>
{% if js_data %}
<script>
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
data = {{ js_data|tojson }};
// @license-end
</script>
{% endif %}
<script> <script>
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
let storyboard_url = {{ storyboard_url | tojson }}; let storyboard_url = {{ storyboard_url | tojson }};
let hls_manifest_url = {{ hls_manifest_url | tojson }};
let hls_unavailable = {{ hls_unavailable | tojson }};
let playback_mode = {{ playback_mode | tojson }};
let pair_sources = {{ pair_sources | tojson }};
let pair_idx = {{ pair_idx | tojson }};
// @license-end // @license-end
</script> </script>
{% if settings.use_video_player == 2 %}
{% set hls_should_work = (playback_mode == 'hls' or playback_mode == 'auto') and not hls_unavailable %}
{% set use_dash = not hls_should_work %}
{% if not use_dash %}
<script src="/youtube.com/static/js/hls.min.js"
integrity="sha512-CSVqc4a7tn+tizDNt+eDoVn2fXYAwMDpCLrwGlWrOktNfZQ9gp4dKKScElMeRlrIifhliXs0a06BLaUgmMlCUw=="
crossorigin="anonymous"></script>
{% endif %}
<script src="/youtube.com/static/js/common.js"></script>
{% if settings.use_video_player == 0 %}
<!-- Native player -->
{% if use_dash %}
<script src="/youtube.com/static/js/watch.dash.js"></script>
{% else %}
<script src="/youtube.com/static/js/watch.hls.js"></script>
{% endif %}
{% elif settings.use_video_player == 1 %}
<!-- Native player with hotkeys -->
<script src="/youtube.com/static/js/hotkeys.js"></script>
{% if use_dash %}
<script src="/youtube.com/static/js/watch.dash.js"></script>
{% else %}
<script src="/youtube.com/static/js/watch.hls.js"></script>
{% endif %}
{% elif settings.use_video_player == 2 %}
<!-- plyr --> <!-- plyr -->
<script src="/youtube.com/static/modules/plyr/plyr.min.js" <script src="/youtube.com/static/modules/plyr/plyr.min.js"
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw==" integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="/youtube.com/static/js/plyr-start.js"></script> {% if use_dash %}
<script src="/youtube.com/static/js/plyr.dash.start.js"></script>
{% else %}
<script src="/youtube.com/static/js/plyr.hls.start.js"></script>
{% endif %}
<!-- /plyr --> <!-- /plyr -->
{% elif settings.use_video_player == 1 %} {% endif %}
<script src="/youtube.com/static/js/hotkeys.js"></script>
{% if use_dash %}
<script src="/youtube.com/static/js/av-merge.js"></script>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -29,6 +29,11 @@
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td> <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/common.js">common.js</a></td> <td data-label="Source"><a href="/youtube.com/static/js/common.js">common.js</a></td>
</tr> </tr>
<tr>
<td data-label="File"><a href="/youtube.com/static/js/hls.min.js">hls.min.js</a></td>
<td data-label="License"><a href="https://spdx.org/licenses/BSD-3-Clause.html">BSD-3-Clause</a></td>
<td data-label="Source"><a href="https://github.com/video-dev/hls.js/tree/v1.6.15/src">hls.js v1.6.15 source</a></td>
</tr>
<tr> <tr>
<td data-label="File"><a href="/youtube.com/static/js/hotkeys.js">hotkeys.js</a></td> <td data-label="File"><a href="/youtube.com/static/js/hotkeys.js">hotkeys.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td> <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
@@ -40,9 +45,24 @@
<td data-label="Source"><a href="/youtube.com/static/js/playlistadd.js">playlistadd.js</a></td> <td data-label="Source"><a href="/youtube.com/static/js/playlistadd.js">playlistadd.js</a></td>
</tr> </tr>
<tr> <tr>
<td data-label="File"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td> <td data-label="File"><a href="/youtube.com/static/js/plyr.dash.start.js">plyr.dash.start.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td> <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td> <td data-label="Source"><a href="/youtube.com/static/js/plyr.dash.start.js">plyr.dash.start.js</a></td>
</tr>
<tr>
<td data-label="File"><a href="/youtube.com/static/js/plyr.hls.start.js">plyr.hls.start.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/plyr.hls.start.js">plyr.hls.start.js</a></td>
</tr>
<tr>
<td data-label="File"><a href="/youtube.com/static/js/sponsorblock.js">sponsorblock.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/sponsorblock.js">sponsorblock.js</a></td>
</tr>
<tr>
<td data-label="File"><a href="/youtube.com/static/js/storyboard-preview.js">storyboard-preview.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/storyboard-preview.js">storyboard-preview.js</a></td>
</tr> </tr>
<tr> <tr>
<td data-label="File"><a href="/youtube.com/static/modules/plyr/plyr.min.js">plyr.min.js</a></td> <td data-label="File"><a href="/youtube.com/static/modules/plyr/plyr.min.js">plyr.min.js</a></td>
@@ -55,9 +75,14 @@
<td data-label="Source"><a href="/youtube.com/static/js/transcript-table.js">transcript-table.js</a></td> <td data-label="Source"><a href="/youtube.com/static/js/transcript-table.js">transcript-table.js</a></td>
</tr> </tr>
<tr> <tr>
<td data-label="File"><a href="/youtube.com/static/js/watch.js">watch.js</a></td> <td data-label="File"><a href="/youtube.com/static/js/watch.dash.js">watch.dash.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td> <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/watch.js">watch.js</a></td> <td data-label="Source"><a href="/youtube.com/static/js/watch.dash.js">watch.dash.js</a></td>
</tr>
<tr>
<td data-label="File"><a href="/youtube.com/static/js/watch.hls.js">watch.hls.js</a></td>
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
<td data-label="Source"><a href="/youtube.com/static/js/watch.hls.js">watch.hls.js</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -9,7 +9,7 @@
<!-- plyr --> <!-- plyr -->
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet"> <link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
<link href="/youtube.com/static/modules/plyr/custom_plyr.css" rel="stylesheet"> <link href="/youtube.com/static/modules/plyr/custom_plyr.css" rel="stylesheet">
<!--/ plyr --> <!-- /plyr -->
{% endif %} {% endif %}
{% endblock style %} {% endblock style %}
@@ -23,22 +23,9 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
{% elif (uni_sources.__len__() == 0 or live) and hls_formats.__len__() != 0 %}
<div class="live-url-choices">
<span>Copy a url into your video player:</span>
<ol>
{% for fmt in hls_formats %}
<li class="url-choice"><div class="url-choice-label">{{ fmt['video_quality'] }}: </div><input class="url-choice-copy" value="{{ fmt['url'] }}" readonly onclick="this.select();"></li>
{% endfor %}
</ol>
</div>
{% else %} {% else %}
<figure class="sc-video"> <figure class="sc-video">
<video id="js-video-player" playsinline controls {{ 'autoplay' if settings.autoplay_videos }}> <video id="js-video-player" playsinline controls {{ 'autoplay' if settings.autoplay_videos }}>
{% if uni_sources %}
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
{% endif %}
{% for source in subtitle_sources %} {% for source in subtitle_sources %}
{% if source['on'] %} {% if source['on'] %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default> <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
@@ -46,7 +33,18 @@
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}"> <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if uni_sources %}
{% for source in uni_sources %}
<source src="{{ source['url'] }}" type="{{ source['type'] }}" title="{{ source['quality_string'] }}">
{% endfor %}
{% endif %}
</video> </video>
{% if hls_unavailable and not uni_sources %}
<div class="playability-error">
<span>Error: HLS streams unavailable. Video may not play without JavaScript fallback.</span>
</div>
{% endif %}
</figure> </figure>
{% endif %} {% endif %}
@@ -76,16 +74,25 @@
<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 %}
<!-- Native player quality selector -->
<select id="quality-select" autocomplete="off">
<option value="-1" selected>Auto</option>
<!-- Quality options will be populated by HLS -->
</select>
{% else %}
<select id="quality-select" autocomplete="off" style="display: none;">
<!-- Quality options will be populated by HLS -->
</select>
{% endif %}
{% if settings.use_video_player != 2 %} {% if settings.use_video_player != 2 %}
<select id="quality-select" autocomplete="off"> {% if audio_tracks|length > 1 %}
{% for src in uni_sources %} <select id="audio-track-select" autocomplete="off">
<option value='{"type": "uni", "index": {{ loop.index0 }}}' {{ 'selected' if loop.index0 == uni_idx and not using_pair_sources else '' }} >{{ src['quality_string'] }}</option> {% for track in audio_tracks %}
{% endfor %} <option value="{{ track['id'] }}" {{ 'selected' if track['is_default'] else '' }}>{{ track['name'] }}</option>
{% for src_pair in pair_sources %}
<option value='{"type": "pair", "index": {{ loop.index0}}}' {{ 'selected' if loop.index0 == pair_idx and using_pair_sources else '' }} >{{ src_pair['quality_string'] }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% endif %}
{% endif %} {% endif %}
</div> </div>
<input class="v-checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox"> <input class="v-checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
@@ -244,26 +251,64 @@
</div> </div>
<script src="/youtube.com/static/js/av-merge.js"></script>
<script src="/youtube.com/static/js/watch.js"></script>
<script> <script>
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
let storyboard_url = {{ storyboard_url | tojson }}; let storyboard_url = {{ storyboard_url | tojson }};
let hls_manifest_url = {{ hls_manifest_url | tojson }};
let hls_unavailable = {{ hls_unavailable | tojson }};
let playback_mode = {{ playback_mode | tojson }};
let pair_sources = {{ pair_sources | tojson }};
let pair_idx = {{ pair_idx | tojson }};
// @license-end // @license-end
</script> </script>
<script src="/youtube.com/static/js/common.js"></script> <script src="/youtube.com/static/js/common.js"></script>
<script src="/youtube.com/static/js/transcript-table.js"></script> <script src="/youtube.com/static/js/transcript-table.js"></script>
{% if settings.use_video_player == 2 %}
{% set hls_should_work = (playback_mode == 'hls' or playback_mode == 'auto') and not hls_unavailable %}
{% set use_dash = not hls_should_work %}
{% if use_dash %}
<script src="/youtube.com/static/js/av-merge.js"></script>
{% else %}
<script src="/youtube.com/static/js/hls.min.js"
integrity="sha512-CSVqc4a7tn+tizDNt+eDoVn2fXYAwMDpCLrwGlWrOktNfZQ9gp4dKKScElMeRlrIifhliXs0a06BLaUgmMlCUw=="
crossorigin="anonymous"></script>
{% endif %}
{% if settings.use_video_player == 0 %}
<!-- Native player (no hotkeys) -->
{% if use_dash %}
<script src="/youtube.com/static/js/watch.dash.js"></script>
{% else %}
<script src="/youtube.com/static/js/watch.hls.js"></script>
{% endif %}
{% elif settings.use_video_player == 1 %}
<!-- Native player with hotkeys -->
<script src="/youtube.com/static/js/hotkeys.js"></script>
{% if use_dash %}
<script src="/youtube.com/static/js/watch.dash.js"></script>
{% else %}
<script src="/youtube.com/static/js/watch.hls.js"></script>
{% endif %}
{% elif settings.use_video_player == 2 %}
<!-- plyr --> <!-- plyr -->
<script src="/youtube.com/static/modules/plyr/plyr.min.js" <script src="/youtube.com/static/modules/plyr/plyr.min.js"
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw==" integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="/youtube.com/static/js/plyr-start.js"></script> {% if use_dash %}
<script src="/youtube.com/static/js/plyr.dash.start.js"></script>
{% else %}
<script src="/youtube.com/static/js/plyr.hls.start.js"></script>
{% endif %}
<!-- /plyr --> <!-- /plyr -->
{% elif settings.use_video_player == 1 %}
<script src="/youtube.com/static/js/hotkeys.js"></script>
{% endif %} {% endif %}
<!-- Storyboard Preview Thumbnails -->
{% if settings.use_video_player != 2 %}
<script src="/youtube.com/static/js/storyboard-preview.js"></script>
{% endif %}
{% if settings.use_comments_js %} <script src="/youtube.com/static/js/comments.js"></script> {% endif %} {% if settings.use_comments_js %} <script src="/youtube.com/static/js/comments.js"></script> {% endif %}
{% if settings.use_sponsorblock_js %} <script src="/youtube.com/static/js/sponsorblock.js"></script> {% endif %} {% if settings.use_sponsorblock_js %} <script src="/youtube.com/static/js/sponsorblock.js"></script> {% endif %}
{% endblock main %} {% endblock main %}

View File

@@ -899,6 +899,25 @@ INNERTUBE_CLIENTS = {
'INNERTUBE_CONTEXT_CLIENT_NAME': 28, 'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
'REQUIRE_JS_PLAYER': False, '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():

View File

@@ -42,73 +42,68 @@ def codec_name(vcodec):
def get_video_sources(info, target_resolution): def get_video_sources(info, target_resolution):
'''return dict with organized sources: { '''return dict with organized sources'''
'uni_sources': [{}, ...], # video and audio in one file audio_by_track = {}
'uni_idx': int, # default unified source index
'pair_sources': [{video: {}, audio: {}, quality: ..., ...}, ...],
'pair_idx': int, # default pair source index
}
'''
audio_sources = []
video_only_sources = {} video_only_sources = {}
uni_sources = [] uni_sources = []
pair_sources = [] pair_sources = []
for fmt in info['formats']: for fmt in info['formats']:
if not all(fmt[attr] for attr in ('ext', 'url', 'itag')): if not all(fmt[attr] for attr in ('ext', 'url', 'itag')):
continue continue
# unified source
if fmt['acodec'] and fmt['vcodec']: if fmt['acodec'] and fmt['vcodec']:
source = { if fmt.get('audio_track_is_default', True) is False:
'type': 'video/' + fmt['ext'], continue
'quality_string': short_video_quality_string(fmt), source = {'type': 'video/' + fmt['ext'],
} 'quality_string': short_video_quality_string(fmt)}
source['quality_string'] += ' (integrated)' source['quality_string'] += ' (integrated)'
source.update(fmt) source.update(fmt)
uni_sources.append(source) uni_sources.append(source)
continue continue
if not (fmt['init_range'] and fmt['index_range']): if not (fmt['init_range'] and fmt['index_range']):
continue # Allow HLS-backed audio tracks (served locally, no init/index needed)
if not fmt.get('url', '').startswith('http://127.') and not '/ytl-api/' in fmt.get('url', ''):
# audio source continue
if fmt['acodec'] and not fmt['vcodec'] and ( # Mark as HLS for frontend
fmt['audio_bitrate'] or fmt['bitrate']): fmt['is_hls'] = True
if fmt['bitrate']: # prefer this one, more accurate right now if fmt['acodec'] and not fmt['vcodec'] and (fmt['audio_bitrate'] or fmt['bitrate']):
if fmt['bitrate']:
fmt['audio_bitrate'] = int(fmt['bitrate']/1000) fmt['audio_bitrate'] = int(fmt['bitrate']/1000)
source = { source = {'type': 'audio/' + fmt['ext'],
'type': 'audio/' + fmt['ext'], 'quality_string': audio_quality_string(fmt)}
'quality_string': audio_quality_string(fmt),
}
source.update(fmt) source.update(fmt)
source['mime_codec'] = (source['type'] + '; codecs="' source['mime_codec'] = source['type'] + '; codecs="' + source['acodec'] + '"'
+ source['acodec'] + '"') tid = fmt.get('audio_track_id') or 'default'
audio_sources.append(source) if tid not in audio_by_track:
# video-only source audio_by_track[tid] = {
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps', 'name': fmt.get('audio_track_name') or 'Default',
'file_size')): 'is_default': fmt.get('audio_track_is_default', True),
'sources': [],
}
audio_by_track[tid]['sources'].append(source)
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps', 'file_size')):
if codec_name(fmt['vcodec']) == 'unknown': if codec_name(fmt['vcodec']) == 'unknown':
continue continue
source = { source = {'type': 'video/' + fmt['ext'],
'type': 'video/' + fmt['ext'], 'quality_string': short_video_quality_string(fmt)}
'quality_string': short_video_quality_string(fmt),
}
source.update(fmt) source.update(fmt)
source['mime_codec'] = (source['type'] + '; codecs="' source['mime_codec'] = source['type'] + '; codecs="' + source['vcodec'] + '"'
+ source['vcodec'] + '"')
quality = str(fmt['quality']) + 'p' + str(fmt['fps']) quality = str(fmt['quality']) + 'p' + str(fmt['fps'])
if quality in video_only_sources: video_only_sources.setdefault(quality, []).append(source)
video_only_sources[quality].append(source)
else:
video_only_sources[quality] = [source]
audio_sources.sort(key=lambda source: source['audio_bitrate']) audio_tracks = []
default_track_id = 'default'
for tid, ti in audio_by_track.items():
audio_tracks.append({'id': tid, 'name': ti['name'], 'is_default': ti['is_default']})
if ti['is_default']:
default_track_id = tid
audio_tracks.sort(key=lambda t: (not t['is_default'], t['name']))
default_audio = audio_by_track.get(default_track_id, {}).get('sources', [])
default_audio.sort(key=lambda s: s['audio_bitrate'])
uni_sources.sort(key=lambda src: src['quality']) uni_sources.sort(key=lambda src: src['quality'])
webm_audios = [a for a in default_audio if a['ext'] == 'webm']
webm_audios = [a for a in audio_sources if a['ext'] == 'webm'] mp4_audios = [a for a in default_audio if a['ext'] == 'mp4']
mp4_audios = [a for a in audio_sources if a['ext'] == 'mp4']
for quality_string, sources in video_only_sources.items(): for quality_string, sources in video_only_sources.items():
# choose an audio source to go with it # choose an audio source to go with it
@@ -166,11 +161,19 @@ def get_video_sources(info, target_resolution):
break break
pair_idx = i pair_idx = i
audio_track_sources = {}
for tid, ti in audio_by_track.items():
srcs = ti['sources']
srcs.sort(key=lambda s: s.get('audio_bitrate', 0))
audio_track_sources[tid] = srcs
return { return {
'uni_sources': uni_sources, 'uni_sources': uni_sources,
'uni_idx': uni_idx, 'uni_idx': uni_idx,
'pair_sources': pair_sources, 'pair_sources': pair_sources,
'pair_idx': pair_idx, 'pair_idx': pair_idx,
'audio_tracks': audio_tracks,
'audio_track_sources': audio_track_sources,
} }
@@ -423,8 +426,115 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
'captionTracks', default=[]) 'captionTracks', default=[])
info['_android_caption_tracks'] = android_caption_tracks info['_android_caption_tracks'] = android_caption_tracks
# Save streamingData for multi-audio extraction
pr_streaming_data = pr_data.get('streamingData', {})
info['_streamingData'] = pr_streaming_data
yt_data_extract.update_with_new_urls(info, player_response) yt_data_extract.update_with_new_urls(info, player_response)
# HLS manifest - try multiple clients in case one is blocked
info['hls_manifest_url'] = None
info['hls_audio_tracks'] = {}
hls_data = None
hls_client_used = None
for hls_client in ('ios', 'ios_vr', 'android'):
try:
resp = fetch_player_response(hls_client, video_id) or {}
hls_data = json.loads(resp) if isinstance(resp, str) else resp
hls_manifest_url = (hls_data.get('streamingData') or {}).get('hlsManifestUrl', '')
if hls_manifest_url:
hls_client_used = hls_client
break
except Exception as e:
print(f'HLS fetch with {hls_client} failed: {e}')
if hls_manifest_url:
info['hls_manifest_url'] = hls_manifest_url
import re as _re
from urllib.parse import urljoin
hls_manifest = util.fetch_url(hls_manifest_url,
headers=(('User-Agent', 'Mozilla/5.0'),),
debug_name='hls_manifest').decode('utf-8')
# Parse EXT-X-MEDIA audio tracks from HLS manifest
for line in hls_manifest.split('\n'):
if '#EXT-X-MEDIA' not in line or 'TYPE=AUDIO' not in line:
continue
name_m = _re.search(r'NAME="([^"]+)"', line)
lang_m = _re.search(r'LANGUAGE="([^"]+)"', line)
default_m = _re.search(r'DEFAULT=(YES|NO)', line)
group_m = _re.search(r'GROUP-ID="([^"]+)"', line)
uri_m = _re.search(r'URI="([^"]+)"', line)
if not uri_m or not lang_m:
continue
lang = lang_m.group(1)
is_default = default_m and default_m.group(1) == 'YES'
group = group_m.group(1) if group_m else '0'
key = lang
absolute_hls_url = urljoin(hls_manifest_url, uri_m.group(1))
if key not in info['hls_audio_tracks'] or group > info['hls_audio_tracks'][key].get('group', '0'):
info['hls_audio_tracks'][key] = {
'name': name_m.group(1) if name_m else lang,
'lang': lang,
'hls_url': absolute_hls_url,
'group': group,
'is_default': is_default,
}
# Register HLS audio tracks for proxy access
added = 0
for lang, track in info['hls_audio_tracks'].items():
ck = video_id + '_' + lang
from youtube.hls_cache import register_track
register_track(ck, track['hls_url'],
video_id=video_id, track_id=lang)
fmt = {
'audio_track_id': lang,
'audio_track_name': track['name'],
'audio_track_is_default': track['is_default'],
'itag': 'hls_' + lang,
'ext': 'mp4',
'audio_bitrate': 128,
'bitrate': 128000,
'acodec': 'mp4a.40.2',
'vcodec': None,
'width': None,
'height': None,
'file_size': None,
'audio_sample_rate': 44100,
'duration_ms': None,
'fps': None,
'init_range': {'start': 0, 'end': 0},
'index_range': {'start': 0, 'end': 0},
'url': '/ytl-api/audio-track?id=' + urllib.parse.quote(ck),
's': None,
'sp': None,
'quality': None,
'type': 'audio/mp4',
'quality_string': track['name'],
'mime_codec': 'audio/mp4; codecs="mp4a.40.2"',
'is_hls': True,
}
info['formats'].append(fmt)
added += 1
if added:
print(f"Added {added} HLS audio tracks (via {hls_client_used})")
else:
print("No HLS manifest available from any client")
info['hls_manifest_url'] = None
info['hls_audio_tracks'] = {}
info['hls_unavailable'] = True
# Register HLS manifest for proxying
if info['hls_manifest_url']:
ck = video_id + '_video'
from youtube.hls_cache import register_track
register_track(ck, info['hls_manifest_url'], video_id=video_id, track_id='video')
# Use proxy URL instead of direct Google Video URL
info['hls_manifest_url'] = '/ytl-api/hls-manifest?id=' + urllib.parse.quote(ck)
# Fallback to 'ios' if no valid URLs are found # Fallback to 'ios' if no valid URLs are found
if not info.get('formats') or info.get('player_urls_missing'): if not info.get('formats') or info.get('player_urls_missing'):
print(f"No URLs found in '{primary_client}', attempting with '{fallback_client}'.") print(f"No URLs found in '{primary_client}', attempting with '{fallback_client}'.")
@@ -556,6 +666,339 @@ def format_bytes(bytes):
return '%.2f%s' % (converted, suffix) return '%.2f%s' % (converted, suffix)
@yt_app.route('/ytl-api/audio-track-proxy')
def audio_track_proxy():
"""Proxy for DASH audio tracks to avoid throttling."""
cache_key = request.args.get('id', '')
audio_url = request.args.get('url', '')
if not audio_url:
flask.abort(400, 'Missing URL')
try:
headers = (
('User-Agent', 'Mozilla/5.0'),
('Accept', '*/*'),
)
content = util.fetch_url(audio_url, headers=headers,
debug_name='audio_dash', report_text=None)
return flask.Response(content, mimetype='audio/mp4',
headers={'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=3600'})
except Exception as e:
flask.abort(502, f'Audio fetch failed: {e}')
@yt_app.route('/ytl-api/audio-track')
def get_audio_track():
"""Proxy HLS audio/video: playlist or individual segment."""
from youtube.hls_cache import get_hls_url, _tracks
cache_key = request.args.get('id', '')
seg_url = request.args.get('seg', '')
playlist_url = request.args.get('url', '')
# Handle playlist/manifest URL (used for audio track playlists)
if playlist_url:
# Unwrap if double-proxied
if '/ytl-api/audio-track' in playlist_url:
import urllib.parse as _up
parsed = _up.parse_qs(_up.urlparse(playlist_url).query)
if 'url' in parsed:
playlist_url = parsed['url'][0]
try:
playlist = util.fetch_url(playlist_url,
headers=(('User-Agent', 'Mozilla/5.0'),),
debug_name='audio_playlist').decode('utf-8')
# Rewrite segment URLs
import re as _re
from urllib.parse import urljoin
base_url = request.url_root.rstrip('/')
playlist_base = playlist_url.rsplit('/', 1)[0] + '/'
playlist_lines = []
for line in playlist.split('\n'):
line = line.strip()
if not line or line.startswith('#'):
playlist_lines.append(line)
continue
# Resolve and proxy segment URL
seg = line if line.startswith('http') else urljoin(playlist_base, line)
# Always use &seg= parameter, never &url= for segments
playlist_lines.append(
base_url + '/ytl-api/audio-track?id='
+ urllib.parse.quote(cache_key)
+ '&seg=' + urllib.parse.quote(seg, safe='')
)
playlist = '\n'.join(playlist_lines)
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
headers={'Access-Control-Allow-Origin': '*'})
except Exception as e:
import traceback
traceback.print_exc()
flask.abort(502, f'Playlist fetch failed: {e}')
# Handle individual segment or nested playlist
if seg_url:
# Check if seg_url is already a proxied URL
if '/ytl-api/audio-track' in seg_url:
import urllib.parse as _up
parsed = _up.parse_qs(_up.urlparse(seg_url).query)
if 'seg' in parsed:
seg_url = parsed['seg'][0]
elif 'url' in parsed:
seg_url = parsed['url'][0]
# Check if this is a nested playlist (m3u8) that needs rewriting
# Playlists END with .m3u8 (optionally followed by query params)
# Segments may contain /index.m3u8/ in their path but end with .ts or similar
url_path = urllib.parse.urlparse(seg_url).path
# Only treat as playlist if path ends with .m3u8
# Don't use 'in' check because segments can have /index.m3u8/ in their path
is_playlist = url_path.endswith('.m3u8')
if is_playlist:
# This is a variant playlist - fetch and rewrite it
try:
raw_content = util.fetch_url(seg_url,
headers=(('User-Agent', 'Mozilla/5.0'),),
debug_name='nested_playlist')
# Check if this is actually binary data (segment) misidentified as playlist
try:
playlist = raw_content.decode('utf-8')
except UnicodeDecodeError:
is_playlist = False # Fall through to segment handler
if is_playlist:
# Rewrite segment URLs in this playlist
from urllib.parse import urljoin
import re as _re
base_url = request.url_root.rstrip('/')
playlist_base = seg_url.rsplit('/', 1)[0] + '/'
def proxy_url(url):
"""Rewrite a single URL to go through the proxy"""
if not url or url.startswith('/ytl-api/'):
return url
if not url.startswith('http://') and not url.startswith('https://'):
url = urljoin(playlist_base, url)
return (base_url + '/ytl-api/audio-track?id='
+ urllib.parse.quote(cache_key)
+ '&seg=' + urllib.parse.quote(url, safe=''))
playlist_lines = []
for line in playlist.split('\n'):
line = line.strip()
if not line:
playlist_lines.append(line)
continue
# Handle tags with URI attributes (EXT-X-MAP, EXT-X-KEY, etc.)
if line.startswith('#') and 'URI=' in line:
def rewrite_uri_attr(match):
uri = match.group(1)
return 'URI="' + proxy_url(uri) + '"'
line = _re.sub(r'URI="([^"]+)"', rewrite_uri_attr, line)
playlist_lines.append(line)
elif line.startswith('#'):
# Other tags pass through unchanged
playlist_lines.append(line)
else:
# This is a segment URL line
seg = line if line.startswith('http') else urljoin(playlist_base, line)
playlist_lines.append(proxy_url(seg))
playlist = '\n'.join(playlist_lines)
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
headers={'Access-Control-Allow-Origin': '*'})
except Exception as e:
import traceback
traceback.print_exc()
flask.abort(502, f'Nested playlist fetch failed: {e}')
# This is an actual segment - fetch and serve it
try:
headers = (
('User-Agent', 'Mozilla/5.0'),
('Accept', '*/*'),
)
content = util.fetch_url(seg_url, headers=headers,
debug_name='hls_seg', report_text=None)
# Determine content type based on URL or content
# 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'):
content_type = 'video/mp4'
elif '.webm' in seg_url or seg_url.lower().endswith('.webm'):
content_type = 'video/webm'
else:
# Default to MPEG-TS for HLS
content_type = 'video/mp2t'
return flask.Response(content, mimetype=content_type,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Cache-Control': 'max-age=3600',
'Content-Type': content_type,
})
except Exception as e:
import traceback
traceback.print_exc()
flask.abort(502, f'Segment fetch failed: {e}')
# Legacy: Proxy the HLS playlist for audio tracks (using get_hls_url)
hls_url = get_hls_url(cache_key)
if not hls_url:
flask.abort(404, 'Audio track not found')
try:
playlist = util.fetch_url(hls_url,
headers=(('User-Agent', 'Mozilla/5.0'),),
debug_name='audio_hls_playlist').decode('utf-8')
# Rewrite segment URLs to go through our proxy endpoint
import re as _re
from urllib.parse import urljoin
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
def make_proxy_url(segment_url):
if segment_url.startswith('/ytl-api/audio-track'):
return segment_url
base_url = request.url_root.rstrip('/')
return (base_url + '/ytl-api/audio-track?id='
+ urllib.parse.quote(cache_key)
+ '&seg=' + urllib.parse.quote(segment_url))
playlist_lines = []
for line in playlist.split('\n'):
line = line.strip()
if not line or line.startswith('#'):
playlist_lines.append(line)
continue
if line.startswith('http://') or line.startswith('https://'):
segment_url = line
else:
segment_url = urljoin(hls_base_url, line)
playlist_lines.append(make_proxy_url(segment_url))
playlist = '\n'.join(playlist_lines)
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
headers={'Access-Control-Allow-Origin': '*'})
except Exception as e:
flask.abort(502, f'Playlist fetch failed: {e}')
@yt_app.route('/ytl-api/hls-manifest')
def get_hls_manifest():
"""Proxy HLS video manifest, rewriting ALL URLs including audio tracks."""
from youtube.hls_cache import get_hls_url
cache_key = request.args.get('id', '')
is_audio = '_audio_' in cache_key or cache_key.endswith('_audio')
print(f'[hls-manifest] Request: id={cache_key[:40] if cache_key else ""}... (audio={is_audio})')
hls_url = get_hls_url(cache_key)
print(f'[hls-manifest] HLS URL: {hls_url[:80] if hls_url else None}...')
if not hls_url:
flask.abort(404, 'HLS manifest not found')
try:
print(f'[hls-manifest] Fetching HLS manifest...')
manifest = util.fetch_url(hls_url,
headers=(('User-Agent', 'Mozilla/5.0'),),
debug_name='hls_manifest').decode('utf-8')
print(f'[hls-manifest] Successfully fetched manifest ({len(manifest)} bytes)')
# Rewrite all URLs in the manifest to go through our proxy
import re as _re
from urllib.parse import urljoin
# Get the base URL for resolving relative URLs
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
base_url = request.url_root.rstrip('/')
# Rewrite URLs - handle both segment URLs and audio track URIs
def rewrite_url(url, is_audio_track=False):
if not url or url.startswith('/ytl-api/'):
return url
# Resolve relative URLs
if not url.startswith('http://') and not url.startswith('https://'):
url = urljoin(hls_base_url, url)
if is_audio_track:
# Audio track playlist - proxy through audio-track endpoint
return (base_url + '/ytl-api/audio-track?id='
+ urllib.parse.quote(cache_key)
+ '&url=' + urllib.parse.quote(url, safe=''))
else:
# Video segment or variant playlist - proxy through audio-track endpoint
return (base_url + '/ytl-api/audio-track?id='
+ urllib.parse.quote(cache_key)
+ '&seg=' + urllib.parse.quote(url, safe=''))
# Parse and rewrite the manifest
manifest_lines = []
rewritten_count = 0
for line in manifest.split('\n'):
line = line.strip()
if not line:
manifest_lines.append(line)
continue
# Handle EXT-X-MEDIA tags with URI (audio tracks)
if line.startswith('#EXT-X-MEDIA:') and 'URI=' in line:
# Extract and rewrite the URI attribute
def rewrite_media_uri(match):
nonlocal rewritten_count
uri = match.group(1)
rewritten_count += 1
return 'URI="' + rewrite_url(uri, is_audio_track=True) + '"'
line = _re.sub(r'URI="([^"]+)"', rewrite_media_uri, line)
manifest_lines.append(line)
elif line.startswith('#'):
# Other tags pass through
manifest_lines.append(line)
else:
# This is a URL (segment or variant playlist)
if line.startswith('http://') or line.startswith('https://'):
url = line
else:
url = urljoin(hls_base_url, line)
rewritten_count += 1
manifest_lines.append(rewrite_url(url))
manifest = '\n'.join(manifest_lines)
print(f'[hls-manifest] Rewrote manifest with {len(manifest_lines)} lines, {rewritten_count} URLs rewritten')
return flask.Response(manifest, mimetype='application/vnd.apple.mpegurl',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Cache-Control': 'no-cache',
'Content-Type': 'application/vnd.apple.mpegurl',
})
except Exception as e:
print(f'[hls-manifest] Error: {e}')
import traceback
traceback.print_exc()
flask.abort(502, f'Manifest fetch failed: {e}')
@yt_app.route('/ytl-api/storyboard.vtt') @yt_app.route('/ytl-api/storyboard.vtt')
def get_storyboard_vtt(): def get_storyboard_vtt():
""" """
@@ -731,47 +1174,50 @@ def get_watch_page(video_id=None):
if (settings.route_tor == 2) or info['tor_bypass_used']: if (settings.route_tor == 2) or info['tor_bypass_used']:
target_resolution = 240 target_resolution = 240
else: else:
target_resolution = settings.default_resolution res = settings.default_resolution
target_resolution = 1080 if res == 'auto' else int(res)
source_info = get_video_sources(info, target_resolution) # Get video sources for no-JS fallback and DASH (av-merge) fallback
uni_sources = source_info['uni_sources'] video_sources = get_video_sources(info, target_resolution)
pair_sources = source_info['pair_sources'] uni_sources = video_sources['uni_sources']
uni_idx, pair_idx = source_info['uni_idx'], source_info['pair_idx'] pair_sources = video_sources['pair_sources']
pair_idx = video_sources['pair_idx']
audio_track_sources = video_sources['audio_track_sources']
pair_quality = yt_data_extract.deep_get(pair_sources, pair_idx, 'quality') # Build audio tracks list from HLS
uni_quality = yt_data_extract.deep_get(uni_sources, uni_idx, 'quality') audio_tracks = []
hls_audio_tracks = info.get('hls_audio_tracks', {})
hls_manifest_url = info.get('hls_manifest_url')
if hls_audio_tracks:
# Prefer "original" audio track
original_lang = None
for lang, track in hls_audio_tracks.items():
if 'original' in (track.get('name') or '').lower():
original_lang = lang
break
pair_error = abs((pair_quality or 360) - target_resolution) # Add tracks, preferring original as default
uni_error = abs((uni_quality or 360) - target_resolution) for lang, track in hls_audio_tracks.items():
if uni_error == pair_error: is_default = (lang == original_lang) if original_lang else track['is_default']
# use settings.prefer_uni_sources as a tiebreaker if is_default:
closer_to_target = 'uni' if settings.prefer_uni_sources else 'pair' audio_tracks.insert(0, {
elif uni_error < pair_error: 'id': lang,
closer_to_target = 'uni' 'name': track['name'],
'is_default': True,
})
else:
audio_tracks.append({
'id': lang,
'name': track['name'],
'is_default': False,
})
else: else:
closer_to_target = 'pair' # Fallback: single default audio track
audio_tracks = [{'id': 'default', 'name': 'Default', 'is_default': True}]
if settings.prefer_uni_sources == 2: # Get video dimensions
# Use uni sources unless there's no choice. video_height = info.get('height') or 360
using_pair_sources = ( video_width = info.get('width') or 640
bool(pair_sources) and (not uni_sources)
)
else:
# Use the pair sources if they're closer to the desired resolution
using_pair_sources = (
bool(pair_sources)
and (not uni_sources or closer_to_target == 'pair')
)
if using_pair_sources:
video_height = pair_sources[pair_idx]['height']
video_width = pair_sources[pair_idx]['width']
else:
video_height = yt_data_extract.deep_get(
uni_sources, uni_idx, 'height', default=360
)
video_width = yt_data_extract.deep_get(
uni_sources, uni_idx, 'width', default=640
)
@@ -818,7 +1264,14 @@ def get_watch_page(video_id=None):
other_downloads = other_downloads, other_downloads = other_downloads,
video_info = json.dumps(video_info), video_info = json.dumps(video_info),
hls_formats = info['hls_formats'], hls_formats = info['hls_formats'],
hls_manifest_url = hls_manifest_url,
audio_tracks = audio_tracks,
subtitle_sources = subtitle_sources, subtitle_sources = subtitle_sources,
uni_sources = uni_sources,
pair_sources = pair_sources,
pair_idx = pair_idx,
hls_unavailable = info.get('hls_unavailable', False),
playback_mode = settings.playback_mode,
related = info['related_videos'], related = info['related_videos'],
playlist = info['playlist'], playlist = info['playlist'],
music_list = info['music_list'], music_list = info['music_list'],
@@ -855,16 +1308,20 @@ def get_watch_page(video_id=None):
'video_duration': info['duration'], 'video_duration': info['duration'],
'settings': settings.current_settings_dict, 'settings': settings.current_settings_dict,
'has_manual_captions': any(s.get('on') for s in subtitle_sources), 'has_manual_captions': any(s.get('on') for s in subtitle_sources),
**source_info, 'audio_tracks': audio_tracks,
'using_pair_sources': using_pair_sources, 'hls_manifest_url': hls_manifest_url,
'time_start': time_start, 'time_start': time_start,
'playlist': info['playlist'], 'playlist': info['playlist'],
'related': info['related_videos'], 'related': info['related_videos'],
'playability_error': info['playability_error'], 'playability_error': info['playability_error'],
'hls_unavailable': info.get('hls_unavailable', False),
'pair_sources': pair_sources,
'pair_idx': pair_idx,
'uni_sources': uni_sources,
'uni_idx': video_sources['uni_idx'],
'using_pair_sources': bool(pair_sources),
}, },
font_family = youtube.font_choices[settings.font], # for embed page font_family = youtube.font_choices[settings.font], # for embed page
**source_info,
using_pair_sources = using_pair_sources,
) )

View File

@@ -10,4 +10,4 @@ from .watch_extraction import (extract_watch_info, get_caption_url,
update_with_new_urls, requires_decryption, update_with_new_urls, requires_decryption,
extract_decryption_function, decrypt_signatures, _formats, extract_decryption_function, decrypt_signatures, _formats,
update_format_with_type_info, extract_hls_formats, update_format_with_type_info, extract_hls_formats,
extract_watch_info_from_html, captions_available) extract_watch_info_from_html, captions_available, parse_format)

View File

@@ -473,13 +473,22 @@ def _extract_formats(info, player_response):
itag = yt_fmt.get('itag') itag = yt_fmt.get('itag')
# Translated audio track # Translated audio track
# Example: https://www.youtube.com/watch?v=gF9kkB0UWYQ # Keep non-default tracks for multi-audio support
# Only get the original language for now so a foreign # (they will be served via local proxy)
# translation will not be picked just because it comes first
if deep_get(yt_fmt, 'audioTrack', 'audioIsDefault') is False:
continue
fmt = {} fmt = {}
# Audio track info
audio_track = yt_fmt.get('audioTrack')
if audio_track:
fmt['audio_track_id'] = audio_track.get('id')
fmt['audio_track_name'] = audio_track.get('displayName')
fmt['audio_track_is_default'] = audio_track.get('audioIsDefault', True)
else:
fmt['audio_track_id'] = None
fmt['audio_track_name'] = None
fmt['audio_track_is_default'] = True
fmt['itag'] = itag fmt['itag'] = itag
fmt['ext'] = None fmt['ext'] = None
fmt['audio_bitrate'] = None fmt['audio_bitrate'] = None
@@ -532,6 +541,61 @@ def _extract_formats(info, player_response):
else: else:
info['ip_address'] = None info['ip_address'] = None
def parse_format(yt_fmt):
'''Parse a single YouTube format dict into our internal format dict.'''
itag = yt_fmt.get('itag')
fmt = {}
audio_track = yt_fmt.get('audioTrack')
if audio_track:
fmt['audio_track_id'] = audio_track.get('id')
fmt['audio_track_name'] = audio_track.get('displayName')
fmt['audio_track_is_default'] = audio_track.get('audioIsDefault', True)
else:
fmt['audio_track_id'] = None
fmt['audio_track_name'] = None
fmt['audio_track_is_default'] = True
fmt['itag'] = itag
fmt['ext'] = None
fmt['audio_bitrate'] = None
fmt['bitrate'] = yt_fmt.get('bitrate')
fmt['acodec'] = None
fmt['vcodec'] = None
fmt['width'] = yt_fmt.get('width')
fmt['height'] = yt_fmt.get('height')
fmt['file_size'] = extract_int(yt_fmt.get('contentLength'))
fmt['audio_sample_rate'] = extract_int(yt_fmt.get('audioSampleRate'))
fmt['duration_ms'] = yt_fmt.get('approxDurationMs')
fmt['fps'] = yt_fmt.get('fps')
fmt['init_range'] = yt_fmt.get('initRange')
fmt['index_range'] = yt_fmt.get('indexRange')
for key in ('init_range', 'index_range'):
if fmt[key]:
fmt[key]['start'] = int(fmt[key]['start'])
fmt[key]['end'] = int(fmt[key]['end'])
update_format_with_type_info(fmt, yt_fmt)
cipher = dict(urllib.parse.parse_qsl(multi_get(yt_fmt,
'cipher', 'signatureCipher', default='')))
if cipher:
fmt['url'] = cipher.get('url')
else:
fmt['url'] = yt_fmt.get('url')
fmt['s'] = cipher.get('s')
fmt['sp'] = cipher.get('sp')
hardcoded_itag_info = _formats.get(str(itag), {})
for key, value in hardcoded_itag_info.items():
conservative_update(fmt, key, value)
fmt['quality'] = hardcoded_itag_info.get('height')
conservative_update(fmt, 'quality',
extract_int(yt_fmt.get('quality'), whole_word=False))
conservative_update(fmt, 'quality',
extract_int(yt_fmt.get('qualityLabel'), whole_word=False))
return fmt
hls_regex = re.compile(r'[\w_-]+=(?:"[^"]+"|[^",]+),') hls_regex = re.compile(r'[\w_-]+=(?:"[^"]+"|[^",]+),')
def extract_hls_formats(hls_manifest): def extract_hls_formats(hls_manifest):
'''returns hls_formats, err''' '''returns hls_formats, err'''