Compare commits
13 Commits
2026.02.04
...
2026.02.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5170c708cd | ||
|
|
56258a4f1b | ||
|
|
3bf7fb51f4 | ||
|
|
8ae06c65d0 | ||
|
|
97378d8704 | ||
|
|
de7e1418b5 | ||
|
|
f47e5db284 | ||
|
|
76bdb376c3 | ||
|
|
9896ce6820 | ||
|
|
79d0c3895e | ||
|
|
ffe1112dc6 | ||
|
|
393add34b1 | ||
|
|
96e1863a68 |
@@ -69,8 +69,8 @@ RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com
|
|||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
||||||
|
|
||||||
ENV UID=1000
|
ENV PUID=1000
|
||||||
ENV GID=1000
|
ENV PGID=1000
|
||||||
ENV UMASK=022
|
ENV UMASK=022
|
||||||
|
|
||||||
ENV DOWNLOAD_DIR /downloads
|
ENV DOWNLOAD_DIR /downloads
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __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__: 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_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.
|
* __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.
|
||||||
|
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
|
||||||
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). 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. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
|
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). 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. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
|
||||||
* __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. The file will be monitored for changes and reloaded automatically when changes are detected.
|
* __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. The file will be monitored for changes and reloaded automatically when changes are detected.
|
||||||
|
|
||||||
@@ -73,8 +74,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
|
|
||||||
### 🏠 Basic Setup
|
### 🏠 Basic Setup
|
||||||
|
|
||||||
* __UID__: User under which MeTube will run. Defaults to `1000`.
|
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||||
* __GID__: Group under which MeTube will run. Defaults to `1000`.
|
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||||
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
||||||
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
||||||
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class Config:
|
|||||||
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
||||||
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s',
|
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s',
|
||||||
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
||||||
|
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||||
'YTDL_OPTIONS': '{}',
|
'YTDL_OPTIONS': '{}',
|
||||||
'YTDL_OPTIONS_FILE': '',
|
'YTDL_OPTIONS_FILE': '',
|
||||||
@@ -249,6 +250,8 @@ async def add(request):
|
|||||||
|
|
||||||
if custom_name_prefix is None:
|
if custom_name_prefix is None:
|
||||||
custom_name_prefix = ''
|
custom_name_prefix = ''
|
||||||
|
if custom_name_prefix and ('..' in custom_name_prefix or custom_name_prefix.startswith('/') or custom_name_prefix.startswith('\\')):
|
||||||
|
raise web.HTTPBadRequest(reason='custom_name_prefix must not contain ".." or start with a path separator')
|
||||||
if auto_start is None:
|
if auto_start is None:
|
||||||
auto_start = True
|
auto_start = True
|
||||||
if playlist_item_limit is None:
|
if playlist_item_limit is None:
|
||||||
@@ -257,6 +260,8 @@ async def add(request):
|
|||||||
split_by_chapters = False
|
split_by_chapters = False
|
||||||
if chapter_template is None:
|
if chapter_template is None:
|
||||||
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
|
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
|
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
|
||||||
|
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
|
||||||
|
|
||||||
playlist_item_limit = int(playlist_item_limit)
|
playlist_item_limit = int(playlist_item_limit)
|
||||||
|
|
||||||
|
|||||||
97
app/ytdl.py
97
app/ytdl.py
@@ -11,13 +11,53 @@ import re
|
|||||||
import types
|
import types
|
||||||
import dbm
|
import dbm
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
import yt_dlp.networking.impersonate
|
import yt_dlp.networking.impersonate
|
||||||
|
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
||||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger('ytdl')
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _compile_outtmpl_pattern(field: str) -> re.Pattern:
|
||||||
|
"""Compile a regex pattern to match a specific field in an output template, including optional format specifiers."""
|
||||||
|
conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]"
|
||||||
|
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
||||||
|
|
||||||
|
|
||||||
|
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
||||||
|
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
||||||
|
pattern = _compile_outtmpl_pattern(field)
|
||||||
|
|
||||||
|
def replacement(match: re.Match) -> str:
|
||||||
|
if match.group("has_key") is None:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
prefix = match.group("prefix") or ""
|
||||||
|
format_spec = match.group("format")
|
||||||
|
|
||||||
|
if not format_spec:
|
||||||
|
return f"{prefix}{value}"
|
||||||
|
|
||||||
|
conversion_type = format_spec[-1]
|
||||||
|
try:
|
||||||
|
if conversion_type in "diouxX":
|
||||||
|
coerced_value = int(value)
|
||||||
|
elif conversion_type in "eEfFgG":
|
||||||
|
coerced_value = float(value)
|
||||||
|
else:
|
||||||
|
coerced_value = value
|
||||||
|
|
||||||
|
return f"{prefix}{('%' + format_spec) % coerced_value}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return f"{prefix}{value}"
|
||||||
|
|
||||||
|
return pattern.sub(replacement, template)
|
||||||
|
|
||||||
def _convert_generators_to_lists(obj):
|
def _convert_generators_to_lists(obj):
|
||||||
"""Recursively convert generators to lists in a dictionary to make it pickleable."""
|
"""Recursively convert generators to lists in a dictionary to make it pickleable."""
|
||||||
if isinstance(obj, types.GeneratorType):
|
if isinstance(obj, types.GeneratorType):
|
||||||
@@ -104,10 +144,21 @@ class Download:
|
|||||||
|
|
||||||
def put_status_postprocessor(d):
|
def put_status_postprocessor(d):
|
||||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||||
|
filepath = d['info_dict']['filepath']
|
||||||
if '__finaldir' in d['info_dict']:
|
if '__finaldir' in d['info_dict']:
|
||||||
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
|
finaldir = d['info_dict']['__finaldir']
|
||||||
|
# Compute relative path from temp dir to preserve
|
||||||
|
# subdirectory structure from the output template.
|
||||||
|
try:
|
||||||
|
rel_path = os.path.relpath(filepath, self.temp_dir)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = os.path.basename(filepath)
|
||||||
|
if rel_path.startswith('..'):
|
||||||
|
# filepath is not under temp_dir, fall back to basename
|
||||||
|
rel_path = os.path.basename(filepath)
|
||||||
|
filename = os.path.join(finaldir, rel_path)
|
||||||
else:
|
else:
|
||||||
filename = d['info_dict']['filepath']
|
filename = filepath
|
||||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||||
|
|
||||||
# Capture all chapter files when SplitChapters finishes
|
# Capture all chapter files when SplitChapters finishes
|
||||||
@@ -163,8 +214,14 @@ class Download:
|
|||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.info.status = 'preparing'
|
self.info.status = 'preparing'
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
asyncio.create_task(self.update_status())
|
self.status_task = asyncio.create_task(self.update_status())
|
||||||
return await self.loop.run_in_executor(None, self.proc.join)
|
await self.loop.run_in_executor(None, self.proc.join)
|
||||||
|
# Signal update_status to stop and wait for it to finish
|
||||||
|
# so that all status updates (including MoveFiles with correct
|
||||||
|
# file size) are processed before _post_download_cleanup runs.
|
||||||
|
if self.status_queue is not None:
|
||||||
|
self.status_queue.put(None)
|
||||||
|
await self.status_task
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
log.info(f"Cancelling download: {self.info.title}")
|
log.info(f"Cancelling download: {self.info.title}")
|
||||||
@@ -181,8 +238,6 @@ class Download:
|
|||||||
log.info(f"Closing download process for: {self.info.title}")
|
log.info(f"Closing download process for: {self.info.title}")
|
||||||
if self.started():
|
if self.started():
|
||||||
self.proc.close()
|
self.proc.close()
|
||||||
if self.status_queue is not None:
|
|
||||||
self.status_queue.put(None)
|
|
||||||
|
|
||||||
def running(self):
|
def running(self):
|
||||||
try:
|
try:
|
||||||
@@ -426,7 +481,7 @@ class DownloadQueue:
|
|||||||
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR
|
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR
|
||||||
if folder:
|
if folder:
|
||||||
if not self.config.CUSTOM_DIRS:
|
if not self.config.CUSTOM_DIRS:
|
||||||
return None, {'status': 'error', 'msg': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
||||||
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
real_base_directory = os.path.realpath(base_directory)
|
real_base_directory = os.path.realpath(base_directory)
|
||||||
if not dldirectory.startswith(real_base_directory):
|
if not dldirectory.startswith(real_base_directory):
|
||||||
@@ -446,12 +501,18 @@ class DownloadQueue:
|
|||||||
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
||||||
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
entry = getattr(dl, 'entry', None)
|
entry = getattr(dl, 'entry', None)
|
||||||
if entry is not None and 'playlist' in entry and entry['playlist'] is not None:
|
if entry is not None and entry.get('playlist_index') is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = _outtmpl_substitute_field(output, property, value)
|
||||||
|
if entry is not None and entry.get('channel_index') is not None:
|
||||||
|
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||||
|
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||||
|
for property, value in entry.items():
|
||||||
|
if property.startswith("channel"):
|
||||||
|
output = _outtmpl_substitute_field(output, property, value)
|
||||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
||||||
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
@@ -480,27 +541,27 @@ class DownloadQueue:
|
|||||||
etype = entry.get('_type') or 'video'
|
etype = entry.get('_type') or 'video'
|
||||||
|
|
||||||
if etype.startswith('url'):
|
if etype.startswith('url'):
|
||||||
log.debug('Processing as an url')
|
log.debug('Processing as a url')
|
||||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
||||||
elif etype == 'playlist':
|
elif etype == 'playlist' or etype == 'channel':
|
||||||
log.debug('Processing as a playlist')
|
log.debug(f'Processing as a {etype}')
|
||||||
entries = entry['entries']
|
entries = entry['entries']
|
||||||
# Convert generator to list if needed (for len() and slicing operations)
|
# Convert generator to list if needed (for len() and slicing operations)
|
||||||
if isinstance(entries, types.GeneratorType):
|
if isinstance(entries, types.GeneratorType):
|
||||||
entries = list(entries)
|
entries = list(entries)
|
||||||
log.info(f'playlist detected with {len(entries)} entries')
|
log.info(f'{etype} detected with {len(entries)} entries')
|
||||||
playlist_index_digits = len(str(len(entries)))
|
index_digits = len(str(len(entries)))
|
||||||
results = []
|
results = []
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
entries = entries[:playlist_item_limit]
|
entries = entries[:playlist_item_limit]
|
||||||
for index, etr in enumerate(entries, start=1):
|
for index, etr in enumerate(entries, start=1):
|
||||||
etr["_type"] = "video"
|
etr["_type"] = "video"
|
||||||
etr["playlist"] = entry["id"]
|
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||||
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
|
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||||
for property in ("id", "title", "uploader", "uploader_id"):
|
for property in ("id", "title", "uploader", "uploader_id"):
|
||||||
if property in entry:
|
if property in entry:
|
||||||
etr[f"playlist_{property}"] = entry[property]
|
etr[f"{etype}_{property}"] = entry[property]
|
||||||
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already))
|
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already))
|
||||||
if any(res['status'] == 'error' for res in results):
|
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': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
PUID="${UID:-$PUID}"
|
||||||
|
PGID="${GID:-$PGID}"
|
||||||
|
|
||||||
echo "Setting umask to ${UMASK}"
|
echo "Setting umask to ${UMASK}"
|
||||||
umask ${UMASK}
|
umask ${UMASK}
|
||||||
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
||||||
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
|
|
||||||
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||||
if [ "${UID}" -eq 0 ]; then
|
if [ "${PUID}" -eq 0 ]; then
|
||||||
echo "Warning: it is not recommended to run as root user, please check your setting of the UID environment variable"
|
echo "Warning: it is not recommended to run as root user, please check your setting of the PUID/PGID (or legacy UID/GID) environment variables"
|
||||||
fi
|
fi
|
||||||
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
||||||
echo "Changing ownership of download and state directories to ${UID}:${GID}"
|
echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
|
||||||
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
fi
|
fi
|
||||||
echo "Starting BgUtils POT Provider"
|
echo "Starting BgUtils POT Provider"
|
||||||
gosu "${UID}":"${GID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||||
echo "Running MeTube as user ${UID}:${GID}"
|
echo "Running MeTube as user ${PUID}:${PGID}"
|
||||||
exec gosu "${UID}":"${GID}" python3 app/main.py
|
exec gosu "${PUID}":"${PGID}" python3 app/main.py
|
||||||
else
|
else
|
||||||
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
||||||
echo "Starting BgUtils POT Provider"
|
echo "Starting BgUtils POT Provider"
|
||||||
|
|||||||
@@ -23,21 +23,21 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.1.2",
|
"@angular/animations": "^21.1.5",
|
||||||
"@angular/common": "^21.1.2",
|
"@angular/common": "^21.1.5",
|
||||||
"@angular/compiler": "^21.1.2",
|
"@angular/compiler": "^21.1.5",
|
||||||
"@angular/core": "^21.1.2",
|
"@angular/core": "^21.1.5",
|
||||||
"@angular/forms": "^21.1.2",
|
"@angular/forms": "^21.1.5",
|
||||||
"@angular/platform-browser": "^21.1.2",
|
"@angular/platform-browser": "^21.1.5",
|
||||||
"@angular/platform-browser-dynamic": "^21.1.2",
|
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||||
"@angular/service-worker": "^21.1.2",
|
"@angular/service-worker": "^21.1.5",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^21.2.0",
|
"@ng-select/ng-select": "^21.4.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ngx-cookie-service": "^21.1.0",
|
"ngx-cookie-service": "^21.1.0",
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/build": "^21.1.2",
|
"@angular/build": "^21.1.4",
|
||||||
"@angular/cli": "^21.1.2",
|
"@angular/cli": "^21.1.4",
|
||||||
"@angular/compiler-cli": "^21.1.2",
|
"@angular/compiler-cli": "^21.1.5",
|
||||||
"@angular/localize": "^21.1.2",
|
"@angular/localize": "^21.1.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
|
|||||||
1333
ui/pnpm-lock.yaml
generated
1333
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -94,7 +94,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="form-control form-control-lg"
|
class="form-control form-control-lg"
|
||||||
placeholder="Enter video or playlist URL"
|
placeholder="Enter video, channel, or playlist URL"
|
||||||
name="addUrl"
|
name="addUrl"
|
||||||
[(ngModel)]="addUrl"
|
[(ngModel)]="addUrl"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || downloads.loading">
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
(keydown)="isNumber($event)"
|
(keydown)="isNumber($event)"
|
||||||
[(ngModel)]="playlistItemLimit"
|
[(ngModel)]="playlistItemLimit"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
38
uv.lock
generated
38
uv.lock
generated
@@ -105,11 +105,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "astroid"
|
name = "astroid"
|
||||||
version = "4.0.3"
|
version = "4.0.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -303,15 +303,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deno"
|
name = "deno"
|
||||||
version = "2.6.8"
|
version = "2.6.10"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/5c/ba3171b42741d1ede4517f73b2a13f07a1828b5a616b086111a01821b0ce/deno-2.6.8.tar.gz", hash = "sha256:dd9e432c67f3d2f1a110d898612dccd7efc51a9486f8466a5fc482b8212d8e19", size = 8129, upload-time = "2026-02-02T18:06:29.122Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/a8/74db2941f56186028b6e91c6116a3e52c8459dfa28a5f990f578be2b69eb/deno-2.6.10.tar.gz", hash = "sha256:41c12a75197da6d9db20120eee7585c27766af0ac62c817c085c93dfc4081428", size = 8128, upload-time = "2026-02-17T16:09:30.841Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/9a/43c7bb102fe58b1318abe00b557c3dfa2f9a9d79f0cd3d9028b6bf8b9637/deno-2.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:078ef3d2a30bcaba91e246f730baddebf1e0e5249adfbfa1b0e169118a41faa5", size = 44303226, upload-time = "2026-02-02T18:06:13.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/2f/030638bfabbd63df9562e3900b791d6c07e5a30346d359cf45a5e1fc4097/deno-2.6.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c381b068b6f58ca1c19a0cf062c8489ee1c7fe430d3ebc48db944f5c80beb2c2", size = 45118320, upload-time = "2026-02-17T16:09:16.536Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/65/fc7c99a0f60fe617eb5a533e37a1cd777f81c03b06b6766d0f52c92e94a8/deno-2.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0c18549037ef7bc856cf3889df424f43810a0b830c0ffc799d8d951a4da5a033", size = 41472003, upload-time = "2026-02-02T18:06:16.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/dc/0f71ff9dd513c8cc094dc76640f80088a27ea9a3b1fc2decd60a8a27148a/deno-2.6.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2da72aa734463600a5a16efd786a4aecd22ddcd6ea907f56fca15f9eb3ca794", size = 42060974, upload-time = "2026-02-17T16:09:19.655Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/4b/617ba0f2e8523c2145e612f4ef610afa231186ba1a860ce2c92c382c3e46/deno-2.6.8-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:332e33ca62bd416cea002a1e50af1c2843d84771e015bfffecdc0d44342eb0eb", size = 45335676, upload-time = "2026-02-02T18:06:19.975Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/f7/3774540e14111026251aef134bf3de3ae1c0591866f05ce5debc97248127/deno-2.6.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b98d6fc98a9cbc10219f6c6d6f2a6e10d1954416bbcd4371d317fc9c20050576", size = 45789105, upload-time = "2026-02-17T16:09:22.413Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/18/5b0af1fb4f52775b58fae4b6a7f0e12f45cf1435e63a0f6af1a8a87d3595/deno-2.6.8-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:2c31bda6da38f9130ef796a9298642b2ca5525420033c74c8b6bbac9d1ab35b0", size = 47240835, upload-time = "2026-02-02T18:06:22.715Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/80/a2b5827f4715f0bcb5a550728ac98a32258f31e2a6f1c63803a078a425b0/deno-2.6.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d1600f4ee7b4a9699b7057841bdb5d7b7eac3fb073df072540064166841c2f4c", size = 47737107, upload-time = "2026-02-17T16:09:25.339Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/e8/defc9de7c0991bfb3c6d3e2a6a5d344fd0022ebe114743af483ff718609b/deno-2.6.8-py3-none-win_amd64.whl", hash = "sha256:dc7d517d8cd0cf43d55e3c8dbd2bbd911dc875cfc116982d882c9aa22f48863f", size = 46136023, upload-time = "2026-02-02T18:06:26.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/60/75fbdc63d97f381182be1eeebe4bd1630d9819d05ae7c20143f700856a3a/deno-2.6.10-py3-none-win_amd64.whl", hash = "sha256:dc0b6b7a3e558b159c6e599eab6556766c96fe1d75d59383ce291ebf0083fcfd", size = 47088397, upload-time = "2026-02-17T16:09:28.571Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -555,11 +555,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.5.1"
|
version = "4.9.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -690,27 +690,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-engineio"
|
name = "python-engineio"
|
||||||
version = "4.13.0"
|
version = "4.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "simple-websocket" },
|
{ name = "simple-websocket" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/42/5a/349caac055e03ef9e56ed29fa304846063b1771ee54ab8132bf98b29491e/python_engineio-4.13.0.tar.gz", hash = "sha256:f9c51a8754d2742ba832c24b46ed425fdd3064356914edd5a1e8ffde76ab7709", size = 92194, upload-time = "2025-12-24T22:38:05.111Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/74/c655a6eda0fd188d490c14142a0f0380655ac7099604e1fbf8fa1a97f0a1/python_engineio-4.13.0-py3-none-any.whl", hash = "sha256:57b94eac094fa07b050c6da59f48b12250ab1cd920765f4849963e3d89ad9de3", size = 59676, upload-time = "2025-12-24T22:38:03.56Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.16.0"
|
version = "5.16.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "python-engineio" },
|
{ name = "python-engineio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/55/5d8af5884283b58e4405580bcd84af1d898c457173c708736e065f10ca4a/python_socketio-5.16.0.tar.gz", hash = "sha256:f79403c7f1ba8b84460aa8fe4c671414c8145b21a501b46b676f3740286356fd", size = 127120, upload-time = "2025-12-24T23:51:48.826Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/d2/2ccc2b69a187b80fda3152745670cfba936704f296a9fa54c6c8ac694d12/python_socketio-5.16.0-py3-none-any.whl", hash = "sha256:d95802961e15c7bd54ecf884c6e7644f81be8460f0a02ee66b473df58088ee8a", size = 79607, upload-time = "2025-12-24T23:51:47.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user