Merge pull request #844 from pierrenedelec/clean-FE
feat(frontend): modernize Angular App
This commit is contained in:
@@ -2,8 +2,8 @@ FROM node:lts-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /metube
|
WORKDIR /metube
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
RUN npm ci && \
|
RUN corepack enable && corepack prepare pnpm --activate
|
||||||
node_modules/.bin/ng build --configuration production
|
RUN pnpm install && pnpm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.13-alpine
|
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
|
## 🛠️ 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
|
```bash
|
||||||
cd metube/ui
|
cd metube/ui
|
||||||
# install Angular and build the UI
|
# install Angular and build the UI
|
||||||
npm install
|
pnpm install
|
||||||
node_modules/.bin/ng build
|
pnpm run build
|
||||||
# install python dependencies
|
# install python dependencies
|
||||||
cd ..
|
cd ..
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|||||||
@@ -15,17 +15,13 @@
|
|||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": {
|
"outputPath": {
|
||||||
"base": "dist/metube"
|
"base": "dist/metube"
|
||||||
},
|
},
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"polyfills": [
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"aot": true,
|
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets",
|
"src/assets",
|
||||||
@@ -41,17 +37,14 @@
|
|||||||
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||||
],
|
],
|
||||||
"serviceWorker": "ngsw-config.json",
|
"serviceWorker": "ngsw-config.json",
|
||||||
"browser": "src/main.ts"
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"@angular/localize/init"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
|
||||||
{
|
|
||||||
"replace": "src/environments/environment.ts",
|
|
||||||
"with": "src/environments/environment.prod.ts"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"optimization": true,
|
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
"namedChunks": false,
|
||||||
@@ -68,75 +61,45 @@
|
|||||||
"maximumError": "10kb"
|
"maximumError": "10kb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
|
||||||
"buildTarget": "metube:build"
|
|
||||||
},
|
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "metube:build:production"
|
"buildTarget": "metube:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "metube:build:development"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
"defaultConfiguration": "development"
|
||||||
"extract-i18n": {
|
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
|
||||||
"options": {
|
|
||||||
"buildTarget": "metube:build"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular/build:unit-test"
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-devkit/build-angular:tslint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": [
|
"lintFilePatterns": [
|
||||||
"tsconfig.app.json",
|
"src/**/*.ts",
|
||||||
"tsconfig.spec.json",
|
"src/**/*.html"
|
||||||
"e2e/tsconfig.json"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"**/node_modules/**"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"e2e": {
|
|
||||||
"builder": "@angular-devkit/build-angular:protractor",
|
|
||||||
"options": {
|
|
||||||
"protractorConfig": "e2e/protractor.conf.js",
|
|
||||||
"devServerTarget": "metube:serve"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"devServerTarget": "metube:serve:production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": false
|
"analytics": false,
|
||||||
|
"packageManager": "pnpm"
|
||||||
},
|
},
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@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",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:watch": "ng build --watch",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint"
|
||||||
"e2e": "ng e2e"
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.3.15",
|
"@angular/animations": "^21.0.0",
|
||||||
"@angular/common": "^20.3.15",
|
"@angular/common": "^21.0.0",
|
||||||
"@angular/compiler": "^20.3.15",
|
"@angular/compiler": "^21.0.0",
|
||||||
"@angular/core": "^20.3.15",
|
"@angular/core": "^21.0.0",
|
||||||
"@angular/forms": "^20.3.15",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/localize": "^20.3.15",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/platform-browser": "^20.3.15",
|
"@angular/platform-browser-dynamic": "^21.0.0",
|
||||||
"@angular/platform-browser-dynamic": "^20.3.15",
|
"@angular/service-worker": "^21.0.0",
|
||||||
"@angular/router": "^20.3.15",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@angular/service-worker": "^20.3.15",
|
|
||||||
"@fortawesome/angular-fontawesome": "~3.0.0",
|
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^20.0.0",
|
"@ng-select/ng-select": "^21.1.0",
|
||||||
"bootstrap": "^5.3.6",
|
"bootstrap": "^5.3.6",
|
||||||
"ngx-cookie-service": "^20.0.0",
|
"ngx-cookie-service": "^21.1.0",
|
||||||
"ngx-socket-io": "~4.9.0",
|
"ngx-socket-io": "~4.9.3",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "~0.15.1"
|
"zone.js": "0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^20.3.13",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/cli": "^20.3.13",
|
"@angular/build": "^21.0.3",
|
||||||
"@angular/compiler-cli": "^20.3.15",
|
"@angular/cli": "^21.0.3",
|
||||||
"@types/node": "^22.15.29",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"codelyzer": "^6.0.2",
|
"@angular/localize": "^21.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"tslint": "~6.1.3",
|
"angular-eslint": "21.1.0",
|
||||||
"typescript": "~5.8.3"
|
"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 { HttpClient } from '@angular/common/http';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
|
||||||
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
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 { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
|
import { DownloadsService } from './services/downloads.service';
|
||||||
|
import { Themes } from './theme';
|
||||||
import { Download, DownloadsService, Status } from './downloads.service';
|
import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces';
|
||||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||||
import { Formats, Format, Quality } from './formats';
|
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
|
||||||
import { Theme, Themes } from './theme';
|
|
||||||
import {KeyValue} from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
imports: [
|
||||||
styleUrls: ['./app.component.sass'],
|
FormsModule,
|
||||||
standalone: false
|
KeyValuePipe,
|
||||||
|
AsyncPipe,
|
||||||
|
FontAwesomeModule,
|
||||||
|
NgbModule,
|
||||||
|
NgSelectModule,
|
||||||
|
EtaPipe,
|
||||||
|
SpeedPipe,
|
||||||
|
FileSizePipe,
|
||||||
|
MasterCheckboxComponent,
|
||||||
|
SlaveCheckboxComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.sass',
|
||||||
})
|
})
|
||||||
export class AppComponent implements AfterViewInit {
|
export class App implements AfterViewInit, OnInit {
|
||||||
addUrl: string;
|
downloads = inject(DownloadsService);
|
||||||
|
private cookieService = inject(CookieService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
addUrl!: string;
|
||||||
formats: Format[] = Formats;
|
formats: Format[] = Formats;
|
||||||
qualities: Quality[];
|
qualities!: Quality[];
|
||||||
quality: string;
|
quality: string;
|
||||||
format: string;
|
format: string;
|
||||||
folder: string;
|
folder!: string;
|
||||||
customNamePrefix: string;
|
customNamePrefix!: string;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
playlistStrictMode: boolean;
|
playlistStrictMode!: boolean;
|
||||||
playlistItemLimit: number;
|
playlistItemLimit!: number;
|
||||||
addInProgress = false;
|
addInProgress = false;
|
||||||
themes: Theme[] = Themes;
|
themes: Theme[] = Themes;
|
||||||
activeTheme: Theme;
|
activeTheme: Theme | undefined;
|
||||||
customDirs$: Observable<string[]>;
|
customDirs$!: Observable<string[]>;
|
||||||
showBatchPanel: boolean = false;
|
showBatchPanel = false;
|
||||||
batchImportModalOpen = false;
|
batchImportModalOpen = false;
|
||||||
batchImportText = '';
|
batchImportText = '';
|
||||||
batchImportStatus = '';
|
batchImportStatus = '';
|
||||||
@@ -51,15 +70,15 @@ export class AppComponent implements AfterViewInit {
|
|||||||
failedDownloads = 0;
|
failedDownloads = 0;
|
||||||
totalSpeed = 0;
|
totalSpeed = 0;
|
||||||
|
|
||||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
readonly queueMasterCheckbox = viewChild<MasterCheckboxComponent>('queueMasterCheckboxRef');
|
||||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
|
||||||
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
|
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
|
||||||
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
|
readonly doneMasterCheckbox = viewChild<MasterCheckboxComponent>('doneMasterCheckboxRef');
|
||||||
@ViewChild('doneDelSelected') doneDelSelected: ElementRef;
|
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
|
||||||
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
|
readonly doneClearCompleted = viewChild.required<ElementRef>('doneClearCompleted');
|
||||||
@ViewChild('doneClearFailed') doneClearFailed: ElementRef;
|
readonly doneClearFailed = viewChild.required<ElementRef>('doneClearFailed');
|
||||||
@ViewChild('doneRetryFailed') doneRetryFailed: ElementRef;
|
readonly doneRetryFailed = viewChild.required<ElementRef>('doneRetryFailed');
|
||||||
@ViewChild('doneDownloadSelected') doneDownloadSelected: ElementRef;
|
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
|
||||||
|
|
||||||
faTrashAlt = faTrashAlt;
|
faTrashAlt = faTrashAlt;
|
||||||
faCheckCircle = faCheckCircle;
|
faCheckCircle = faCheckCircle;
|
||||||
@@ -78,14 +97,14 @@ export class AppComponent implements AfterViewInit {
|
|||||||
faClock = faClock;
|
faClock = faClock;
|
||||||
faTachometerAlt = faTachometerAlt;
|
faTachometerAlt = faTachometerAlt;
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
constructor() {
|
||||||
this.format = cookieService.get('metube_format') || 'any';
|
this.format = this.cookieService.get('metube_format') || 'any';
|
||||||
// Needs to be set or qualities won't automatically be set
|
// Needs to be set or qualities won't automatically be set
|
||||||
this.setQualities()
|
this.setQualities()
|
||||||
this.quality = cookieService.get('metube_quality') || 'best';
|
this.quality = this.cookieService.get('metube_quality') || 'best';
|
||||||
this.autoStart = cookieService.get('metube_auto_start') !== 'false';
|
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
||||||
|
|
||||||
this.activeTheme = this.getPreferredTheme(cookieService);
|
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
||||||
|
|
||||||
// Subscribe to download updates
|
// Subscribe to download updates
|
||||||
this.downloads.queueChanged.subscribe(() => {
|
this.downloads.queueChanged.subscribe(() => {
|
||||||
@@ -104,10 +123,10 @@ export class AppComponent implements AfterViewInit {
|
|||||||
this.getConfiguration();
|
this.getConfiguration();
|
||||||
this.getYtdlOptionsUpdateTime();
|
this.getYtdlOptionsUpdateTime();
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
this.setTheme(this.activeTheme);
|
this.setTheme(this.activeTheme!);
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (this.activeTheme.id === 'auto') {
|
if (this.activeTheme && this.activeTheme.id === 'auto') {
|
||||||
this.setTheme(this.activeTheme);
|
this.setTheme(this.activeTheme);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -115,27 +134,30 @@ export class AppComponent implements AfterViewInit {
|
|||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.downloads.queueChanged.subscribe(() => {
|
this.downloads.queueChanged.subscribe(() => {
|
||||||
this.queueMasterCheckbox.selectionChanged();
|
this.queueMasterCheckbox()?.selectionChanged();
|
||||||
});
|
});
|
||||||
this.downloads.doneChanged.subscribe(() => {
|
this.downloads.doneChanged.subscribe(() => {
|
||||||
this.doneMasterCheckbox.selectionChanged();
|
this.doneMasterCheckbox()?.selectionChanged();
|
||||||
let completed: number = 0, failed: number = 0;
|
let completed = 0, failed = 0;
|
||||||
this.downloads.done.forEach(dl => {
|
this.downloads.done.forEach(dl => {
|
||||||
if (dl.status === 'finished')
|
if (dl.status === 'finished')
|
||||||
completed++;
|
completed++;
|
||||||
else if (dl.status === 'error')
|
else if (dl.status === 'error')
|
||||||
failed++;
|
failed++;
|
||||||
});
|
});
|
||||||
this.doneClearCompleted.nativeElement.disabled = completed === 0;
|
this.doneClearCompleted().nativeElement.disabled = completed === 0;
|
||||||
this.doneClearFailed.nativeElement.disabled = failed === 0;
|
this.doneClearFailed().nativeElement.disabled = failed === 0;
|
||||||
this.doneRetryFailed.nativeElement.disabled = failed === 0;
|
this.doneRetryFailed().nativeElement.disabled = failed === 0;
|
||||||
});
|
});
|
||||||
this.fetchVersionInfo();
|
this.fetchVersionInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// workaround to allow fetching of Map values in the order they were inserted
|
// workaround to allow fetching of Map values in the order they were inserted
|
||||||
// https://github.com/angular/angular/issues/31420
|
// https://github.com/angular/angular/issues/31420
|
||||||
asIsOrder(a, b) {
|
|
||||||
|
|
||||||
|
|
||||||
|
asIsOrder() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +184,8 @@ export class AppComponent implements AfterViewInit {
|
|||||||
|
|
||||||
getMatchingCustomDir() : Observable<string[]> {
|
getMatchingCustomDir() : Observable<string[]> {
|
||||||
return this.downloads.customDirsChanged.asObservable().pipe(
|
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
|
// Keep logic consistent with app/ytdl.py
|
||||||
if (this.isAudioType()) {
|
if (this.isAudioType()) {
|
||||||
console.debug("Showing audio-specific download directories");
|
console.debug("Showing audio-specific download directories");
|
||||||
@@ -178,7 +201,8 @@ export class AppComponent implements AfterViewInit {
|
|||||||
|
|
||||||
getYtdlOptionsUpdateTime() {
|
getYtdlOptionsUpdateTime() {
|
||||||
this.downloads.ytdlOptionsChanged.subscribe({
|
this.downloads.ytdlOptionsChanged.subscribe({
|
||||||
next: (data) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
next: (data:any) => {
|
||||||
if (data['success']){
|
if (data['success']){
|
||||||
const date = new Date(data['update_time'] * 1000);
|
const date = new Date(data['update_time'] * 1000);
|
||||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
||||||
@@ -190,7 +214,8 @@ export class AppComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
getConfiguration() {
|
getConfiguration() {
|
||||||
this.downloads.configurationChanged.subscribe({
|
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'];
|
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
|
||||||
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
||||||
if (playlistItemLimit !== '0') {
|
if (playlistItemLimit !== '0') {
|
||||||
@@ -236,21 +261,24 @@ export class AppComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueSelectionChanged(checked: number) {
|
queueSelectionChanged(checked: number) {
|
||||||
this.queueDelSelected.nativeElement.disabled = checked == 0;
|
this.queueDelSelected().nativeElement.disabled = checked == 0;
|
||||||
this.queueDownloadSelected.nativeElement.disabled = checked == 0;
|
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
doneSelectionChanged(checked: number) {
|
doneSelectionChanged(checked: number) {
|
||||||
this.doneDelSelected.nativeElement.disabled = checked == 0;
|
this.doneDelSelected().nativeElement.disabled = checked == 0;
|
||||||
this.doneDownloadSelected.nativeElement.disabled = checked == 0;
|
this.doneDownloadSelected().nativeElement.disabled = checked == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQualities() {
|
setQualities() {
|
||||||
// qualities for specific format
|
// qualities for specific format
|
||||||
this.qualities = this.formats.find(el => el.id == this.format).qualities
|
const format = this.formats.find(el => el.id == this.format)
|
||||||
const exists = this.qualities.find(el => el.id === this.quality)
|
if (format) {
|
||||||
this.quality = exists ? this.quality : 'best'
|
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) {
|
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) {
|
||||||
url = url ?? this.addUrl
|
url = url ?? this.addUrl
|
||||||
@@ -283,16 +311,16 @@ export class AppComponent implements AfterViewInit {
|
|||||||
this.downloads.delById('done', [key]).subscribe();
|
this.downloads.delById('done', [key]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
delDownload(where: string, id: string) {
|
delDownload(where: State, id: string) {
|
||||||
this.downloads.delById(where, [id]).subscribe();
|
this.downloads.delById(where, [id]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
startSelectedDownloads(where: string){
|
startSelectedDownloads(where: State){
|
||||||
this.downloads.startByFilter(where, dl => dl.checked).subscribe();
|
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
delSelectedDownloads(where: string) {
|
delSelectedDownloads(where: State) {
|
||||||
this.downloads.delByFilter(where, dl => dl.checked).subscribe();
|
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCompletedDownloads() {
|
clearCompletedDownloads() {
|
||||||
@@ -312,7 +340,8 @@ export class AppComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadSelectedFiles() {
|
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) {
|
if (dl.status === 'finished' && dl.checked) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = this.buildDownloadLink(dl);
|
link.href = this.buildDownloadLink(dl);
|
||||||
@@ -338,13 +367,10 @@ export class AppComponent implements AfterViewInit {
|
|||||||
return baseDir + encodeURIComponent(download.filename);
|
return baseDir + encodeURIComponent(download.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
identifyDownloadRow(index: number, row: KeyValue<string, Download>) {
|
|
||||||
return row.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
isNumber(event) {
|
isNumber(event: KeyboardEvent) {
|
||||||
const charCode = (event.which) ? event.which : event.keyCode;
|
const charCode = +event.code || event.keyCode;
|
||||||
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
|
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,6 +511,7 @@ export class AppComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchVersionInfo(): void {
|
fetchVersionInfo(): void {
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
|
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
|
||||||
const versionUrl = `${baseUrl}version`;
|
const versionUrl = `${baseUrl}version`;
|
||||||
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)
|
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 {
|
import { Format } from "./format";
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
qualities: Quality[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quality {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Formats: Format[] = [
|
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 { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { Observable, of, Subject } from 'rxjs';
|
import { of, Subject } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { MeTubeSocket } from './metube-socket';
|
import { MeTubeSocket } from './metube-socket.service';
|
||||||
|
import { Download, Status, State } from '../interfaces';
|
||||||
export interface Status {
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DownloadsService {
|
export class DownloadsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private socket = inject(MeTubeSocket);
|
||||||
loading = true;
|
loading = true;
|
||||||
queue = new Map<string, Download>();
|
queue = new Map<string, Download>();
|
||||||
done = new Map<string, Download>();
|
done = new Map<string, Download>();
|
||||||
@@ -43,13 +21,16 @@ export class DownloadsService {
|
|||||||
configurationChanged = new Subject();
|
configurationChanged = new Subject();
|
||||||
updated = new Subject();
|
updated = new Subject();
|
||||||
|
|
||||||
configuration = {};
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
configuration: any = {};
|
||||||
customDirs = {};
|
customDirs = {};
|
||||||
|
|
||||||
constructor(private http: HttpClient, private socket: MeTubeSocket) {
|
constructor() {
|
||||||
socket.fromEvent('all').subscribe((strdata: string) => {
|
this.socket.fromEvent('all')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
let data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
|
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
data[0].forEach(entry => this.queue.set(...entry));
|
data[0].forEach(entry => this.queue.set(...entry));
|
||||||
this.done.clear();
|
this.done.clear();
|
||||||
@@ -57,56 +38,72 @@ export class DownloadsService {
|
|||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
this.doneChanged.next(null);
|
this.doneChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('added').subscribe((strdata: string) => {
|
this.socket.fromEvent('added')
|
||||||
let data: Download = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: Download = JSON.parse(strdata);
|
||||||
this.queue.set(data.url, data);
|
this.queue.set(data.url, data);
|
||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('updated').subscribe((strdata: string) => {
|
this.socket.fromEvent('updated')
|
||||||
let data: Download = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
let dl: Download = this.queue.get(data.url);
|
.subscribe((strdata: string) => {
|
||||||
data.checked = dl.checked;
|
const data: Download = JSON.parse(strdata);
|
||||||
data.deleting = dl.deleting;
|
const dl: Download | undefined = this.queue.get(data.url);
|
||||||
|
data.checked = !!dl?.checked;
|
||||||
|
data.deleting = !!dl?.deleting;
|
||||||
this.queue.set(data.url, data);
|
this.queue.set(data.url, data);
|
||||||
this.updated.next(null);
|
this.updated.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('completed').subscribe((strdata: string) => {
|
this.socket.fromEvent('completed')
|
||||||
let data: Download = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: Download = JSON.parse(strdata);
|
||||||
this.queue.delete(data.url);
|
this.queue.delete(data.url);
|
||||||
this.done.set(data.url, data);
|
this.done.set(data.url, data);
|
||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
this.doneChanged.next(null);
|
this.doneChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('canceled').subscribe((strdata: string) => {
|
this.socket.fromEvent('canceled')
|
||||||
let data: string = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: string = JSON.parse(strdata);
|
||||||
this.queue.delete(data);
|
this.queue.delete(data);
|
||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('cleared').subscribe((strdata: string) => {
|
this.socket.fromEvent('cleared')
|
||||||
let data: string = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: string = JSON.parse(strdata);
|
||||||
this.done.delete(data);
|
this.done.delete(data);
|
||||||
this.doneChanged.next(null);
|
this.doneChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('configuration').subscribe((strdata: string) => {
|
this.socket.fromEvent('configuration')
|
||||||
let data = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
console.debug("got configuration:", data);
|
console.debug("got configuration:", data);
|
||||||
this.configuration = data;
|
this.configuration = data;
|
||||||
this.configurationChanged.next(data);
|
this.configurationChanged.next(data);
|
||||||
});
|
});
|
||||||
socket.fromEvent('custom_dirs').subscribe((strdata: string) => {
|
this.socket.fromEvent('custom_dirs')
|
||||||
let data = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
console.debug("got custom_dirs:", data);
|
console.debug("got custom_dirs:", data);
|
||||||
this.customDirs = data;
|
this.customDirs = data;
|
||||||
this.customDirsChanged.next(data);
|
this.customDirsChanged.next(data);
|
||||||
});
|
});
|
||||||
socket.fromEvent('ytdl_options_changed').subscribe((strdata: string) => {
|
this.socket.fromEvent('ytdl_options_changed')
|
||||||
let data = JSON.parse(strdata);
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
this.ytdlOptionsChanged.next(data);
|
this.ytdlOptionsChanged.next(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHTTPError(error: HttpErrorResponse) {
|
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})
|
return of({status: 'error', msg: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,23 +117,32 @@ export class DownloadsService {
|
|||||||
return this.http.post('start', {ids: ids});
|
return this.http.post('start', {ids: ids});
|
||||||
}
|
}
|
||||||
|
|
||||||
public delById(where: string, ids: string[]) {
|
public delById(where: State, ids: string[]) {
|
||||||
ids.forEach(id => this[where].get(id).deleting = true);
|
ids.forEach(id => {
|
||||||
|
const obj = this[where].get(id)
|
||||||
|
if (obj) {
|
||||||
|
obj.deleting = true
|
||||||
|
}
|
||||||
|
});
|
||||||
return this.http.post('delete', {where: where, ids: ids});
|
return this.http.post('delete', {where: where, ids: ids});
|
||||||
}
|
}
|
||||||
|
|
||||||
public startByFilter(where: string, filter: (dl: Download) => boolean) {
|
public startByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||||
let ids: string[] = [];
|
const ids: string[] = [];
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
return this.startById(ids);
|
return this.startById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
public delByFilter(where: string, filter: (dl: Download) => boolean) {
|
public delByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||||
let ids: string[] = [];
|
const ids: string[] = [];
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
return this.delById(where, ids);
|
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 defaultQuality = 'best';
|
||||||
const defaultFormat = 'mp4';
|
const defaultFormat = 'mp4';
|
||||||
const defaultFolder = '';
|
const defaultFolder = '';
|
||||||
@@ -147,10 +153,10 @@ export class DownloadsService {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart)
|
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart)
|
||||||
.subscribe(
|
.subscribe({
|
||||||
response => resolve(response),
|
next: (response) => resolve(response),
|
||||||
error => reject(error)
|
error: (error) => reject(error)
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
public exportQueueUrls(): string[] {
|
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 { ApplicationRef } from '@angular/core';
|
||||||
import { Socket } from 'ngx-socket-io';
|
import { Socket } from 'ngx-socket-io';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable(
|
||||||
|
{ providedIn: 'root' }
|
||||||
|
)
|
||||||
export class MeTubeSocket extends Socket {
|
export class MeTubeSocket extends Socket {
|
||||||
constructor(appRef: ApplicationRef) {
|
|
||||||
|
constructor() {
|
||||||
|
const appRef = inject(ApplicationRef);
|
||||||
|
|
||||||
const path =
|
const path =
|
||||||
document.location.pathname.replace(/share-target/, '') + 'socket.io';
|
document.location.pathname.replace(/share-target/, '') + 'socket.io';
|
||||||
super({ url: '', options: { path } }, appRef);
|
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 { 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[] = [
|
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';
|
/// <reference types="@angular/localize" />
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { environment } from './environments/environment';
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
if (environment.production) {
|
|
||||||
enableProdMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch(err => console.error(err));
|
.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",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": [
|
||||||
|
"@angular/localize"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"src/main.ts",
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts"
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,30 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"strict": true,
|
||||||
"outDir": "./dist/out-tsc",
|
"noImplicitOverride": true,
|
||||||
"sourceMap": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"esModuleInterop": true,
|
"noImplicitReturns": true,
|
||||||
"declaration": false,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "es2020",
|
"module": "preserve"
|
||||||
"lib": [
|
},
|
||||||
"es2018",
|
"angularCompilerOptions": {
|
||||||
"dom"
|
"strictInjectionParameters": true,
|
||||||
],
|
"strictInputAccessModifiers": true,
|
||||||
"useDefineForClassFields": false
|
"strictTemplates": true
|
||||||
}
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jasmine"
|
"vitest/globals"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"src/test.ts",
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.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