mirror of
https://github.com/alexta69/metube.git
synced 2026-03-03 02:27:01 +00:00
feat(frontend): modernize Angular app
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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
44
ui/eslint.config.js
Normal 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: {},
|
||||
}
|
||||
]);
|
||||
@@ -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
14890
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
7163
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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> 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> 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> 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> 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> 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> 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> 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>
|
||||
@@ -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
17
ui/src/app/app.config.ts
Normal 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
464
ui/src/app/app.html
Normal 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" /> 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>
|
||||
</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" /> 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" /> 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" /> 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" /> 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" /> 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>
|
||||
@@ -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
33
ui/src/app/app.spec.ts
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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)
|
||||
2
ui/src/app/components/index.ts
Normal file
2
ui/src/app/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MasterCheckboxComponent } from './master-checkbox.component';
|
||||
export { SlaveCheckboxComponent } from './slave-checkbox.component';
|
||||
40
ui/src/app/components/master-checkbox.component.ts
Normal file
40
ui/src/app/components/master-checkbox.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
ui/src/app/components/slave-checkbox.component.ts
Normal file
22
ui/src/app/components/slave-checkbox.component.ts
Normal 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>();
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
}
|
||||
3
ui/src/app/interfaces/checkable.ts
Normal file
3
ui/src/app/interfaces/checkable.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Checkable {
|
||||
checked: boolean;
|
||||
}
|
||||
22
ui/src/app/interfaces/download.ts
Normal file
22
ui/src/app/interfaces/download.ts
Normal 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;
|
||||
}
|
||||
7
ui/src/app/interfaces/format.ts
Normal file
7
ui/src/app/interfaces/format.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Quality } from "./quality";
|
||||
|
||||
export interface Format {
|
||||
id: string;
|
||||
text: string;
|
||||
qualities: Quality[];
|
||||
}
|
||||
@@ -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[] = [
|
||||
{
|
||||
9
ui/src/app/interfaces/index.ts
Normal file
9
ui/src/app/interfaces/index.ts
Normal 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';
|
||||
|
||||
5
ui/src/app/interfaces/quality.ts
Normal file
5
ui/src/app/interfaces/quality.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
export interface Quality {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
1
ui/src/app/interfaces/state.ts
Normal file
1
ui/src/app/interfaces/state.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type State = 'queue' | 'done';
|
||||
4
ui/src/app/interfaces/status.ts
Normal file
4
ui/src/app/interfaces/status.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Status {
|
||||
status: string;
|
||||
msg?: string;
|
||||
}
|
||||
7
ui/src/app/interfaces/theme.ts
Normal file
7
ui/src/app/interfaces/theme.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
displayName: string;
|
||||
icon: IconDefinition;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
21
ui/src/app/pipes/eta.pipe.ts
Normal file
21
ui/src/app/pipes/eta.pipe.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
16
ui/src/app/pipes/file-size.pipe.ts
Normal file
16
ui/src/app/pipes/file-size.pipe.ts
Normal 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]}`;
|
||||
}
|
||||
}
|
||||
3
ui/src/app/pipes/index.ts
Normal file
3
ui/src/app/pipes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { EtaPipe } from './eta.pipe';
|
||||
export { SpeedPipe } from './speed.pipe';
|
||||
export { FileSizePipe } from './file-size.pipe';
|
||||
43
ui/src/app/pipes/speed.pipe.ts
Normal file
43
ui/src/app/pipes/speed.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
3
ui/src/app/services/index.ts
Normal file
3
ui/src/app/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DownloadsService } from './downloads.service';
|
||||
export { SpeedService } from './speed.service';
|
||||
export { MeTubeSocket } from './metube-socket.service';
|
||||
@@ -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);
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
152
ui/tslint.json
152
ui/tslint.json
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user