Merge remote-tracking branch 'upstream/master'

This commit is contained in:
rdiaz738 2025-03-01 14:07:31 -08:00
commit 4acb48b12a
13 changed files with 3613 additions and 2244 deletions

View file

@ -16,7 +16,7 @@ COPY Pipfile* docker-entrypoint.sh ./
# Install dependencies
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
chmod +x docker-entrypoint.sh && \
apk add --update ffmpeg aria2 coreutils shadow su-exec curl && \
apk add --update ffmpeg aria2 coreutils shadow su-exec curl tini && \
apk add --update --virtual .build-deps gcc g++ musl-dev && \
pip install --no-cache-dir pipenv && \
pipenv install --system --deploy --clear && \
@ -37,4 +37,4 @@ ENV STATE_DIR /downloads/.metube
ENV TEMP_DIR /downloads
VOLUME /downloads
EXPOSE 8081
CMD [ "./docker-entrypoint.sh" ]
ENTRYPOINT ["/sbin/tini", "-g", "--", "./docker-entrypoint.sh"]

1108
Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,6 @@ docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/met
## Run using docker-compose
```yaml
version: "3"
services:
metube:
image: ghcr.io/alexta69/metube
@ -50,11 +49,18 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
* __URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
* __PUBLIC_HOST_URL__: base URL for the download links shown in the UI for completed files. By default MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
* __HTTPS__: use `https` instead of `http`(__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
* __CERTFILE__: HTTPS certificate file path.
* __KEYFILE__: HTTPS key file path.
* __PUBLIC_HOST_AUDIO_URL__: same as PUBLIC_HOST_URL but for audio downloads.
* __OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
* __OUTPUT_TEMPLATE_CHAPTER__: the template for the filenames of the downloaded videos, when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
* __OUTPUT_TEMPLATE_PLAYLIST__: the template for the filenames of the downloaded videos, when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty then `OUTPUT_TEMPLATE` is used.
* __DEFAULT_OPTION_PLAYLIST_STRICT_MODE__: if `true`, the "Strict Playlist mode" switch will be enabled by default. In this mode the playlists will be downloaded only if the url strictly points to a playlist. Urls to videos inside a playlist will be treated same as direct video url. Defaults to `false` .
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
* __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L183). They roughly correspond to command-line options, though some do not have exact equivalents here, for example `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores.
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence.
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container
The following example value for `YTDL_OPTIONS` embeds English subtitles and chapter markers (for videos that have them), and also changes the permissions on the downloaded video and sets the file modification timestamp to the date of when it was downloaded:
@ -104,15 +110,22 @@ __Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can
## iOS Shortcut
[rithask](https://github.com/rithask) has created an iOS shortcut to send the URL to MeTube from Safari. Initially, you'll need to enter the server address and port, but after that, it will be saved and you can just run the shortcut from the share menu in Safari. The address should include the protocol (http/https) and the port, if it's not the default 80/443. For example: `https://metube.example.com` or `http://192.168.1.1:8081`. The shortcut can be found [here](https://www.icloud.com/shortcuts/f1548df15b734418a77a709103bc1dd5).
[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safari’s share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
## iOS Compatibility
iOS has strict requirements for video files, requiring h264 or h265 video codec and aac audio codec in MP4 container. This can sometimes be a lower quality than the best quality available. To accommodate iOS requirements, when downloading a MP4 format you can choose "Best (iOS)" to get the best quality formats as compatible as possible with iOS requirements.
To force all downloads to be converted to an iOS compatible codec insert this as an environment variable
```yaml
environment:
- 'YTDL_OPTIONS={"format": "best", "exec": "ffmpeg -i %(filepath)q -c:v libx264 -c:a aac %(filepath)q.h264.mp4"}'
```
## Bookmarklet
[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MeTube instance.
@ -140,9 +153,33 @@ Firefox:
javascript:(function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}})();
```
## Running behind a reverse proxy
## Raycast extension
It's advisable to run MeTube behind a reverse proxy, if authentication and/or HTTPS support are required.
[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) that allows adding videos to MeTube directly from Raycast.
## HTTPS support, and running behind a reverse proxy
It's possible to configure MeTube to listen in HTTPS mode. `docker-compose` example:
```yaml
services:
metube:
image: ghcr.io/alexta69/metube
container_name: metube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- /path/to/downloads:/downloads
- /path/to/ssl/crt:/ssl/crt.pem
- /path/to/ssl/key:/ssl/key.pem
environment:
- HTTPS=true
- CERTFILE=/ssl/crt.pem
- KEYFILE=/ssl/key.pem
```
It's also possible to run MeTube behind a reverse proxy, in order to support authentication. HTTPS support can also be added in this way.
When running behind a reverse proxy which remaps the URL (i.e. serves MeTube under a subdirectory and not under root), don't forget to set the URL_PREFIX environment variable to the correct value.
@ -217,6 +254,10 @@ cd /downloads
Once there, you can use the yt-dlp command freely.
## Submitting feature requests
MeTube development relies on code contributions by the community. The program as it currently stands fits my own use cases, and is therefore feature-complete as far as I'm concerned. If your use cases are different and require additional features, please feel free to submit PRs that implement those features. It's advisable to create an issue first to discuss the planned implementation, because in an effort to reduce bloat, some PRs may not be accepted. However, note that opening a feature request when you don't intend to implement the feature will rarely result in the request being fulfilled.
## Building and running locally
Make sure you have node.js and Python 3.11 installed.

View file

@ -35,7 +35,7 @@ def get_format(format: str, quality: str) -> str:
return "bestaudio/best"
# video {res} {vfmt} + audio {afmt} {res} {vfmt}
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "best_ios") else ""
vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else ""
vcombo = vres + vfmt
if quality == "best_ios":

View file

@ -4,6 +4,8 @@
import os
import sys
from aiohttp import web
import ssl
import socket
import socketio
import logging
import json
@ -28,17 +30,24 @@ class Config:
'PUBLIC_HOST_AUDIO_URL': 'audio_download/',
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_title)s.%(ext)s',
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
'DEFAULT_OPTION_PLAYLIST_STRICT_MODE' : 'false',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
'YTDL_OPTIONS': '{}',
'YTDL_OPTIONS_FILE': '',
'ROBOTS_TXT': '',
'HOST': '0.0.0.0',
'PORT': '8081',
'HTTPS': 'false',
'CERTFILE': '',
'KEYFILE': '',
'BASE_DIR': '',
'DEFAULT_THEME': 'auto',
'DOWNLOAD_MODE': 'concurrent', # Can be 'sequential', 'concurrent', or 'limited'
'MAX_CONCURRENT_DOWNLOADS': 3, # Used if DOWNLOAD_MODE is 'limited'
}
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN')
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE', 'HTTPS')
def __init__(self):
for k, v in self._DEFAULTS.items():
@ -129,13 +138,22 @@ async def add(request):
format = post.get('format')
folder = post.get('folder')
custom_name_prefix = post.get('custom_name_prefix')
playlist_strict_mode = post.get('playlist_strict_mode')
playlist_item_limit = post.get('playlist_item_limit')
auto_start = post.get('auto_start')
if custom_name_prefix is None:
custom_name_prefix = ''
if auto_start is None:
auto_start = True
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, auto_start)
log.info(f"Download added to queue: {url}")
if playlist_strict_mode is None:
playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE
if playlist_item_limit is None:
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT
playlist_item_limit = int(playlist_item_limit)
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'delete')
@ -160,12 +178,14 @@ async def start(request):
@routes.get(config.URL_PREFIX + 'history')
async def history(request):
history = { 'done': [], 'queue': []}
history = { 'done': [], 'queue': [], 'pending': []}
for _, v in dqueue.queue.saved_items():
history['queue'].append(v)
for _, v in dqueue.done.saved_items():
history['done'].append(v)
for _ ,v in dqueue.pending.saved_items():
history['pending'].append(v)
log.info("Sending download history")
return web.Response(text=serializer.encode(history))
@ -216,6 +236,16 @@ def index(request):
response.set_cookie('metube_theme', config.DEFAULT_THEME)
return response
@routes.get(config.URL_PREFIX + 'robots.txt')
def robots(request):
if config.ROBOTS_TXT:
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
else:
response = web.Response(
text="User-agent: *\nDisallow: /download/\nDisallow: /audio_download/\n"
)
return response
if config.URL_PREFIX != '/':
@routes.get('/')
def index_redirect_root(request):
@ -248,15 +278,23 @@ async def on_prepare(request, response):
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
app.on_response_prepare.append(on_prepare)
def supports_reuse_port():
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.close()
return True
except (AttributeError, OSError):
return False
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
log.info(f"Listening on {config.HOST}:{config.PORT}")
try:
if sys.platform.startswith('win'):
web.run_app(app, host=config.HOST, port=int(config.PORT))
else:
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=True)
except Exception as e:
log.error(f"Failed to start the server: {str(e)}")
sys.exit(1)
if config.HTTPS:
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), ssl_context=ssl_context)
else:
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port())

View file

@ -7,6 +7,8 @@ import asyncio
import multiprocessing
import logging
import re
import yt_dlp.networking.impersonate
from dl_formats import get_format, get_opts, AUDIO_FORMATS
from datetime import datetime
@ -225,7 +227,7 @@ class DownloadQueue:
async def __import_queue(self):
for k, v in self.queue.saved_items():
await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix)
await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix, v.playlist_strict_mode, v.playlist_item_limit)
async def initialize(self):
log.info("Initializing DownloadQueue")
@ -274,13 +276,15 @@ class DownloadQueue:
self.done.put(download)
asyncio.create_task(self.notifier.completed(download.info))
def __extract_info(self, url):
def __extract_info(self, url, playlist_strict_mode):
return yt_dlp.YoutubeDL(params={
'quiet': True,
'no_color': True,
'extract_flat': True,
'ignore_no_formats_error': True,
'noplaylist': playlist_strict_mode,
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}),
**self.config.YTDL_OPTIONS,
}).extract_info(url, download=False)
@ -301,7 +305,7 @@ class DownloadQueue:
dldirectory = base_directory
return dldirectory, None
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, auto_start, already):
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already):
if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."}
@ -314,46 +318,65 @@ class DownloadQueue:
error = entry["msg"]
etype = entry.get('_type') or 'video'
if etype == 'playlist':
if etype.startswith('url'):
log.debug('Processing as an url')
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
elif etype == 'playlist':
log.debug('Processing as a playlist')
entries = entry['entries']
log.info(f'playlist detected with {len(entries)} entries')
playlist_index_digits = len(str(len(entries)))
results = []
if playlist_item_limit > 0:
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
entries = entries[:playlist_item_limit]
for index, etr in enumerate(entries, start=1):
etr["_type"] = "video" # Prevents video to be treated as url and lose below properties during processing
etr["playlist"] = entry["id"]
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
for property in ("id", "title", "uploader", "uploader_id"):
if property in entry:
etr[f"playlist_{property}"] = entry[property]
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, auto_start, already))
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already))
if any(res['status'] == 'error' for res in results):
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
return {'status': 'ok'}
elif etype == 'video' or etype.startswith('url') and 'id' in entry and 'title' in entry:
log.debug('Processing as a video')
if not self.queue.exists(entry['id']):
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format, folder, custom_name_prefix, error)
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], entry.get('webpage_url') or entry['url'], quality, format, folder, custom_name_prefix, error)
dldirectory, error_message = self.__calc_download_path(quality, format, folder)
if error_message is not None:
return error_message
output = self.config.OUTPUT_TEMPLATE if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
for property, value in entry.items():
if property.startswith("playlist"):
output = output.replace(f"%({property})s", str(value))
if 'playlist' in entry and entry['playlist'] is not None:
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
for property, value in entry.items():
if property.startswith("playlist"):
output = output.replace(f"%({property})s", str(value))
ytdl_options = dict(self.config.YTDL_OPTIONS)
if playlist_item_limit > 0:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
ytdl_options['playlistend'] = playlist_item_limit
if auto_start is True:
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, self.config.YTDL_OPTIONS, dl)
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl)
self.queue.put(download)
asyncio.create_task(self.__start_download(download))
else:
self.pending.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, self.config.YTDL_OPTIONS, dl))
self.pending.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl))
await self.notifier.added(dl)
return {'status': 'ok'}
elif etype.startswith('url'):
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, auto_start, already)
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
async def add(self, url, quality, format, folder, custom_name_prefix, auto_start=True, already=None):
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=}')
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=}')
already = set() if already is None else already
if url in already:
log.info('recursion detected, skipping')
@ -361,10 +384,10 @@ class DownloadQueue:
else:
already.add(url)
try:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(exc)}
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, auto_start, already)
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
async def start_pending(self, ids):
for id in ids:

View file

@ -8,7 +8,6 @@
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"

4279
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@
data-bs-display="static">
<fa-icon [icon]="activeTheme.icon"></fa-icon>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="theme-select">
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
<li *ngFor="let theme of themes">
<button type="button" class="dropdown-item d-flex align-items-center" [ngClass]="{'active' : activeTheme == theme}" (click)="themeChanged(theme)">
<span class="me-2 opacity-50">
@ -50,7 +50,7 @@
</div>
</div>
<div class="row">
<div class="col-md-3 add-url-component">
<div class="col-md-6 col-lg-3 add-url-component">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select" name="quality" [(ngModel)]="quality" (change)="qualityChanged()" [disabled]="addInProgress || downloads.loading">
@ -58,7 +58,7 @@
</select>
</div>
</div>
<div class="col-md-3 add-url-component">
<div class="col-md-6 col-lg-3 add-url-component">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select" name="format" [(ngModel)]="format" (change)="formatChanged()" [disabled]="addInProgress || downloads.loading">
@ -66,7 +66,7 @@
</select>
</div>
</div>
<div class="col-md-3 add-url-component">
<div class="col-md-6 col-lg-3 add-url-component">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select" name="autoStart" [(ngModel)]="autoStart" (change)="autoStartChanged()" [disabled]="addInProgress || downloads.loading">
@ -75,7 +75,7 @@
</select>
</div>
</div>
<div class="col-md-3 add-url-component">
<div class="col-md-6 col-lg-3 add-url-component">
<div [attr.class]="showAdvanced() ? 'btn-group add-url-group' : 'add-url-group'" ngbDropdown #advancedDropdown="ngbDropdown" display="dynamic" placement="bottom-end">
<button class="btn btn-primary add-url" type="submit" (click)="addDownload()" [disabled]="addInProgress || downloads.loading">
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
@ -94,6 +94,24 @@
<span class="input-group-text">Custom Name Prefix</span>
<input type="text" autocomplete="off" spellcheck="false" class="form-control" placeholder="Default" name="customNamePrefix" [(ngModel)]="customNamePrefix" [disabled]="addInProgress || downloads.loading">
</div>
<div class="add-url-component">
<div class="row align-items-center gy-2">
<div class="col-12 col-md-6 order-md-2">
<div class="input-group">
<span class="input-group-text">Items limit</span>
<input type="number" min="0" autocomplete="off" class="form-control" placeholder="Default" name="playlistItemLimit" (keydown)="isNumber($event)" [(ngModel)]="playlistItemLimit" [disabled]="addInProgress || downloads.loading">
</div>
</div>
<div class="col-12 col-md-6 order-md-1">
<div class="input-group ms-1">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="playlistStrictMode" [(ngModel)]="playlistStrictMode" [disabled]="addInProgress || downloads.loading">
<label class="form-check-label">Strict Playlist mode</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -105,96 +123,100 @@
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
Connecting to server...
</div>
<div class="metube-section-header">Downloading</div>
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
</th>
<th scope="col" style="width: 14rem;"></th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 2rem;"></th>
<th scope="col" style="width: 2rem;"></th>
<th scope="col" style="width: 2rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td title="{{ download.value.filename }}">{{ download.value.title }}</td>
<td><ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'"></ngb-progressbar></td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<button *ngIf="download.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
</td>
<td><button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td>
<td><a href="{{download.value.url}}" target="_blank"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a></td>
</tr>
</tbody>
</table>
<div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }}</div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="metube-section-header">Completed</div>
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
</th>
<th scope="col" >File Size</th>
<th scope="col" style="width: 2rem;"></th>
<th scope="col" style="width: 2rem;"></th>
<th scope="col" style="width: 2rem;"></th>
<th scope="col" style="width: 2rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
</td>
<td>
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
</td>
<td>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download><fa-icon [icon]="faDownload"></fa-icon></a>
</td>
<td>
<a href="{{download.value.url}}" target="_blank"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</td>
<td>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</td>
</tr>
</tbody>
</table>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</main><!-- /.container -->

View file

@ -17,6 +17,7 @@ button.add-url
.folder-dropdown-menu
width: 500px
max-width: calc(100vw - 3rem)
.folder-dropdown-menu .input-group
display: flex
@ -48,6 +49,7 @@ th
border-top: 0
border-bottom-width: 3px !important
vertical-align: middle !important
white-space: nowrap
td
vertical-align: middle
@ -55,3 +57,11 @@ td
.disabled
opacity: 0.5
pointer-events: none
.form-switch
input
margin-top: 5px
.download-progressbar
width: 12rem
margin-left: auto

View file

@ -24,6 +24,8 @@ export class AppComponent implements AfterViewInit {
folder: string;
customNamePrefix: string;
autoStart: boolean;
playlistStrictMode: boolean;
playlistItemLimit: number;
addInProgress = false;
themes: Theme[] = Themes;
activeTheme: Theme;
@ -31,12 +33,13 @@ export class AppComponent implements AfterViewInit {
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
@ViewChild('doneDelSelected') doneDelSelected: ElementRef;
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
@ViewChild('doneClearFailed') doneClearFailed: ElementRef;
@ViewChild('doneRetryFailed') doneRetryFailed: ElementRef;
@ViewChild('doneDownloadSelected') doneDownloadSelected: ElementRef;
faTrashAlt = faTrashAlt;
faCheckCircle = faCheckCircle;
@ -60,6 +63,7 @@ export class AppComponent implements AfterViewInit {
}
ngOnInit() {
this.getConfiguration();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme);
@ -129,6 +133,18 @@ export class AppComponent implements AfterViewInit {
}));
}
getConfiguration() {
this.downloads.configurationChanged.subscribe({
next: (config) => {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (playlistItemLimit !== '0') {
this.playlistItemLimit = playlistItemLimit;
}
}
});
}
getPreferredTheme(cookieService: CookieService) {
let theme = 'auto';
if (cookieService.check('metube_theme')) {
@ -166,10 +182,12 @@ export class AppComponent implements AfterViewInit {
queueSelectionChanged(checked: number) {
this.queueDelSelected.nativeElement.disabled = checked == 0;
this.queueDownloadSelected.nativeElement.disabled = checked == 0;
}
doneSelectionChanged(checked: number) {
this.doneDelSelected.nativeElement.disabled = checked == 0;
this.doneDownloadSelected.nativeElement.disabled = checked == 0;
}
setQualities() {
@ -179,17 +197,19 @@ export class AppComponent implements AfterViewInit {
this.quality = exists ? this.quality : 'best'
}
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, autoStart?: boolean) {
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) {
url = url ?? this.addUrl
quality = quality ?? this.quality
format = format ?? this.format
folder = folder ?? this.folder
customNamePrefix = customNamePrefix ?? this.customNamePrefix
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
autoStart = autoStart ?? this.autoStart
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' autoStart='+autoStart);
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart);
this.addInProgress = true;
this.downloads.add(url, quality, format, folder, customNamePrefix, autoStart).subscribe((status: Status) => {
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => {
if (status.status === 'error') {
alert(`Error adding URL: ${status.msg}`);
} else {
@ -204,7 +224,7 @@ export class AppComponent implements AfterViewInit {
}
retryDownload(key: string, download: Download) {
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, true);
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true);
this.downloads.delById('done', [key]).subscribe();
}
@ -212,6 +232,10 @@ export class AppComponent implements AfterViewInit {
this.downloads.delById(where, [id]).subscribe();
}
startSelectedDownloads(where: string){
this.downloads.startByFilter(where, dl => dl.checked).subscribe();
}
delSelectedDownloads(where: string) {
this.downloads.delByFilter(where, dl => dl.checked).subscribe();
}
@ -232,6 +256,20 @@ export class AppComponent implements AfterViewInit {
});
}
downloadSelectedFiles() {
this.downloads.done.forEach((dl, key) => {
if (dl.status === 'finished' && dl.checked) {
const link = document.createElement('a');
link.href = this.buildDownloadLink(dl);
link.setAttribute('download', dl.filename);
link.setAttribute('target', '_self');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
});
}
buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
@ -248,4 +286,11 @@ export class AppComponent implements AfterViewInit {
identifyDownloadRow(index: number, row: KeyValue<string, Download>) {
return row.key;
}
isNumber(event) {
const charCode = (event.which) ? event.which : event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
event.preventDefault();
}
}
}

View file

@ -17,6 +17,8 @@ export interface Download {
format: string;
folder: string;
custom_name_prefix: string;
playlist_strict_mode: boolean;
playlist_item_limit: number;
status: string;
msg: string;
percent: number;
@ -37,6 +39,7 @@ export class DownloadsService {
queueChanged = new Subject();
doneChanged = new Subject();
customDirsChanged = new Subject();
configurationChanged = new Subject();
configuration = {};
customDirs = {};
@ -85,6 +88,7 @@ export class DownloadsService {
let data = JSON.parse(strdata);
console.debug("got configuration:", data);
this.configuration = data;
this.configurationChanged.next(data);
});
socket.fromEvent('custom_dirs').subscribe((strdata: string) => {
let data = JSON.parse(strdata);
@ -99,8 +103,8 @@ export class DownloadsService {
return of({status: 'error', msg: msg})
}
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, autoStart: boolean) {
return this.http.post<Status>('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, auto_start: autoStart}).pipe(
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) {
return this.http.post<Status>('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe(
catchError(this.handleHTTPError)
);
}
@ -114,6 +118,12 @@ export class DownloadsService {
return this.http.post('delete', {where: where, ids: ids});
}
public startByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.startById(ids);
}
public delByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });

View file

@ -15,10 +15,12 @@ export const Formats: Format[] = [
text: 'Any',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: 'worst', text: 'Worst' },
{ id: 'audio', text: 'Audio Only' },
],
},
@ -28,10 +30,12 @@ export const Formats: Format[] = [
qualities: [
{ id: 'best', text: 'Best' },
{ id: 'best_ios', text: 'Best (iOS)' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: 'worst', text: 'Worst' },
],
},
{