8 Commits

Author SHA1 Message Date
Jesus
c6c8030907 feat: add Spanish README and improve channel/playlist handling
All checks were successful
CI / test (push) Successful in 52s
* Add complete Spanish translation (README.es.md)
* Restructure English README for clarity and conciseness
* Filter out YouTube Shorts from channel video listings (sort=4)
* Add fallback for video count using playlist metadata when API returns zero
* Add get_playlist_metadata() to fetch metadata without full video list
* Add is_short() utility to detect YouTube Shorts by duration, badges, and type
* Export is_short from yt_data_extract for use across modules
2026-04-12 20:20:32 -05:00
Jesus
550457936a fix(settings): add AST compatibility for Python 3.12+
All checks were successful
CI / test (push) Successful in 51s
* Use `ast.Constant` as primary node for Python 3.8+
* Maintain backward compatibility with `ast.Num`, `ast.Str`, and `ast.NameConstant`
* Prevent crashes on Python 3.12 where legacy nodes were removed
* Add safe handling via `try/except AttributeError`
2026-04-11 12:43:17 -05:00
3795d9e4ff fix(playlists): make playlist parsing robust against filename and formatting issues
All checks were successful
CI / test (push) Successful in 53s
- Use glob lookup to find playlist files even with trailing spaces in filenames
- Sanitize lines (strip whitespace) before JSON parsing to ignore trailing spaces/empty lines
- Handle JSONDecodeError gracefully to prevent 500 errors from corrupt entries
- Return empty list on FileNotFoundError in read_playlist instead of crashing
- Extract _find_playlist_path and _parse_playlist_lines helpers for reuse
2026-04-05 18:47:21 -05:00
3cf221a1ed minor fix 2026-04-05 18:32:29 -05:00
13a0e6ceed fix(hls): improve audio track selection and auto-detect "Original"
- Auto-select "Original" audio track by default in both native and Plyr HLS players
- Fix native HLS audio selector to use numeric indices instead of string matching
- Robustly detect "original" track by checking both `name` and `lang` attributes
- Fix audio track change handler to correctly switch between available tracks
2026-04-05 18:31:35 -05:00
e8e2aa93d6 fix(channel): fix shorts/streams pagination using continuation tokens
- Add continuation_token_cache to store ctokens between page requests
- Use cached ctoken for page 2+ instead of generating fresh tokens
- Switch shorts/streams to Next/Previous buttons (no page numbers)
- Show "N+ videos" indicator when more pages are available
- Fix UnboundLocalError when page_call was undefined for shorts/streams

The issue was that YouTube's InnerTube API requires continuation tokens
for pagination on shorts/streams tabs, but the code was generating a new
ctoken each time, always returning the same 30 videos.
2026-04-05 18:19:05 -05:00
8403e30b3a Many fixes to i18n 2026-04-05 17:43:01 -05:00
f0649be5de Add HLS support to multi-audio 2026-04-05 14:56:51 -05:00
33 changed files with 3597 additions and 404 deletions

5
.gitignore vendored
View File

@@ -144,6 +144,11 @@ banned_addresses.txt
*.orig *.orig
*.cache/ *.cache/
# -----------------------------------------------------------------------------
# Localization / Compiled translations
# -----------------------------------------------------------------------------
*.mo
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# AI assistants / LLM tools # AI assistants / LLM tools
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

168
README.es.md Normal file
View File

@@ -0,0 +1,168 @@
# yt-local
Un cliente local de YouTube que se ejecuta en tu máquina. Sin anuncios, sin JavaScript, con soporte para Tor.
<p align="center">
<a href="#caracteristicas">Características</a> •
<a href="#instalación">Instalación</a> •
<a href="#uso">Uso</a> •
<a href="#tor">Tor</a> •
<a href="#desarrollo">Desarrollo</a>
</p>
---
## ¿Qué es?
yt-local es un servidor web local que actúa como proxy entre tu navegador y YouTube. Las páginas se cargan rápido, sin anuncios ni tracking. Las peticiones pueden pasar por Tor de forma opcional.
**No usa la API de YouTube.** Hace las mismas peticiones que haría la web normal, pero sin todo lo demás.
## Características
- Navegación anónima con **Tor opcional**
- Sin anuncios ni JavaScript
- Tres temas: claro, gris y oscuro
- Listas de reproducción locales (no se pierden si YouTube borra videos)
- Sistema de suscripciones independiente de YouTube
- Soporte de subtítulos
- Segmentos de SponsorBlock saltados automáticamente
- Calidades de 144p a 2160p con soporte HLS
- Descarga de videos (desactivada por defecto)
- Comentarios visibles al instante, sin lazy-loading
## Instalación
### Linux / macOS
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python server.py
```
### Windows
Descarga el zip de [Releases](https://github.com/user234683/youtube-local/releases) y ejecuta `run.bat`.
### Con Make
```bash
make install # Dependencias
make dev # Servidor
make test # Tests
make help # Todos los comandos
```
## Uso
El servidor arranca en `http://localhost:9010/`
Para ver un video, añade el dominio como prefijo:
```
http://localhost:9010/https://www.youtube.com/watch?v=VIDEO_ID
```
### Redirector
Puedes usar una extensión del navegador como [Redirector](https://github.com/einaregilsson/Redirector) para redirigir YouTube automáticamente:
| Campo | Valor |
|-------|-------|
| Patrón | `^(https?://(?:[a-zA-Z0-9_-]*\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)/.*)` |
| Redirigir a | `http://localhost:9010/$1` |
| Modo | Expresión regular |
Activa "Iframes" en las opciones avanzadas para que funcionen los embeds.
### Modo portable
Crea un archivo vacío `settings.txt` en el directorio principal. Así toda la configuración y datos se guardan ahí en vez de en `~/.yt-local`.
## Tor
### Opción rápida: Tor Browser
Abre Tor Browser y déjalo abierto. En los ajustes de yt-local pon "Route Tor" en "On, except video".
### Tor independiente
**Debian/Ubuntu:**
```bash
sudo apt install tor
sudo systemctl start tor
```
Cambia el puerto Tor a `9050` en los ajustes.
**Windows:**
Crea un acceso directo con: `tor.exe SOCKSPort 9150 ControlPort 9151`
### Routing de video por Tor
Pon "Route Tor" en "On, including video". Es más lento y consume ancho de banda de la red Tor — considera hacer una [donación a los nodos de Tor](https://torservers.net/donate.html).
## Importar suscripciones
1. Ve a [Google Takeout](https://takeout.google.com/takeout/custom/youtube)
2. Selecciona solo "subscriptions" y descarga el CSV
3. En yt-local, importa el archivo desde el gestor de suscripciones
Formatos soportados: CSV de Google Takeout, JSON de NewPipe, OPML, y JSON antiguo de Google Takeout.
## Desarrollo
```
yt-local/
├── server.py # Punto de entrada, servidor WSGI
├── settings.py # Gestión de configuración
├── youtube/
│ ├── util.py # Funciones auxiliares, routing Tor
│ ├── watch.py # Página de video
│ ├── channel.py # Páginas de canal
│ ├── search.py # Búsquedas
│ ├── subscriptions.py # Suscripciones (SQLite)
│ ├── local_playlist.py # Listas locales
│ ├── yt_data_extract/ # Parser del JSON de YouTube
│ └── ...
├── tests/ # Tests con pytest
└── docs/HACKING.md # Guía de desarrollo
```
### Comandos útiles
```bash
make test # Ejecutar tests
make lint # Verificar estilo
make i18n-extract # Extraer cadenas
make i18n-init LANG_CODE=fr # Nuevo idioma
make i18n-compile # Compilar traducciones
```
Python **3.8+** requerido. El código sigue PEP 8. Los mensajes de commit deben ser descriptivos.
### Notas de compatibilidad
- **Python 3.8+**: Versión mínima requerida
- **HLS**: Soporte experimental para streaming en videos multi-audio
- **Canales con limitaciones**: Algunos canales devuelven pocos videos por respuesta. Esto es comportamiento de YouTube, no de yt-local. El proyecto usa la API interna de YouTube (innertube), **no la API pública de YouTube**.
## Licencia
GNU AGPLv3 o posterior. Ver [LICENSE](LICENSE).
## Proyectos similares
- [invidious](https://github.com/iv-org/invidious) Similar a este proyecto, pero permite alojarlo como servidor para muchos usuarios
- [Yotter](https://github.com/ytorg/Yotter) Similar a este proyecto y a invidious. También soporta Twitter
- [FreeTube](https://github.com/FreeTubeApp/FreeTube) (Similar a este proyecto, pero es una app Electron fuera del navegador)
- [youtube-local](https://github.com/user234683/youtube-local) primer proyecto en el que se basa yt-local
- [NewPipe](https://newpipe.schabi.org/) (app para Android)
- [mps-youtube](https://github.com/mps-youtube/mps-youtube) (programa solo para terminal)
- [youtube-viewer](https://github.com/trizen/youtube-viewer)
- [smtube](https://www.smtube.org/)
- [Minitube](https://flavio.tordini.org/minitube), [github aquí](https://github.com/flaviotordini/minitube)
- [toogles](https://github.com/mikecrittenden/toogles) (solo inserta videos, no usa mp4)
- [YTLibre](https://git.sr.ht/~heckyel/ytlibre) solo extrae video
- [youtube-dl](https://rg3.github.io/youtube-dl/), en el que se basó este proyecto

246
README.md
View File

@@ -1,171 +1,159 @@
# yt-local # yt-local
Fork of [youtube-local](https://github.com/user234683/youtube-local) A local YouTube client that runs on your machine. No ads, no JavaScript, with Tor support.
yt-local is a browser-based client written in Python for watching YouTube anonymously and without the lag of the slow page used by YouTube. One of the primary features is that all requests are routed through Tor, except for the video file at googlevideo.com. This is analogous to what HookTube (defunct) and Invidious do, except that you do not have to trust a third-party to respect your privacy. The assumption here is that Google won't put the effort in to incorporate the video file requests into their tracking, as it's not worth pursuing the incredibly small number of users who care about privacy (Tor video routing is also provided as an option). Tor has high latency, so this will not be as fast network-wise as regular YouTube. However, using Tor is optional; when not routing through Tor, video pages may load faster than they do with YouTube's page depending on your browser. <p align="center">
<a href="#features">Features</a> •
<a href="#installation">Installation</a> •
<a href="#usage">Usage</a> •
<a href="#tor">Tor</a> •
<a href="#development">Development</a>
</p>
The YouTube API is not used, so no keys or anything are needed. It uses the same requests as the YouTube webpage. ---
## Screenshots ## What is it?
[Light theme](https://pic.infini.fr/l7WINjzS/0Ru6MrhA.png) yt-local is a local web server that acts as a proxy between your browser and YouTube. Pages load fast, without ads or tracking. Requests can optionally go through Tor.
[Gray theme](https://pic.infini.fr/znnQXWNc/hL78CRzo.png) **It doesn't use the YouTube API.** It makes the same requests the normal website would, minus all the rest.
[Dark theme](https://pic.infini.fr/iXwFtTWv/mt2kS5bv.png)
[Channel](https://pic.infini.fr/JsenWVYe/SbdIQlS6.png)
## Features ## Features
* Standard pages of YouTube: search, channels, playlists
* Anonymity from Google's tracking by routing requests through Tor
* Local playlists: These solve the two problems with creating playlists on YouTube: (1) they're datamined and (2) videos frequently get deleted by YouTube and lost from the playlist, making it very difficult to find a reupload as the title of the deleted video is not displayed.
* Themes: Light, Gray, and Dark
* Subtitles
* Easily download videos or their audio. (Disabled by default)
* No ads
* View comments
* JavaScript not required
* Theater and non-theater mode
* Subscriptions that are independent from YouTube
* Can import subscriptions from YouTube
* Works by checking channels individually
* Can be set to automatically check channels.
* For efficiency of requests, frequency of checking is based on how quickly channel posts videos
* Can mute channels, so as to have a way to "soft" unsubscribe. Muted channels won't be checked automatically or when using the "Check all" button. Videos from these channels will be hidden.
* Can tag subscriptions to organize them or check specific tags
* Fast page
* No distracting/slow layout rearrangement
* No lazy-loading of comments; they are ready instantly.
* Settings allow fine-tuned control over when/how comments or related videos are shown:
1. Shown by default, with click to hide
2. Hidden by default, with click to show
3. Never shown
* Optionally skip sponsored segments using [SponsorBlock](https://github.com/ajayyy/SponsorBlock)'s API
* Custom video speeds
* Video transcript
* Supports all available video qualities: 144p through 2160p
## Planned features - Anonymous browsing with **optional Tor**
- [ ] Putting videos from subscriptions or local playlists into the related videos - No ads or JavaScript required
- [x] Information about video (geographic regions, region of Tor exit node, etc) - Three themes: light, gray, dark
- [ ] Ability to delete playlists - Local playlists (videos aren't lost when YouTube removes them)
- [ ] Auto-saving of local playlist videos - Subscription system independent from YouTube
- [ ] Import youtube playlist into a local playlist - Subtitle support
- [ ] Rearrange items of local playlist - SponsorBlock segments skipped automatically
- [x] Video qualities other than 360p and 720p by muxing video and audio - Qualities from 144p to 2160p with HLS support
- [x] Indicate if comments are disabled - Video download (disabled by default)
- [x] Indicate how many comments a video has - Comments load instantly, no lazy-loading
- [ ] Featured channels page
- [ ] Channel comments
- [x] Video transcript
- [x] Automatic Tor circuit change when blocked
- [x] Support &t parameter
- [ ] Subscriptions: Option to mark what has been watched
- [ ] Subscriptions: Option to filter videos based on keywords in title or description
- [ ] Subscriptions: Delete old entries and thumbnails
- [ ] Support for more sites, such as Vimeo, Dailymotion, LBRY, etc.
## Installing ## Installation
### Linux / macOS
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python server.py
```
### Windows ### Windows
Download the zip file under the Releases page. Unzip it anywhere you choose. Download the zip from [Releases](https://github.com/user234683/youtube-local/releases) and run `run.bat`.
### GNU+Linux/MacOS ### With Make
Download the tarball under the Releases page and extract it. `cd` into the directory and run ```bash
make install # Dependencies
1. `cd yt-local` make dev # Server
2. `virtualenv -p python3 venv` make test # Tests
3. `source venv/bin/activate` make help # All commands
4. `pip install -r requirements.txt` ```
5. `python server.py`
**Note**: If pip isn't installed, first try installing it from your package manager. Make sure you install pip for python 3. For example, the package you need on debian is python3-pip rather than python-pip. If your package manager doesn't provide it, try to install it according to [this answer](https://unix.stackexchange.com/a/182467), but make sure you run `python3 get-pip.py` instead of `python get-pip.py`
## Usage ## Usage
Firstly, if you wish to run this in portable mode, create the empty file "settings.txt" in the program's main directory. If the file is there, settings and data will be stored in the same directory as the program. Otherwise, settings and data will be stored in `C:\Users\[your username]\.yt-local` on Windows and `~/.yt-local` on GNU+Linux/MacOS. The server starts at `http://localhost:9010/`
To run the program on windows, open `run.bat`. On GNU+Linux/MacOS, run `python3 server.py`. To watch a video, prefix the YouTube URL:
Access youtube URLs by prefixing them with `http://localhost:9010/`. ```
For instance, `http://localhost:9010/https://www.youtube.com/watch?v=vBgulDeV2RU` http://localhost:9010/https://www.youtube.com/watch?v=VIDEO_ID
You can use an addon such as Redirector ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/redirector/)|[Chrome](https://chrome.google.com/webstore/detail/redirector/ocgpenflpmgnfapjedencafcfakcekcd)) to automatically redirect YouTube URLs to yt-local. I use the include pattern `^(https?://(?:[a-zA-Z0-9_-]*\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)/.*)` and redirect pattern `http://localhost:9010/$1` (Make sure you're using regular expression mode). ```
If you want embeds on web to also redirect to yt-local, make sure "Iframes" is checked under advanced options in your redirector rule. Check test `http://localhost:9010/youtube.com/embed/vBgulDeV2RU` ### Redirector
yt-local can be added as a search engine in firefox to make searching more convenient. See [here](https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox) for information on firefox search plugins. You can use a browser extension like [Redirector](https://github.com/einaregilsson/Redirector) to redirect YouTube automatically:
### Using Tor | Field | Value |
|-------|-------|
| Pattern | `^(https?://(?:[a-zA-Z0-9_-]*\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)/.*)` |
| Redirect to | `http://localhost:9010/$1` |
| Mode | Regular expression |
In the settings page, set "Route Tor" to "On, except video" (the second option). Be sure to save the settings. Enable "Iframes" in advanced options so embedded videos work too.
Ensure Tor is listening for Socks5 connections on port 9150. A simple way to accomplish this is by opening the Tor Browser Bundle and leaving it open. However, you will not be accessing the program (at https://localhost:8080) through the Tor Browser. You will use your regular browser for that. Rather, this is just a quick way to give the program access to Tor routing. ### Portable mode
Create an empty `settings.txt` file in the main directory. All settings and data will be stored there instead of `~/.yt-local`.
## Tor
### Quick option: Tor Browser
Open Tor Browser and leave it running. In yt-local settings set "Route Tor" to "On, except video".
### Standalone Tor ### Standalone Tor
If you don't want to waste system resources leaving the Tor Browser open in addition to your regular browser, you can configure standalone Tor to run instead using the following instructions. **Debian/Ubuntu:**
For Windows, to make standalone Tor run at startup, press Windows Key + R and type `shell:startup` to open the Startup folder. Create a new shortcut there. For the command of the shortcut, enter `"C:\[path-to-Tor-Browser-directory]\Tor\tor.exe" SOCKSPort 9150 ControlPort 9151`. You can then launch this shortcut to start it. Alternatively, if something isn't working, to see what's wrong, open `cmd.exe` and go to the directory `C:\[path-to-Tor-Browser-directory]\Tor`. Then run `tor SOCKSPort 9150 ControlPort 9151 | more`. The `more` part at the end is just to make sure any errors are displayed, to fix a bug in Windows cmd where tor doesn't display any output. You can stop tor in the task manager.
For Debian/Ubuntu, you can `sudo apt install tor` to install the command line version of Tor, and then run `sudo systemctl start tor` to run it as a background service that will get started during boot as well. However, Tor on the command line uses the port `9050` by default (rather than the 9150 used by the Tor Browser). So you will need to change `Tor port` to 9050 and `Tor control port` to `9051` in yt-local settings page. Additionally, you will need to enable the Tor control port by uncommenting the line `ControlPort 9051`, and setting `CookieAuthentication` to 0 in `/etc/tor/torrc`. If no Tor package is available for your distro, you can configure the `tor` binary located at `./Browser/TorBrowser/Tor/tor` inside the Tor Browser installation location to run at start time, or create a service to do it.
### Tor video routing
If you wish to route the video through Tor, set "Route Tor" to "On, including video". Because this is bandwidth-intensive, you are strongly encouraged to donate to the [consortium of Tor node operators](https://torservers.net/donate.html). For instance, donations to [NoiseTor](https://noisetor.net/) go straight towards funding nodes. Using their numbers for bandwidth costs, together with an average of 485 kbit/sec for a diverse sample of videos, and assuming n hours of video watched per day, gives $0.03n/month. A $1/month donation will be a very generous amount to not only offset losses, but help keep the network healthy.
In general, Tor video routing will be slower (for instance, moving around in the video is quite slow). I've never seen any signs that watch history in yt-local affects on-site Youtube recommendations. It's likely that requests to googlevideo are logged for some period of time, but are not integrated into Youtube's larger advertisement/recommendation systems, since those presumably depend more heavily on in-page tracking through Javascript rather than CDN requests to googlevideo.
### Importing subscriptions
1. Go to the [Google takeout manager](https://takeout.google.com/takeout/custom/youtube).
2. Log in if asked.
3. Click on "All data included", then on "Deselect all", then select only "subscriptions" and click "OK".
4. Click on "Next step" and then on "Create export".
5. Click on the "Download" button after it appears.
6. From the downloaded takeout zip extract the .csv file. It is usually located under `YouTube and YouTube Music/subscriptions/subscriptions.csv`
7. Go to the subscriptions manager in yt-local. In the import area, select your .csv file, then press import.
Supported subscriptions import formats:
- NewPipe subscriptions export JSON
- Google Takeout CSV
- Old Google Takeout JSON
- OPML format from now-removed YouTube subscriptions manager
## Contributing
Pull requests and issues are welcome
For coding guidelines and an overview of the software architecture, see the [HACKING.md](docs/HACKING.md) file.
## GPG public KEY
```bash ```bash
72CFB264DFC43F63E098F926E607CE7149F4D71C sudo apt install tor
sudo systemctl start tor
```
Change the Tor port to `9050` in settings.
**Windows:**
Create a shortcut with: `tor.exe SOCKSPort 9150 ControlPort 9151`
### Video routing through Tor
Set "Route Tor" to "On, including video". It's slower and consumes Tor bandwidth — consider [donating to Tor nodes](https://torservers.net/donate.html).
## Import subscriptions
1. Go to [Google Takeout](https://takeout.google.com/takeout/custom/youtube)
2. Select only "subscriptions" and download the CSV
3. In yt-local, import the file from the subscriptions manager
Supported formats: Google Takeout CSV, NewPipe JSON, OPML, and old Google Takeout JSON.
## Development
```
yt-local/
├── server.py # Entry point, WSGI server
├── settings.py # Configuration management
├── youtube/
│ ├── util.py # Utility functions, Tor routing
│ ├── watch.py # Video page
│ ├── channel.py # Channel pages
│ ├── search.py # Search
│ ├── subscriptions.py # Subscriptions (SQLite)
│ ├── local_playlist.py # Local playlists
│ ├── yt_data_extract/ # YouTube JSON parser
│ └── ...
├── tests/ # Tests with pytest
└── docs/HACKING.md # Development guide
``` ```
## Public instances ### Useful commands
yt-local is not made to work in public mode, however there is an instance of yt-local in public mode but with less features ```bash
make test # Run tests
make lint # Check style
make i18n-extract # Extract strings
make i18n-init LANG_CODE=fr # New language
make i18n-compile # Compile translations
```
- <https://m.fridu.us/https://youtube.com> Python **3.8+** required. Code follows PEP 8. Commit messages should be descriptive.
### Compatibility Notes
- **Python 3.8+**: Minimum required version
- **HLS**: Experimental streaming support for multi-audio videos
- **Channels with limitations**: Some channels return few videos per response. This is YouTube behavior, not yt-local. The project uses YouTube's internal API (innertube), **not the public YouTube API**.
## License ## License
This project is licensed under the GNU Affero General Public License v3 (GNU AGPLv3) or any later version. GNU AGPLv3 or later. See [LICENSE](LICENSE).
Permission is hereby granted to the youtube-dl project at [https://github.com/ytdl-org/youtube-dl](https://github.com/ytdl-org/youtube-dl) to relicense any portion of this software under the Unlicense, public domain, or whichever license is in use by youtube-dl at the time of relicensing, for the purpose of inclusion of said portion into youtube-dl. Relicensing permission is not granted for any purpose outside of direct inclusion into the [official repository](https://github.com/ytdl-org/youtube-dl) of youtube-dl. If inclusion happens during the process of a pull-request, relicensing happens at the moment the pull request is merged into youtube-dl; until that moment, any cloned repositories of youtube-dl which make use of this software are subject to the terms of the GNU AGPLv3.
## Donate
This project is completely free/Libre and will always be.
#### Crypto:
- **Bitcoin**: `1JrC3iqs3PP5Ge1m1vu7WE8LEf4S85eo7y`
## Similar projects ## Similar projects
- [invidious](https://github.com/iv-org/invidious) Similar to this project, but also allows it to be hosted as a server to serve many users - [invidious](https://github.com/iv-org/invidious) Similar to this project, but also allows it to be hosted as a server to serve many users
- [Yotter](https://github.com/ytorg/Yotter) Similar to this project and to invidious. Also supports Twitter - [Yotter](https://github.com/ytorg/Yotter) Similar to this project and to invidious. Also supports Twitter
- [FreeTube](https://github.com/FreeTubeApp/FreeTube) (Similar to this project, but is an electron app outside the browser) - [FreeTube](https://github.com/FreeTubeApp/FreeTube) (Similar to this project, but is an electron app outside the browser)

View File

@@ -1,7 +1,16 @@
[python: youtube/**.py] [python: youtube/**.py]
keywords = lazy_gettext:1,2 _l:1,2
[python: server.py]
[python: settings.py]
[jinja2: youtube/templates/**.html]
extensions=jinja2.ext.i18n
encoding = utf-8 encoding = utf-8
keywords = lazy_gettext _l _
[python: server.py]
encoding = utf-8
keywords = _
[python: settings.py]
encoding = utf-8
keywords = _
[jinja2: youtube/templates/**.html]
encoding = utf-8
extensions=jinja2.ext.i18n
silent=false

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

@@ -1,4 +1,18 @@
from youtube import util from youtube import util
from youtube.i18n_strings import (
AUTO,
AUTO_HLS_PREFERRED,
ENGLISH,
ESPANOL,
FORCE_DASH,
FORCE_HLS,
NEWEST,
PLAYBACK_MODE,
RANKING_1,
RANKING_2,
RANKING_3,
TOP,
)
import ast import ast
import re import re
import os import os
@@ -139,8 +153,8 @@ For security reasons, enabling this is not recommended.''',
'comment': '''0 to sort by top 'comment': '''0 to sort by top
1 to sort by newest''', 1 to sort by newest''',
'options': [ 'options': [
(0, 'Top'), (0, TOP),
(1, 'Newest'), (1, NEWEST),
], ],
}), }),
@@ -159,18 +173,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',
}), }),
@@ -180,7 +208,7 @@ For security reasons, enabling this is not recommended.''',
'default': 1, 'default': 1,
'label': 'AV1 Codec Ranking', 'label': 'AV1 Codec Ranking',
'comment': '', 'comment': '',
'options': [(1, '#1'), (2, '#2'), (3, '#3')], 'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
'category': 'playback', 'category': 'playback',
}), }),
@@ -189,7 +217,7 @@ For security reasons, enabling this is not recommended.''',
'default': 2, 'default': 2,
'label': 'VP8/VP9 Codec Ranking', 'label': 'VP8/VP9 Codec Ranking',
'comment': '', 'comment': '',
'options': [(1, '#1'), (2, '#2'), (3, '#3')], 'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
'category': 'playback', 'category': 'playback',
}), }),
@@ -198,7 +226,7 @@ For security reasons, enabling this is not recommended.''',
'default': 3, 'default': 3,
'label': 'H.264 Codec Ranking', 'label': 'H.264 Codec Ranking',
'comment': '', 'comment': '',
'options': [(1, '#1'), (2, '#2'), (3, '#3')], 'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
'category': 'playback', 'category': 'playback',
'description': ( 'description': (
'Which video codecs to prefer. Codecs given the same ' 'Which video codecs to prefer. Codecs given the same '
@@ -217,7 +245,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', {
@@ -301,8 +330,8 @@ Archive: https://archive.ph/OZQbN''',
'default': 'en', 'default': 'en',
'comment': 'Interface language', 'comment': 'Interface language',
'options': [ 'options': [
('en', 'English'), ('en', ENGLISH),
('es', 'Español'), ('es', ESPANOL),
], ],
'category': 'interface', 'category': 'interface',
}), }),
@@ -470,12 +499,16 @@ else:
else: else:
# parse settings in a safe way, without exec # parse settings in a safe way, without exec
current_settings_dict = {} current_settings_dict = {}
# Python 3.8+ uses ast.Constant; older versions use ast.Num, ast.Str, ast.NameConstant
attributes = { attributes = {
ast.Constant: 'value', ast.Constant: 'value',
ast.NameConstant: 'value',
ast.Num: 'n',
ast.Str: 's',
} }
try:
attributes[ast.Num] = 'n'
attributes[ast.Str] = 's'
attributes[ast.NameConstant] = 'value'
except AttributeError:
pass # Removed in Python 3.12+
module_node = ast.parse(settings_text) module_node = ast.parse(settings_text)
for node in module_node.body: for node in module_node.body:
if type(node) != ast.Assign: if type(node) != ast.Assign:
@@ -522,7 +555,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 +575,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:

View File

@@ -1,14 +1,16 @@
# Spanish translations for yt-local. # Spanish translations template for PROJECT.
# Copyright (C) 2026 yt-local # Copyright (C) 2026 ORGANIZATION
# This file is distributed under the same license as the yt-local project. # This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
# #
#, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-03-22 15:05-0500\n" "POT-Creation-Date: 2026-04-05 16:52-0500\n"
"PO-Revision-Date: 2026-03-22 15:06-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: \n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n" "Language: es\n"
"Language-Team: es <LL@li.org>\n" "Language-Team: es <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -17,58 +19,415 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n" "Generated-By: Babel 2.18.0\n"
#: youtube/templates/base.html:38 #: youtube/i18n_strings.py:13
msgid "Type to search..." msgid "Network"
msgstr "Escribe para buscar..." msgstr "Red"
#: youtube/templates/base.html:39 #: youtube/i18n_strings.py:14
msgid "Search" msgid "Playback"
msgstr "Buscar" msgstr "Reproducción"
#: youtube/templates/base.html:45 #: youtube/i18n_strings.py:15
msgid "Options" msgid "Interface"
msgstr "Opciones" msgstr "Interfaz"
#: youtube/templates/base.html:47 #: youtube/i18n_strings.py:18
msgid "Route Tor"
msgstr "Enrutar por Tor"
#: youtube/i18n_strings.py:19
msgid "Default subtitles mode"
msgstr "Modo de subtítulos predeterminado"
#: youtube/i18n_strings.py:20
msgid "AV1 Codec Ranking"
msgstr "Prioridad códec AV1"
#: youtube/i18n_strings.py:21
msgid "VP8/VP9 Codec Ranking"
msgstr "Prioridad códec VP8/VP9"
#: youtube/i18n_strings.py:22
msgid "H.264 Codec Ranking"
msgstr "Prioridad códec H.264"
#: youtube/i18n_strings.py:23
msgid "Use integrated sources"
msgstr "Usar fuentes integradas"
#: youtube/i18n_strings.py:24
msgid "Route images"
msgstr "Enrutar imágenes"
#: youtube/i18n_strings.py:25
msgid "Enable comments.js"
msgstr "Activar comments.js"
#: youtube/i18n_strings.py:26
msgid "Enable SponsorBlock"
msgstr "Activar SponsorBlock"
#: youtube/i18n_strings.py:27
msgid "Enable embed page"
msgstr "Activar página embed"
#: youtube/i18n_strings.py:30
msgid "Related videos mode"
msgstr "Modo videos relacionados"
#: youtube/i18n_strings.py:31
msgid "Comments mode"
msgstr "Modo comentarios"
#: youtube/i18n_strings.py:32
msgid "Enable comment avatars"
msgstr "Activar avatares en comentarios"
#: youtube/i18n_strings.py:33
msgid "Default comment sorting"
msgstr "Orden de comentarios predeterminado"
#: youtube/i18n_strings.py:34
msgid "Theater mode"
msgstr "Modo teatro"
#: youtube/i18n_strings.py:35
msgid "Autoplay videos"
msgstr "Reproducción automática"
#: youtube/i18n_strings.py:36
msgid "Default resolution"
msgstr "Resolución predeterminada"
#: youtube/i18n_strings.py:37
msgid "Use video player"
msgstr "Usar reproductor de video"
#: youtube/i18n_strings.py:38
msgid "Use video download"
msgstr "Usar descarga de video"
#: youtube/i18n_strings.py:39
msgid "Proxy images"
msgstr "Imágenes por proxy"
#: youtube/i18n_strings.py:40
msgid "Theme"
msgstr "Tema"
#: youtube/i18n_strings.py:41
msgid "Font"
msgstr "Fuente"
#: youtube/i18n_strings.py:42
msgid "Language"
msgstr "Idioma"
#: youtube/i18n_strings.py:43
msgid "Embed page mode"
msgstr "Modo página embed"
#: youtube/i18n_strings.py:46
msgid "Off"
msgstr "Apagado"
#: youtube/i18n_strings.py:47
msgid "On"
msgstr "Encendido"
#: youtube/i18n_strings.py:48
msgid "Disabled"
msgstr "Deshabilitado"
#: youtube/i18n_strings.py:49
msgid "Enabled"
msgstr "Habilitado"
#: youtube/i18n_strings.py:50
msgid "Always shown"
msgstr "Siempre visible"
#: youtube/i18n_strings.py:51
msgid "Shown by clicking button"
msgstr "Mostrar al hacer clic"
#: youtube/i18n_strings.py:52
msgid "Native"
msgstr "Nativo"
#: youtube/i18n_strings.py:53
msgid "Native with hotkeys"
msgstr "Nativo con atajos"
#: youtube/i18n_strings.py:54
msgid "Plyr"
msgstr "Plyr"
#: youtube/i18n_strings.py:57
msgid "Light"
msgstr "Claro"
#: youtube/i18n_strings.py:58
msgid "Gray"
msgstr "Gris"
#: youtube/i18n_strings.py:59
msgid "Dark"
msgstr "Oscuro"
#: youtube/i18n_strings.py:62
msgid "Browser default"
msgstr "Predeterminado del navegador"
#: youtube/i18n_strings.py:63
msgid "Liberation Serif"
msgstr ""
#: youtube/i18n_strings.py:64
msgid "Arial"
msgstr ""
#: youtube/i18n_strings.py:65
msgid "Verdana"
msgstr ""
#: youtube/i18n_strings.py:66
msgid "Tahoma"
msgstr ""
#: youtube/i18n_strings.py:69 youtube/templates/base.html:53
msgid "Sort by" msgid "Sort by"
msgstr "Ordenar por" msgstr "Ordenar por"
#: youtube/templates/base.html:50 #: youtube/i18n_strings.py:70 youtube/templates/base.html:56
msgid "Relevance" msgid "Relevance"
msgstr "Relevancia" msgstr "Relevancia"
#: youtube/templates/base.html:54 youtube/templates/base.html:65 #: youtube/i18n_strings.py:71 youtube/templates/base.html:60
#: youtube/templates/base.html:71
msgid "Upload date" msgid "Upload date"
msgstr "Fecha de subida" msgstr "Fecha de subida"
#: youtube/templates/base.html:58 #: youtube/i18n_strings.py:72 youtube/templates/base.html:64
msgid "View count" msgid "View count"
msgstr "Número de visualizaciones" msgstr "Número de visualizaciones"
#: youtube/templates/base.html:62 #: youtube/i18n_strings.py:73 youtube/templates/base.html:68
msgid "Rating" msgid "Rating"
msgstr "Calificación" msgstr "Calificación"
#: youtube/templates/base.html:68 #: youtube/i18n_strings.py:76 youtube/templates/base.html:74
msgid "Any" msgid "Any"
msgstr "Cualquiera" msgstr "Cualquiera"
#: youtube/templates/base.html:72 #: youtube/i18n_strings.py:77 youtube/templates/base.html:78
msgid "Last hour" msgid "Last hour"
msgstr "Última hora" msgstr "Última hora"
#: youtube/templates/base.html:76 #: youtube/i18n_strings.py:78 youtube/templates/base.html:82
msgid "Today" msgid "Today"
msgstr "Hoy" msgstr "Hoy"
#: youtube/templates/base.html:80 #: youtube/i18n_strings.py:79 youtube/templates/base.html:86
msgid "This week" msgid "This week"
msgstr "Esta semana" msgstr "Esta semana"
#: youtube/templates/base.html:84 #: youtube/i18n_strings.py:80 youtube/templates/base.html:90
msgid "This month" msgid "This month"
msgstr "Este mes" msgstr "Este mes"
#: youtube/templates/base.html:88 #: youtube/i18n_strings.py:81 youtube/templates/base.html:94
msgid "This year" msgid "This year"
msgstr "Este año" msgstr "Este año"
#: youtube/i18n_strings.py:84
msgid "Type"
msgstr ""
#: youtube/i18n_strings.py:85
msgid "Video"
msgstr ""
#: youtube/i18n_strings.py:86
msgid "Channel"
msgstr ""
#: youtube/i18n_strings.py:87
msgid "Playlist"
msgstr ""
#: youtube/i18n_strings.py:88
msgid "Movie"
msgstr ""
#: youtube/i18n_strings.py:89
msgid "Show"
msgstr ""
#: youtube/i18n_strings.py:92
msgid "Duration"
msgstr ""
#: youtube/i18n_strings.py:93
msgid "Short (< 4 minutes)"
msgstr ""
#: youtube/i18n_strings.py:94
msgid "Long (> 20 minutes)"
msgstr ""
#: youtube/i18n_strings.py:97 youtube/templates/base.html:45
msgid "Search"
msgstr "Buscar"
#: youtube/i18n_strings.py:98 youtube/templates/watch.html:104
msgid "Download"
msgstr "Descargar"
#: youtube/i18n_strings.py:99
msgid "Subscribe"
msgstr ""
#: youtube/i18n_strings.py:100
msgid "Unsubscribe"
msgstr ""
#: youtube/i18n_strings.py:101
msgid "Import"
msgstr ""
#: youtube/i18n_strings.py:102
msgid "Export"
msgstr ""
#: youtube/i18n_strings.py:103
msgid "Save"
msgstr ""
#: youtube/i18n_strings.py:104
msgid "Check"
msgstr ""
#: youtube/i18n_strings.py:105
msgid "Mute"
msgstr ""
#: youtube/i18n_strings.py:106
msgid "Unmute"
msgstr ""
#: youtube/i18n_strings.py:109 youtube/templates/base.html:51
msgid "Options"
msgstr "Opciones"
#: youtube/i18n_strings.py:110
msgid "Settings"
msgstr ""
#: youtube/i18n_strings.py:111
msgid "Error"
msgstr ""
#: youtube/i18n_strings.py:112
msgid "loading..."
msgstr ""
#: youtube/i18n_strings.py:115
msgid "Top"
msgstr "Popularidad"
#: youtube/i18n_strings.py:116
msgid "Newest"
msgstr "Más reciente"
#: youtube/i18n_strings.py:117
msgid "Auto"
msgstr "Automático"
#: youtube/i18n_strings.py:118
msgid "English"
msgstr "Inglés"
#: youtube/i18n_strings.py:119
msgid "Español"
msgstr "Español"
#: youtube/i18n_strings.py:122
msgid "Auto (HLS preferred)"
msgstr "Auto (HLS preferido)"
#: youtube/i18n_strings.py:123
msgid "Force HLS"
msgstr "Forzar HLS"
#: youtube/i18n_strings.py:124
msgid "Force DASH"
msgstr "Forzar DASH"
#: youtube/i18n_strings.py:125
msgid "#1"
msgstr "#1"
#: youtube/i18n_strings.py:126
msgid "#2"
msgstr "#2"
#: youtube/i18n_strings.py:127
msgid "#3"
msgstr "#3"
#: youtube/i18n_strings.py:130 youtube/templates/settings.html:53
msgid "Save settings"
msgstr "Guardar configuración"
#: youtube/i18n_strings.py:133
msgid "Other"
msgstr "Otros"
#: youtube/i18n_strings.py:136
msgid "Playback mode"
msgstr "Modo de reproducción"
#: youtube/i18n_strings.py:139
msgid "Autocheck subscriptions"
msgstr "Verificar suscripciones automáticamente"
#: youtube/i18n_strings.py:140
msgid "Include shorts in subscriptions"
msgstr "Incluir shorts en suscripciones"
#: youtube/i18n_strings.py:141
msgid "Include shorts in channel"
msgstr "Incluir shorts en el canal"
#: youtube/templates/base.html:44
msgid "Type to search..."
msgstr "Escribe para buscar..."
#: youtube/templates/comments.html:61
msgid "More comments"
msgstr "Más comentarios"
#: youtube/templates/watch.html:100
msgid "Direct Link"
msgstr "Enlace directo"
#: youtube/templates/watch.html:152
msgid "More info"
msgstr "Más información"
#: youtube/templates/watch.html:176 youtube/templates/watch.html:203
msgid "AutoNext"
msgstr "Siguiente automático"
#: youtube/templates/watch.html:225
msgid "Related Videos"
msgstr "Videos relacionados"
#: youtube/templates/watch.html:239
msgid "Comments disabled"
msgstr "Comentarios deshabilitados"
#: youtube/templates/watch.html:242
msgid "Comment"
msgstr "Comentario"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-03-22 15:05-0500\n" "POT-Creation-Date: 2026-04-05 16:52-0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,59 +17,415 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n" "Generated-By: Babel 2.18.0\n"
#: youtube/templates/base.html:38 #: youtube/i18n_strings.py:13
msgid "Type to search..." msgid "Network"
msgstr "" msgstr ""
#: youtube/templates/base.html:39 #: youtube/i18n_strings.py:14
msgid "Search" msgid "Playback"
msgstr "" msgstr ""
#: youtube/templates/base.html:45 #: youtube/i18n_strings.py:15
msgid "Options" msgid "Interface"
msgstr "" msgstr ""
#: youtube/templates/base.html:47 #: youtube/i18n_strings.py:18
msgid "Route Tor"
msgstr ""
#: youtube/i18n_strings.py:19
msgid "Default subtitles mode"
msgstr ""
#: youtube/i18n_strings.py:20
msgid "AV1 Codec Ranking"
msgstr ""
#: youtube/i18n_strings.py:21
msgid "VP8/VP9 Codec Ranking"
msgstr ""
#: youtube/i18n_strings.py:22
msgid "H.264 Codec Ranking"
msgstr ""
#: youtube/i18n_strings.py:23
msgid "Use integrated sources"
msgstr ""
#: youtube/i18n_strings.py:24
msgid "Route images"
msgstr ""
#: youtube/i18n_strings.py:25
msgid "Enable comments.js"
msgstr ""
#: youtube/i18n_strings.py:26
msgid "Enable SponsorBlock"
msgstr ""
#: youtube/i18n_strings.py:27
msgid "Enable embed page"
msgstr ""
#: youtube/i18n_strings.py:30
msgid "Related videos mode"
msgstr ""
#: youtube/i18n_strings.py:31
msgid "Comments mode"
msgstr ""
#: youtube/i18n_strings.py:32
msgid "Enable comment avatars"
msgstr ""
#: youtube/i18n_strings.py:33
msgid "Default comment sorting"
msgstr ""
#: youtube/i18n_strings.py:34
msgid "Theater mode"
msgstr ""
#: youtube/i18n_strings.py:35
msgid "Autoplay videos"
msgstr ""
#: youtube/i18n_strings.py:36
msgid "Default resolution"
msgstr ""
#: youtube/i18n_strings.py:37
msgid "Use video player"
msgstr ""
#: youtube/i18n_strings.py:38
msgid "Use video download"
msgstr ""
#: youtube/i18n_strings.py:39
msgid "Proxy images"
msgstr ""
#: youtube/i18n_strings.py:40
msgid "Theme"
msgstr ""
#: youtube/i18n_strings.py:41
msgid "Font"
msgstr ""
#: youtube/i18n_strings.py:42
msgid "Language"
msgstr ""
#: youtube/i18n_strings.py:43
msgid "Embed page mode"
msgstr ""
#: youtube/i18n_strings.py:46
msgid "Off"
msgstr ""
#: youtube/i18n_strings.py:47
msgid "On"
msgstr ""
#: youtube/i18n_strings.py:48
msgid "Disabled"
msgstr ""
#: youtube/i18n_strings.py:49
msgid "Enabled"
msgstr ""
#: youtube/i18n_strings.py:50
msgid "Always shown"
msgstr ""
#: youtube/i18n_strings.py:51
msgid "Shown by clicking button"
msgstr ""
#: youtube/i18n_strings.py:52
msgid "Native"
msgstr ""
#: youtube/i18n_strings.py:53
msgid "Native with hotkeys"
msgstr ""
#: youtube/i18n_strings.py:54
msgid "Plyr"
msgstr ""
#: youtube/i18n_strings.py:57
msgid "Light"
msgstr ""
#: youtube/i18n_strings.py:58
msgid "Gray"
msgstr ""
#: youtube/i18n_strings.py:59
msgid "Dark"
msgstr ""
#: youtube/i18n_strings.py:62
msgid "Browser default"
msgstr ""
#: youtube/i18n_strings.py:63
msgid "Liberation Serif"
msgstr ""
#: youtube/i18n_strings.py:64
msgid "Arial"
msgstr ""
#: youtube/i18n_strings.py:65
msgid "Verdana"
msgstr ""
#: youtube/i18n_strings.py:66
msgid "Tahoma"
msgstr ""
#: youtube/i18n_strings.py:69 youtube/templates/base.html:53
msgid "Sort by" msgid "Sort by"
msgstr "" msgstr ""
#: youtube/templates/base.html:50 #: youtube/i18n_strings.py:70 youtube/templates/base.html:56
msgid "Relevance" msgid "Relevance"
msgstr "" msgstr ""
#: youtube/templates/base.html:54 youtube/templates/base.html:65 #: youtube/i18n_strings.py:71 youtube/templates/base.html:60
#: youtube/templates/base.html:71
msgid "Upload date" msgid "Upload date"
msgstr "" msgstr ""
#: youtube/templates/base.html:58 #: youtube/i18n_strings.py:72 youtube/templates/base.html:64
msgid "View count" msgid "View count"
msgstr "" msgstr ""
#: youtube/templates/base.html:62 #: youtube/i18n_strings.py:73 youtube/templates/base.html:68
msgid "Rating" msgid "Rating"
msgstr "" msgstr ""
#: youtube/templates/base.html:68 #: youtube/i18n_strings.py:76 youtube/templates/base.html:74
msgid "Any" msgid "Any"
msgstr "" msgstr ""
#: youtube/templates/base.html:72 #: youtube/i18n_strings.py:77 youtube/templates/base.html:78
msgid "Last hour" msgid "Last hour"
msgstr "" msgstr ""
#: youtube/templates/base.html:76 #: youtube/i18n_strings.py:78 youtube/templates/base.html:82
msgid "Today" msgid "Today"
msgstr "" msgstr ""
#: youtube/templates/base.html:80 #: youtube/i18n_strings.py:79 youtube/templates/base.html:86
msgid "This week" msgid "This week"
msgstr "" msgstr ""
#: youtube/templates/base.html:84 #: youtube/i18n_strings.py:80 youtube/templates/base.html:90
msgid "This month" msgid "This month"
msgstr "" msgstr ""
#: youtube/templates/base.html:88 #: youtube/i18n_strings.py:81 youtube/templates/base.html:94
msgid "This year" msgid "This year"
msgstr "" msgstr ""
#: youtube/i18n_strings.py:84
msgid "Type"
msgstr ""
#: youtube/i18n_strings.py:85
msgid "Video"
msgstr ""
#: youtube/i18n_strings.py:86
msgid "Channel"
msgstr ""
#: youtube/i18n_strings.py:87
msgid "Playlist"
msgstr ""
#: youtube/i18n_strings.py:88
msgid "Movie"
msgstr ""
#: youtube/i18n_strings.py:89
msgid "Show"
msgstr ""
#: youtube/i18n_strings.py:92
msgid "Duration"
msgstr ""
#: youtube/i18n_strings.py:93
msgid "Short (< 4 minutes)"
msgstr ""
#: youtube/i18n_strings.py:94
msgid "Long (> 20 minutes)"
msgstr ""
#: youtube/i18n_strings.py:97 youtube/templates/base.html:45
msgid "Search"
msgstr ""
#: youtube/i18n_strings.py:98 youtube/templates/watch.html:104
msgid "Download"
msgstr ""
#: youtube/i18n_strings.py:99
msgid "Subscribe"
msgstr ""
#: youtube/i18n_strings.py:100
msgid "Unsubscribe"
msgstr ""
#: youtube/i18n_strings.py:101
msgid "Import"
msgstr ""
#: youtube/i18n_strings.py:102
msgid "Export"
msgstr ""
#: youtube/i18n_strings.py:103
msgid "Save"
msgstr ""
#: youtube/i18n_strings.py:104
msgid "Check"
msgstr ""
#: youtube/i18n_strings.py:105
msgid "Mute"
msgstr ""
#: youtube/i18n_strings.py:106
msgid "Unmute"
msgstr ""
#: youtube/i18n_strings.py:109 youtube/templates/base.html:51
msgid "Options"
msgstr ""
#: youtube/i18n_strings.py:110
msgid "Settings"
msgstr ""
#: youtube/i18n_strings.py:111
msgid "Error"
msgstr ""
#: youtube/i18n_strings.py:112
msgid "loading..."
msgstr ""
#: youtube/i18n_strings.py:115
msgid "Top"
msgstr ""
#: youtube/i18n_strings.py:116
msgid "Newest"
msgstr ""
#: youtube/i18n_strings.py:117
msgid "Auto"
msgstr ""
#: youtube/i18n_strings.py:118
msgid "English"
msgstr ""
#: youtube/i18n_strings.py:119
msgid "Español"
msgstr ""
#: youtube/i18n_strings.py:122
msgid "Auto (HLS preferred)"
msgstr ""
#: youtube/i18n_strings.py:123
msgid "Force HLS"
msgstr ""
#: youtube/i18n_strings.py:124
msgid "Force DASH"
msgstr ""
#: youtube/i18n_strings.py:125
msgid "#1"
msgstr ""
#: youtube/i18n_strings.py:126
msgid "#2"
msgstr ""
#: youtube/i18n_strings.py:127
msgid "#3"
msgstr ""
#: youtube/i18n_strings.py:130 youtube/templates/settings.html:53
msgid "Save settings"
msgstr ""
#: youtube/i18n_strings.py:133
msgid "Other"
msgstr ""
#: youtube/i18n_strings.py:136
msgid "Playback mode"
msgstr ""
#: youtube/i18n_strings.py:139
msgid "Autocheck subscriptions"
msgstr ""
#: youtube/i18n_strings.py:140
msgid "Include shorts in subscriptions"
msgstr ""
#: youtube/i18n_strings.py:141
msgid "Include shorts in channel"
msgstr ""
#: youtube/templates/base.html:44
msgid "Type to search..."
msgstr ""
#: youtube/templates/comments.html:61
msgid "More comments"
msgstr ""
#: youtube/templates/watch.html:100
msgid "Direct Link"
msgstr ""
#: youtube/templates/watch.html:152
msgid "More info"
msgstr ""
#: youtube/templates/watch.html:176 youtube/templates/watch.html:203
msgid "AutoNext"
msgstr ""
#: youtube/templates/watch.html:225
msgid "Related Videos"
msgstr ""
#: youtube/templates/watch.html:239
msgid "Comments disabled"
msgstr ""
#: youtube/templates/watch.html:242
msgid "Comment"
msgstr ""

View File

@@ -274,6 +274,8 @@ def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1,
# cache entries expire after 30 minutes # cache entries expire after 30 minutes
number_of_videos_cache = cachetools.TTLCache(128, 30*60) number_of_videos_cache = cachetools.TTLCache(128, 30*60)
# Cache for continuation tokens (shorts/streams pagination)
continuation_token_cache = cachetools.TTLCache(512, 15*60)
@cachetools.cached(number_of_videos_cache) @cachetools.cached(number_of_videos_cache)
def get_number_of_videos_channel(channel_id): def get_number_of_videos_channel(channel_id):
if channel_id is None: if channel_id is None:
@@ -473,6 +475,27 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
pl_info = yt_data_extract.extract_playlist_info(pl_json) pl_info = yt_data_extract.extract_playlist_info(pl_json)
number_of_videos = tasks[2].value number_of_videos = tasks[2].value
# Filter out shorts locally if sort=4 (YouTube may not honor API filter)
if sort == '4' and pl_info.get('items'):
pl_info['items'] = [
item for item in pl_info['items']
if not yt_data_extract.is_short(item)
]
# If channel API count is missing/zero, get from playlist metadata
if not number_of_videos or number_of_videos == 0:
try:
metadata_json = playlist.get_playlist_metadata(
'UU' + channel_id[2:],
report_text='Retrieved playlist metadata'
)
metadata_info = yt_data_extract.extract_playlist_info(metadata_json)
metadata_video_count = metadata_info['metadata'].get('video_count')
if metadata_video_count:
number_of_videos = metadata_video_count
except Exception:
pass
info = pl_info info = pl_info
info['channel_id'] = channel_id info['channel_id'] = channel_id
info['current_tab'] = 'videos' info['current_tab'] = 'videos'
@@ -487,7 +510,43 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
if not channel_id: if not channel_id:
channel_id = get_channel_id(base_url) channel_id = get_channel_id(base_url)
# Use youtubei browse API with continuation token for all pages # For shorts/streams, use continuation token from cache or request
if tab in ('shorts', 'streams'):
if ctoken:
# Use ctoken directly from request (passed via pagination)
polymer_json = util.call_youtube_api('web', 'browse', {
'continuation': ctoken,
})
continuation = True
elif page_number > 1:
# For page 2+, get ctoken from cache
cache_key = (channel_id, tab, sort, page_number - 1)
cached_ctoken = continuation_token_cache.get(cache_key)
if cached_ctoken:
polymer_json = util.call_youtube_api('web', 'browse', {
'continuation': cached_ctoken,
})
continuation = True
else:
# Fallback: generate fresh ctoken
page_call = (get_channel_tab, channel_id, str(page_number), sort, tab, int(view))
continuation = True
polymer_json = gevent.spawn(*page_call)
polymer_json.join()
if polymer_json.exception:
raise polymer_json.exception
polymer_json = polymer_json.value
else:
# Page 1: generate fresh ctoken
page_call = (get_channel_tab, channel_id, str(page_number), sort, tab, int(view))
continuation = True
polymer_json = gevent.spawn(*page_call)
polymer_json.join()
if polymer_json.exception:
raise polymer_json.exception
polymer_json = polymer_json.value
else:
# videos tab - original logic
page_call = (get_channel_tab, channel_id, str(page_number), sort, page_call = (get_channel_tab, channel_id, str(page_number), sort,
tab, int(view)) tab, int(view))
continuation = True continuation = True
@@ -505,14 +564,7 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
gevent.joinall(tasks) gevent.joinall(tasks)
util.check_gevent_exceptions(*tasks) util.check_gevent_exceptions(*tasks)
number_of_videos, polymer_json = tasks[0].value, tasks[1].value number_of_videos, polymer_json = tasks[0].value, tasks[1].value
else: # For shorts/streams, polymer_json is already set above, nothing to do here
# For shorts/streams, item count is used instead
polymer_json = gevent.spawn(*page_call)
polymer_json.join()
if polymer_json.exception:
raise polymer_json.exception
polymer_json = polymer_json.value
number_of_videos = 0 # will be replaced by actual item count later
elif tab == 'about': elif tab == 'about':
# polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about') # polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about')
@@ -580,9 +632,13 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
if tab in ('videos', 'shorts', 'streams'): if tab in ('videos', 'shorts', 'streams'):
if tab in ('shorts', 'streams'): if tab in ('shorts', 'streams'):
# For shorts/streams, use the actual item count since # For shorts/streams, use ctoken to determine pagination
# get_number_of_videos_channel counts regular uploads only info['is_last_page'] = (info.get('ctoken') is None)
number_of_videos = len(info.get('items', [])) number_of_videos = len(info.get('items', []))
# Cache the ctoken for next page
if info.get('ctoken'):
cache_key = (channel_id, tab, sort, page_number)
continuation_token_cache[cache_key] = info['ctoken']
info['number_of_videos'] = number_of_videos info['number_of_videos'] = number_of_videos
info['number_of_pages'] = math.ceil(number_of_videos/page_size) if number_of_videos else 1 info['number_of_pages'] = math.ceil(number_of_videos/page_size) if number_of_videos else 1
info['header_playlist_names'] = local_playlist.get_playlist_names() info['header_playlist_names'] = local_playlist.get_playlist_names()

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

View File

@@ -110,3 +110,32 @@ OPTIONS = _l('Options')
SETTINGS = _l('Settings') SETTINGS = _l('Settings')
ERROR = _l('Error') ERROR = _l('Error')
LOADING = _l('loading...') LOADING = _l('loading...')
# Settings option values
TOP = _l('Top')
NEWEST = _l('Newest')
AUTO = _l('Auto')
ENGLISH = _l('English')
ESPANOL = _l('Español')
# Playback options
AUTO_HLS_PREFERRED = _l('Auto (HLS preferred)')
FORCE_HLS = _l('Force HLS')
FORCE_DASH = _l('Force DASH')
RANKING_1 = _l('#1')
RANKING_2 = _l('#2')
RANKING_3 = _l('#3')
# Form actions
SAVE_SETTINGS = _l('Save settings')
# Other category
OTHER = _l('Other')
# Settings labels
PLAYBACK_MODE = _l('Playback mode')
# Subscription settings (may be used in future)
AUTOCHECK_SUBSCRIPTIONS = _l('Autocheck subscriptions')
INCLUDE_SHORTS_SUBSCRIPTIONS = _l('Include shorts in subscriptions')
INCLUDE_SHORTS_CHANNEL = _l('Include shorts in channel')

View File

@@ -8,6 +8,7 @@ import html
import gevent import gevent
import urllib import urllib
import math import math
import glob
import flask import flask
from flask import request from flask import request
@@ -16,11 +17,34 @@ playlists_directory = os.path.join(settings.data_dir, "playlists")
thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails") thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails")
def _find_playlist_path(name):
"""Find playlist file robustly, handling trailing spaces in filenames"""
name = name.strip()
pattern = os.path.join(playlists_directory, name + "*.txt")
files = glob.glob(pattern)
return files[0] if files else os.path.join(playlists_directory, name + ".txt")
def _parse_playlist_lines(data):
"""Parse playlist data lines robustly, skipping empty/malformed entries"""
videos = []
for line in data.splitlines():
clean_line = line.strip()
if not clean_line:
continue
try:
videos.append(json.loads(clean_line))
except json.decoder.JSONDecodeError:
print('Corrupt playlist entry: ' + clean_line)
return videos
def video_ids_in_playlist(name): def video_ids_in_playlist(name):
try: try:
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file: playlist_path = _find_playlist_path(name)
with open(playlist_path, 'r', encoding='utf-8') as file:
videos = file.read() videos = file.read()
return set(json.loads(video)['id'] for video in videos.splitlines()) return set(json.loads(line.strip())['id'] for line in videos.splitlines() if line.strip())
except FileNotFoundError: except FileNotFoundError:
return set() return set()
@@ -29,7 +53,8 @@ def add_to_playlist(name, video_info_list):
os.makedirs(playlists_directory, exist_ok=True) os.makedirs(playlists_directory, exist_ok=True)
ids = video_ids_in_playlist(name) ids = video_ids_in_playlist(name)
missing_thumbnails = [] missing_thumbnails = []
with open(os.path.join(playlists_directory, name + ".txt"), "a", encoding='utf-8') as file: playlist_path = _find_playlist_path(name)
with open(playlist_path, "a", encoding='utf-8') as file:
for info in video_info_list: for info in video_info_list:
id = json.loads(info)['id'] id = json.loads(info)['id']
if id not in ids: if id not in ids:
@@ -67,20 +92,14 @@ def add_extra_info_to_videos(videos, playlist_name):
def read_playlist(name): def read_playlist(name):
'''Returns a list of videos for the given playlist name''' '''Returns a list of videos for the given playlist name'''
playlist_path = os.path.join(playlists_directory, name + '.txt') playlist_path = _find_playlist_path(name)
try:
with open(playlist_path, 'r', encoding='utf-8') as f: with open(playlist_path, 'r', encoding='utf-8') as f:
data = f.read() data = f.read()
except FileNotFoundError:
return []
videos = [] return _parse_playlist_lines(data)
videos_json = data.splitlines()
for video_json in videos_json:
try:
info = json.loads(video_json)
videos.append(info)
except json.decoder.JSONDecodeError:
if not video_json.strip() == '':
print('Corrupt playlist video entry: ' + video_json)
return videos
def get_local_playlist_videos(name, offset=0, amount=50): def get_local_playlist_videos(name, offset=0, amount=50):
@@ -102,14 +121,21 @@ def get_playlist_names():
def remove_from_playlist(name, video_info_list): def remove_from_playlist(name, video_info_list):
ids = [json.loads(video)['id'] for video in video_info_list] ids = [json.loads(video)['id'] for video in video_info_list]
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file: playlist_path = _find_playlist_path(name)
with open(playlist_path, 'r', encoding='utf-8') as file:
videos = file.read() videos = file.read()
videos_in = videos.splitlines() videos_in = videos.splitlines()
videos_out = [] videos_out = []
for video in videos_in: for video in videos_in:
if json.loads(video)['id'] not in ids: clean = video.strip()
videos_out.append(video) if not clean:
with open(os.path.join(playlists_directory, name + ".txt"), 'w', encoding='utf-8') as file: continue
try:
if json.loads(clean)['id'] not in ids:
videos_out.append(clean)
except json.decoder.JSONDecodeError:
pass
with open(playlist_path, 'w', encoding='utf-8') as file:
file.write("\n".join(videos_out) + "\n") file.write("\n".join(videos_out) + "\n")
try: try:

View File

@@ -28,6 +28,32 @@ def playlist_ctoken(playlist_id, offset, include_shorts=True):
return base64.urlsafe_b64encode(pointless_nest).decode('ascii') return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
def get_playlist_metadata(playlist_id, report_text="Retrieved playlist metadata"):
"""Get playlist metadata (video_count, title, etc.) without fetching videos."""
key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
url = f'https://www.youtube.com/youtubei/v1/browse?key={key}'
data = {
'context': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'WEB',
'clientVersion': '2.20240327.00.00',
},
},
'browseId': 'VL' + playlist_id,
}
content_type_header = (('Content-Type', 'application/json'),)
content = util.fetch_url(
url, util.desktop_xhr_headers + content_type_header,
data=json.dumps(data),
report_text=report_text, debug_name='playlist_metadata'
)
return json.loads(content.decode('utf-8'))
def playlist_first_page(playlist_id, report_text="Retrieved playlist", def playlist_first_page(playlist_id, report_text="Retrieved playlist",
use_mobile=False): use_mobile=False):
# Use innertube API (pbj=1 no longer works for many playlists) # Use innertube API (pbj=1 no longer works for many playlists)

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,538 @@
(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 => {
const name = (t.name || '').toLowerCase();
const lang = (t.lang || '').toLowerCase();
return name.includes('original') || lang === '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,329 @@
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 trackIdx = parseInt(this.value);
if (!isNaN(trackIdx) && hls && hls.audioTracks && trackIdx >= 0 && trackIdx < hls.audioTracks.length) {
hls.audioTrack = trackIdx;
console.log('Audio track changed to:', hls.audioTracks[trackIdx].name || trackIdx);
}
});
}
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>';
let originalIdx = -1;
data.audioTracks.forEach((track, idx) => {
// Find "original" track
if (originalIdx === -1 && (track.name || '').toLowerCase().includes('original')) {
originalIdx = idx;
}
const option = document.createElement('option');
option.value = String(idx);
option.textContent = track.name || track.lang || `Track ${idx}`;
audioTrackSelect.appendChild(option);
});
audioTrackSelect.disabled = false;
// Auto-select "original" audio track
if (originalIdx !== -1) {
hls.audioTrack = originalIdx;
audioTrackSelect.value = String(originalIdx);
console.log('Auto-selected original audio track:', data.audioTracks[originalIdx].name);
}
}
});
}
});
// ============
// 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

@@ -82,7 +82,11 @@
<div id="links-metadata"> <div id="links-metadata">
{% if current_tab in ('videos', 'shorts', 'streams') %} {% if current_tab in ('videos', 'shorts', 'streams') %}
{% set sorts = [('3', 'newest'), ('4', 'newest - no shorts')] %} {% set sorts = [('3', 'newest'), ('4', 'newest - no shorts')] %}
{% if current_tab in ('shorts', 'streams') and not is_last_page %}
<div id="number-of-results">{{ number_of_videos }}+ videos</div>
{% else %}
<div id="number-of-results">{{ number_of_videos }} videos</div> <div id="number-of-results">{{ number_of_videos }} videos</div>
{% endif %}
{% elif current_tab == 'playlists' %} {% elif current_tab == 'playlists' %}
{% set sorts = [('3', 'newest'), ('4', 'last video added')] %} {% set sorts = [('3', 'newest'), ('4', 'last video added')] %}
{% if items %} {% if items %}
@@ -117,7 +121,11 @@
<hr/> <hr/>
<footer class="pagination-container"> <footer class="pagination-container">
{% if current_tab in ('videos', 'shorts', 'streams') %} {% if current_tab in ('shorts', 'streams') %}
<nav class="next-previous-button-row">
{{ common_elements.next_previous_buttons(is_last_page, channel_url + '/' + current_tab, parameters_dictionary) }}
</nav>
{% elif current_tab == 'videos' %}
<nav class="pagination-list"> <nav class="pagination-list">
{{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary, include_ends=(current_sort.__str__() in '34')) }} {{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary, include_ends=(current_sort.__str__() in '34')) }}
</nav> </nav>

View File

@@ -58,7 +58,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% if 'more_comments_url' is in comments_info %} {% if 'more_comments_url' is in comments_info %}
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a> <a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">{{ _('More comments') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -3,7 +3,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'; 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 %}
@@ -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 %}
</video>
{% if js_data %} {% if uni_sources %}
<script> {% for source in uni_sources %}
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later <source src="{{ source['url'] }}" type="{{ source['type'] }}" title="{{ source['quality_string'] }}">
data = {{ js_data|tojson }}; {% endfor %}
// @license-end
</script>
{% endif %} {% endif %}
</video>
<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

@@ -7,15 +7,15 @@
{% block main %} {% block main %}
<form method="POST" class="settings-form"> <form method="POST" class="settings-form">
{% for categ in categories %} {% for categ in categories %}
<h2>{{ categ|capitalize }}</h2> <h2>{{ _(categ|capitalize) }}</h2>
<ul class="settings-list"> <ul class="settings-list">
{% for setting_name, setting_info, value in settings_by_category[categ] %} {% for setting_name, setting_info, value in settings_by_category[categ] %}
{% if not setting_info.get('hidden', false) %} {% if not setting_info.get('hidden', false) %}
<li class="setting-item"> <li class="setting-item">
{% if 'label' is in(setting_info) %} {% if 'label' is in(setting_info) %}
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_info['label'] }}</label> <label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ _(setting_info['label']) }}</label>
{% else %} {% else %}
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_name.replace('_', ' ')|capitalize }}</label> <label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ _(setting_name.replace('_', ' ')|capitalize) }}</label>
{% endif %} {% endif %}
{% if setting_info['type'].__name__ == 'bool' %} {% if setting_info['type'].__name__ == 'bool' %}
@@ -24,7 +24,7 @@
{% if 'options' is in(setting_info) %} {% if 'options' is in(setting_info) %}
<select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}"> <select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
{% for option in setting_info['options'] %} {% for option in setting_info['options'] %}
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option> <option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ _(option[1]) }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% else %}
@@ -36,7 +36,7 @@
{% if 'options' is in(setting_info) %} {% if 'options' is in(setting_info) %}
<select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}"> <select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
{% for option in setting_info['options'] %} {% for option in setting_info['options'] %}
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option> <option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ _(option[1]) }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% else %}
@@ -50,6 +50,6 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endfor %} {% endfor %}
<input type="submit" value="Save settings"> <input type="submit" value="{{ _('Save settings') }}">
</form> </form>
{% endblock main %} {% endblock main %}

View File

@@ -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,25 +74,34 @@
<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 -->
<select id="quality-select" autocomplete="off"> <select id="quality-select" autocomplete="off">
{% for src in uni_sources %} <option value="-1" selected>Auto</option>
<option value='{"type": "uni", "index": {{ loop.index0 }}}' {{ 'selected' if loop.index0 == uni_idx and not using_pair_sources else '' }} >{{ src['quality_string'] }}</option> <!-- Quality options will be populated by HLS -->
{% endfor %} </select>
{% for src_pair in pair_sources %} {% else %}
<option value='{"type": "pair", "index": {{ loop.index0}}}' {{ 'selected' if loop.index0 == pair_idx and using_pair_sources else '' }} >{{ src_pair['quality_string'] }}</option> <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 audio_tracks|length > 1 %}
<select id="audio-track-select" autocomplete="off">
{% for track in audio_tracks %}
<option value="{{ track['id'] }}" {{ 'selected' if track['is_default'] else '' }}>{{ track['name'] }}</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">
<span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">Direct Link</a></span> <span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">{{ _('Direct Link') }}</a></span>
{% if settings.use_video_download != 0 %} {% if settings.use_video_download != 0 %}
<details class="v-download"> <details class="v-download">
<summary class="download-dropdown-label">Download</summary> <summary class="download-dropdown-label">{{ _('Download') }}</summary>
<ul class="download-dropdown-content"> <ul class="download-dropdown-content">
{% for format in download_formats %} {% for format in download_formats %}
<li class="download-format"> <li class="download-format">
@@ -142,7 +149,7 @@
{% endif %} {% endif %}
</div> </div>
<details class="v-more-info"> <details class="v-more-info">
<summary>More info</summary> <summary>{{ _('More info') }}</summary>
<div class="more-info-content"> <div class="more-info-content">
<p>Tor exit node: {{ ip_address }}</p> <p>Tor exit node: {{ ip_address }}</p>
{% if invidious_used %} {% if invidious_used %}
@@ -166,7 +173,7 @@
<div class="playlist-header"> <div class="playlist-header">
<a href="{{ playlist['url'] }}" title="{{ playlist['title'] }}"><h3>{{ playlist['title'] }}</h3></a> <a href="{{ playlist['url'] }}" title="{{ playlist['title'] }}"><h3>{{ playlist['title'] }}</h3></a>
<ul class="playlist-metadata"> <ul class="playlist-metadata">
<li><label for="playlist-autoplay-toggle">Autoplay: </label><input id="playlist-autoplay-toggle" type="checkbox" class="autoplay-toggle"></li> <li><label for="playlist-autoplay-toggle">{{ _('AutoNext') }}: </label><input id="playlist-autoplay-toggle" type="checkbox" class="autoplay-toggle"></li>
{% if playlist['current_index'] is none %} {% if playlist['current_index'] is none %}
<li>[Error!]/{{ playlist['video_count'] }}</li> <li>[Error!]/{{ playlist['video_count'] }}</li>
{% else %} {% else %}
@@ -193,7 +200,7 @@
</nav> </nav>
</div> </div>
{% elif settings.related_videos_mode != 0 %} {% elif settings.related_videos_mode != 0 %}
<div class="related-autoplay"><label for="related-autoplay-toggle">Autoplay: </label><input id="related-autoplay-toggle" type="checkbox" class="autoplay-toggle"></div> <div class="related-autoplay"><label for="related-autoplay-toggle">{{ _('AutoNext') }}: </label><input id="related-autoplay-toggle" type="checkbox" class="autoplay-toggle"></div>
{% endif %} {% endif %}
{% if subtitle_sources %} {% if subtitle_sources %}
@@ -215,7 +222,7 @@
{% if settings.related_videos_mode != 0 %} {% if settings.related_videos_mode != 0 %}
<details class="related-videos-outer" {{'open' if settings.related_videos_mode == 1 else ''}}> <details class="related-videos-outer" {{'open' if settings.related_videos_mode == 1 else ''}}>
<summary>Related Videos</summary> <summary>{{ _('Related Videos') }}</summary>
<nav class="related-videos-inner"> <nav class="related-videos-inner">
{% for info in related %} {% for info in related %}
{{ common_elements.item(info, include_badges=false) }} {{ common_elements.item(info, include_badges=false) }}
@@ -229,10 +236,10 @@
<!-- comments --> <!-- comments -->
{% if settings.comments_mode != 0 %} {% if settings.comments_mode != 0 %}
{% if comments_disabled %} {% if comments_disabled %}
<div class="comments-area-outer comments-disabled">Comments disabled</div> <div class="comments-area-outer comments-disabled">{{ _('Comments disabled') }}</div>
{% else %} {% else %}
<details class="comments-area-outer" {{'open' if settings.comments_mode == 1 else ''}}> <details class="comments-area-outer" {{'open' if settings.comments_mode == 1 else ''}}>
<summary>{{ comment_count|commatize }} comment{{'s' if comment_count != '1' else ''}}</summary> <summary>{{ comment_count|commatize }} {{ _('Comment') }}{{'s' if comment_count != '1' else ''}}</summary>
<div class="comments-area-inner comments-area"> <div class="comments-area-inner comments-area">
{% if comments_info %} {% if comments_info %}
{{ comments.video_comments(comments_info) }} {{ comments.video_comments(comments_info) }}
@@ -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 %}
<!-- /plyr --> <script src="/youtube.com/static/js/plyr.dash.start.js"></script>
{% elif settings.use_video_player == 1 %} {% else %}
<script src="/youtube.com/static/js/hotkeys.js"></script> <script src="/youtube.com/static/js/plyr.hls.start.js"></script>
{% endif %} {% endif %}
<!-- /plyr -->
{% 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']):
# 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', ''):
continue continue
# Mark as HLS for frontend
# audio source fmt['is_hls'] = True
if fmt['acodec'] and not fmt['vcodec'] and ( if fmt['acodec'] and not fmt['vcodec'] and (fmt['audio_bitrate'] or fmt['bitrate']):
fmt['audio_bitrate'] or fmt['bitrate']): if fmt['bitrate']:
if fmt['bitrate']: # prefer this one, more accurate right now
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: else:
closer_to_target = 'pair' audio_tracks.append({
'id': lang,
'name': track['name'],
'is_default': False,
})
else:
# 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

@@ -1,7 +1,7 @@
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, is_short)
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)
@@ -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

@@ -410,6 +410,51 @@ def extract_shorts_lockup_view_model_info(item, additional_info={}):
return info return info
def is_short(item_info):
"""Check if a video item is a YouTube Short.
Shorts are identified by:
1. Duration < 60 seconds (typical Shorts length)
2. Having "Shorts" badge or type
3. Being extracted from shortsLockupViewModel or reelItemRenderer
"""
if not item_info or item_info.get('error'):
return False
# Check renderer type
item_type = item_info.get('type', '')
if item_type == 'unsupported':
return False
# Check for "Shorts" badge
badges = item_info.get('badges', [])
if any('short' in str(badge).lower() for badge in badges):
return True
# Check duration (Shorts are typically < 60 seconds)
duration = item_info.get('duration')
if duration is not None:
# Duration can be string like "0:58" or "1:23:45" or int
if isinstance(duration, str):
# Parse duration string to seconds
parts = duration.split(':')
try:
if len(parts) == 2: # MM:SS
duration_seconds = int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3: # HH:MM:SS
duration_seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
else:
duration_seconds = int(duration)
if duration_seconds < 60:
return True
except (ValueError, IndexError):
pass
elif isinstance(duration, (int, float)) and duration < 60:
return True
return False
def extract_item_info(item, additional_info={}): def extract_item_info(item, additional_info={}):
if not item: if not item:
return {'error': 'No item given'} return {'error': 'No item given'}

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'''