fix: Clear buttons now effective immediately

- Updated navbar to fit theme
- Updated workflow
- Fixed clear buttons not effective immediately
This commit is contained in:
TonyBlur 2026-05-06 10:41:49 +08:00
parent 5639b5432c
commit de11f5c638
8 changed files with 143 additions and 51 deletions

View file

@ -4,8 +4,9 @@ on:
push:
branches:
- 'master'
paths-ignore:
- '**.md'
pull_request:
branches:
- 'master'
jobs:
quality-checks:
@ -16,7 +17,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install frontend dependencies
@ -35,10 +36,14 @@ jobs:
CI: true
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Python dependencies
run: uv sync --frozen --group dev
- name: Run backend smoke checks
run: python -m compileall app
run: uv run python -m compileall app
- name: Run backend tests
run: uv run pytest app/tests/
- name: Run Trivy filesystem scan
@ -51,7 +56,10 @@ jobs:
dockerhub-build-push:
needs: quality-checks
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
ghcr_repo: ${{ steps.ghcr-repo.outputs.ghcr_repo }}
steps:
-
name: Get current date
@ -62,7 +70,7 @@ jobs:
id: ghcr-repo
run: |
GHCR_REPO=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "repository=$GHCR_REPO" >> "$GITHUB_OUTPUT"
echo "ghcr_repo=$GHCR_REPO" >> "$GITHUB_OUTPUT"
-
name: Checkout
uses: actions/checkout@v6
@ -99,8 +107,8 @@ jobs:
tags: |
${{ secrets.DOCKERHUB_REPOSITORY }}:latest
${{ secrets.DOCKERHUB_REPOSITORY }}:${{ steps.date.outputs.date }}
${{ steps.ghcr-repo.outputs.repository }}:latest
${{ steps.ghcr-repo.outputs.repository }}:${{ steps.date.outputs.date }}
${{ steps.ghcr-repo.outputs.ghcr_repo }}:latest
${{ steps.ghcr-repo.outputs.ghcr_repo }}:${{ steps.date.outputs.date }}
dockerhub-sync-readme:
needs: dockerhub-build-push
@ -167,7 +175,7 @@ jobs:
id: release_body
env:
DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_REPOSITORY }}
GHCR_REPO: ghcr.io/${{ github.repository }}
GHCR_REPO: ${{ needs.dockerhub-build-push.outputs.ghcr_repo }}
DATE: ${{ steps.date.outputs.date }}
HAS_COMMITS: ${{ steps.commits.outputs.has_commits }}
COMMITS: ${{ steps.commits.outputs.commits }}

View file

@ -1,11 +1,18 @@
<div align="center">
<img src="https://github.com/TonyBlur/mytube/blob/master/ui/src/assets/icons/android-chrome-192x192.png?raw=true" width="96" height="96">
# MyTube
![Build Status](https://github.com/TonyBlur/mytube/actions/workflows/main.yml/badge.svg)
![Docker Pulls](https://img.shields.io/docker/pulls/tonyblu/mytube.svg)
</div>
MyTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
Key capabilities:
* Download videos, audio, captions, and thumbnails from a browser UI.
* Download playlists and channels, with configurable output and download options.
* Subscribe to channels and playlists, periodically check for new items, and queue new uploads automatically.
@ -45,6 +52,7 @@ docker-compose up -d
```
Logs can be viewed with:
```bash
docker-compose logs -f mytube
```
@ -62,10 +70,7 @@ docker-compose logs -f mytube
### Available variables
You can also load variables from files:
* Docker CLI supports `--env-file /path/to/.env`.
* The container entrypoint auto-loads `/app/.env` if present, and also loads a custom file when `ENV_FILE` points to it.
You can also load variables from files:
* Docker CLI supports `--env-file /path/to/.env`.
* The container entrypoint auto-loads `/app/.env` if present, and also loads a custom file when `ENV_FILE` points to it.
@ -83,15 +88,15 @@ You can also load variables from files:
* __DOWNLOAD_DIR__: Path to where the downloads will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* __AUDIO_DOWNLOAD_DIR__: Path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`.
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`.
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
* __CUSTOM_DIRS__: Enable choosing custom subdirectories under __DOWNLOAD_DIR__ or __AUDIO_DOWNLOAD_DIR__. Defaults to `true`.
* __CREATE_CUSTOM_DIRS__: Allow free-text directory names and create missing directories recursively. Defaults to `true`.
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regex for directories hidden from the dropdown. Empty disables exclusion. Defaults to `(^|/)[.@].*$`.
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
* __STATE_DIR__: Path to where mytube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.mytube` in the Docker image, and `.` otherwise.
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* __TEMP_DIR__: Path for intermediary download files. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which mytube runs has necessary access to these directories already. Defaults to `true`.
* __CHOWN_DIRS__: If `false`, skip chown for configured directories on container start. Defaults to `true`.
### 📝 File Naming & yt-dlp
@ -115,7 +120,7 @@ You can also load variables from files:
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
* __CERTFILE__: HTTPS certificate file path.
* __KEYFILE__: HTTPS key file path.
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the mytube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact mytube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
* __CORS_ALLOWED_ORIGINS__: Comma-separated origins allowed to call the API. Empty denies cross-origin requests; `*` allows all. Required for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and similar tools.
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
### 🏠 Basic Setup
@ -126,6 +131,7 @@ You can also load variables from files:
* __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`.
* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
* __PUBLIC_MODE__: Hide other visitors' download history and disable subscriptions for shared/public instances. Defaults to `false`.
## 🎛️ Configuring yt-dlp options
@ -212,6 +218,7 @@ If both are used and they define a preset with the same name, the **file's versi
```
This makes three presets available in the UI:
* **sponsorblock** — strips sponsor, self-promo, and interaction segments from videos.
* **embed-subs** — downloads English and German subtitles and embeds them into the video file.
* **limit-rate** — caps download speed to ~5 MB/s.
@ -246,8 +253,9 @@ MyTube always forces its own flat-extract behaviour during the initial metadata
### Configuration cookbooks
The project's Wiki contains examples of useful configurations contributed by users of MyTube:
* [YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)
* [OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)
* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)
* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)
## 🍪 Using browser cookies
@ -421,4 +429,5 @@ docker build -t mytube .
Note that if you're running the server in VSCode, your downloads will go to your user's Downloads folder (this is configured via the environment in `.vscode/launch.json`).
## 🍴 Fork note
This project is forked from [alexta69/metube](https://github.com/alexta69/metube).
This project is forked from [alexta69/metube](https://github.com/alexta69/metube).

View file

@ -2,7 +2,7 @@ version: '3.8'
services:
mytube:
image: ghcr.io/tonyblur/mytube
image: mytube
container_name: mytube
restart: unless-stopped
ports:
@ -45,7 +45,7 @@ services:
# === Web Server ===
# - HOST=0.0.0.0
# - PORT=8081
- PUBLIC_MODE=false
- PUBLIC_MODE=true
# - URL_PREFIX=/mytube/
# - PUBLIC_HOST_URL=download/
# - PUBLIC_HOST_AUDIO_URL=audio_download/

View file

@ -1,4 +1,4 @@
<nav class="navbar navbar-expand-md navbar-dark">
<nav class="navbar navbar-expand-md shadow-sm">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MyTube Logo" height="32" class="me-2">
@ -681,7 +681,7 @@
Connecting to server...
</div>
}
<div class="metube-section-header">Downloading</div>
<div class="metube-section-header shadow-sm">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" />&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" />&nbsp; Download selected</button>
@ -729,7 +729,7 @@
</table>
</div>
<div class="metube-section-header">Completed</div>
<div class="metube-section-header shadow-sm">Completed</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
@ -882,7 +882,7 @@
</div>
@if (!isPublicMode()) {
<div class="metube-section-header">Subscriptions</div>
<div class="metube-section-header shadow-sm">Subscriptions</div>
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>

View file

@ -13,11 +13,15 @@
main
flex-grow: 1
margin-top: 60px
.navbar
backdrop-filter: blur(8px)
background: color-mix(in srgb, var(--bs-dark) 92%, #000)
border-bottom: 1px solid rgba(255, 255, 255, 0.08)
position: fixed
width: 100%
z-index: 99
.navbar-brand
font-weight: 700
@ -107,7 +111,6 @@ main
background: var(--bs-primary)
.table
border-radius: var(--mt-radius-md)
overflow: hidden
box-shadow: var(--mt-shadow-sm)

View file

@ -46,6 +46,28 @@ function basePayload(): AddDownloadPayload {
};
}
function makeDownload(overrides: Partial<Download> = {}): Download {
return {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
...overrides,
};
}
describe('DownloadsService', () => {
let socket: MeTubeSocketStub;
let httpMock: HttpTestingController;
@ -132,25 +154,7 @@ describe('DownloadsService', () => {
});
it('delById marks items deleting and posts delete', () => {
const dl: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
deleting: false,
};
const dl = makeDownload({ deleting: false });
service.queue.set('u1', dl);
service.delById('queue', ['u1']).subscribe();
expect(dl.deleting).toBe(true);
@ -159,6 +163,47 @@ describe('DownloadsService', () => {
req.flush({});
});
it('delById removes done items immediately', () => {
const dl = makeDownload({ deleting: false });
let doneChanged = 0;
service.doneChanged.subscribe(() => doneChanged++);
service.done.set('done-key', dl);
service.delById('done', ['done-key']).subscribe();
expect(service.done.has('done-key')).toBe(false);
expect(dl.deleting).toBe(true);
expect(doneChanged).toBe(1);
const req = httpMock.expectOne('delete');
expect(req.request.body).toEqual({ where: 'done', ids: ['done-key'] });
req.flush({});
});
it('delById restores optimistically removed done items on error', () => {
const dl = makeDownload({ deleting: false });
service.done.set('done-key', dl);
service.delById('done', ['done-key']).subscribe({ error: () => undefined });
const req = httpMock.expectOne('delete');
req.flush('failed', { status: 500, statusText: 'Server Error' });
expect(service.done.get('done-key')).toBe(dl);
expect(dl.deleting).toBe(false);
});
it('delByFilter posts map keys instead of source URLs', () => {
service.done.set('unique-key', makeDownload({
url: 'https://example.com/original-url',
status: 'finished',
}));
service.delByFilter('done', dl => dl.status === 'finished').subscribe();
const req = httpMock.expectOne('delete');
expect(req.request.body).toEqual({ where: 'done', ids: ['unique-key'] });
req.flush({});
});
it('handleHTTPError extracts msg from object body', async () => {
const err = new HttpErrorResponse({
error: { msg: 'bad' },

View file

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, Subject } from 'rxjs';
import { of, Subject, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service';
import { Download, Status, State } from '../interfaces';
@ -179,15 +179,42 @@ export class DownloadsService {
public delById(where: State, ids: string[]) {
const map = this[where];
const touched: [string, Download, boolean | undefined][] = [];
if (map) {
for (const id of ids) {
const obj = map.get(id);
if (obj) {
touched.push([id, obj, obj.deleting]);
obj.deleting = true;
if (where === 'done') {
map.delete(id);
}
}
}
if (where === 'queue') {
this.queueChanged.next();
} else if (where === 'done') {
this.doneChanged.next();
}
}
return this.http.post('delete', {where: where, ids: ids});
return this.http.post('delete', {where: where, ids: ids}).pipe(
catchError((error: HttpErrorResponse) => {
if (map) {
for (const [id, obj, deleting] of touched) {
obj.deleting = deleting;
if (where === 'done' && !map.has(id)) {
map.set(id, obj);
}
}
if (where === 'queue') {
this.queueChanged.next();
} else if (where === 'done') {
this.doneChanged.next();
}
}
return throwError(() => error);
})
);
}
public startByFilter(where: State, filter: (dl: Download) => boolean) {
@ -198,7 +225,7 @@ export class DownloadsService {
public delByFilter(where: State, filter: (dl: Download) => boolean) {
const ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
this[where].forEach((dl: Download, key: string) => { if (filter(dl)) ids.push(key) });
return this.delById(where, ids);
}
public cancelAdd() {

View file

@ -1,10 +1,10 @@
/* You can add global styles to this file, and also import other style files */
.navbar
background-color: var(--bs-dark) !important
background-color: rgba(var(--bs-light-rgb), 0.9) !important
[data-bs-theme="dark"] &
background-color: var(--bs-dark-bg-subtle) !important
background-color: rgba(var(--bs-dark-rgb), 0.9) !important
.ng-select
flex: 1