fix: Clear buttons now effective immediately
- Updated navbar to fit theme - Updated workflow - Fixed clear buttons not effective immediately
This commit is contained in:
parent
5639b5432c
commit
de11f5c638
8 changed files with 143 additions and 51 deletions
24
.github/workflows/main.yml
vendored
24
.github/workflows/main.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
</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).
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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" /> 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" /> 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" /> {{ 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" /> 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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue