feat(frontend): modernize Angular app

This commit is contained in:
Pierre Nédélec
2025-12-15 01:56:47 +01:00
parent 03f1fa106a
commit 183c4ba898
52 changed files with 8215 additions and 16157 deletions

View File

@@ -2,8 +2,8 @@ FROM node:lts-alpine AS builder
WORKDIR /metube
COPY ui ./
RUN npm ci && \
node_modules/.bin/ng build --configuration production
RUN corepack enable && corepack prepare pnpm --activate
RUN pnpm install && pnpm run build
FROM python:3.13-alpine

View File

@@ -267,13 +267,13 @@ MeTube development relies on code contributions by the community. The program as
## 🛠️ Building and running locally
Make sure you have Node.js and Python 3.13 installed.
Make sure you have Node.js 22+ and Python 3.13 installed.
```bash
cd metube/ui
# install Angular and build the UI
npm install
node_modules/.bin/ng build
pnpm install
pnpm run build
# install python dependencies
cd ..
curl -LsSf https://astral.sh/uv/install.sh | sh

View File

@@ -15,17 +15,13 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/metube"
},
"index": "src/index.html",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets",
@@ -41,17 +37,14 @@
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"serviceWorker": "ngsw-config.json",
"browser": "src/main.ts"
"browser": "src/main.ts",
"polyfills": [
"zone.js",
"@angular/localize/init"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
@@ -68,75 +61,45 @@
"maximumError": "10kb"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "metube:build"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "metube:build:production"
},
"development": {
"buildTarget": "metube:build:development"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "metube:build"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
"src/custom-service-worker.js"
],
"styles": [
"src/styles.sass"
],
"scripts": []
}
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "metube:serve"
},
"configurations": {
"production": {
"devServerTarget": "metube:serve:production"
}
}
}
}
}
},
"cli": {
"analytics": false
"analytics": false,
"packageManager": "pnpm"
},
"schematics": {
"@schematics/angular:component": {

View File

@@ -1,36 +0,0 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: StacktraceOption.PRETTY
}
}));
}
};

View File

@@ -1,23 +0,0 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('metube app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

View File

@@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl) as Promise<unknown>;
}
getTitleText(): Promise<string> {
return element(by.css('app-root .content span')).getText() as Promise<string>;
}
}

View File

@@ -1,14 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

44
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,44 @@
// @ts-check
const eslint = require("@eslint/js");
const { defineConfig } = require("eslint/config");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = defineConfig([
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
angular.configs.templateRecommended,
angular.configs.templateAccessibility,
],
rules: {},
}
]);

View File

@@ -1,32 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/metube'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

14890
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,44 +5,58 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:watch": "ng build --watch",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
"lint": "ng lint"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.15",
"@angular/common": "^20.3.15",
"@angular/compiler": "^20.3.15",
"@angular/core": "^20.3.15",
"@angular/forms": "^20.3.15",
"@angular/localize": "^20.3.15",
"@angular/platform-browser": "^20.3.15",
"@angular/platform-browser-dynamic": "^20.3.15",
"@angular/router": "^20.3.15",
"@angular/service-worker": "^20.3.15",
"@fortawesome/angular-fontawesome": "~3.0.0",
"@angular/animations": "^21.0.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/platform-browser-dynamic": "^21.0.0",
"@angular/service-worker": "^21.0.0",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@ng-bootstrap/ng-bootstrap": "^19.0.0",
"@ng-select/ng-select": "^20.0.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.1.0",
"bootstrap": "^5.3.6",
"ngx-cookie-service": "^20.0.0",
"ngx-socket-io": "~4.9.0",
"ngx-cookie-service": "^21.1.0",
"ngx-socket-io": "~4.9.3",
"rxjs": "~7.8.0",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
"zone.js": "0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.3.13",
"@angular/cli": "^20.3.13",
"@angular/compiler-cli": "^20.3.15",
"@types/node": "^22.15.29",
"codelyzer": "^6.0.2",
"ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~5.8.3"
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.0.3",
"@angular/cli": "^21.0.3",
"@angular/compiler-cli": "^21.0.0",
"@angular/localize": "^21.0.0",
"@eslint/js": "^9.39.1",
"angular-eslint": "21.1.0",
"eslint": "^9.39.1",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.8"
}
}
}

7163
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,402 +0,0 @@
<nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
MeTube
</a>
<div class="download-metrics">
<div class="metric" *ngIf="activeDownloads > 0">
<fa-icon [icon]="faDownload" class="text-primary"></fa-icon>
<span>{{activeDownloads}} downloading</span>
</div>
<div class="metric" *ngIf="queuedDownloads > 0">
<fa-icon [icon]="faClock" class="text-warning"></fa-icon>
<span>{{queuedDownloads}} queued</span>
</div>
<div class="metric" *ngIf="completedDownloads > 0">
<fa-icon [icon]="faCheck" class="text-success"></fa-icon>
<span>{{completedDownloads}} completed</span>
</div>
<div class="metric" *ngIf="failedDownloads > 0">
<fa-icon [icon]="faTimesCircle" class="text-danger"></fa-icon>
<span>{{failedDownloads}} failed</span>
</div>
<div class="metric" *ngIf="(totalSpeed | speed) !== ''">
<fa-icon [icon]="faTachometerAlt" class="text-info"></fa-icon>
<span>{{totalSpeed | speed }}</span>
</div>
</div>
<!--
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
</ul>
</div>
-->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
data-bs-display="static">
<fa-icon [icon]="activeTheme.icon"></fa-icon>
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
<li *ngFor="let theme of themes">
<button type="button" class="dropdown-item d-flex align-items-center" [ngClass]="{'active' : activeTheme == theme}" (click)="themeChanged(theme)">
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon"></fa-icon>
</span>
{{ theme.displayName }}
<span class="ms-auto" [ngClass]="{'d-none' : activeTheme != theme}">
<fa-icon [icon]="faCheck"></fa-icon>
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</nav>
<main role="main" class="container container-xl">
<form #f="ngForm">
<div class="container add-url-box">
<!-- Main URL Input with Download Button -->
<div class="row mb-4">
<div class="col">
<div class="input-group input-group-lg shadow-sm">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading">
<button class="btn btn-primary btn-lg px-4"
type="submit"
(click)="addDownload()"
[disabled]="addInProgress || downloads.loading">
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
{{ addInProgress ? "Adding..." : "Download" }}
</button>
</div>
</div>
</div>
<!-- Options Row -->
<div class="row mb-3 g-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
</select>
</div>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-outline-secondary w-100 h-100"
(click)="toggleAdvanced()">
Advanced Options
</button>
</div>
</div>
<!-- Advanced Options Panel -->
<div class="row">
<div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="card card-body">
<!-- Advanced Settings -->
<div class="row g-3 mb-2">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Download Folder</span>
<ng-select [items]="customDirs$ | async"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
[loading]="downloads.loading"
[searchable]="true"
[closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder.">
</ng-select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom Name Prefix</span>
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Items Limit</span>
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
name="playlistStrictMode"
[(ngModel)]="playlistStrictMode"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
<label class="form-check-label">Strict Playlist Mode</label>
</div>
</div>
</div>
<!-- Advanced Actions -->
<div class="row">
<div class="col-12">
<hr class="my-3">
<div class="row g-2">
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
Import URLs
</button>
</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"></fa-icon>
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"></fa-icon>
Copy URLs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
</div>
<div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea>
<div class="mt-2">
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">
Cancel Import
</button>
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
Import URLs
</button>
</div>
</div>
</div>
</div>
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
Connecting to server...
</div>
<div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }}</div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="metube-section-header">Completed</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center">
<div class="footer-content" *ngIf="ytDlpVersion && metubeVersion">
<div class="version-item">
<span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item" *ngIf="ytDlpOptionsUpdateTime">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
<div class="version-separator" *ngIf="ytDlpOptionsUpdateTime"></div>
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub"></fa-icon>
<span>GitHub</span>
</a>
</div>
</div>
</footer>

View File

@@ -1,31 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'metube'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('metube');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('metube app is running!');
});
});

17
ui/src/app/app.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideServiceWorker('custom-service-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
}),
provideHttpClient(withInterceptorsFromDi()),
]
};

464
ui/src/app/app.html Normal file
View File

@@ -0,0 +1,464 @@
<nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
MeTube
</a>
<div class="download-metrics">
@if (activeDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span>
</div>
}
@if (queuedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span>
</div>
}
@if (completedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span>
</div>
}
@if (failedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span>
</div>
}
@if ((totalSpeed | speed) !== '') {
<div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span>
</div>
}
</div>
<!--
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
</ul>
</div>
-->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
data-bs-display="static">
@if(activeTheme){
<fa-icon [icon]="activeTheme.icon" />
}
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
@for (theme of themes; track theme) {
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme"
(click)="themeChanged(theme)">
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" />
</span>
{{ theme.displayName }}
<span class="ms-auto"
[class.d-none]="activeTheme !== theme">
<fa-icon [icon]="faCheck" />
</span>
</button>
</li>
}
</ul>
</div>
</div>
</div>
</nav>
<main role="main" class="container container-xl">
<form #f="ngForm">
<div class="container add-url-box">
<!-- Main URL Input with Download Button -->
<div class="row mb-4">
<div class="col">
<div class="input-group input-group-lg shadow-sm">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading">
<button class="btn btn-primary btn-lg px-4"
type="submit"
(click)="addDownload()"
[disabled]="addInProgress || downloads.loading">
@if (addInProgress) {
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span>
}
{{ addInProgress ? "Adding..." : "Download" }}
</button>
</div>
</div>
</div>
<!-- Options Row -->
<div class="row mb-3 g-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
@for (f of formats; track f) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-outline-secondary w-100 h-100"
(click)="toggleAdvanced()">
Advanced Options
</button>
</div>
</div>
<!-- Advanced Options Panel -->
<div class="row">
<div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="card card-body">
<!-- Advanced Settings -->
<div class="row g-3 mb-2">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Download Folder</span>
@if (customDirs$ | async; as customDirs) {
<ng-select [items]="customDirs"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
[loading]="downloads.loading"
[searchable]="true"
[closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
}
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom Name Prefix</span>
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Items Limit</span>
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
id="checkbox-strict-mode"
name="playlistStrictMode"
[(ngModel)]="playlistStrictMode"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
<label class="form-check-label" for="checkbox-strict-mode">Strict Playlist Mode</label>
</div>
</div>
</div>
<!-- Advanced Actions -->
<div class="row">
<div class="col-12">
<hr class="my-3">
<div class="row g-2">
<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>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog"
[class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
</div>
<div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea>
<div class="mt-2">
@if (batchImportStatus) {
<small>{{ batchImportStatus }}</small>
}
</div>
</div>
<div class="modal-footer">
@if (importInProgress) {
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import
</button>
}
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
Import URLs
</button>
</div>
</div>
</div>
</div>
@if (downloads.loading) {
<div class="alert alert-info" role="alert">
Connecting to server...
</div>
}
<div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&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>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
@if (download.value.status === 'pending') {
<button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="metube-section-header">Completed</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" />&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" />&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" />
}
@if (download.value.status === 'error') {
<fa-icon [icon]="faTimesCircle" class="text-danger" />
}
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}">@if (!!download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
} @else {
{{download.value.title}}
@if (download.value.msg) {
<span><br>{{download.value.msg}}</span>
}
@if (download.value.error) {
<span><br>Error: {{download.value.error}}</span>
}
}</span>
</td>
<td>
@if (download.value.size) {
<span>{{ download.value.size | fileSize }}</span>
}
</td>
<td>
<div class="d-flex">
@if (download.value.status === 'error') {
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button>
}
@if (download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
}
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center">
@if (ytDlpVersion && metubeVersion) {
<div class="footer-content">
<div class="version-item">
<span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
@if (ytDlpOptionsUpdateTime) {
<div class="version-item">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
}
@if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div>
}
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub" />
<span>GitHub</span>
</a>
</div>
}
</div>
</footer>

View File

@@ -1,36 +0,0 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, isDevMode } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { CookieService } from 'ngx-cookie-service';
import { AppComponent } from './app.component';
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe } from './downloads.pipe';
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
import { MeTubeSocket } from './metube-socket';
import { NgSelectModule } from '@ng-select/ng-select';
import { ServiceWorkerModule } from '@angular/service-worker';
@NgModule({ declarations: [
AppComponent,
EtaPipe,
SpeedPipe,
FileSizePipe,
EncodeURIComponent,
MasterCheckboxComponent,
SlaveCheckboxComponent
],
bootstrap: [AppComponent], imports: [BrowserModule,
FormsModule,
NgbModule,
FontAwesomeModule,
NgSelectModule,
ServiceWorkerModule.register('custom-service-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
})], providers: [CookieService, MeTubeSocket, provideHttpClient(withInterceptorsFromDi())] })
export class AppModule { }

33
ui/src/app/app.spec.ts Normal file
View File

@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

View File

@@ -1,39 +1,58 @@
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { AsyncPipe, KeyValuePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs';
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 } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
import { Download, DownloadsService, Status } from './downloads.service';
import { MasterCheckboxComponent } from './master-checkbox.component';
import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common";
import { DownloadsService } from './services/downloads.service';
import { Themes } from './theme';
import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces';
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.sass'],
standalone: false
selector: 'app-root',
imports: [
FormsModule,
KeyValuePipe,
AsyncPipe,
FontAwesomeModule,
NgbModule,
NgSelectModule,
EtaPipe,
SpeedPipe,
FileSizePipe,
MasterCheckboxComponent,
SlaveCheckboxComponent,
],
templateUrl: './app.html',
styleUrl: './app.sass',
})
export class AppComponent implements AfterViewInit {
addUrl: string;
export class App implements AfterViewInit, OnInit {
downloads = inject(DownloadsService);
private cookieService = inject(CookieService);
private http = inject(HttpClient);
addUrl!: string;
formats: Format[] = Formats;
qualities: Quality[];
qualities!: Quality[];
quality: string;
format: string;
folder: string;
customNamePrefix: string;
folder!: string;
customNamePrefix!: string;
autoStart: boolean;
playlistStrictMode: boolean;
playlistItemLimit: number;
playlistStrictMode!: boolean;
playlistItemLimit!: number;
addInProgress = false;
themes: Theme[] = Themes;
activeTheme: Theme;
customDirs$: Observable<string[]>;
showBatchPanel: boolean = false;
activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>;
showBatchPanel = false;
batchImportModalOpen = false;
batchImportText = '';
batchImportStatus = '';
@@ -51,15 +70,15 @@ export class AppComponent implements AfterViewInit {
failedDownloads = 0;
totalSpeed = 0;
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
@ViewChild('doneDelSelected') doneDelSelected: ElementRef;
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
@ViewChild('doneClearFailed') doneClearFailed: ElementRef;
@ViewChild('doneRetryFailed') doneRetryFailed: ElementRef;
@ViewChild('doneDownloadSelected') doneDownloadSelected: ElementRef;
readonly queueMasterCheckbox = viewChild<MasterCheckboxComponent>('queueMasterCheckboxRef');
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
readonly doneMasterCheckbox = viewChild<MasterCheckboxComponent>('doneMasterCheckboxRef');
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
readonly doneClearCompleted = viewChild.required<ElementRef>('doneClearCompleted');
readonly doneClearFailed = viewChild.required<ElementRef>('doneClearFailed');
readonly doneRetryFailed = viewChild.required<ElementRef>('doneRetryFailed');
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
faTrashAlt = faTrashAlt;
faCheckCircle = faCheckCircle;
@@ -78,14 +97,14 @@ export class AppComponent implements AfterViewInit {
faClock = faClock;
faTachometerAlt = faTachometerAlt;
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
this.format = cookieService.get('metube_format') || 'any';
constructor() {
this.format = this.cookieService.get('metube_format') || 'any';
// Needs to be set or qualities won't automatically be set
this.setQualities()
this.quality = cookieService.get('metube_quality') || 'best';
this.autoStart = cookieService.get('metube_auto_start') !== 'false';
this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.activeTheme = this.getPreferredTheme(cookieService);
this.activeTheme = this.getPreferredTheme(this.cookieService);
// Subscribe to download updates
this.downloads.queueChanged.subscribe(() => {
@@ -104,10 +123,10 @@ export class AppComponent implements AfterViewInit {
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme);
this.setTheme(this.activeTheme!);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.activeTheme.id === 'auto') {
if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme);
}
});
@@ -115,27 +134,30 @@ export class AppComponent implements AfterViewInit {
ngAfterViewInit() {
this.downloads.queueChanged.subscribe(() => {
this.queueMasterCheckbox.selectionChanged();
this.queueMasterCheckbox()?.selectionChanged();
});
this.downloads.doneChanged.subscribe(() => {
this.doneMasterCheckbox.selectionChanged();
let completed: number = 0, failed: number = 0;
this.doneMasterCheckbox()?.selectionChanged();
let completed = 0, failed = 0;
this.downloads.done.forEach(dl => {
if (dl.status === 'finished')
completed++;
else if (dl.status === 'error')
failed++;
});
this.doneClearCompleted.nativeElement.disabled = completed === 0;
this.doneClearFailed.nativeElement.disabled = failed === 0;
this.doneRetryFailed.nativeElement.disabled = failed === 0;
this.doneClearCompleted().nativeElement.disabled = completed === 0;
this.doneClearFailed().nativeElement.disabled = failed === 0;
this.doneRetryFailed().nativeElement.disabled = failed === 0;
});
this.fetchVersionInfo();
}
// workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420
asIsOrder(a, b) {
asIsOrder() {
return 1;
}
@@ -162,7 +184,8 @@ export class AppComponent implements AfterViewInit {
getMatchingCustomDir() : Observable<string[]> {
return this.downloads.customDirsChanged.asObservable().pipe(
map((output) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map((output: any) => {
// Keep logic consistent with app/ytdl.py
if (this.isAudioType()) {
console.debug("Showing audio-specific download directories");
@@ -178,7 +201,8 @@ export class AppComponent implements AfterViewInit {
getYtdlOptionsUpdateTime() {
this.downloads.ytdlOptionsChanged.subscribe({
next: (data) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data:any) => {
if (data['success']){
const date = new Date(data['update_time'] * 1000);
this.ytDlpOptionsUpdateTime=date.toLocaleString();
@@ -190,7 +214,8 @@ export class AppComponent implements AfterViewInit {
}
getConfiguration() {
this.downloads.configurationChanged.subscribe({
next: (config) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (playlistItemLimit !== '0') {
@@ -236,21 +261,24 @@ export class AppComponent implements AfterViewInit {
}
queueSelectionChanged(checked: number) {
this.queueDelSelected.nativeElement.disabled = checked == 0;
this.queueDownloadSelected.nativeElement.disabled = checked == 0;
this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
}
doneSelectionChanged(checked: number) {
this.doneDelSelected.nativeElement.disabled = checked == 0;
this.doneDownloadSelected.nativeElement.disabled = checked == 0;
this.doneDelSelected().nativeElement.disabled = checked == 0;
this.doneDownloadSelected().nativeElement.disabled = checked == 0;
}
setQualities() {
// qualities for specific format
this.qualities = this.formats.find(el => el.id == this.format).qualities
const exists = this.qualities.find(el => el.id === this.quality)
this.quality = exists ? this.quality : 'best'
const format = this.formats.find(el => el.id == this.format)
if (format) {
this.qualities = format.qualities
const exists = this.qualities.find(el => el.id === this.quality)
this.quality = exists ? this.quality : 'best'
}
}
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) {
url = url ?? this.addUrl
@@ -283,16 +311,16 @@ export class AppComponent implements AfterViewInit {
this.downloads.delById('done', [key]).subscribe();
}
delDownload(where: string, id: string) {
delDownload(where: State, id: string) {
this.downloads.delById(where, [id]).subscribe();
}
startSelectedDownloads(where: string){
this.downloads.startByFilter(where, dl => dl.checked).subscribe();
startSelectedDownloads(where: State){
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
}
delSelectedDownloads(where: string) {
this.downloads.delByFilter(where, dl => dl.checked).subscribe();
delSelectedDownloads(where: State) {
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
}
clearCompletedDownloads() {
@@ -312,7 +340,8 @@ export class AppComponent implements AfterViewInit {
}
downloadSelectedFiles() {
this.downloads.done.forEach((dl, key) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
this.downloads.done.forEach((dl, _) => {
if (dl.status === 'finished' && dl.checked) {
const link = document.createElement('a');
link.href = this.buildDownloadLink(dl);
@@ -338,13 +367,10 @@ export class AppComponent implements AfterViewInit {
return baseDir + encodeURIComponent(download.filename);
}
identifyDownloadRow(index: number, row: KeyValue<string, Download>) {
return row.key;
}
isNumber(event) {
const charCode = (event.which) ? event.which : event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
isNumber(event: KeyboardEvent) {
const charCode = +event.code || event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
event.preventDefault();
}
}
@@ -485,6 +511,7 @@ export class AppComponent implements AfterViewInit {
}
fetchVersionInfo(): void {
// eslint-disable-next-line no-useless-escape
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
const versionUrl = `${baseUrl}version`;
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)

View File

@@ -0,0 +1,2 @@
export { MasterCheckboxComponent } from './master-checkbox.component';
export { SlaveCheckboxComponent } from './slave-checkbox.component';

View File

@@ -0,0 +1,40 @@
import { Component, ElementRef, viewChild, output, input } from "@angular/core";
import { Checkable } from "../interfaces";
import { FormsModule } from "@angular/forms";
@Component({
selector: 'app-master-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
<label class="form-check-label" for="{{id()}}-select-all"></label>
</div>
`,
imports: [
FormsModule
]
})
export class MasterCheckboxComponent {
readonly id = input.required<string>();
readonly list = input.required<Map<string, Checkable>>();
readonly changed = output<number>();
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
selected!: boolean;
clicked() {
this.list().forEach(item => item.checked = this.selected);
this.selectionChanged();
}
selectionChanged() {
const masterCheckbox = this.masterCheckbox();
if (!masterCheckbox)
return;
let checked = 0;
this.list().forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked == this.list().size;
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked);
}
}

View File

@@ -0,0 +1,22 @@
import { Component, input } from '@angular/core';
import { MasterCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-slave-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()">
<label class="form-check-label" for="{{master().id()}}-{{id()}}-select"></label>
</div>
`,
imports: [
FormsModule
]
})
export class SlaveCheckboxComponent {
readonly id = input.required<string>();
readonly master = input.required<MasterCheckboxComponent>();
readonly checkable = input.required<Checkable>();
}

View File

@@ -1,93 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core';
import { SpeedService } from './speed.service';
import { BehaviorSubject } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
@Pipe({
name: 'eta',
standalone: false
})
export class EtaPipe implements PipeTransform {
transform(value: number, ...args: any[]): any {
if (value === null) {
return null;
}
if (value < 60) {
return `${Math.round(value)}s`;
}
if (value < 3600) {
return `${Math.floor(value/60)}m ${Math.round(value%60)}s`;
}
const hours = Math.floor(value/3600)
const minutes = value % 3600
return `${hours}h ${Math.floor(minutes/60)}m ${Math.round(minutes%60)}s`;
}
}
@Pipe({
name: 'speed',
standalone: false,
pure: false // Make the pipe impure so it can handle async updates
})
export class SpeedPipe implements PipeTransform {
private speedSubject = new BehaviorSubject<number>(0);
private formattedSpeed: string = '';
constructor(private speedService: SpeedService) {
// Throttle updates to once per second
this.speedSubject.pipe(
throttleTime(1000)
).subscribe(speed => {
// If speed is invalid or 0, return empty string
if (speed === null || speed === undefined || isNaN(speed) || speed <= 0) {
this.formattedSpeed = '';
return;
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(speed) / Math.log(k));
this.formattedSpeed = parseFloat((speed / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
});
}
transform(value: number, ...args: any[]): any {
// If speed is invalid or 0, return empty string
if (value === null || value === undefined || isNaN(value) || value <= 0) {
return '';
}
// Update the speed subject
this.speedSubject.next(value);
// Return the last formatted speed
return this.formattedSpeed;
}
}
@Pipe({
name: 'encodeURIComponent',
standalone: false
})
export class EncodeURIComponent implements PipeTransform {
transform(value: string, ...args: any[]): any {
return encodeURIComponent(value);
}
}
@Pipe({
name: 'fileSize',
standalone: false
})
export class FileSizePipe implements PipeTransform {
transform(value: number): string {
if (isNaN(value) || value === 0) return '0 Bytes';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
const unitValue = value / Math.pow(1000, unitIndex);
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
}
}

View File

@@ -0,0 +1,3 @@
export interface Checkable {
checked: boolean;
}

View File

@@ -0,0 +1,22 @@
export interface Download {
id: string;
title: string;
url: string;
quality: string;
format: string;
folder: string;
custom_name_prefix: string;
playlist_strict_mode: boolean;
playlist_item_limit: number;
status: string;
msg: string;
percent: number;
speed: number;
eta: number;
filename: string;
checked: boolean;
size?: number;
error?: string;
deleting?: boolean;
}

View File

@@ -0,0 +1,7 @@
import { Quality } from "./quality";
export interface Format {
id: string;
text: string;
qualities: Quality[];
}

View File

@@ -1,13 +1,5 @@
export interface Format {
id: string;
text: string;
qualities: Quality[];
}
import { Format } from "./format";
export interface Quality {
id: string;
text: string;
}
export const Formats: Format[] = [
{

View File

@@ -0,0 +1,9 @@
export * from './theme';
export * from './status';
export * from './quality';
export * from './state';
export * from './download';
export * from './checkable';
export * from './format';
export * from './formats';

View File

@@ -0,0 +1,5 @@
export interface Quality {
id: string;
text: string;
}

View File

@@ -0,0 +1 @@
export type State = 'queue' | 'done';

View File

@@ -0,0 +1,4 @@
export interface Status {
status: string;
msg?: string;
}

View File

@@ -0,0 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export interface Theme {
id: string;
displayName: string;
icon: IconDefinition;
}

View File

@@ -1,55 +0,0 @@
import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core';
interface Checkable {
checked: boolean;
}
@Component({
selector: 'app-master-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
<label class="form-check-label" for="{{id}}-select-all"></label>
</div>
`,
standalone: false
})
export class MasterCheckboxComponent {
@Input() id: string;
@Input() list: Map<String, Checkable>;
@Output() changed = new EventEmitter<number>();
@ViewChild('masterCheckbox') masterCheckbox: ElementRef;
selected: boolean;
clicked() {
this.list.forEach(item => item.checked = this.selected);
this.selectionChanged();
}
selectionChanged() {
if (!this.masterCheckbox)
return;
let checked: number = 0;
this.list.forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked == this.list.size;
this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size;
this.changed.emit(checked);
}
}
@Component({
selector: 'app-slave-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master.id}}-{{id}}-select" [(ngModel)]="checkable.checked" (change)="master.selectionChanged()">
<label class="form-check-label" for="{{master.id}}-{{id}}-select"></label>
</div>
`,
standalone: false
})
export class SlaveCheckboxComponent {
@Input() id: string;
@Input() master: MasterCheckboxComponent;
@Input() checkable: Checkable;
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: 'eta',
})
export class EtaPipe implements PipeTransform {
transform(value: number): string | null {
if (value === null) {
return null;
}
if (value < 60) {
return `${Math.round(value)}s`;
}
if (value < 3600) {
return `${Math.floor(value/60)}m ${Math.round(value%60)}s`;
}
const hours = Math.floor(value/3600)
const minutes = value % 3600
return `${hours}h ${Math.floor(minutes/60)}m ${Math.round(minutes%60)}s`;
}
}

View File

@@ -0,0 +1,16 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: 'fileSize',
})
export class FileSizePipe implements PipeTransform {
transform(value: number): string {
if (isNaN(value) || value === 0) return '0 Bytes';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
const unitValue = value / Math.pow(1000, unitIndex);
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
}
}

View File

@@ -0,0 +1,3 @@
export { EtaPipe } from './eta.pipe';
export { SpeedPipe } from './speed.pipe';
export { FileSizePipe } from './file-size.pipe';

View File

@@ -0,0 +1,43 @@
import { Pipe, PipeTransform } from "@angular/core";
import { BehaviorSubject, throttleTime } from "rxjs";
@Pipe({
name: 'speed',
pure: false // Make the pipe impure so it can handle async updates
})
export class SpeedPipe implements PipeTransform {
private speedSubject = new BehaviorSubject<number>(0);
private formattedSpeed = '';
constructor() {
// Throttle updates to once per second
this.speedSubject.pipe(
throttleTime(1000)
).subscribe(speed => {
// If speed is invalid or 0, return empty string
if (speed === null || speed === undefined || isNaN(speed) || speed <= 0) {
this.formattedSpeed = '';
return;
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(speed) / Math.log(k));
this.formattedSpeed = parseFloat((speed / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
});
}
transform(value: number): string {
// If speed is invalid or 0, return empty string
if (value === null || value === undefined || isNaN(value) || value <= 0) {
return '';
}
// Update the speed subject
this.speedSubject.next(value);
// Return the last formatted speed
return this.formattedSpeed;
}
}

View File

@@ -1,38 +1,16 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, Subject } from 'rxjs';
import { of, Subject } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket';
export interface Status {
status: string;
msg?: string;
}
export interface Download {
id: string;
title: string;
url: string;
quality: string;
format: string;
folder: string;
custom_name_prefix: string;
playlist_strict_mode: boolean;
playlist_item_limit: number;
status: string;
msg: string;
percent: number;
speed: number;
eta: number;
filename: string;
checked?: boolean;
deleting?: boolean;
}
import { MeTubeSocket } from './metube-socket.service';
import { Download, Status, State } from '../interfaces';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable({
providedIn: 'root'
})
export class DownloadsService {
private http = inject(HttpClient);
private socket = inject(MeTubeSocket);
loading = true;
queue = new Map<string, Download>();
done = new Map<string, Download>();
@@ -43,13 +21,16 @@ export class DownloadsService {
configurationChanged = new Subject();
updated = new Subject();
configuration = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configuration: any = {};
customDirs = {};
constructor(private http: HttpClient, private socket: MeTubeSocket) {
socket.fromEvent('all').subscribe((strdata: string) => {
constructor() {
this.socket.fromEvent('all')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
this.loading = false;
let data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
this.queue.clear();
data[0].forEach(entry => this.queue.set(...entry));
this.done.clear();
@@ -57,56 +38,72 @@ export class DownloadsService {
this.queueChanged.next(null);
this.doneChanged.next(null);
});
socket.fromEvent('added').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata);
this.socket.fromEvent('added')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
this.queueChanged.next(null);
});
socket.fromEvent('updated').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata);
let dl: Download = this.queue.get(data.url);
data.checked = dl.checked;
data.deleting = dl.deleting;
this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url);
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.updated.next(null);
});
socket.fromEvent('completed').subscribe((strdata: string) => {
let data: Download = JSON.parse(strdata);
this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.delete(data.url);
this.done.set(data.url, data);
this.queueChanged.next(null);
this.doneChanged.next(null);
});
socket.fromEvent('canceled').subscribe((strdata: string) => {
let data: string = JSON.parse(strdata);
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.queue.delete(data);
this.queueChanged.next(null);
});
socket.fromEvent('cleared').subscribe((strdata: string) => {
let data: string = JSON.parse(strdata);
this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.done.delete(data);
this.doneChanged.next(null);
});
socket.fromEvent('configuration').subscribe((strdata: string) => {
let data = JSON.parse(strdata);
this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got configuration:", data);
this.configuration = data;
this.configurationChanged.next(data);
});
socket.fromEvent('custom_dirs').subscribe((strdata: string) => {
let data = JSON.parse(strdata);
this.socket.fromEvent('custom_dirs')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data);
this.customDirs = data;
this.customDirsChanged.next(data);
});
socket.fromEvent('ytdl_options_changed').subscribe((strdata: string) => {
let data = JSON.parse(strdata);
this.socket.fromEvent('ytdl_options_changed')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data = JSON.parse(strdata);
this.ytdlOptionsChanged.next(data);
});
}
handleHTTPError(error: HttpErrorResponse) {
var msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
const msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
return of({status: 'error', msg: msg})
}
@@ -120,23 +117,32 @@ export class DownloadsService {
return this.http.post('start', {ids: ids});
}
public delById(where: string, ids: string[]) {
ids.forEach(id => this[where].get(id).deleting = true);
public delById(where: State, ids: string[]) {
ids.forEach(id => {
const obj = this[where].get(id)
if (obj) {
obj.deleting = true
}
});
return this.http.post('delete', {where: where, ids: ids});
}
public startByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = [];
public startByFilter(where: State, filter: (dl: Download) => boolean) {
const ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.startById(ids);
}
public delByFilter(where: string, filter: (dl: Download) => boolean) {
let ids: string[] = [];
public delByFilter(where: State, filter: (dl: Download) => boolean) {
const ids: string[] = [];
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids);
}
public addDownloadByUrl(url: string): Promise<any> {
public addDownloadByUrl(url: string): Promise<{
response: Status} | {
status: string;
msg?: string;
}> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
@@ -147,10 +153,10 @@ export class DownloadsService {
return new Promise((resolve, reject) => {
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart)
.subscribe(
response => resolve(response),
error => reject(error)
);
.subscribe({
next: (response) => resolve(response),
error: (error) => reject(error)
});
});
}
public exportQueueUrls(): string[] {

View File

@@ -0,0 +1,3 @@
export { DownloadsService } from './downloads.service';
export { SpeedService } from './speed.service';
export { MeTubeSocket } from './metube-socket.service';

View File

@@ -1,10 +1,15 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { ApplicationRef } from '@angular/core';
import { Socket } from 'ngx-socket-io';
@Injectable()
@Injectable(
{ providedIn: 'root' }
)
export class MeTubeSocket extends Socket {
constructor(appRef: ApplicationRef) {
constructor() {
const appRef = inject(ApplicationRef);
const path =
document.location.pathname.replace(/share-target/, '') + 'socket.io';
super({ url: '', options: { path } }, appRef);

View File

@@ -1,11 +1,6 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faCircleHalfStroke, faMoon, faSun } from "@fortawesome/free-solid-svg-icons";
import { Theme } from "./interfaces/theme";
export interface Theme {
id: string;
displayName: string;
icon: IconDefinition;
}
export const Themes: Theme[] = [
{

View File

@@ -1,3 +0,0 @@
export const environment = {
production: true
};

View File

@@ -1,16 +0,0 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -1,12 +1,9 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
/// <reference types="@angular/localize" />
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -1,69 +0,0 @@
/***************************************************************************************************
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
*/
import '@angular/localize/init';
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* IE11 requires the following for NgClass support on SVG elements
*/
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -1,14 +0,0 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);

View File

@@ -3,13 +3,14 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": [
"@angular/localize"
]
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -2,20 +2,30 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"esModuleInterop": true,
"declaration": false,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "es2020",
"lib": [
"es2018",
"dom"
],
"useDefineForClassFields": false
}
"module": "preserve"
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -4,13 +4,9 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
"vitest/globals"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"

View File

@@ -1,152 +0,0 @@
{
"extends": "tslint:recommended",
"rulesDirectory": [
"codelyzer"
],
"rules": {
"align": {
"options": [
"parameters",
"statements"
]
},
"array-type": false,
"arrow-return-shorthand": true,
"curly": true,
"deprecation": {
"severity": "warning"
},
"eofline": true,
"import-blacklist": [
true,
"rxjs/Rx"
],
"import-spacing": true,
"indent": {
"options": [
"spaces"
]
},
"max-classes-per-file": false,
"max-line-length": [
true,
140
],
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-empty": false,
"no-inferrable-types": [
true,
"ignore-params"
],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-var-requires": false,
"object-literal-key-quotes": [
true,
"as-needed"
],
"quotemark": [
true,
"single"
],
"semicolon": {
"options": [
"always"
]
},
"space-before-function-paren": {
"options": {
"anonymous": "never",
"asyncArrow": "always",
"constructor": "never",
"method": "never",
"named": "never"
}
},
"typedef": [
true,
"call-signature"
],
"typedef-whitespace": {
"options": [
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
]
},
"variable-name": {
"options": [
"ban-keywords",
"check-format",
"allow-pascal-case"
]
},
"whitespace": {
"options": [
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type",
"check-typecast"
]
},
"component-class-suffix": true,
"contextual-lifecycle": true,
"directive-class-suffix": true,
"no-conflicting-lifecycle": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-inputs-metadata-property": true,
"no-output-native": true,
"no-output-on-prefix": true,
"no-output-rename": true,
"no-outputs-metadata-property": true,
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true,
"directive-selector": [
true,
"attribute",
"app",
"camelCase"
],
"component-selector": [
true,
"element",
"app",
"kebab-case"
]
}
}