Compare commits

...

14 Commits

Author SHA1 Message Date
Alex Shnitman
3b0eaad67e Merge branch 'dependabot/github_actions/github-actions-292e5e2d7a' of https://github.com/alexta69/metube into feature/download-timestamp 2026-03-08 22:19:01 +02:00
dependabot[bot]
2a166ccf1f Bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/login-action](https://github.com/docker/login-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-08 16:12:41 +00:00
CyCl0ne
3bbe1e8424 Add "Downloaded" timestamp column to completed downloads list
Display the completion time for each download in the done list.
The backend already stores a nanosecond timestamp on DownloadInfo;                                     this wires it up to the frontend using Angular's DatePipe.
2026-03-08 14:56:16 +01:00
Alex
a2740375be Merge pull request #922 from alexta69/copilot/scan-open-issues-for-fixes
Fix #898, #542, #561, #504: config leak, custom-dirs fallback, NTFS path sanitization
2026-03-07 16:06:12 +02:00
copilot-swe-agent[bot]
2736425e19 Revert #504 default change: restore original playlist/channel output templates
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 14:00:29 +00:00
copilot-swe-agent[bot]
0d905c0b61 Fix issues #898, #542, #561, #504
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 07:19:05 +00:00
copilot-swe-agent[bot]
6de4a56f28 Remove DEFAULT_DOWNLOAD_FOLDER feature
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 06:37:09 +00:00
copilot-swe-agent[bot]
1f4c4df847 Implement DEFAULT_DOWNLOAD_FOLDER and CLEAR_COMPLETED_AFTER features (#875, #869)
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-06 15:37:35 +00:00
copilot-swe-agent[bot]
d211f24e00 Initial plan 2026-03-06 15:27:55 +00:00
Alex Shnitman
13acd5b309 upgrade dependencies 2026-03-06 15:44:20 +02:00
Alex Shnitman
fc5f8cf8ca pin deno version to 2.7.2 2026-03-06 15:41:08 +02:00
Alex Shnitman
4565d5abb3 use precompiled binaries of bgutils POT provider 2026-03-06 14:20:51 +02:00
Alex Shnitman
54e25484c5 some fixes in cookie upload functionality 2026-03-06 14:20:16 +02:00
ddmoney420
7cfb0c3a1d Add cookie file upload for authenticated downloads 2026-03-04 13:29:43 -07:00
12 changed files with 689 additions and 383 deletions

View File

@@ -18,26 +18,26 @@ jobs:
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
-
name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -6,24 +6,6 @@ RUN corepack enable && corepack prepare pnpm --activate
RUN CI=true pnpm install && pnpm run build
FROM rust:1.93-slim AS bgutil-builder
WORKDIR /src
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
build-essential \
pkg-config \
libssl-dev \
python3 && \
BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
curl -L "https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/archive/refs/tags/${BGUTIL_TAG}.tar.gz" \
| tar -xz --strip-components=1 && \
cargo build --release
FROM python:3.13-slim
WORKDIR /app
@@ -52,14 +34,22 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
uv cache clean && \
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \
apt-get purge -y --auto-remove build-essential && \
rm -rf /var/lib/apt/lists/* && \
mkdir /.cache && chmod 777 /.cache
COPY --from=bgutil-builder /src/target/release/bgutil-pot /usr/local/bin/bgutil-pot
ARG TARGETARCH
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
case "$TARGETARCH" in \
amd64) BGUTIL_ARCH="x86_64" ;; \
arm64) BGUTIL_ARCH="aarch64" ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
esac && \
curl -L -o /usr/local/bin/bgutil-pot \
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-pot-linux-${BGUTIL_ARCH}" && \
chmod +x /usr/local/bin/bgutil-pot && \
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \

View File

@@ -36,6 +36,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
* __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`.
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
### 📁 Storage & Directories
@@ -55,8 +56,8 @@ 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_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_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.
* __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`. Set to empty to use `OUTPUT_TEMPLATE` instead.
* __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`. Set to empty to use `OUTPUT_TEMPLATE` instead.
* __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.
@@ -89,21 +90,13 @@ The project's Wiki contains examples of useful configurations contributed by use
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
* Add the following to your docker-compose.yml:
```yaml
volumes:
- /path/to/cookies:/cookies
environment:
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
```
* Install in your browser an extension to extract cookies:
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
* Extract the cookies you need with the extension and rename the file `cookies.txt`
* Drop the file in the folder you configured in the docker-compose.yml above
* Restart the container
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
* After upload, the cookie indicator should show as active.
* Use **Delete Cookies** in the same section to remove uploaded cookies.
## 🔌 Browser extensions

View File

@@ -60,6 +60,7 @@ class Config:
'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',
'CLEAR_COMPLETED_AFTER': '0',
'YTDL_OPTIONS': '{}',
'YTDL_OPTIONS_FILE': '',
'ROBOTS_TXT': '',
@@ -97,10 +98,42 @@ class Config:
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
self._runtime_overrides = {}
success,_ = self.load_ytdl_options()
if not success:
sys.exit(1)
def set_runtime_override(self, key, value):
self._runtime_overrides[key] = value
self.YTDL_OPTIONS[key] = value
def remove_runtime_override(self, key):
self._runtime_overrides.pop(key, None)
self.YTDL_OPTIONS.pop(key, None)
def _apply_runtime_overrides(self):
self.YTDL_OPTIONS.update(self._runtime_overrides)
# Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
# paths, TLS config, etc.) are intentionally excluded.
_FRONTEND_KEYS = (
'CUSTOM_DIRS',
'CREATE_CUSTOM_DIRS',
'OUTPUT_TEMPLATE_CHAPTER',
'PUBLIC_HOST_URL',
'PUBLIC_HOST_AUDIO_URL',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
)
def frontend_safe(self) -> dict:
"""Return only the config keys that are safe to expose to browser clients.
Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS
settings, etc.) are intentionally excluded.
"""
return {k: getattr(self, k) for k in self._FRONTEND_KEYS}
def load_ytdl_options(self) -> tuple[bool, str]:
try:
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
@@ -111,6 +144,7 @@ class Config:
return (False, msg)
if not self.YTDL_OPTIONS_FILE:
self._apply_runtime_overrides()
return (True, '')
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
@@ -128,6 +162,7 @@ class Config:
return (False, msg)
self.YTDL_OPTIONS.update(opts)
self._apply_runtime_overrides()
return (True, '')
config = Config()
@@ -327,6 +362,65 @@ async def start(request):
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
@routes.post(config.URL_PREFIX + 'upload-cookies')
async def upload_cookies(request):
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != 'cookies':
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
size = 0
with open(COOKIES_PATH, 'wb') as f:
while True:
chunk = await field.read_chunk()
if not chunk:
break
size += len(chunk)
if size > 1_000_000: # 1MB limit
os.remove(COOKIES_PATH)
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
f.write(chunk)
config.set_runtime_override('cookiefile', COOKIES_PATH)
log.info(f'Cookies file uploaded ({size} bytes)')
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
@routes.post(config.URL_PREFIX + 'delete-cookies')
async def delete_cookies(request):
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
has_manual_cookiefile = isinstance(configured_cookiefile, str) and configured_cookiefile and configured_cookiefile != COOKIES_PATH
if not has_uploaded_cookies:
if has_manual_cookiefile:
return web.Response(
status=400,
text=serializer.encode({
'status': 'error',
'msg': 'Cookies are configured manually via YTDL_OPTIONS (cookiefile). Remove or change that setting manually; UI delete only removes uploaded cookies.'
})
)
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No uploaded cookies to delete'}))
os.remove(COOKIES_PATH)
config.remove_runtime_override('cookiefile')
success, msg = config.load_ytdl_options()
if not success:
log.error(f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}')
return web.Response(status=500, text=serializer.encode({'status': 'error', 'msg': f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}'}))
log.info('Cookies file deleted')
return web.Response(text=serializer.encode({'status': 'ok'}))
@routes.get(config.URL_PREFIX + 'cookie-status')
async def cookie_status(request):
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
has_configured_cookies = isinstance(configured_cookiefile, str) and os.path.exists(configured_cookiefile)
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
exists = has_uploaded_cookies or has_configured_cookies
return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists}))
@routes.get(config.URL_PREFIX + 'history')
async def history(request):
history = { 'done': [], 'queue': [], 'pending': []}
@@ -345,7 +439,7 @@ async def history(request):
async def connect(sid, environ):
log.info(f"Client connected: {sid}")
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
await sio.emit('configuration', serializer.encode(config), to=sid)
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
if config.YTDL_OPTIONS_FILE:
@@ -373,8 +467,12 @@ def get_custom_dirs():
else:
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
# Recursively lists all subdirectories of DOWNLOAD_DIR
# Recursively lists all subdirectories of DOWNLOAD_DIR.
# Always include '' (the base directory itself) even when the
# directory is empty or does not yet exist.
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
if '' not in dirs:
dirs.insert(0, '')
return dirs
@@ -439,6 +537,8 @@ async def add_cors(request):
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
async def on_prepare(request, response):
if 'Origin' in request.headers:
@@ -466,6 +566,12 @@ if __name__ == '__main__':
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
log.info(f"Listening on {config.HOST}:{config.PORT}")
# Auto-detect cookie file on startup
if os.path.exists(COOKIES_PATH):
config.set_runtime_override('cookiefile', COOKIES_PATH)
log.info(f'Cookie file detected at {COOKIES_PATH}')
if config.HTTPS:
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)

View File

@@ -29,6 +29,26 @@ def _compile_outtmpl_pattern(field: str) -> re.Pattern:
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
# Characters that are invalid in Windows/NTFS path components. These are pre-
# sanitised when substituting playlist/channel titles into output templates so
# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts.
_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]')
def _sanitize_path_component(value: Any) -> Any:
"""Replace characters that are invalid in Windows path components with '_'.
Non-string values (int, float, None, …) are passed through unchanged so
that ``_outtmpl_substitute_field`` can still coerce them with format specs
(e.g. ``%(playlist_index)02d``). Only string values are sanitised because
Windows-invalid characters are only a concern for human-readable strings
(titles, channel names, etc.) that may end up as directory names.
"""
if not isinstance(value, str):
return value
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
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)
@@ -573,6 +593,20 @@ class DownloadQueue:
else:
self.done.put(download)
asyncio.create_task(self.notifier.completed(download.info))
try:
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
except ValueError:
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
clear_after = 0
if clear_after > 0:
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
async def __auto_clear_after_delay(self, url, delay_seconds):
await asyncio.sleep(delay_seconds)
if self.done.exists(url):
log.debug(f'Auto-clearing completed download: {url}')
await self.clear([url])
def __extract_info(self, url):
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
@@ -617,13 +651,13 @@ class DownloadQueue:
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
for property, value in entry.items():
if property.startswith("playlist"):
output = _outtmpl_substitute_field(output, property, value)
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(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)
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
ytdl_options = dict(self.config.YTDL_OPTIONS)
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
if playlist_item_limit > 0:

View File

@@ -23,14 +23,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.0",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/platform-browser-dynamic": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"@angular/animations": "^21.2.1",
"@angular/common": "^21.2.1",
"@angular/compiler": "^21.2.1",
"@angular/core": "^21.2.1",
"@angular/forms": "^21.2.1",
"@angular/platform-browser": "^21.2.1",
"@angular/platform-browser-dynamic": "^21.2.1",
"@angular/service-worker": "^21.2.1",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
@@ -48,10 +48,10 @@
},
"devDependencies": {
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"@angular/localize": "^21.2.0",
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.1",
"@angular/localize": "^21.2.1",
"@eslint/js": "^9.39.3",
"angular-eslint": "21.1.0",
"eslint": "^9.39.3",

602
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -308,30 +308,71 @@
<div class="row">
<div class="col-12">
<hr class="my-3">
<div class="row g-2">
<div class="row g-3">
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2" />
Import URLs
</button>
<div class="action-group-label">Cookies</div>
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
(change)="onCookieFileSelect($event)"
[disabled]="cookieUploadInProgress || addInProgress">
<div class="btn-group w-100" role="group">
<label class="btn mb-0"
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
[class.disabled]="cookieUploadInProgress || addInProgress"
for="cookie-upload"
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
@if (cookieUploadInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
} @else {
<fa-icon [icon]="faUpload" class="me-2" />
}
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
</label>
@if (hasCookies) {
<button type="button" class="btn btn-outline-danger"
(click)="deleteCookies()"
[disabled]="cookieUploadInProgress || addInProgress"
ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
<div class="cookie-status" [class.active]="hasCookies">
@if (hasCookies) {
<fa-icon [icon]="faCheckCircle" class="me-1" />
Cookies active
} @else {
No cookies configured
}
</div>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
<div class="col-md-8">
<div class="action-group-label">Bulk Actions</div>
<div class="row g-2">
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2" />
Import URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
</div>
</div>
</div>
</div>
</div>
@@ -449,6 +490,7 @@
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
@@ -515,6 +557,11 @@
<span>{{ entry[1].size | fileSize }}</span>
}
</td>
<td class="text-nowrap">
@if (entry[1].timestamp) {
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
}
</td>
<td>
<div class="d-flex">
@if (entry[1].status === 'error') {
@@ -544,6 +591,7 @@
<span>{{ chapterFile.size | fileSize }}</span>
}
</td>
<td></td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download

View File

@@ -209,3 +209,48 @@ main
span
white-space: nowrap
.cookie-btn
flex: 1 1 auto
background-color: var(--bs-secondary-bg)
border-color: var(--bs-border-color)
color: var(--bs-emphasis-color)
&:hover
background-color: var(--bs-tertiary-bg)
border-color: var(--bs-secondary)
color: var(--bs-emphasis-color)
&.disabled
opacity: 0.65
pointer-events: none
.cookie-active-btn
flex: 1 1 auto
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success-border-subtle)
color: var(--bs-success-text-emphasis)
&:hover
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success)
color: var(--bs-success-text-emphasis)
&.disabled
opacity: 0.65
pointer-events: none
.action-group-label
font-size: 0.7rem
text-transform: uppercase
letter-spacing: 0.05em
color: var(--bs-secondary-color)
margin-bottom: 0.4rem
.cookie-status
font-size: 0.8rem
margin-top: 0.35rem
color: var(--bs-secondary-color)
&.active
color: var(--bs-success-text-emphasis)

View File

@@ -1,4 +1,4 @@
import { AsyncPipe, KeyValuePipe } from '@angular/common';
import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs';
@@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faUpload } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service';
@@ -21,6 +21,7 @@ import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
FormsModule,
KeyValuePipe,
AsyncPipe,
DatePipe,
FontAwesomeModule,
NgbModule,
NgSelectModule,
@@ -54,6 +55,8 @@ export class App implements AfterViewInit, OnInit {
subtitleMode: string;
addInProgress = false;
cancelRequested = false;
hasCookies = false;
cookieUploadInProgress = false;
themes: Theme[] = Themes;
activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>;
@@ -108,6 +111,7 @@ export class App implements AfterViewInit, OnInit {
faSortAmountDown = faSortAmountDown;
faSortAmountUp = faSortAmountUp;
faChevronRight = faChevronRight;
faUpload = faUpload;
subtitleFormats = [
{ id: 'srt', text: 'SRT' },
{ id: 'txt', text: 'TXT (Text only)' },
@@ -205,6 +209,9 @@ export class App implements AfterViewInit, OnInit {
}
ngOnInit() {
this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false;
});
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.customDirs$ = this.getMatchingCustomDir();
@@ -782,6 +789,74 @@ export class App implements AfterViewInit, OnInit {
return this.expandedErrors.has(id);
}
onCookieFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
this.cookieUploadInProgress = true;
this.downloads.uploadCookies(input.files[0]).subscribe({
next: (response) => {
if (response?.status === 'ok') {
this.hasCookies = true;
} else {
this.refreshCookieStatus();
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
}
this.cookieUploadInProgress = false;
input.value = '';
},
error: () => {
this.refreshCookieStatus();
this.cookieUploadInProgress = false;
input.value = '';
alert('Error uploading cookies.');
}
});
}
private formatErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object') {
const obj = error as Record<string, unknown>;
for (const key of ['msg', 'reason', 'error', 'detail']) {
const value = obj[key];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
return 'Unknown error';
}
deleteCookies() {
this.downloads.deleteCookies().subscribe({
next: (response) => {
if (response?.status === 'ok') {
this.refreshCookieStatus();
return;
}
this.refreshCookieStatus();
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
},
error: () => {
this.refreshCookieStatus();
alert('Error deleting cookies.');
}
});
}
private refreshCookieStatus() {
this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false;
});
}
private updateMetrics() {
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;

View File

@@ -20,6 +20,7 @@ export interface Download {
eta: number;
filename: string;
checked: boolean;
timestamp?: number;
size?: number;
error?: string;
deleting?: boolean;

View File

@@ -213,4 +213,24 @@ export class DownloadsService {
catchError(this.handleHTTPError)
);
}
uploadCookies(file: File) {
const formData = new FormData();
formData.append('cookies', file);
return this.http.post<any>('upload-cookies', formData).pipe(
catchError(this.handleHTTPError)
);
}
deleteCookies() {
return this.http.post<any>('delete-cookies', {}).pipe(
catchError(this.handleHTTPError)
);
}
getCookieStatus() {
return this.http.get<any>('cookie-status').pipe(
catchError(this.handleHTTPError)
);
}
}