init
This commit is contained in:
commit
b5966f98b2
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# IDEs and editors
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Other
|
||||
test
|
20
.gitmodules
vendored
Normal file
20
.gitmodules
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
[submodule "client"]
|
||||
path = client
|
||||
url = ../client.git
|
||||
ignore = all
|
||||
branch = develop
|
||||
[submodule "server"]
|
||||
path = server
|
||||
url = ../server.git
|
||||
ignore = all
|
||||
branch = develop
|
||||
[submodule "worker"]
|
||||
path = worker
|
||||
url = ../worker.git
|
||||
ignore = all
|
||||
branch = develop
|
||||
[submodule "common"]
|
||||
path = common
|
||||
url = ../common.git
|
||||
ignore = all
|
||||
branch = develop
|
97
README.md
Normal file
97
README.md
Normal file
@ -0,0 +1,97 @@
|
||||
![Transmute](./docs/images/logo.png)
|
||||
|
||||
---
|
||||
|
||||
A distributed video transcoder
|
||||
|
||||
[![Latest Release](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/badges/release.svg)](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/releases)
|
||||
|
||||
[![Common](https://gitlab.zakscode.com/zakscode/transmute/common/badges/develop/pipeline.svg?key_text=Common)](https://gitlab.zakscode.com/zakscode/transmute/common)
|
||||
[![Client](https://gitlab.zakscode.com/zakscode/transmute/client/badges/develop/pipeline.svg?key_text=Client)](https://gitlab.zakscode.com/zakscode/transmute/client)
|
||||
[![Server](https://gitlab.zakscode.com/zakscode/transmute/server/badges/develop/pipeline.svg?key_text=Server)](https://gitlab.zakscode.com/zakscode/transmute/server)
|
||||
[![Worker](https://gitlab.zakscode.com/zakscode/transmute/worker/badges/develop/pipeline.svg?key_text=Worker)](https://gitlab.zakscode.com/zakscode/transmute/worker)
|
||||
|
||||
![Placeholder Screenshot](./docs/images/screenshot.png)
|
||||
|
||||
Transmute provides a simple web based tool to manage your video library. Transmute will watch a directory and automatically
|
||||
transcode videos using [FFmpeg](https://ffmpeg.org/) & hardware acceloration (if available) so that your entire library
|
||||
meets a specified standard. In addition to transcoding, Transmute can also remove undesired audio tracks, subtitles,
|
||||
perform healthchecks & upscale/downscale video.
|
||||
|
||||
---
|
||||
|
||||
> Why do I care?
|
||||
|
||||
1. Transcoding `MPEG-2/MPEG-4` video to `h.264/h.265` can reduce file size by **50-90%**
|
||||
2. A more extreme measure is downscaling `4K` to `1080p` to reduce size by a further **75%**
|
||||
3. Stripping out unused audio tracks & subtitles can result in more savings
|
||||
4. Some devices or players will be unable to play specific formats
|
||||
5. If you use a self-hosted streaming service, your host will have to transcode videos on the fly when a device doesn't
|
||||
support the original format/resolution leading to choppy playback, transcoding your library ahead of time can avoid this
|
||||
in most cases
|
||||
|
||||
> Why should I use this over __________?
|
||||
|
||||
Transmute's goal is to make the transcoding process as fast & simple as possible. Most transcoders provide hundreds of
|
||||
settings requiring some expertise to properly setup. With Transmute you simply specify what your video files should look
|
||||
like & it will transcode your videos as lossless-ly as possible using multiple computers to make short work of large
|
||||
libraries.
|
||||
|
||||
> How does it work?
|
||||
|
||||
After choosing your desired settings & creating a new library in the WebUI, the server will begin scanning videos to
|
||||
collect metadata. If this metadata doesn't match your settings or a healthcheck is required, the server will queue a job.
|
||||
|
||||
Once a worker is available, it will request a job from the server & complete it using FFmpeg.
|
||||
|
||||
## Documentation
|
||||
- [Wiki Docs](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/wikis/home)
|
||||
- [Release Notes](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/releases)
|
||||
- [Tickets & Issues](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/issues)
|
||||
|
||||
## Setup
|
||||
<details>
|
||||
<summary>
|
||||
<h3 style="display: inline">Production</h3>
|
||||
</summary>
|
||||
|
||||
#### Prerequisites
|
||||
- [Docker](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
#### Instructions
|
||||
1. Download the [example compose file](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/blob/main/docker-compose.yml) on your server: `curl -o transmute.yml https://gitlab.zakscode.com/zakscode/transmute/transmute/-/raw/main/docker-compose.yml`
|
||||
2. Read the [Configuration Guide](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/wikis/home) & edit the compose file accordingly
|
||||
3. Run the compose file:
|
||||
- Using Docker Compose: `docker compose -f transmute.yml up`
|
||||
- Using Docker Swarm: `docker stack deploy transmute -c transmute.yml`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<h3 style="display: inline">Development</h3>
|
||||
</summary>
|
||||
|
||||
#### Prerequisites
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Node](https://nodejs.org/en/download)
|
||||
- [FFmpeg](https://ffmpeg.org/download.html)
|
||||
- *[Docker](https://www.docker.com/products/docker-desktop/) (Optional)*
|
||||
|
||||
#### Instructions
|
||||
1. Clone this project: `git clone `
|
||||
2. Pull the submodules: `git submodule update --remote --init`
|
||||
3. Checkout the latest: `git submodule foreach git switch develop`
|
||||
4. Install dependencies: `git submodule foreach npm i`
|
||||
5. Link common:
|
||||
1. Link common to npm: `cd common && npm link`
|
||||
2. Link common to client (re-run after every `npm install`): `cd ../client && npm link @transmute/common`
|
||||
3. Link common to server (re-run after every `npm install`): `cd ../server && npm link @transmute/common`
|
||||
4. Link common to worker (re-run after every `npm install`): `cd ../worker && npm link @transmute/common`
|
||||
6. Start each project as needed:
|
||||
- Start common: `cd ../common && npm run watch`
|
||||
- Start client: `cd ../client && npm run start`
|
||||
- Start server: `cd ../server && npm run start`
|
||||
- Start worker: `cd ../worker && npm run start`
|
||||
|
||||
</details>
|
35
client/.gitignore
vendored
Normal file
35
client/.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
.angular
|
||||
bazel-out
|
||||
coverage
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
.sass-cache/
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9
|
||||
*.launch
|
||||
.settings
|
||||
*.sublime-workspace
|
||||
.vscode
|
||||
|
||||
# Miscellaneous
|
||||
connect.lock
|
||||
libpeerconnection.log
|
||||
testem.log
|
||||
typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
86
client/.gitlab/.gitlab-ci.yml
Normal file
86
client/.gitlab/.gitlab-ci.yml
Normal file
@ -0,0 +1,86 @@
|
||||
image: node:18
|
||||
|
||||
npm:
|
||||
stage: build
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull-push
|
||||
- key: $CI_PIPELINE_ID
|
||||
paths:
|
||||
- dist
|
||||
policy: push
|
||||
script:
|
||||
- npm install
|
||||
- npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- dist
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
audit:
|
||||
stage: test
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull
|
||||
script:
|
||||
- echo "vulnerabilities_high $(npm audit | grep -oE '[0-9]+ high' | grep -oE '[0-9]+' || echo 0)" > metrics.txt
|
||||
- echo "vulnerabilities_medium $(npm audit | grep -oE '[0-9]+ moderate' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
|
||||
- echo "vulnerabilities_low $(npm audit | grep -oE '[0-9]+ low' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
|
||||
artifacts:
|
||||
reports:
|
||||
metrics: metrics.txt
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
registry:
|
||||
stage: deploy
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull
|
||||
- key: $CI_PIPELINE_ID
|
||||
paths:
|
||||
- dist
|
||||
policy: pull
|
||||
before_script:
|
||||
- VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
|
||||
- if [ "$CI_COMMIT_BRANCH" != "$CI_DEFAULT_BRANCH" ] && [ "$VERSION" != *"-$CI_COMMIT_BRANCH" ]; then VERSION="$VERSION-$(echo "$CI_COMMIT_BRANCH" | sed -E "s/[_/]/-/g")"; npm version --no-git-tag-version $VERSION; fi
|
||||
script:
|
||||
- PACKAGES=$(curl -s -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages)
|
||||
- ID=$(node -pe "JSON.parse(process.argv[1]).find(p => p['version'] == process.argv[2])?.id || ''" $PACKAGES $VERSION)
|
||||
- if [ -n "$ID" ]; then curl -s -X DELETE -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/$ID; fi
|
||||
- printf "@transmute:registry=https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/\n//$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/:_authToken=$DEPLOY_TOKEN" > .npmrc
|
||||
- npm publish
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
tag:
|
||||
stage: deploy
|
||||
image:
|
||||
name: alpine/git
|
||||
entrypoint: [""]
|
||||
cache: []
|
||||
before_script:
|
||||
- git remote set-url origin "https://ReleaseBot:$DEPLOY_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git"
|
||||
script:
|
||||
- VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
|
||||
- git tag -f $VERSION $CI_COMMIT_SHA
|
||||
- git push -f origin $VERSION
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
2
client/.npmrc
Normal file
2
client/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
@transmute:registry=https://gitlab.zakscode.com/api/v4/projects/85/packages/npm/
|
||||
//gitlab.zakscode.com/api/v4/projects/85/packages/npm/:_authToken=tvNAnPtzjy59xFrHBJ2J
|
32
client/README.md
Normal file
32
client/README.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Transmute Client
|
||||
|
||||
This is Web UI part of the Transmute stack which talks directly to the Transmute Server/API.
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.2.2.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[[_TOC_]]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
- [NodeJS 18](https://nodejs.org/en/)
|
||||
- _[Docker](https://docs.docker.com/install/) (Optional)_
|
||||
|
||||
## Setup
|
||||
The project can be run using NPM.
|
||||
|
||||
1. Install the dependencies: `npm install`
|
||||
2. Start the Angular server: `npm run start`
|
||||
|
||||
The client should now be accessible on [http://localhost:4200](http://localhost:4200)
|
||||
|
||||
## Cheatsheet
|
||||
```bash
|
||||
# Start Angular server
|
||||
npm run start
|
||||
|
||||
# Build production
|
||||
npm run build:prod
|
||||
```
|
75
client/angular.json
Normal file
75
client/angular.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"transmute-client": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "tm",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"allowedCommonJsDependencies": [
|
||||
"@transmute/common"
|
||||
],
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "transmute-client:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "transmute-client:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12348
client/package-lock.json
generated
Normal file
12348
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
client/package.json
Normal file
39
client/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@transmute/client",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^15.2.0",
|
||||
"@angular/cdk": "^15.2.2",
|
||||
"@angular/common": "^15.2.0",
|
||||
"@angular/compiler": "^15.2.0",
|
||||
"@angular/core": "^15.2.0",
|
||||
"@angular/forms": "^15.2.0",
|
||||
"@angular/material": "^15.2.2",
|
||||
"@angular/platform-browser": "^15.2.0",
|
||||
"@angular/platform-browser-dynamic": "^15.2.0",
|
||||
"@angular/router": "^15.2.0",
|
||||
"@transmute/common": "^0.0.0",
|
||||
"bootstrap": "^5.2.3",
|
||||
"ng2-charts": "^4.1.1",
|
||||
"ngx-toastr": "^16.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.2.2",
|
||||
"@angular/cli": "~15.2.2",
|
||||
"@angular/compiler-cli": "^15.2.0",
|
||||
"typescript": "~4.9.4"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
41
client/src/app/app.module.ts
Normal file
41
client/src/app/app.module.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {HttpClientModule} from '@angular/common/http';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
import {NgChartsModule} from 'ng2-charts';
|
||||
import {LibraryFormComponent} from './components/library-form/library-form.component';
|
||||
import {LibrarySelectorComponent} from './components/library-selector/library-selector.component';
|
||||
import {PiechartComponent} from './components/piechart/piechart.component';
|
||||
import {SettingsFormComponent} from './components/settings-form/settings-form.component';
|
||||
import {ToolbarComponent} from './components/toolbar/toolbar.component';
|
||||
import {FormHelperModule} from './modules/form-helper';
|
||||
import {SizePipe} from './pipes/size.pipe';
|
||||
|
||||
import {AppComponent} from './views/app.component';
|
||||
import {MaterialModule} from './modules/material.module';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
LibraryFormComponent,
|
||||
LibrarySelectorComponent,
|
||||
PiechartComponent,
|
||||
SettingsFormComponent,
|
||||
SizePipe,
|
||||
ToolbarComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule.withServerTransition({appId: 'serverApp'}),
|
||||
BrowserAnimationsModule,
|
||||
FormHelperModule,
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
MaterialModule,
|
||||
NgChartsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule {}
|
@ -0,0 +1,13 @@
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field class="w-50">
|
||||
<mat-label>Name</mat-label>
|
||||
<input formControlName="name" matInput type="text">
|
||||
<mat-error *ngIf="getError('name') as error">{{error.key}}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-checkbox formControlName="watch" class="ms-3">Watch for changes</mat-checkbox>
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Directory</mat-label>
|
||||
<input formControlName="path" matInput type="text">
|
||||
<mat-error *ngIf="getError('path') as error">{{error.key}}</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
@ -0,0 +1,29 @@
|
||||
import {Component, forwardRef} from '@angular/core';
|
||||
import {FormBuilder, NG_VALUE_ACCESSOR, Validators} from '@angular/forms';
|
||||
import {Library} from '@transmute/common';
|
||||
import {FormBoilerplateComponent} from '../../modules/form-helper';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-library-form',
|
||||
templateUrl: './library-form.component.html',
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => LibraryFormComponent),
|
||||
multi: true,
|
||||
}],
|
||||
})
|
||||
export class LibraryFormComponent extends FormBoilerplateComponent<Library> {
|
||||
constructor(fb: FormBuilder) {
|
||||
super(fb.group({
|
||||
name: ['', [Validators.required]],
|
||||
path: ['', [Validators.required]],
|
||||
watch: [true],
|
||||
}));
|
||||
}
|
||||
|
||||
resetHook(preventDefault: () => void) { }
|
||||
|
||||
validateHook(value: Library, preventDefault: () => void): void | boolean { }
|
||||
|
||||
writeHook(value: Library, preventDefault: () => void): void { }
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<mat-form-field appearance="outline" class="me-3">
|
||||
<mat-label>Library</mat-label>
|
||||
<mat-select [value]="selected" (valueChange)="selectionChanged($event)">
|
||||
<mat-option class="fw-bold" value="">All</mat-option>
|
||||
<mat-option *ngFor="let l of libraries" [value]="l">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
{{l.name}}
|
||||
</div>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button *ngIf="selected" mat-mini-fab color="primary" [matMenuTriggerFor]="menu" class="mb-4">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<button *ngIf="!selected" mat-mini-fab color="primary" (click)="scan(!selected ? null : selected)" class="mb-4" matTooltip="Scan">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="accent" (click)="edit()" matTooltip="New" class="ms-2 mb-4">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="scan(!selected ? null : selected)">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
<span>Scan</span>
|
||||
</button>
|
||||
<button *ngIf="selected" mat-menu-item (click)="edit(selected)">
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button *ngIf="selected" mat-menu-item (click)="delete(selected)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
@ -0,0 +1,61 @@
|
||||
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core';
|
||||
import {Library} from '@transmute/common';
|
||||
import {FormHelperService} from '../../modules/form-helper/services/form-helper.service';
|
||||
import {LibraryClient} from '../../services/library.service';
|
||||
import {LibraryFormComponent} from '../library-form/library-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-library-selector',
|
||||
templateUrl: './library-selector.component.html'
|
||||
})
|
||||
export class LibrarySelectorComponent implements OnChanges {
|
||||
libraries: Library[] = [];
|
||||
|
||||
@Input() selected?: Library | '';
|
||||
@Output() selectedChange = new EventEmitter();
|
||||
|
||||
constructor(private fh: FormHelperService,
|
||||
private libraryApi: LibraryClient
|
||||
) {
|
||||
this.libraryApi.listen().subscribe(l => this.libraries = l);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if(changes['selected'] && !this.selected) this.selected = <any>'';
|
||||
}
|
||||
|
||||
async delete(library: Library) {
|
||||
if(!(await this.fh.confirm('Delete',
|
||||
`Are you sure you want to delete: ${library.name}`
|
||||
))) return;
|
||||
await this.libraryApi.delete(library);
|
||||
this.selectionChanged(null);
|
||||
}
|
||||
|
||||
edit(library?: Library) {
|
||||
console.log(library);
|
||||
this.fh.form<Library>(LibraryFormComponent, (value, close, banner) => {
|
||||
if(!value) return;
|
||||
const create = value['id'] == undefined;
|
||||
this.libraryApi[create ? 'create' : 'update'](value).then(saved => {
|
||||
const exists = this.libraries.findIndex(l => l.id == saved.id);
|
||||
if(exists == -1) this.libraries.push(saved);
|
||||
else this.libraries.splice(exists, 1, saved);
|
||||
this.selectionChanged(saved);
|
||||
close();
|
||||
}).catch(resp => banner(resp.error.message));
|
||||
}, {
|
||||
title: `${library ? 'Edit' : 'New'} Library`,
|
||||
value: library
|
||||
});
|
||||
}
|
||||
|
||||
scan(library: Library | null) {
|
||||
this.libraryApi.api.scan(library?.id);
|
||||
}
|
||||
|
||||
selectionChanged(library: Library | null) {
|
||||
this.selected = library || '';
|
||||
this.selectedChange.emit(library);
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
<h2>{{title}}</h2>
|
||||
<canvas baseChart
|
||||
[data]="dataset"
|
||||
[options]="options"
|
||||
type="doughnut">
|
||||
</canvas>
|
29
client/src/app/components/piechart/piechart.component.ts
Normal file
29
client/src/app/components/piechart/piechart.component.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-pichart',
|
||||
templateUrl: './piechart.component.html'
|
||||
})
|
||||
export class PiechartComponent implements OnChanges {
|
||||
@Input() data!: {[key: string]: number};
|
||||
@Input() title?: string;
|
||||
|
||||
dataset!: {labels: string[], datasets: {data: number[]}[]};
|
||||
options: any = {
|
||||
plugins: {
|
||||
legend: {position: 'bottom'}
|
||||
}
|
||||
};
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if(changes['title']) this.options.plugins.title = {text: this.title};
|
||||
if(changes['data']) {
|
||||
const labels: string[] = [], data: number[] = [];
|
||||
Object.entries(this.data).forEach(([key, value]) => {
|
||||
labels.push(key);
|
||||
data.push(value);
|
||||
});
|
||||
this.dataset = {labels, datasets: [{data}]};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
<div class="mb-2" [formGroup]="form">
|
||||
<h2>General</h2>
|
||||
<mat-form-field>
|
||||
<mat-label>Job Priority</mat-label>
|
||||
<mat-select formControlName="priority">
|
||||
<mat-option value="">Auto</mat-option>
|
||||
<mat-option value="healthcheck">Healthcheck</mat-option>
|
||||
<mat-option value="transcode">Transcode</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [formGroup]="form">
|
||||
<h2>Healthchecks</h2>
|
||||
<mat-radio-group formControlName="healthcheck">
|
||||
<mat-radio-button class="mb-3 me-2" value="">Disable</mat-radio-button>
|
||||
<mat-radio-button class="mb-3 me-2" value="quick">Quick</mat-radio-button>
|
||||
<mat-radio-button class="mb-3 me-2" value="frame-by-frame">Frame-by-Frame</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<div>
|
||||
<mat-checkbox formControlName="deleteUnhealthy">
|
||||
Delete unhealthy files
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2" [formGroup]="form">
|
||||
<h2>Transcoding</h2>
|
||||
<div>
|
||||
<mat-checkbox formControlName="healthyOnly" class="mb-3 me-2">Only transcode healthy files</mat-checkbox>
|
||||
<mat-checkbox formControlName="deleteOriginal" class="mb-3 me-2">Delete after transcoding</mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Container</mat-label>
|
||||
<mat-select formControlName="targetContainer">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let c of containers" [value]="c[0]">{{c[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Video Codec</mat-label>
|
||||
<mat-select formControlName="targetVideoCodec">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let v of videoCodecs" [value]="v[0]">{{v[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Audio Codec</mat-label>
|
||||
<mat-select formControlName="targetAudioCodec">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let a of audioCodecs" [value]="a[0]">{{a[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox class="mb-3 me-3" [(ngModel)]="sizeCutoff" [ngModelOptions]="{standalone: true}">
|
||||
Stop transcoding if larger than
|
||||
</mat-checkbox>
|
||||
<mat-form-field style="width: 100px" floatLabel="always">
|
||||
<mat-label>Original</mat-label>
|
||||
<input class="text-end hide-incrementor" matInput type="number" [disabled]="!sizeCutoff">
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" [formGroup]="form">
|
||||
<h2>Audio</h2>
|
||||
<mat-checkbox [(ngModel)]="audioFilter" [ngModelOptions]="{standalone: true}">
|
||||
Filter Audio
|
||||
</mat-checkbox>
|
||||
<div *ngIf="audioFilter">
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Languages</mat-label>
|
||||
<!-- <mat-chip-grid #aLangChips class="d-inline-flex">-->
|
||||
<!-- <mat-chip-row *ngFor="let l of aLangs; let i = index" (removed)="removeLang(aLangs, i)">-->
|
||||
<!-- {{l}} <button matChipRemove><mat-icon>cancel</mat-icon></button>-->
|
||||
<!-- </mat-chip-row>-->
|
||||
<!-- </mat-chip-grid>-->
|
||||
<!-- <input class="d-inline" style="width: auto" [matChipInputFor]="aLangChips" (matChipInputTokenEnd)="addLang(aLangs, $event)"/>-->
|
||||
<input matInput>
|
||||
<mat-hint>
|
||||
<span class="fw-bold">Warning:</span> Leaving blank will remove all audio tracks
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" [formGroup]="form">
|
||||
<h2>Subtitles</h2>
|
||||
<mat-checkbox [(ngModel)]="subFilter" [ngModelOptions]="{standalone: true}">
|
||||
Filter Subtitles
|
||||
</mat-checkbox>
|
||||
<div *ngIf="subFilter">
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Languages</mat-label>
|
||||
<!-- <mat-chip-grid #aLangChips class="d-inline-flex">-->
|
||||
<!-- <mat-chip-row *ngFor="let l of aLangs; let i = index" (removed)="removeLang(aLangs, i)">-->
|
||||
<!-- {{l}} <button matChipRemove><mat-icon>cancel</mat-icon></button>-->
|
||||
<!-- </mat-chip-row>-->
|
||||
<!-- </mat-chip-grid>-->
|
||||
<!-- <input class="d-inline" style="width: auto" [matChipInputFor]="aLangChips" (matChipInputTokenEnd)="addLang(aLangs, $event)"/>-->
|
||||
<input matInput>
|
||||
<mat-hint>
|
||||
<span class="fw-bold">Warning:</span> Leaving blank will remove all subtitles
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,52 @@
|
||||
import {Component, forwardRef} from '@angular/core';
|
||||
import {FormBuilder, NG_VALUE_ACCESSOR} from '@angular/forms';
|
||||
import {AudioCodec, Config, Container, VideoCodec} from '@transmute/common';
|
||||
import {FormBoilerplateComponent} from '../../modules/form-helper';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-settings-form',
|
||||
templateUrl: './settings-form.component.html',
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SettingsFormComponent),
|
||||
multi: true,
|
||||
}],
|
||||
})
|
||||
export class SettingsFormComponent extends FormBoilerplateComponent<Config> {
|
||||
containers = Object.entries(Container);
|
||||
videoCodecs = Object.entries(VideoCodec);
|
||||
audioCodecs = Object.entries(AudioCodec);
|
||||
audioFilter = false;
|
||||
sizeCutoff = false;
|
||||
subFilter = false;
|
||||
|
||||
constructor(fb: FormBuilder) {
|
||||
super(fb.group({
|
||||
priority: ['', []],
|
||||
healthcheck: ['', []],
|
||||
deleteUnhealthy: [false, []],
|
||||
healthyOnly: [false, []],
|
||||
deleteOriginal: [false, []],
|
||||
targetContainer: ['', []],
|
||||
targetVideoCodec: ['', []],
|
||||
targetAudioCodec: ['', []],
|
||||
singleAudioTrack: [false, []],
|
||||
audioTracks: [[], []],
|
||||
subTracks: [[], []],
|
||||
}));
|
||||
|
||||
this.form.controls['deleteUnhealthy'].disable();
|
||||
this.form.controls['healthyOnly'].disable();
|
||||
this.form.controls['healthcheck'].valueChanges.subscribe(v => {
|
||||
this.form.controls['deleteUnhealthy'][!!v ? 'enable' : 'disable']();
|
||||
this.form.controls['healthyOnly'][!!v ? 'enable' : 'disable']();
|
||||
if(!v) this.writeValue({deleteUnhealthy: false, onlyTranscodeHealthy: false});
|
||||
});
|
||||
}
|
||||
|
||||
resetHook(preventDefault: () => void) { }
|
||||
|
||||
validateHook(value: Config, preventDefault: () => void): void | boolean { }
|
||||
|
||||
writeHook(value: Config, preventDefault: () => void): void { }
|
||||
}
|
14
client/src/app/components/toolbar/toolbar.component.html
Normal file
14
client/src/app/components/toolbar/toolbar.component.html
Normal file
@ -0,0 +1,14 @@
|
||||
<mat-toolbar style="height: 50px; min-height: 50px">
|
||||
<mat-toolbar-row class="px-3" style="height: 50px">
|
||||
<div class="d-flex align-items-center">
|
||||
<mat-icon>rotate_right</mat-icon>
|
||||
Transmute
|
||||
</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button mat-icon-button (click)="settings()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
19
client/src/app/components/toolbar/toolbar.component.ts
Normal file
19
client/src/app/components/toolbar/toolbar.component.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {FormHelperService} from '../../modules/form-helper/services/form-helper.service';
|
||||
import {SettingsFormComponent} from '../settings-form/settings-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-toolbar',
|
||||
templateUrl: './toolbar.component.html'
|
||||
})
|
||||
export class ToolbarComponent {
|
||||
constructor(private fh: FormHelperService) {}
|
||||
|
||||
|
||||
settings() {
|
||||
this.fh.form(SettingsFormComponent, (config, close, banner) => {
|
||||
console.log(config);
|
||||
close();
|
||||
}, {disableAddAnother: true, matDialog: {width: 'min(100%, 600px)'}});
|
||||
}
|
||||
}
|
98
client/src/app/modules/crud-api/client.ts
Normal file
98
client/src/app/modules/crud-api/client.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import {includes} from '@transmute/common';
|
||||
import {distinctUntilChanged, map, Observable, share} from 'rxjs';
|
||||
import {ReactiveCache} from './reactiveCache';
|
||||
import {CrudApiEndpoint} from './endpoint';
|
||||
|
||||
export abstract class CrudApiClient<K, T> {
|
||||
public abstract api: CrudApiEndpoint<K, T>;
|
||||
|
||||
protected readonly cache = new ReactiveCache<K, T>();
|
||||
protected readonly groups = new ReactiveCache<Partial<T>, K[]>();
|
||||
protected readonly pending = new ReactiveCache<K | Partial<T>, Promise<T | T[]>>();
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.groups.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
list(filter?: Partial<T>, reload?: boolean): Promise<T[]> {
|
||||
const ck = filter ?? {};
|
||||
if(!reload) {
|
||||
const cache = this.groups.get(ck);
|
||||
if(cache) return Promise.resolve(cache.map(k => <T>this.cache.get(k)));
|
||||
const pending = this.pending.get(ck);
|
||||
if(pending) return <Promise<T[]>>pending;
|
||||
}
|
||||
return <Promise<T[]>>this.pending.set(ck, this.api.list(filter).then(rows => {
|
||||
this.groups.set(ck, rows.map(r => {
|
||||
const pk = (<any>r)[this.api.pk.toString()];
|
||||
this.cache.set(pk, r);
|
||||
return pk;
|
||||
}));
|
||||
return rows;
|
||||
}).finally(() => this.pending.delete(ck)));
|
||||
}
|
||||
|
||||
create(value: T): Promise<T> {
|
||||
return this.api.create(value).then(row => {
|
||||
const pk = (<any>row)[this.api.pk];
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
read(filter: K | Partial<T>, reload?: boolean): Promise<T> {
|
||||
const pk = typeof filter == 'object' ? (<any>filter)[this.api.pk] : filter;
|
||||
if(!reload) {
|
||||
const cache = this.cache.get(pk);
|
||||
if(cache) return Promise.resolve(cache);
|
||||
const pending = this.pending.get(pk);
|
||||
if(pending) return <Promise<T>>pending;
|
||||
}
|
||||
return <Promise<T>>this.pending.set(filter, this.api.read(filter).then(row => {
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
}).finally(() => this.pending.delete(pk)));
|
||||
}
|
||||
|
||||
update(value: Partial<T>): Promise<T> {
|
||||
return this.api.update(value).then(row => {
|
||||
const pk = (<any>row)[this.api.pk];
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
delete(filter: K | Partial<T>): Promise<void> {
|
||||
const pk = typeof filter == 'object' ? (<any>filter)[this.api.pk] : filter;
|
||||
return this.api.delete(filter).then(() => {
|
||||
this.cache.delete(pk);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
this.groups.set(key, cached.filter(k => k != pk));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listen(filter?: K | Partial<T>): Observable<T[]> {
|
||||
const key: Partial<T> = <any>(filter == null ? {} : typeof filter == 'object' ? filter : {[this.api.pk]: filter});
|
||||
this.list(key);
|
||||
return this.cache.events.pipe(
|
||||
map(cached => cached.filter(c => includes(c, key))),
|
||||
distinctUntilChanged(),
|
||||
share()
|
||||
);
|
||||
}
|
||||
}
|
52
client/src/app/modules/crud-api/endpoint.ts
Normal file
52
client/src/app/modules/crud-api/endpoint.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
export abstract class CrudApiEndpoint<K, T> {
|
||||
protected abstract http: HttpClient;
|
||||
|
||||
getUrl!: (value?: K | Partial<T>) => string;
|
||||
|
||||
protected constructor(private readonly url: string, public readonly pk: keyof T) {
|
||||
const parts = url.split('/');
|
||||
if(url.indexOf('://') != -1) {
|
||||
const protocol = parts.splice(0, 2)[0];
|
||||
parts[0] = `${protocol}//${parts[0]}`;
|
||||
}
|
||||
|
||||
this.getUrl = (value?: K | Partial<T>) => {
|
||||
if(value == null) value = {};
|
||||
if(typeof value != 'object') value = <Partial<T>>{[this.pk]: value};
|
||||
let last: number;
|
||||
let newUrl: string = parts.map((p, i) => {
|
||||
if(p[0] != ':') return p;
|
||||
last = i;
|
||||
const optional = p.slice(-1) == '?';
|
||||
const key = p.slice(1, optional ? -1 : undefined);
|
||||
const val = (<any>value)?.[key];
|
||||
if(val == undefined && !optional)
|
||||
throw new Error(`'The request to "${url}" is missing the following key: ${key}\n\n${JSON.stringify(value)}`);
|
||||
return val;
|
||||
}).filter((p, i) => !!p || i > last).join('/');
|
||||
return newUrl;
|
||||
};
|
||||
}
|
||||
|
||||
list(filter?: Partial<T>, paginate?: { offset?: number; limit?: number }): Promise<T[]> {
|
||||
return <any>this.http.get<T[]>(this.getUrl(filter), {params: paginate}).toPromise();
|
||||
}
|
||||
|
||||
create(value: T): Promise<T> {
|
||||
return <any>this.http.post<T>(this.getUrl(value), value).toPromise();
|
||||
}
|
||||
|
||||
read(filter: K | Partial<T>): Promise<T> {
|
||||
return <any>this.http.get<T>(this.getUrl(filter)).toPromise();
|
||||
}
|
||||
|
||||
update(value: Partial<T>): Promise<T> {
|
||||
return <any>this.http.patch<T>(this.getUrl(value), value).toPromise();
|
||||
}
|
||||
|
||||
delete(filter: K | Partial<T>): Promise<void> {
|
||||
return this.http.delete<void>(this.getUrl(filter)).toPromise();
|
||||
}
|
||||
}
|
94
client/src/app/modules/crud-api/reactiveCache.ts
Normal file
94
client/src/app/modules/crud-api/reactiveCache.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {Subject} from 'rxjs';
|
||||
|
||||
/**
|
||||
* A mapped cached which accepts anything as a key. This is accomplished by serializing the values using
|
||||
* `JSON.stringify`. Objects go through the extra step of having their properties
|
||||
* sorted to ensure their order.
|
||||
* @template K - How the cache should be indexed
|
||||
* @template T - The type that will be cached
|
||||
*/
|
||||
export class ReactiveCache<K, T> {
|
||||
/** This is where everything is actually stored */
|
||||
private store = new Map<string, T>();
|
||||
|
||||
events = new Subject<T[]>();
|
||||
|
||||
/** Tuple array of keys & values */
|
||||
get entries(): [K, T][] { return [...this.store.entries()].map(([key, val]) => [!key ? key : JSON.parse(key), val]) }
|
||||
/** Cache keys in use */
|
||||
get keys(): K[] { return [...this.store.keys()].map(k => !k ? k : JSON.parse(k)); }
|
||||
/** Number of cached items */
|
||||
get size(): number { return this.store.size; }
|
||||
/** Returns all the stored rows */
|
||||
get values(): T[] { return [...this.store.values()]; }
|
||||
|
||||
/**
|
||||
* Serializes anything with order guaranteed (Array positions wont change & object properties are sorted)
|
||||
* @param value - Anything that needs to be serialized
|
||||
* @returns {string} - The serialized version of the data
|
||||
* @private
|
||||
*/
|
||||
private static serialize(value: any) {
|
||||
const _serialize: (value: any) => string = (value: any) => {
|
||||
if(Array.isArray(value)) return value.map(v => _serialize(v));
|
||||
if(value != null && typeof value == 'object') return Object.keys(value).sort()
|
||||
.reduce((acc, key) => ({...acc, [key]: _serialize(value[key])}), {});
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(_serialize(value));
|
||||
}
|
||||
|
||||
/** Clear everything from the cache */
|
||||
clear() {
|
||||
this.store.clear();
|
||||
this.events.next(this.values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached value
|
||||
* @param {K} key - Cache key
|
||||
*/
|
||||
delete(key: K) {
|
||||
this.store.delete(ReactiveCache.serialize(key));
|
||||
this.events.next(this.values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a value stored in the cache
|
||||
* @param {K} key - Cache key
|
||||
* @returns {T | undefined} - The cached value, or undefined if nothing is cached under the provided key
|
||||
*/
|
||||
get(key: K): T | undefined { return this.store.get(ReactiveCache.serialize(key)); }
|
||||
|
||||
/**
|
||||
* Check if the cache key has an attached value
|
||||
* @param {K} key - Cache key
|
||||
* @returns {boolean} - True if cached
|
||||
*/
|
||||
has(key: K): boolean { return this.store.has(ReactiveCache.serialize(key)); }
|
||||
|
||||
/**
|
||||
* Store a value in the cache with a cache key
|
||||
* @param {K} key - Index to store the value under
|
||||
* @param {T} value - What you will be storing
|
||||
*/
|
||||
set(key: K, value: T) {
|
||||
this.store.set(ReactiveCache.serialize(key), value);
|
||||
this.events.next(this.values);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// export class ApiCache<K, T> {
|
||||
// cache = new Map<K, T>();
|
||||
// pending = new Map<K, Promise<T>>();
|
||||
//
|
||||
// get(key: K, fetchFn: (key: K) => Promise<T>) {
|
||||
// if(this.cache[key]) return Promise.reject(this.cache[key]);
|
||||
// if(this.pending[key]) return this.pending[key];
|
||||
// return fetchFn(key).then(res => {
|
||||
// this.cache[key] = res;
|
||||
// return res;
|
||||
// }).finally(() => this.pending[key] = undefined);
|
||||
// }
|
||||
// }
|
@ -0,0 +1,12 @@
|
||||
<h1 *ngIf="options.title" mat-dialog-title class="mb-3">{{options.title}}</h1>
|
||||
<div mat-dialog-content class="fh-dialog-form">
|
||||
{{options.message}}
|
||||
</div>
|
||||
<div mat-dialog-actions style="justify-content: end">
|
||||
<button mat-button [mat-dialog-close]="false">
|
||||
{{options.cancelLabel}}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [mat-dialog-close]="true">
|
||||
{{options.confirmLabel}}
|
||||
</button>
|
||||
</div>
|
@ -0,0 +1,22 @@
|
||||
import {Component, Inject} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA} from '@angular/material/dialog';
|
||||
|
||||
export type ConfirmDialogOptions = {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'fh-confirm-dialog',
|
||||
templateUrl: './confirm-dialog.component.html'
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public readonly options: ConfirmDialogOptions) {
|
||||
Object.assign(this.options, {
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Ok',
|
||||
}, this.options);
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import {ControlValueAccessor, FormGroup} from '@angular/forms';
|
||||
import {deepCopy, isEqual} from '@transmute/common';
|
||||
|
||||
@Component({template: ''})
|
||||
export abstract class FormBoilerplateComponent<T extends Object> implements ControlValueAccessor {
|
||||
private original?: T;
|
||||
value?: T;
|
||||
|
||||
private _changes: any = () => {};
|
||||
registerOnChange(fn: any): void { this._changes = fn; }
|
||||
|
||||
private _touched: any = () => {};
|
||||
registerOnTouched(fn: any): void { this._touched = fn; }
|
||||
|
||||
@Input() disabled?: boolean;
|
||||
@Input() mode: 'new' | 'edit' = 'new';
|
||||
|
||||
@Output() submit$ = new EventEmitter<void>();
|
||||
@Output() reset$ = new EventEmitter<void>();
|
||||
|
||||
protected constructor(public readonly form: FormGroup) {
|
||||
this.form.valueChanges.subscribe(value => {
|
||||
this.value = {...this.value, ...value}
|
||||
this._changes(this.value);
|
||||
})
|
||||
}
|
||||
|
||||
protected abstract resetHook(preventDefault: () => void): void;
|
||||
protected abstract writeHook(value: T, preventDefault: () => void): void;
|
||||
protected abstract validateHook(value: T, preventDefault: () => void): void | boolean;
|
||||
|
||||
@HostListener('document:keyup.enter')
|
||||
private submit() { this.submit$.emit(); }
|
||||
|
||||
getError(control: string): {key: string, args: any} | null {
|
||||
const c = this.form.get(control);
|
||||
if(!!c?.errors) {
|
||||
const key = Object.keys(c.errors)[0];
|
||||
return {key, args: c.errors[key]};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getValue(control: string): any {
|
||||
return this.form.get(control) || (<any>this.value)[control];
|
||||
}
|
||||
|
||||
reset() {
|
||||
let preventDefault = false;
|
||||
this.resetHook(() => preventDefault = true);
|
||||
if(preventDefault) return;
|
||||
|
||||
this.form.reset();
|
||||
this.value = this.original ?? undefined;
|
||||
if(this.original) this.form.setValue(this.original);
|
||||
this.reset$.emit();
|
||||
}
|
||||
|
||||
setDisabledState?(disabled: boolean): void {
|
||||
this.disabled = disabled;
|
||||
this.disabled ? this.form.disable() : this.form.enable();
|
||||
}
|
||||
|
||||
writeValue(value: Partial<T>): void {
|
||||
if(this.disabled || isEqual(this.value, {...(this.value ?? {}), ...value})) return;
|
||||
if(!this.original) {
|
||||
this.original = <T>(deepCopy(value) || {});
|
||||
Object.keys(this.form.controls).forEach((key: string) => {
|
||||
if(this.original && (<any>this.original)[key] == undefined)
|
||||
(<any>this.original)[key] = null;
|
||||
});
|
||||
}
|
||||
this.value = {...(this.value ?? this.original), ...value};
|
||||
let preventDefault = false;
|
||||
this.writeHook(this.value, () => preventDefault = true);
|
||||
if(preventDefault) return;
|
||||
this.form.patchValue(this.value);
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
<h1 *ngIf="options.title" mat-dialog-title class="mb-3">{{options.title}}</h1>
|
||||
<div *ngIf="banner.text" class="alert" [classList]="banner.cssClass" role="alert">
|
||||
{{banner.text}}
|
||||
</div>
|
||||
<div mat-dialog-content class="fh-dialog-form">
|
||||
<ng-template addHost></ng-template>
|
||||
</div>
|
||||
<div mat-dialog-actions style="justify-content: end">
|
||||
<mat-checkbox *ngIf="!options?.disableAddAnother" class="mb-0 me-3" [(ngModel)]="anotherOne" [ngModelOptions]="{standalone: true}">
|
||||
{{options.addAnotherLabel}}
|
||||
</mat-checkbox>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{options.cancelLabel}}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" (click)="save()" [disabled]="loading">
|
||||
{{options.saveLabel}}
|
||||
</button>
|
||||
</div>
|
@ -0,0 +1,115 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Directive, ElementRef,
|
||||
Inject, OnDestroy, OnInit, ViewChild, ViewContainerRef,
|
||||
} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {FormBoilerplateComponent} from '../form-boilerplate/form-boilerplate.component';
|
||||
|
||||
export type FormDialogBanner = {
|
||||
text?: string,
|
||||
cssClass?: string
|
||||
};
|
||||
|
||||
export type FormDialogOptions<T extends object> = {
|
||||
addAnotherLabel?: string;
|
||||
banner?: FormDialogBanner;
|
||||
cancelLabel?: string;
|
||||
disableAddAnother?: boolean;
|
||||
form: FormBoilerplateComponent<T>;
|
||||
formArgs?: any;
|
||||
saveFn: FormDialogSaveFn<T>;
|
||||
saveLabel?: string;
|
||||
title?: string;
|
||||
value?: T | (() => T);
|
||||
};
|
||||
|
||||
export type FormDialogSaveFn<T> = (value: T, closeFn: () => void, bannerFn: (text: string, cssClass?: string) => void) => void | Promise<void>;
|
||||
|
||||
@Directive({
|
||||
selector: '[addHost]',
|
||||
})
|
||||
export class AddHostDirective {
|
||||
constructor(public viewContainerRef: ViewContainerRef) { }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fh-form-dialog',
|
||||
templateUrl: './form-dialog.component.html'
|
||||
})
|
||||
export class FormDialogComponent<T extends Object, F extends FormBoilerplateComponent<T>> implements OnInit, OnDestroy {
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
anotherOne: boolean = false;
|
||||
banner: FormDialogBanner = {text: '', cssClass: ''}
|
||||
form!: FormBoilerplateComponent<T>;
|
||||
loading: boolean = false;
|
||||
|
||||
@ViewChild(AddHostDirective, {static: true}) addHost!: AddHostDirective;
|
||||
@ViewChild(AddHostDirective, { read: ElementRef }) addHostEl!:ElementRef;
|
||||
|
||||
constructor(private changeDetector: ChangeDetectorRef,
|
||||
private dialog: MatDialogRef<FormDialogComponent<T, F>>,
|
||||
@Inject(MAT_DIALOG_DATA) public readonly options: FormDialogOptions<T>
|
||||
) {
|
||||
Object.assign(this.options, {
|
||||
addAnotherLabel: 'Add Another',
|
||||
cancelLabel: 'Cancel',
|
||||
saveLabel: 'Save',
|
||||
}, this.options);
|
||||
this.setBanner(this.options.banner?.text, this.options.banner?.cssClass);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.addHost.viewContainerRef.clear();
|
||||
this.form = <any>this.addHost.viewContainerRef.createComponent(<any>this.options.form).instance;
|
||||
if(this.options.formArgs) Object.assign(this.form, this.options.formArgs);
|
||||
if(this.options.value) {
|
||||
this.form.mode = 'edit';
|
||||
this.options.disableAddAnother = true;
|
||||
this.form.writeValue(
|
||||
typeof this.options.value == 'function'
|
||||
? this.options.value()
|
||||
: this.options.value
|
||||
);
|
||||
}
|
||||
this.subs.push(this.form.submit$.subscribe(() => this.save()));
|
||||
}
|
||||
|
||||
ngOnDestroy() { this.subs = this.subs.filter(s => s.unsubscribe()); }
|
||||
|
||||
save(): void {
|
||||
// TODO: connect form properties to dialog
|
||||
if(!this.form.value) return;
|
||||
this.loading = true;
|
||||
this.clearBanner();
|
||||
this.changeDetector.detectChanges();
|
||||
const value = this.form.value;
|
||||
const resp = this.options.saveFn(<any>value, () => {
|
||||
if(!this.anotherOne) {
|
||||
this.dialog.close(value);
|
||||
} else {
|
||||
this.clearBanner();
|
||||
this.form.reset();
|
||||
const control = <HTMLElement>document
|
||||
.querySelector('.fh-dialog-form [formcontrolname]:not(:disabled)');
|
||||
setTimeout(() => control.focus());
|
||||
}
|
||||
}, (text, cssClass) => this.setBanner(text, cssClass))
|
||||
if(resp instanceof Promise) {
|
||||
resp.finally(() => {
|
||||
this.loading = false;
|
||||
this.changeDetector.detectChanges();
|
||||
});
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearBanner(): void { this.setBanner(); }
|
||||
setBanner(text?: string, cssClass: string = 'alert alert-danger') {
|
||||
this.banner = {text: text ?? '', cssClass};
|
||||
}
|
||||
}
|
32
client/src/app/modules/form-helper/form-helper.module.ts
Normal file
32
client/src/app/modules/form-helper/form-helper.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {ConfirmDialogComponent} from './components/confirm-dialog/confirm-dialog.component';
|
||||
import {AddHostDirective, FormDialogComponent} from './components/form-dialog/form-dialog.component';
|
||||
import {FormHelperService} from './services/form-helper.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatIconModule
|
||||
],
|
||||
declarations: [
|
||||
AddHostDirective,
|
||||
ConfirmDialogComponent,
|
||||
FormDialogComponent
|
||||
],
|
||||
providers: [FormHelperService],
|
||||
exports: [
|
||||
ConfirmDialogComponent,
|
||||
FormDialogComponent
|
||||
]
|
||||
})
|
||||
export class FormHelperModule { }
|
2
client/src/app/modules/form-helper/index.ts
Normal file
2
client/src/app/modules/form-helper/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './form-helper.module';
|
||||
export * from './components/form-boilerplate/form-boilerplate.component';
|
@ -0,0 +1,46 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
|
||||
import {ConfirmDialogComponent, ConfirmDialogOptions} from '../components/confirm-dialog/confirm-dialog.component';
|
||||
import {
|
||||
FormDialogComponent,
|
||||
FormDialogOptions,
|
||||
FormDialogSaveFn
|
||||
} from '../components/form-dialog/form-dialog.component';
|
||||
|
||||
export type FormHelperConfirmDialogOptions =
|
||||
Omit<ConfirmDialogOptions, 'message' | 'title'> & {matDialog?: MatDialogConfig}
|
||||
|
||||
export type FormHelperOpenDialogOptions<T extends object> =
|
||||
Omit<FormDialogOptions<T>, 'form' | 'saveFn'> & {matDialog?: MatDialogConfig}
|
||||
|
||||
@Injectable()
|
||||
export class FormHelperService {
|
||||
constructor(private dialog: MatDialog) { }
|
||||
|
||||
confirm(title: string, message: string, options: FormHelperConfirmDialogOptions = {}): Promise<boolean> {
|
||||
return this.dialog.open(ConfirmDialogComponent, {
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
...options?.matDialog,
|
||||
data: {
|
||||
...options,
|
||||
title,
|
||||
message,
|
||||
matDialog: undefined
|
||||
}
|
||||
}).afterClosed().toPromise();
|
||||
}
|
||||
|
||||
form<T extends object>(form: any, saveFn: FormDialogSaveFn<T>, options: FormHelperOpenDialogOptions<T> = {}): Promise<T | null> {
|
||||
return this.dialog.open(FormDialogComponent, {
|
||||
disableClose: true,
|
||||
...options?.matDialog,
|
||||
data: {
|
||||
...options,
|
||||
form,
|
||||
matDialog: undefined,
|
||||
saveFn,
|
||||
}
|
||||
}).afterClosed().toPromise();
|
||||
}
|
||||
}
|
56
client/src/app/modules/material.module.js
Normal file
56
client/src/app/modules/material.module.js
Normal file
@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
exports.__esModule = true;
|
||||
exports.MaterialModule = void 0;
|
||||
var core_1 = require("@angular/core");
|
||||
var button_1 = require("@angular/material/button");
|
||||
var card_1 = require("@angular/material/card");
|
||||
var checkbox_1 = require("@angular/material/checkbox");
|
||||
var chips_1 = require("@angular/material/chips");
|
||||
var dialog_1 = require("@angular/material/dialog");
|
||||
var divider_1 = require("@angular/material/divider");
|
||||
var form_field_1 = require("@angular/material/form-field");
|
||||
var icon_1 = require("@angular/material/icon");
|
||||
var input_1 = require("@angular/material/input");
|
||||
var list_1 = require("@angular/material/list");
|
||||
var paginator_1 = require("@angular/material/paginator");
|
||||
var progress_bar_1 = require("@angular/material/progress-bar");
|
||||
var radio_1 = require("@angular/material/radio");
|
||||
var select_1 = require("@angular/material/select");
|
||||
var tabs_1 = require("@angular/material/tabs");
|
||||
var toolbar_1 = require("@angular/material/toolbar");
|
||||
var MATERIAL_MODULES = [
|
||||
toolbar_1.MatToolbarModule,
|
||||
button_1.MatButtonModule,
|
||||
card_1.MatCardModule,
|
||||
checkbox_1.MatCheckboxModule,
|
||||
chips_1.MatChipsModule,
|
||||
dialog_1.MatDialogModule,
|
||||
divider_1.MatDividerModule,
|
||||
form_field_1.MatFormFieldModule,
|
||||
icon_1.MatIconModule,
|
||||
input_1.MatInputModule,
|
||||
list_1.MatListModule,
|
||||
paginator_1.MatPaginatorModule,
|
||||
progress_bar_1.MatProgressBarModule,
|
||||
radio_1.MatRadioModule,
|
||||
select_1.MatSelectModule,
|
||||
tabs_1.MatTabsModule,
|
||||
];
|
||||
var MaterialModule = /** @class */ (function () {
|
||||
function MaterialModule() {
|
||||
}
|
||||
MaterialModule = __decorate([
|
||||
(0, core_1.NgModule)({
|
||||
imports: MATERIAL_MODULES,
|
||||
exports: MATERIAL_MODULES
|
||||
})
|
||||
], MaterialModule);
|
||||
return MaterialModule;
|
||||
}());
|
||||
exports.MaterialModule = MaterialModule;
|
48
client/src/app/modules/material.module.ts
Normal file
48
client/src/app/modules/material.module.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatChipsModule} from '@angular/material/chips';
|
||||
import {MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatDividerModule} from '@angular/material/divider';
|
||||
import {MatExpansionModule} from '@angular/material/expansion';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatListModule} from '@angular/material/list';
|
||||
import {MatMenuModule} from '@angular/material/menu';
|
||||
import {MatPaginatorModule} from '@angular/material/paginator';
|
||||
import {MatProgressBarModule} from '@angular/material/progress-bar';
|
||||
import {MatRadioModule} from '@angular/material/radio';
|
||||
import {MatSelectModule} from '@angular/material/select';
|
||||
import {MatTabsModule} from '@angular/material/tabs';
|
||||
import {MatToolbarModule} from '@angular/material/toolbar';
|
||||
import {MatTooltipModule} from '@angular/material/tooltip';
|
||||
|
||||
const MATERIAL_MODULES = [
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatDividerModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatTabsModule,
|
||||
MatTooltipModule,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: MATERIAL_MODULES,
|
||||
exports: MATERIAL_MODULES,
|
||||
})
|
||||
export class MaterialModule {}
|
11
client/src/app/pipes/size.pipe.ts
Normal file
11
client/src/app/pipes/size.pipe.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({name: 'size'})
|
||||
export class SizePipe implements PipeTransform {
|
||||
transform(size: number = 0) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let i = 0;
|
||||
while(size / (1024 ** i) > 1024) i++;
|
||||
return `${Number((size / (1024 ** i)).toFixed(1))} ${units[i]}`;
|
||||
}
|
||||
}
|
30
client/src/app/services/library.service.ts
Normal file
30
client/src/app/services/library.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Library, Metrics, Video} from '@transmute/common';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {CrudApiClient} from '../modules/crud-api/client';
|
||||
import {CrudApiEndpoint} from '../modules/crud-api/endpoint';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class LibraryEndpoint extends CrudApiEndpoint<number, Library> {
|
||||
constructor(protected http: HttpClient) {
|
||||
super(`${environment.apiUrl}/api/library/:id?`, 'id');
|
||||
}
|
||||
|
||||
metrics(library?: number): Promise<Metrics> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/metrics`).toPromise();
|
||||
}
|
||||
|
||||
scan(library?: number): Promise<{length: number}> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/scan`).toPromise();
|
||||
}
|
||||
|
||||
videos(library?: number): Promise<Video[]> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/videos`).toPromise();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class LibraryClient extends CrudApiClient<number, Library> {
|
||||
constructor(public api: LibraryEndpoint) { super(); }
|
||||
}
|
149
client/src/app/views/app.component.html
Normal file
149
client/src/app/views/app.component.html
Normal file
@ -0,0 +1,149 @@
|
||||
<!-- Toolbar -->
|
||||
<tm-toolbar />
|
||||
<!-- Viewport -->
|
||||
<div class="max-height d-flex flex-row">
|
||||
<!-- Main panel -->
|
||||
<div class="d-flex flex-column p-4" style="flex-grow: 2">
|
||||
<!-- Library Selector -->
|
||||
<tm-library-selector [(selected)]="library" (selectedChange)="librarySelected($event)" />
|
||||
<mat-accordion class="d-flex flex-column flex-grow-1" multi>
|
||||
<!-- Charts -->
|
||||
<mat-expansion-panel [disabled]="!metrics">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<span>Metrics</span>
|
||||
<span class="ms-3 text-muted" *ngIf="metrics">Files: {{metrics.videos}}</span>
|
||||
<span class="ms-3 text-muted" *ngIf="metrics">Size: {{ metrics.size | size }}</span>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div *ngIf="metrics">
|
||||
<div class="d-flex justify-content-between">
|
||||
<tm-pichart title="Resolution" [data]="metrics.resolution"></tm-pichart>
|
||||
<tm-pichart title="Container" [data]="metrics.container"></tm-pichart>
|
||||
<tm-pichart title="Video Codec" [data]="metrics.videoCodec"></tm-pichart>
|
||||
<tm-pichart title="Audio Codec" [data]="metrics.audioCodec"></tm-pichart>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<tm-pichart title="Health" [data]="metrics.health"></tm-pichart>
|
||||
<tm-pichart title="Audio Languages" [data]="metrics.audioLang"></tm-pichart>
|
||||
<tm-pichart title="Subtitle Languages" [data]="metrics.subLang"></tm-pichart>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
<!-- Files -->
|
||||
<mat-expansion-panel expanded class="expanded-fill d-flex flex-column">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>Files</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<!-- Table -->
|
||||
<div class="flex-grow-0">
|
||||
<table class="table">
|
||||
<colgroup>
|
||||
<col>
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Resolution</th>
|
||||
<th>Container</th>
|
||||
<th>Video Codec</th>
|
||||
<th>Audio Codec</th>
|
||||
<th># Audio</th>
|
||||
<th># Subtitle</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let v of videos">
|
||||
<td>
|
||||
<span class="me-3">{{v.name}}</span>
|
||||
<mat-chip *ngIf="v.healthy == null">Unknown</mat-chip>
|
||||
<mat-chip *ngIf="v.healthy" style="background: #0f0">Healthy</mat-chip>
|
||||
<mat-chip *ngIf="v.healthy == false" style="background: #f00">Unhealthy</mat-chip>
|
||||
</td>
|
||||
<td>{{v.resolution}}</td>
|
||||
<td>{{v.container}}</td>
|
||||
<td>{{v.videoCodec}}</td>
|
||||
<td>{{v.audioCodec}}</td>
|
||||
<td class="text-decoration-underline" [matTooltip]="list(v.audioTracks)">
|
||||
{{v.audioTracks?.length}}
|
||||
</td>
|
||||
<td class="text-decoration-underline" [matTooltip]="list(v.subtitleTracks)">
|
||||
{{v.subtitleTracks?.length}}
|
||||
</td>
|
||||
<td>{{v.size | size}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="flex-grow-0">
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [length]="metrics?.videos ?? 0"></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div class="py-4 pe-4 h-100" style="flex-grow: 1">
|
||||
<mat-card class="h-100">
|
||||
<mat-card-content class="p-0">
|
||||
<mat-tab-group>
|
||||
<!-- Queue -->
|
||||
<mat-tab label="Queue">
|
||||
<mat-divider></mat-divider>
|
||||
</mat-tab>
|
||||
<!-- Logs -->
|
||||
<mat-tab label="Logs">
|
||||
<mat-divider></mat-divider>
|
||||
</mat-tab>
|
||||
<!-- Workers -->
|
||||
<mat-tab label="Workers">
|
||||
<mat-divider></mat-divider>
|
||||
<div class="agent-list">
|
||||
<div *ngFor="let n of nodes" style="height: auto" class="mt-2 agent">
|
||||
<div class="d-flex px-3 align-items-center">
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job == null">storage
|
||||
</mat-icon>
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job?.type == 'healthcheck'">troubleshoot
|
||||
</mat-icon>
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job?.type == 'transcode'">rotate_right
|
||||
</mat-icon>
|
||||
<div class="ms-3 d-flex flex-column flex-grow-1">
|
||||
<span>{{n.name}}</span>
|
||||
<span class="text-muted">
|
||||
State: {{n.job ? n.job.type.toUpperCase() : 'IDLE'}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-100">
|
||||
<button mat-icon-button class="mb-2 agent-settings">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="n.job?.file">
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
<div class="d-flex justify-content-between text-muted mt-2 px-3">
|
||||
<div>{{n.job?.file?.name}}</div>
|
||||
<div>60%</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider class="mt-2"></mat-divider>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
0
client/src/app/views/app.component.scss
Normal file
0
client/src/app/views/app.component.scss
Normal file
44
client/src/app/views/app.component.ts
Normal file
44
client/src/app/views/app.component.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {Job, Library, Metrics, Video} from '@transmute/common';
|
||||
import {LibraryClient} from '../services/library.service';
|
||||
|
||||
export type Node = {
|
||||
name: string;
|
||||
job: Job | null
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
library?: Library;
|
||||
metrics?: Metrics;
|
||||
nodes: Node[] = [
|
||||
{name: 'manager-1', job: null},
|
||||
{name: 'Node-2', job: null}
|
||||
];
|
||||
videos: Video[] = [];
|
||||
|
||||
constructor(private dialog: MatDialog,
|
||||
private libraryApi: LibraryClient,
|
||||
private changeRef: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.librarySelected();
|
||||
}
|
||||
|
||||
librarySelected(library?: Library) {
|
||||
Promise.all([
|
||||
this.libraryApi.api.videos(library?.id).then(videos => this.videos = videos),
|
||||
this.libraryApi.api.metrics(library?.id).then((m: any) => this.metrics = m)
|
||||
]).then(() => this.changeRef.detectChanges());
|
||||
}
|
||||
|
||||
list(arr?: string[]): string {
|
||||
return !!arr ? arr.join(', ') : '';
|
||||
}
|
||||
}
|
BIN
client/src/assets/img/favicon.png
Normal file
BIN
client/src/assets/img/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
4
client/src/environments/environment.prod.ts
Normal file
4
client/src/environments/environment.prod.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
apiUrl: '{{API_URL}}',
|
||||
production: true,
|
||||
};
|
4
client/src/environments/environment.ts
Normal file
4
client/src/environments/environment.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
apiUrl: 'http://localhost:5000',
|
||||
production: true,
|
||||
}
|
18
client/src/index.html
Normal file
18
client/src/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Transmute</title>
|
||||
<base href="/">
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/favicon.png">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
9
client/src/main.ts
Normal file
9
client/src/main.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import {enableProdMode} from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
import { AppModule } from './app/app.module';
|
||||
import {environment} from './environments/environment';
|
||||
|
||||
if(environment.production) enableProdMode();
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
69
client/src/styles.scss
Normal file
69
client/src/styles.scss
Normal file
@ -0,0 +1,69 @@
|
||||
@use 'bootstrap/dist/css/bootstrap.min.css';
|
||||
// Custom Theming for Angular Material
|
||||
// For more information: https://material.angular.io/guide/theming
|
||||
@use '@angular/material' as mat;
|
||||
// Plus imports for other components in your app.
|
||||
|
||||
// Include the common styles for Angular Material. We include this here so that you only
|
||||
// have to load a single css file for Angular Material in your app.
|
||||
// Be sure that you only ever include this mixin once!
|
||||
@include mat.core();
|
||||
|
||||
// Define the palettes for your theme using the Material Design palettes available in palette.scss
|
||||
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
|
||||
// hue. Available color palettes: https://material.io/design/color/
|
||||
$transmute-client-primary: mat.define-palette(mat.$indigo-palette);
|
||||
$transmute-client-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
|
||||
|
||||
// The warn palette is optional (defaults to red).
|
||||
$transmute-client-warn: mat.define-palette(mat.$red-palette);
|
||||
|
||||
// Create the theme object. A theme consists of configurations for individual
|
||||
// theming systems such as "color" or "typography".
|
||||
$transmute-client-theme: mat.define-light-theme((
|
||||
color: (
|
||||
primary: $transmute-client-primary,
|
||||
accent: $transmute-client-accent,
|
||||
warn: $transmute-client-warn,
|
||||
)
|
||||
));
|
||||
|
||||
// Include theme styles for core and each component used in your app.
|
||||
// Alternatively, you can import and @include the theme mixins for each component
|
||||
// that you are using.
|
||||
@include mat.all-component-themes($transmute-client-theme);
|
||||
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
|
||||
.max-height { height: calc(100vh - 50px); }
|
||||
|
||||
.agent-list {
|
||||
.agent {
|
||||
&:hover {
|
||||
.agent-settings {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
input.hide-incrementor::-webkit-outer-spin-button,
|
||||
input.hide-incrementor::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-expanded.expanded-fill {
|
||||
flex-grow: 1;
|
||||
}
|
37
client/tsconfig.json
Normal file
37
client/tsconfig.json
Normal file
@ -0,0 +1,37 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./out-tsc/app",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": ["dom"],
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
9
common/.gitignore
vendored
Normal file
9
common/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# IDEs
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Artifacts
|
||||
coverage
|
||||
dist
|
||||
junit.xml
|
||||
node_modules
|
105
common/.gitlab/.gitlab-ci.yml
Normal file
105
common/.gitlab/.gitlab-ci.yml
Normal file
@ -0,0 +1,105 @@
|
||||
image: node:18
|
||||
|
||||
npm:
|
||||
stage: build
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull-push
|
||||
- key: $CI_PIPELINE_ID
|
||||
paths:
|
||||
- dist
|
||||
policy: push
|
||||
script:
|
||||
- npm install
|
||||
- npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- dist
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
audit:
|
||||
stage: test
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull
|
||||
script:
|
||||
- echo "vulnerabilities_high $(npm audit | grep -oE '[0-9]+ high' | grep -oE '[0-9]+' || echo 0)" > metrics.txt
|
||||
- echo "vulnerabilities_medium $(npm audit | grep -oE '[0-9]+ moderate' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
|
||||
- echo "vulnerabilities_low $(npm audit | grep -oE '[0-9]+ low' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
|
||||
artifacts:
|
||||
reports:
|
||||
metrics: metrics.txt
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
jest:
|
||||
stage: test
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull
|
||||
script:
|
||||
- npm run test:coverage
|
||||
coverage: /All\sfiles.*?\s+(\d+.\d+)/
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: junit.xml
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
registry:
|
||||
stage: deploy
|
||||
cache:
|
||||
- key:
|
||||
files:
|
||||
- package.json
|
||||
paths:
|
||||
- node_modules
|
||||
policy: pull
|
||||
- key: $CI_PIPELINE_ID
|
||||
paths:
|
||||
- dist
|
||||
policy: pull
|
||||
before_script:
|
||||
- VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
|
||||
- if [ "$CI_COMMIT_BRANCH" != "$CI_DEFAULT_BRANCH" ] && [ "$VERSION" != *"-$CI_COMMIT_BRANCH" ]; then VERSION="$VERSION-$(echo "$CI_COMMIT_BRANCH" | sed -E "s/[_/]/-/g")"; npm version --no-git-tag-version $VERSION; fi
|
||||
script:
|
||||
- PACKAGES=$(curl -s -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages)
|
||||
- ID=$(node -pe "JSON.parse(process.argv[1]).find(p => p['version'] == process.argv[2])?.id || ''" $PACKAGES $VERSION)
|
||||
- if [ -n "$ID" ]; then curl -s -X DELETE -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/$ID; fi
|
||||
- printf "@transmute:registry=https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/\n//$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/:_authToken=$DEPLOY_TOKEN" > .npmrc
|
||||
- npm publish
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
when: manual
|
||||
allow_failure: true
|
||||
|
||||
tag:
|
||||
stage: deploy
|
||||
image:
|
||||
name: alpine/git
|
||||
entrypoint: [""]
|
||||
cache: []
|
||||
before_script:
|
||||
- git remote set-url origin "https://ReleaseBot:$DEPLOY_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git"
|
||||
script:
|
||||
- VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
|
||||
- git tag -f $VERSION $CI_COMMIT_SHA
|
||||
- git push -f origin $VERSION
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
65
common/README.md
Normal file
65
common/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Transmute - Common
|
||||
|
||||
Provides common types & utilities used throughout the Transmute stack.
|
||||
|
||||
Please check out the [Transmute repository](https://gitlab.zakscode.com/zakscode/transmute/transmute) for more info.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
<!-- TOC -->
|
||||
* [Transmute - Common](#transmute---common)
|
||||
* [Table of Contents](#table-of-contents)
|
||||
* [Prerequisites](#prerequisites)
|
||||
* [Setup](#setup)
|
||||
* [Cheatsheet](#cheatsheet)
|
||||
<!-- TOC -->
|
||||
|
||||
## Prerequisites
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
- [NodeJS 18](https://nodejs.org/en/)
|
||||
|
||||
## Setup
|
||||
<details>
|
||||
<summary>NPM Install</summary>
|
||||
|
||||
This will install the [prebuilt library](https://gitlab.zakscode.com/zakscode/transmute/transmute/-/packages) from GitLab:
|
||||
1. Create a `.npmrc` file & add the GitLab's package registry URL':
|
||||
```
|
||||
@transmute:registry=https://gitlab.zakscode.com/api/v4/projects/85/packages/npm/
|
||||
//gitlab.zakscode.com/api/v4/projects/85/packages/npm/:_authToken=tvNAnPtzjy59xFrHBJ2J
|
||||
```
|
||||
2. Install as normal: `npm install --save @transmute/common`
|
||||
|
||||
If you would like to use your local source code instead of the prebuilt library, continue to the <ins>NPM Link</ins> section.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NPM Link</summary>
|
||||
|
||||
Make sure you have completed the <ins>NPM Install</ins> section before continuing.
|
||||
|
||||
A local copy of common can be used to test changes using [npm link](https://docs.npmjs.com/cli/v8/commands/npm-link). After cloning:
|
||||
1. Install the dependencies: `npm install`
|
||||
2. Build or watch the common library: `npm run build` or `npm run watch`
|
||||
3. link the library to npm from common's root directory: `npm link`
|
||||
4. Link the library to a project: `cd ../project && npm link @transmute/common`
|
||||
|
||||
**Warning:** Step 4 will need to be re-run when ever an `npm install` is completed.
|
||||
|
||||
This will only work on your local machine. Make sure you have completed the __NPM Install__ & `@cwb/common` is part of `package.json`.
|
||||
</details>
|
||||
|
||||
## Cheatsheet
|
||||
```bash
|
||||
# Build JS
|
||||
npm run build
|
||||
|
||||
# Watch for changes
|
||||
npm run watch
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
|
||||
# Re-run tests on changes
|
||||
npm run test:watch
|
||||
```
|
16
common/jest.config.js
Normal file
16
common/jest.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
"reporters": ["default", "jest-junit"],
|
||||
"roots": [
|
||||
"<rootDir>/tests"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/?(*.)+(spec|test).+(ts|tsx|js)"
|
||||
],
|
||||
"transform": {
|
||||
".+\\.(ts)$": "ts-jest"
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/utils/**/*.ts',
|
||||
'!src/**/utils/**/*.d.ts'
|
||||
],
|
||||
};
|
3605
common/package-lock.json
generated
Normal file
3605
common/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
common/package.json
Normal file
27
common/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@transmute/common",
|
||||
"version": "0.0.0",
|
||||
"description": "Transmute dependencies",
|
||||
"author": "ztimson",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"test": "npx jest --verbose",
|
||||
"test:watch": "npx jest --watch",
|
||||
"test:coverage": "npx jest --verbose --coverage",
|
||||
"watch": "npm run build && npx tsc --watch"
|
||||
},
|
||||
"dependencies": { },
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.2.3",
|
||||
"@types/node": "^18.15.3",
|
||||
"jest": "^29.3.1",
|
||||
"jest-junit": "^15.0.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
11
common/src/index.ts
Normal file
11
common/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Models
|
||||
export * from './models/config';
|
||||
export * from './models/job';
|
||||
export * from './models/languages';
|
||||
export * from './models/library';
|
||||
export * from './models/metrics';
|
||||
export * from './models/video';
|
||||
|
||||
// Utilities
|
||||
export * from './utils/logger.utils';
|
||||
export * from './utils/object.utils';
|
45
common/src/models/config.ts
Normal file
45
common/src/models/config.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export type Config = {
|
||||
/** What type of job type should be prioritized, leaving blank will automatically both queues */
|
||||
priority: 'healthcheck' | 'transcode' | null;
|
||||
/** Enable healthchecks using the given method, leaving blank disables */
|
||||
healthcheck: 'quick' | 'verbose' | null;
|
||||
/** Automatically delete unhealthy files */
|
||||
deleteUnhealthy: boolean;
|
||||
/** Require videos pass a healthcheck before being transcoded */
|
||||
onlyTranscodeHealthy: boolean;
|
||||
/** Delete original video file after it's been successfully transcoded */
|
||||
deleteOriginal: boolean;
|
||||
/** Desired video container/extension, leaving blank will accept any container type */
|
||||
targetContainer: string | null;
|
||||
/** Desired video codec, leaving blank will accept any codec */
|
||||
targetVideoCodec: string | null;
|
||||
/** Desired audio codec, leaving blank will accept any codec */
|
||||
targetAudioCodec: string | null;
|
||||
/** Only keep 1 audio track if multiple match */
|
||||
singleAudioTrack: boolean;
|
||||
/** Accepted audio track languages, leaving blank removes all */
|
||||
audioWhitelist: string[];
|
||||
/** Accepted subtitle languages, leaving blank removes all */
|
||||
subtitleWhitelist: string[];
|
||||
};
|
||||
|
||||
export const ConfigDefaults: Config = {
|
||||
priority: null,
|
||||
healthcheck: null,
|
||||
deleteUnhealthy: false,
|
||||
onlyTranscodeHealthy: false,
|
||||
deleteOriginal: false,
|
||||
targetContainer: null,
|
||||
targetVideoCodec: null,
|
||||
targetAudioCodec: null,
|
||||
singleAudioTrack: false,
|
||||
audioWhitelist: ['eng', 'unk'],
|
||||
subtitleWhitelist: [],
|
||||
}
|
||||
|
||||
export type KeyVal = {
|
||||
/** Configuration key */
|
||||
key: string;
|
||||
/** Configuration value */
|
||||
value: any;
|
||||
}
|
8
common/src/models/job.ts
Normal file
8
common/src/models/job.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import {File} from 'buffer';
|
||||
|
||||
export type JobType = 'healthcheck' | 'transcode'
|
||||
|
||||
export type Job = {
|
||||
type: JobType,
|
||||
file: File
|
||||
}
|
6
common/src/models/languages.ts
Normal file
6
common/src/models/languages.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Languages {
|
||||
eng = 'English',
|
||||
fre = 'French',
|
||||
spa = 'Spanish',
|
||||
unk = 'Unknown'
|
||||
}
|
10
common/src/models/library.ts
Normal file
10
common/src/models/library.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type Library = {
|
||||
/** Primary Key */
|
||||
id?: number;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Path to library folder */
|
||||
path: string;
|
||||
/** Monitor directory for changes */
|
||||
watch: boolean;
|
||||
}
|
15
common/src/models/metrics.ts
Normal file
15
common/src/models/metrics.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type Metrics = {
|
||||
resolution: {[key: string]: number},
|
||||
container: {[key: string]: number},
|
||||
videoCodec: {[key: string]: number},
|
||||
audioCodec: {[key: string]: number},
|
||||
health: {
|
||||
healthy: number,
|
||||
unhealthy: number,
|
||||
unknown: number
|
||||
},
|
||||
audioLang: {[key: string]: number},
|
||||
subLang: {[key: string]: number},
|
||||
size: number,
|
||||
videos: number
|
||||
}
|
63
common/src/models/video.ts
Normal file
63
common/src/models/video.ts
Normal file
@ -0,0 +1,63 @@
|
||||
export const Resolution = {
|
||||
'240p': 240,
|
||||
'360p': 360,
|
||||
'480p': 480,
|
||||
'720p': 720,
|
||||
'1080p': 1080,
|
||||
'4k': 2160,
|
||||
'8k': 4320,
|
||||
}
|
||||
|
||||
export enum Container {
|
||||
avi = 'AVI',
|
||||
mkv = 'MKV',
|
||||
mp4 = 'MP4',
|
||||
webm = 'WebM'
|
||||
}
|
||||
|
||||
export enum VideoCodec {
|
||||
h264 = 'h.264 (AVC)',
|
||||
h265 = 'h.265 (HEVC)',
|
||||
h266 = 'h.266 (VVC)',
|
||||
mpeg2 = 'MPEG-2',
|
||||
mpeg4 = 'MPEG-4'
|
||||
}
|
||||
|
||||
export enum AudioCodec {
|
||||
aac = 'AAC',
|
||||
ac3 = 'AC3',
|
||||
mp3 = 'MP3',
|
||||
vorbis = 'Ogg Vorbis',
|
||||
wav = 'WAV'
|
||||
}
|
||||
|
||||
export type VideoMeta = {
|
||||
/** Closest standard (NOT actual) video resolution (420p, 720p, 1080p, etc..) */
|
||||
resolution: string;
|
||||
/** Algorithm used to encode video */
|
||||
videoCodec?: keyof VideoCodec;
|
||||
/** Algorithm used to encode audio */
|
||||
audioCodec?: keyof AudioCodec;
|
||||
/** List of available audio tracks */
|
||||
audioTracks?: string[];
|
||||
/** List of available subtitle languages */
|
||||
subtitleTracks?: string[];
|
||||
}
|
||||
|
||||
export type Video = VideoMeta & {
|
||||
id?: number;
|
||||
/** Name of the file (extension included, path omitted: "sample.mp4") */
|
||||
name: string;
|
||||
/** Path to file */
|
||||
path: string;
|
||||
/** Library foreign key */
|
||||
library: number;
|
||||
/** Video container/File extension (Binds everything together) */
|
||||
container?: keyof Container;
|
||||
/** Whether the file is healthy or not; null if unchecked */
|
||||
checksum?: string;
|
||||
/** Size of file in bytes */
|
||||
healthy?: boolean;
|
||||
/** Checksum of file - useful for seeing if a file has changed */
|
||||
size: number;
|
||||
}
|
65
common/src/utils/logger.utils.ts
Normal file
65
common/src/utils/logger.utils.ts
Normal file
@ -0,0 +1,65 @@
|
||||
export const CliEffects = {
|
||||
CLEAR: "\x1b[0m",
|
||||
BRIGHT: "\x1b[1m",
|
||||
DIM: "\x1b[2m",
|
||||
UNDERSCORE: "\x1b[4m",
|
||||
BLINK: "\x1b[5m",
|
||||
REVERSE: "\x1b[7m",
|
||||
HIDDEN: "\x1b[8m",
|
||||
}
|
||||
|
||||
export const CliForeground = {
|
||||
BLACK: "\x1b[30m",
|
||||
RED: "\x1b[31m",
|
||||
GREEN: "\x1b[32m",
|
||||
YELLOW: "\x1b[33m",
|
||||
BLUE: "\x1b[34m",
|
||||
MAGENTA: "\x1b[35m",
|
||||
CYAN: "\x1b[36m",
|
||||
WHITE: "\x1b[37m",
|
||||
GREY: "\x1b[90m",
|
||||
}
|
||||
|
||||
export const CliBackground = {
|
||||
BLACK: "\x1b[40m",
|
||||
RED: "\x1b[41m",
|
||||
GREEN: "\x1b[42m",
|
||||
YELLOW: "\x1b[43m",
|
||||
BLUE: "\x1b[44m",
|
||||
MAGENTA: "\x1b[45m",
|
||||
CYAN: "\x1b[46m",
|
||||
WHITE: "\x1b[47m",
|
||||
GREY: "\x1b[100m",
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
constructor(public readonly namespace: string) { }
|
||||
|
||||
private format(...text: string[]): string {
|
||||
return `${new Date().toISOString()} [${this.namespace}] ${text.join(' ')}`;
|
||||
}
|
||||
|
||||
debug(...args: string[]) {
|
||||
console.log(CliForeground.MAGENTA + this.format(...args) + CliEffects.CLEAR);
|
||||
}
|
||||
|
||||
error(...args: string[]) {
|
||||
console.log(CliForeground.RED + this.format(...args) + CliEffects.CLEAR);
|
||||
}
|
||||
|
||||
info(...args: string[]) {
|
||||
console.log(CliForeground.CYAN + this.format(...args) + CliEffects.CLEAR);
|
||||
}
|
||||
|
||||
log(...args: string[]) {
|
||||
console.log(CliEffects.CLEAR + this.format(...args));
|
||||
}
|
||||
|
||||
warn(...args: string[]) {
|
||||
console.log(CliForeground.YELLOW + this.format(...args) + CliEffects.CLEAR);
|
||||
}
|
||||
|
||||
verbose(...args: string[]) {
|
||||
console.log(CliForeground.WHITE + this.format(...args) + CliEffects.CLEAR);
|
||||
}
|
||||
}
|
109
common/src/utils/object.utils.ts
Normal file
109
common/src/utils/object.utils.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Removes any null values from an object in-place
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* let test = {a: 0, b: false, c: null, d: 'abc'}
|
||||
* console.log(clean(test)); // Output: {a: 0, b: false, d: 'abc'}
|
||||
* ```
|
||||
*
|
||||
* @param {T} obj Object reference that will be cleaned
|
||||
* @returns {Partial<T>} Cleaned object
|
||||
*/
|
||||
export function clean<T>(obj: T): Partial<T> {
|
||||
if(obj == null) throw new Error("Cannot clean a NULL value");
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
if(value == null) delete (<any>obj)[key];
|
||||
});
|
||||
return <any>obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deep copy of an object (vs. a shallow copy of references)
|
||||
*
|
||||
* Should be replaced by `structuredClone` once released.
|
||||
*
|
||||
* @param {T} value Object to copy
|
||||
* @returns {T} Type
|
||||
*/
|
||||
export function deepCopy<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/set a property of an object using dot notation
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Get a value
|
||||
* const name = dotNotation<string>(person, 'firstName');
|
||||
* const familyCarMake = dotNotation(family, 'cars[0].make');
|
||||
* // Set a value
|
||||
* dotNotation(family, 'cars[0].make', 'toyota');
|
||||
* ```
|
||||
*
|
||||
* @type T Return type
|
||||
* @param {Object} obj source object to search
|
||||
* @param {string} prop property name (Dot notation & indexing allowed)
|
||||
* @param {any} set Set object property to value, omit to fetch value instead
|
||||
* @return {T} property value
|
||||
*/
|
||||
export function dotNotation<T>(obj: any, prop: string, set: T): T;
|
||||
export function dotNotation<T>(obj: any, prop: string): T | undefined;
|
||||
export function dotNotation<T>(obj: any, prop: string, set?: T): T | undefined {
|
||||
if(obj == null || !prop) return undefined;
|
||||
// Split property string by '.' or [index]
|
||||
return <T>prop.split(/[.[\]]/g).filter(prop => prop.length).reduce((obj, prop, i, arr) => {
|
||||
if(prop[0] == '"' || prop[0] == "'") prop = prop.slice(1, -1); // Take quotes out
|
||||
if(!obj?.hasOwnProperty(prop)) {
|
||||
if(set == undefined) return undefined;
|
||||
obj[prop] = {};
|
||||
}
|
||||
if(set !== undefined && i == arr.length - 1)
|
||||
return obj[prop] = set;
|
||||
return obj[prop];
|
||||
}, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that an object has the following values
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const test = {a: 2, b: 2};
|
||||
* includes(test, {a: 1}); // true
|
||||
* includes(test, {b: 1, c: 3}); // false
|
||||
* ```
|
||||
*
|
||||
* @param target Object to search
|
||||
* @param values Criteria to check against
|
||||
* @param allowMissing Only check the keys that are available on the target
|
||||
* @returns {boolean} Does target include all the values
|
||||
*/
|
||||
export function includes(target: any, values: any, allowMissing = false): boolean {
|
||||
if(target == undefined) return allowMissing;
|
||||
if(Array.isArray(values)) return values.findIndex((e, i) => !includes(target[i], values[i], allowMissing)) == -1;
|
||||
const type = typeof values;
|
||||
if(type != typeof target) return false;
|
||||
if(type == 'object') {
|
||||
return Object.keys(values).find(key => !includes(target[key], values[key], allowMissing)) == null;
|
||||
}
|
||||
if(type == 'function') return target.toString() == values.toString();
|
||||
return target == values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep check if two objects are equal
|
||||
*
|
||||
* @param {any} a - first item to compare
|
||||
* @param {any} b - second item to compare
|
||||
* @returns {boolean} True if they match
|
||||
*/
|
||||
export function isEqual(a: any, b: any): boolean {
|
||||
const ta = typeof a, tb = typeof b;
|
||||
if((ta != 'object' || a == null) || (tb != 'object' || b == null))
|
||||
return ta == 'function' && tb == 'function' ? a.toString() == b.toString() : a === b;
|
||||
const keys = Object.keys(a);
|
||||
if(keys.length != Object.keys(b).length) return false;
|
||||
return Object.keys(a).every(key => isEqual(a[key], b[key]));
|
||||
}
|
98
common/tests/utils/object.spec.ts
Normal file
98
common/tests/utils/object.spec.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import {clean, deepCopy, dotNotation, includes, isEqual} from "../../src";
|
||||
|
||||
describe('Object Utilities', () => {
|
||||
const TEST_OBJECT = {
|
||||
a: 1,
|
||||
b: [
|
||||
[2, 3],
|
||||
[4, 5]
|
||||
],
|
||||
c: {
|
||||
d: [
|
||||
[{e: 6, f: 7}]
|
||||
],
|
||||
},
|
||||
g: {h: 8},
|
||||
i: () => 9
|
||||
};
|
||||
|
||||
describe('clean', () => {
|
||||
test('remove null properties', () => {
|
||||
const a = {a: 1, b: 2, c: null};
|
||||
const final = {a: 1, b: 2};
|
||||
expect(clean(a)).toEqual(final);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepCopy', () => {
|
||||
const copy = deepCopy(TEST_OBJECT);
|
||||
test('Array of arrays', () => {
|
||||
const a = [[1, 2], [3, 4]];
|
||||
const b = deepCopy(a);
|
||||
b[0][1] = 5;
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
test('Change array inside object', () => {
|
||||
copy.b[1] = [1, 1, 1];
|
||||
expect(copy.b[1]).not.toEqual(TEST_OBJECT.b[1]);
|
||||
});
|
||||
test('Change object inside object', () => {
|
||||
copy.g = {h: Math.random()};
|
||||
expect(copy.g).not.toEqual(TEST_OBJECT.g);
|
||||
});
|
||||
test('Change object property inside nested array', () => {
|
||||
copy.c.d[0][0].e = -1;
|
||||
expect(copy.c.d[0][0].e).not.toEqual(TEST_OBJECT.c.d[0][0].e);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dotNotation', () => {
|
||||
test('no object or properties', () => {
|
||||
expect(dotNotation(undefined, 'z')).toStrictEqual(undefined);
|
||||
expect(dotNotation(TEST_OBJECT, '')).toStrictEqual(undefined);
|
||||
});
|
||||
test('invalid property', () => expect(dotNotation(TEST_OBJECT, 'z')).toBeUndefined());
|
||||
test('by property', () => expect(dotNotation(TEST_OBJECT, 'a')).toBe(TEST_OBJECT.a));
|
||||
test('by key', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
|
||||
test('by key (single quote)', () => expect(dotNotation(TEST_OBJECT, '[\'a\']')).toBe(TEST_OBJECT['a']));
|
||||
test('by key (double quote)', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
|
||||
test('by index', () => expect(dotNotation(TEST_OBJECT, 'b[0]')).toBe(TEST_OBJECT.b[0]));
|
||||
test('by index (2d)', () => expect(dotNotation(TEST_OBJECT, 'b[1][1]')).toBe(TEST_OBJECT.b[1][1]));
|
||||
test('everything combined', () => expect(dotNotation(TEST_OBJECT, 'c["d"][0][0].e'))
|
||||
.toBe(TEST_OBJECT.c['d'][0][0].e));
|
||||
test('set value', () => {
|
||||
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
|
||||
dotNotation(COPY, 'c["d"][0][0].e', 'test');
|
||||
expect(COPY['c']['d'][0][0]['e']).toBe('test');
|
||||
});
|
||||
test('set new value', () => {
|
||||
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
|
||||
dotNotation(COPY, 'c.x.y.z', 'test');
|
||||
expect(COPY['c']['x']['y']['z']).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('includes', () => {
|
||||
test('simple', () => expect(includes(TEST_OBJECT, {a: 1})).toBeTruthy());
|
||||
test('nested', () => expect(includes(TEST_OBJECT, {g: {h: 8}})).toBeTruthy());
|
||||
test('array', () => expect(includes(TEST_OBJECT, {b: [[2]]})).toBeTruthy());
|
||||
test('nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [[{e: 6}]]}})).toBeTruthy());
|
||||
test('wong nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [{e: 7}]}})).toBeFalsy());
|
||||
test('wrong value', () => expect(includes(TEST_OBJECT, {a: 1, b: 2})).toBeFalsy());
|
||||
test('missing value', () => expect(includes(TEST_OBJECT, {a: 1, i: 10})).toBeFalsy());
|
||||
});
|
||||
|
||||
describe('isEqual', () => {
|
||||
test('boolean equal', () => expect(isEqual(true, true)).toBeTruthy());
|
||||
test('boolean not-equal', () => expect(isEqual(true, false)).toBeFalsy());
|
||||
test('number equal', () => expect(isEqual(1, 1)).toBeTruthy());
|
||||
test('number not-equal', () => expect(isEqual(1, 0)).toBeFalsy());
|
||||
test('string equal', () => expect(isEqual('abc', 'abc')).toBeTruthy());
|
||||
test('string not-equal', () => expect(isEqual('abc', '')).toBeFalsy());
|
||||
test('array equal', () => expect(isEqual([true, 1, 'a'], [true, 1, 'a'])).toBeTruthy());
|
||||
test('array not-equal', () => expect(isEqual([true, 1, 'a'], [1])).toBeFalsy());
|
||||
test('object equal', () => expect(isEqual({a: 1, b: 2}, {a: 1, b: 2})).toBeTruthy());
|
||||
test('object not-equal', () => expect(isEqual({a: 1, b: 2}, {a: 1})).toBeFalsy());
|
||||
test('complex', () => expect(isEqual(TEST_OBJECT, TEST_OBJECT)).toBeTruthy());
|
||||
});
|
||||
});
|
14
common/tsconfig.json
Normal file
14
common/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"lib": ["ESNext"],
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"target": "es2015"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
||||
version: '3.8'
|
||||
|
||||
service:
|
||||
server:
|
||||
image: transcode-server
|
||||
ports:
|
||||
- 4200:80 # WebUI Port
|
||||
networks:
|
||||
- default
|
||||
volumes:
|
||||
- data:/app/db.sqlite3 # Data persistence
|
||||
|
||||
# Remove one of the following lines
|
||||
- media:/mnt/media # Using NAS (Complete media volume configuration bellow)
|
||||
- /absolute/path/example:/mnt/media # Use local filesystem (Delete media configuration bellow)
|
||||
|
||||
worker:
|
||||
image: transcode-worker
|
||||
environment:
|
||||
SERVER_URL: 'http://server:4200' # Server service URL (above)
|
||||
networks:
|
||||
- default
|
||||
volumes:
|
||||
# Remove one of the following lines
|
||||
- media:/mnt/media # Using NAS (Complete media volume configuration bellow)
|
||||
- /absolute/path/example:/mnt/media # Use local filesystem (Delete media configuration bellow)
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
volumes:
|
||||
data:
|
||||
media: # ---- CONFIGURE OR DELETE ME ----
|
||||
driver_opts:
|
||||
type: 'nfs'
|
||||
o: 'addr=<NAS IP>,nolock,soft,rw'
|
||||
device: ':<PATH on NAS>'
|
BIN
docs/images/logo.png
Normal file
BIN
docs/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
docs/images/screenshot.png
Normal file
BIN
docs/images/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
13
server/.gitignore
vendored
Normal file
13
server/.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# IDEs
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Artifacts
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.db3
|
||||
*.sqlite
|
||||
*.sqlite3
|
2
server/.npmrc
Normal file
2
server/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
@transmute:registry=https://gitlab.zakscode.com/api/v4/projects/85/packages/npm/
|
||||
//gitlab.zakscode.com/api/v4/projects/85/packages/npm/:_authToken=tvNAnPtzjy59xFrHBJ2J
|
49
server/README.md
Normal file
49
server/README.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Transmute Server
|
||||
|
||||
This is the server & orchestrator for the Transmute stack.
|
||||
|
||||
Transmute server is built using Express.js
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[[_TOC_]]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
- [NodeJS 18](https://nodejs.org/en/)
|
||||
- _[Docker](https://docs.docker.com/install/) (Optional)_
|
||||
|
||||
## Setup
|
||||
The project can either be run using NPM or Docker. NPM is recommended for development.
|
||||
<details>
|
||||
<summary>NPM</summary>
|
||||
|
||||
1. Install the dependencies: `npm install`
|
||||
2. Start the Angular server: `npm run start`
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Docker</summary>
|
||||
|
||||
1. Build the docker image: `docker build -t transmute-server:<TAG> .`
|
||||
2. Start the new image: `docker run -p 5000:5000 transmute-server:<TAG>`
|
||||
|
||||
</details>
|
||||
|
||||
The API should now be accessible on [http://localhost:5000](http://localhost:5000)
|
||||
|
||||
## Cheatsheet
|
||||
```bash
|
||||
# Start Angular server
|
||||
npm run start
|
||||
|
||||
# Build production
|
||||
npm run build:prod
|
||||
|
||||
# Build docker image
|
||||
docker build -t transmute-server:<TAG>
|
||||
|
||||
# Run docker image
|
||||
docker run -p 5000:5000 transmute-server:<TAG>
|
||||
```
|
1700
server/package-lock.json
generated
Normal file
1700
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
server/package.json
Normal file
30
server/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@transmute/server",
|
||||
"version": "0.0.0",
|
||||
"description": "Transmute API",
|
||||
"author": "ztimson",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"postbuild": "npx ncp node_modules/@transmute/client/dist/* dist/client",
|
||||
"start": "npm run watch:ts | npm run serve",
|
||||
"serve": "nodemon dist/src/main.js || npm run serve",
|
||||
"watch": "npm run watch:ts",
|
||||
"watch:ts": "npx tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@transmute/common": "^0.0.0",
|
||||
"better-sqlite3": "^8.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"kysely": "^0.23.5",
|
||||
"socket.io": "^4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^18.15.3",
|
||||
"nodemon": "^2.0.21",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
20
server/src/controllers/config.controller.ts
Normal file
20
server/src/controllers/config.controller.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {KeyVal} from '@transmute/common';
|
||||
import {app, config} from '../main';
|
||||
import {ErrorHandler} from '../middleware/error-handler.middleware';
|
||||
import {CrudApiController} from '../modules/crud-api/controller';
|
||||
|
||||
export class ConfigController extends CrudApiController<string, KeyVal>{
|
||||
protected readonly baseUrl = '/api/config';
|
||||
|
||||
constructor() {
|
||||
super(config);
|
||||
super.setup({
|
||||
update: true
|
||||
});
|
||||
|
||||
// Fetch entire config
|
||||
app.get('/api/config', ErrorHandler(async (req, res) => {
|
||||
res.json(await config.formatted());
|
||||
}));
|
||||
}
|
||||
}
|
38
server/src/controllers/library.controller.ts
Normal file
38
server/src/controllers/library.controller.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {Library} from '@transmute/common';
|
||||
import {app, libraries} from '../main';
|
||||
import {ErrorHandler} from '../middleware/error-handler.middleware';
|
||||
import {CrudApiController} from '../modules/crud-api/controller';
|
||||
|
||||
export class LibraryController extends CrudApiController<number, Library>{
|
||||
protected readonly baseUrl = '/api/library';
|
||||
|
||||
constructor() {
|
||||
super(libraries);
|
||||
|
||||
// List all videos (must come before super)
|
||||
app.get(`${this.baseUrl}/videos`, ErrorHandler(async (req, res) =>
|
||||
res.json(await libraries.videos())));
|
||||
|
||||
app.get(`${this.baseUrl}/scan`, ErrorHandler(async (req, res) =>
|
||||
res.json({length: await libraries.scanAll(true)})));
|
||||
|
||||
// All stats (must come before super)
|
||||
app.get(`${this.baseUrl}/metrics`, ErrorHandler(async (req, res) =>
|
||||
res.json(await libraries.metrics())));
|
||||
|
||||
// Library CRUD operations
|
||||
super.setup();
|
||||
|
||||
// Scan library for videos
|
||||
app.get(`${this.baseUrl}/:id/scan`, ErrorHandler(async (req, res) => {
|
||||
res.json({length: await libraries.scan(Number(req.params['id']), true)})}));
|
||||
|
||||
// Library stats
|
||||
app.get(`${this.baseUrl}/:id/metrics`, ErrorHandler(async (req, res) =>
|
||||
res.json(await libraries.metrics(Number(req.params['id'])))));
|
||||
|
||||
// Library videos
|
||||
app.get(`${this.baseUrl}/:library/videos`, ErrorHandler(async (req, res) =>
|
||||
res.json(await libraries.videos(Number(req.params['library'])))));
|
||||
}
|
||||
}
|
0
server/src/controllers/queue.controller.ts
Normal file
0
server/src/controllers/queue.controller.ts
Normal file
7
server/src/environments/environment.ts
Normal file
7
server/src/environments/environment.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import {join} from 'path';
|
||||
import * as process from 'process';
|
||||
|
||||
export const environment = {
|
||||
clientPath: process.env['CLIENT_PATH'] ?? join(process.cwd(), '../client/dist'),
|
||||
port: process.env['PORT'] ?? 5000
|
||||
}
|
52
server/src/main.ts
Normal file
52
server/src/main.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import {ConfigController} from './controllers/config.controller';
|
||||
import {LibraryController} from './controllers/library.controller';
|
||||
import {environment} from './environments/environment';
|
||||
import {ErrorMiddleware} from './middleware/error-handler.middleware';
|
||||
import {LoggerMiddleware} from './middleware/logger.middleware';
|
||||
import {ConfigService} from './services/config.service';
|
||||
import {LibraryService} from './services/library.service';
|
||||
import {QueueService} from './services/queue.service';
|
||||
import {SocketService} from './services/socket.service';
|
||||
import * as SourceMap from 'source-map-support';
|
||||
import {VideoService} from './services/video.service';
|
||||
import * as cors from 'cors';
|
||||
|
||||
SourceMap.install();
|
||||
|
||||
// Globals
|
||||
export const app = express();
|
||||
export const server = http.createServer(app);
|
||||
|
||||
// Singleton services
|
||||
// await connectAndMigrate();
|
||||
export const config = new ConfigService();
|
||||
export const socket = new SocketService(server);
|
||||
export const queue = new QueueService();
|
||||
|
||||
export const videos = new VideoService();
|
||||
export const libraries = new LibraryService(videos);
|
||||
|
||||
// Express setup
|
||||
(async () => {
|
||||
// Middleware
|
||||
app.use(cors.default());
|
||||
app.use(express.json());
|
||||
app.use(LoggerMiddleware);
|
||||
|
||||
// API
|
||||
new ConfigController();
|
||||
new LibraryController();
|
||||
|
||||
// Client/WebUI
|
||||
console.log(environment.clientPath);
|
||||
app.get('*', express.static(environment.clientPath));
|
||||
|
||||
// Error handling
|
||||
app.use(ErrorMiddleware);
|
||||
process.on('unhandledRejection', ErrorMiddleware);
|
||||
|
||||
// Start
|
||||
server.listen(environment.port, () => console.log(`Listening on http://localhost:${environment.port}`));
|
||||
})();
|
29
server/src/middleware/error-handler.middleware.ts
Normal file
29
server/src/middleware/error-handler.middleware.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {Logger} from '@transmute/common';
|
||||
import {Request, Response} from 'express';
|
||||
import {BadRequestError, CustomError, InternalServerError} from '../utils/errors';
|
||||
|
||||
const logger = new Logger('ErrorMiddleware');
|
||||
|
||||
export const ErrorHandler = fn => (req, res, next) => {
|
||||
return Promise
|
||||
.resolve(fn(req, res, next))
|
||||
.catch(next);
|
||||
};
|
||||
|
||||
export const ErrorMiddleware = (err: Error | CustomError, req: Request, res: Response, next) => {
|
||||
const e: CustomError = CustomError.instanceof(err) ? err :
|
||||
err.stack.includes('SqliteError') ? BadRequestError.from(err) : InternalServerError.from(err);
|
||||
const code = (<any>e).constructor?.code;
|
||||
const userError = code >= 400 && code < 500;
|
||||
|
||||
logger[userError ? 'verbose' : 'error'](userError ? `${code} ${err.message}` : err.stack);
|
||||
|
||||
if(res) {
|
||||
res.status(code).json({
|
||||
code,
|
||||
message: err.message,
|
||||
stack: userError ? undefined : err.stack,
|
||||
timestamp: (new Date()).toISOString()
|
||||
})
|
||||
}
|
||||
}
|
8
server/src/middleware/logger.middleware.ts
Normal file
8
server/src/middleware/logger.middleware.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import {Logger} from '@transmute/common';
|
||||
|
||||
const logger = new Logger('LoggerMiddleware');
|
||||
|
||||
export function LoggerMiddleware(req, res, next) {
|
||||
logger.verbose(`${req.method} ${decodeURI(req.url)}`);
|
||||
next();
|
||||
}
|
37
server/src/modules/crud-api/controller.ts
Normal file
37
server/src/modules/crud-api/controller.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {app} from '../../main';
|
||||
import {ErrorHandler} from '../../middleware/error-handler.middleware';
|
||||
import {CrudApiService} from './service';
|
||||
|
||||
export abstract class CrudApiController<K, T> {
|
||||
protected readonly baseUrl: string;
|
||||
|
||||
protected constructor(private service: CrudApiService<K, T>) { }
|
||||
|
||||
setup(methods?: {list?: boolean, read?: boolean, create?: boolean, update?: boolean, delete?: boolean}): void {
|
||||
if(!methods || methods?.create)
|
||||
app.post(this.baseUrl, ErrorHandler(async (req, res) => {
|
||||
res.json(await this.service.create(req.body));
|
||||
}));
|
||||
|
||||
if(!methods || methods?.list)
|
||||
app.get(this.baseUrl, ErrorHandler(async (req, res) => {
|
||||
const pagination = {offset: Number(req.query['offset']), limit: Number(req.query['limit'])}
|
||||
res.json(await this.service.list(Object.keys(req.params).length > 0 ? <Partial<T>>req.params : undefined, pagination));
|
||||
}));
|
||||
|
||||
if(!methods || methods?.read)
|
||||
app.get(`${this.baseUrl}/:${this.service.pk.toString()}`, ErrorHandler(async (req, res) => {
|
||||
res.json(await this.service.read(<K>req.params[this.service.pk]));
|
||||
}));
|
||||
|
||||
if(!methods || methods?.update)
|
||||
app.patch(`${this.baseUrl}/:id?`, ErrorHandler(async (req, res) => {
|
||||
res.json(await this.service.update(req.body));
|
||||
}));
|
||||
|
||||
if(!methods || methods?.delete)
|
||||
app.delete(`${this.baseUrl}/:${this.service.pk.toString()}`, ErrorHandler(async (req, res) => {
|
||||
res.json(await this.service.delete(<K>req.params[this.service.pk]));
|
||||
}));
|
||||
}
|
||||
}
|
63
server/src/modules/crud-api/service.ts
Normal file
63
server/src/modules/crud-api/service.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import {db} from '../../services/sqlite.service';
|
||||
import {NotFoundError} from '../../utils/errors';
|
||||
import {whereBuilder} from '../../utils/sql.utils';
|
||||
|
||||
export abstract class CrudApiService<K, T> {
|
||||
protected constructor(protected readonly table: string, public readonly pk: keyof T, private autoCreate: boolean = false) {}
|
||||
|
||||
abstract afterRead(value: T, list: boolean): Promise<T>;
|
||||
|
||||
abstract beforeWrite(value: Partial<T>, original: T | null): Promise<T>;
|
||||
|
||||
abstract afterWrite(value: T, original: T | null): void | Promise<void>;
|
||||
|
||||
async list(filter?: Partial<T>, paginate?: {offset?: number, limit?: number}): Promise<T[]> {
|
||||
let qb = db.selectFrom(<any>this.table).selectAll();
|
||||
if(filter != null) qb = whereBuilder(qb, filter);
|
||||
return Promise.all((await qb.execute())
|
||||
// .filter((el, i) => !paginate || ((paginate?.offset == null || i >= paginate.offset) && (paginate?.limit == null || i < (paginate?.offset || 0) + paginate.limit)))
|
||||
.map((f: T) => this.afterRead(f, true)));
|
||||
}
|
||||
|
||||
async create(value: Partial<T>): Promise<T> {
|
||||
const row = await db.insertInto(<any>this.table)
|
||||
.values(await this.beforeWrite(value, null))
|
||||
.returning(this.pk.toString())
|
||||
.executeTakeFirst();
|
||||
const newVal = await this.read((<any>row)[this.pk]);
|
||||
await this.afterWrite(newVal, null);
|
||||
return newVal;
|
||||
}
|
||||
|
||||
async read(filter: K | Partial<T>): Promise<T> {
|
||||
const found = await whereBuilder(
|
||||
db.selectFrom(<any>this.table).selectAll(),
|
||||
typeof filter == 'object' ? filter : {[this.pk]: filter}
|
||||
).executeTakeFirst();
|
||||
if(!found) throw new NotFoundError();
|
||||
return this.afterRead(<T>found, false);
|
||||
}
|
||||
|
||||
async update(value: Partial<T>): Promise<T> {
|
||||
const original = await this.read(<any>value[this.pk]);
|
||||
if(!original) {
|
||||
if(!this.autoCreate) throw new NotFoundError();
|
||||
return this.create(value);
|
||||
}
|
||||
return whereBuilder(
|
||||
db.updateTable(<any>this.table).set({...(await this.beforeWrite(value, original)), [this.pk]: undefined}),
|
||||
{[this.pk]: value[this.pk]}
|
||||
).execute().then(async () => {
|
||||
const newVal = await this.read(<any>value[this.pk]);
|
||||
await this.afterWrite(newVal, original);
|
||||
return newVal;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(filter?: K | Partial<T>): Promise<void> {
|
||||
return whereBuilder(
|
||||
db.deleteFrom(<any>this.table),
|
||||
typeof filter == 'object' ? filter : {[this.pk]: filter}
|
||||
).execute().then(() => {});
|
||||
}
|
||||
}
|
36
server/src/services/config.service.ts
Normal file
36
server/src/services/config.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import {Config, ConfigDefaults, KeyVal} from '@transmute/common';
|
||||
import {CrudApiService} from '../modules/crud-api/service';
|
||||
import {BadRequestError} from '../utils/errors';
|
||||
|
||||
export class ConfigService extends CrudApiService<string, KeyVal> {
|
||||
readonly options: Partial<Config> = ConfigDefaults;
|
||||
|
||||
constructor() {
|
||||
super('config', 'key', true);
|
||||
|
||||
// Load config values
|
||||
(async () => {
|
||||
(await this.list()).forEach(({key, value}) => this.options[key] = value);
|
||||
})();
|
||||
}
|
||||
|
||||
async afterRead(value: KeyVal, list: boolean): Promise<KeyVal> {
|
||||
value.value = JSON.parse(value.value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async beforeWrite(value: KeyVal, original: KeyVal): Promise<KeyVal> {
|
||||
if(value.key && this.options[value.key] === undefined)
|
||||
throw new BadRequestError(`Invalid config key: ${value.key}`);
|
||||
this.options[value.key] = value.value;
|
||||
value.value = JSON.stringify(value.value);
|
||||
return value;
|
||||
}
|
||||
|
||||
afterWrite(value, original: | null): void { }
|
||||
|
||||
async formatted(): Promise<Config> {
|
||||
return <Config>(await super.list())
|
||||
.reduce((acc, {key, value}) => ({...acc, [key]: value}), {});
|
||||
}
|
||||
}
|
153
server/src/services/library.service.ts
Normal file
153
server/src/services/library.service.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import {Library, Metrics} from '@transmute/common';
|
||||
import {CrudApiService} from '../modules/crud-api/service';
|
||||
import {existsSync, statSync} from 'fs';
|
||||
import {BadRequestError} from '../utils/errors';
|
||||
import {createChecksum, deepSearch, parseFilePath} from '../utils/file.utils';
|
||||
import {VideoService} from './video.service';
|
||||
|
||||
export class LibraryService extends CrudApiService<number, Library> {
|
||||
constructor(private videoService: VideoService) {
|
||||
super('library', 'id');
|
||||
}
|
||||
|
||||
async afterRead(value: Library, list: boolean): Promise<Library> {
|
||||
value.watch = (<any>value).watch == 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
async beforeWrite(value: Library, original: Library | null): Promise<Library> {
|
||||
if(!existsSync(value.path))
|
||||
throw new BadRequestError(`Library path is invalid: ${value.path}`);
|
||||
value.watch = <any>(value.watch ? 1 : 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
afterWrite(value: Library, original: Library): Promise<void> {
|
||||
return this.scan(value.id).then(() => {});
|
||||
}
|
||||
|
||||
async delete(filter: Partial<Library> | number): Promise<void> {
|
||||
const id = typeof filter == 'object' ? filter.id : filter;
|
||||
await this.videoService.delete({library: id});
|
||||
return super.delete(filter);
|
||||
}
|
||||
|
||||
async scan(library: number, force: boolean = false): Promise<number> {
|
||||
// Check if library exists
|
||||
const lib = await this.read(library);
|
||||
if(!existsSync(lib.path)) throw new BadRequestError(`Library path is invalid: ${lib.path}`)
|
||||
|
||||
// Fetch list of previously scanned videos & current videos on disk
|
||||
const files = deepSearch(lib.path).filter(file => /\.(avi|mp4|mkv)$/gmi.test(file));
|
||||
const videos = await this.videoService.list({library});
|
||||
|
||||
// Initial save to DB
|
||||
const saved = await Promise.all(files.map(async file => {
|
||||
const exists = videos.findIndex(v => v.path == file);
|
||||
if(exists != -1) return videos.splice(exists, 1)[0];
|
||||
const fileInfo = parseFilePath(file);
|
||||
return await this.videoService.create({
|
||||
name: fileInfo.fileName,
|
||||
path: file,
|
||||
library,
|
||||
container: fileInfo.extension,
|
||||
size: statSync(file).size
|
||||
});
|
||||
}));
|
||||
|
||||
// Scan each file asynchronously
|
||||
Promise.all(saved.map(async video => {
|
||||
const checksum = await createChecksum(video.path);
|
||||
if(!force && video.checksum == checksum && !Object.values(video).includes(null)) return video;
|
||||
return this.videoService.update({
|
||||
...video,
|
||||
checksum,
|
||||
...(await this.videoService.scan(video.path)),
|
||||
});
|
||||
})).then(resp => {
|
||||
// Delete scans for files that no longer exist on disk
|
||||
videos.forEach(v => this.videoService.delete(v.id));
|
||||
return resp;
|
||||
});
|
||||
|
||||
// Return number of discovered files
|
||||
return saved.length;
|
||||
}
|
||||
|
||||
async scanAll(force: boolean = false): Promise<number> {
|
||||
const libraries = await this.list();
|
||||
return (await Promise.all(libraries.map(l => this.scan(l.id, force))))
|
||||
.reduce((acc, n) => acc + n, 0);
|
||||
}
|
||||
|
||||
async metrics(library?: number): Promise<Metrics> {
|
||||
// Check if library exists
|
||||
if(library) await this.read(library);
|
||||
|
||||
// Iterate over all video files & add up stats
|
||||
const stats: Metrics = {
|
||||
resolution: {},
|
||||
container: {},
|
||||
videoCodec: {},
|
||||
audioCodec: {},
|
||||
health: {healthy: 0, unhealthy: 0, unknown: 0},
|
||||
audioLang: {},
|
||||
subLang: {},
|
||||
size: 0,
|
||||
videos: 0
|
||||
};
|
||||
(await this.videoService.list(library != null ? {library} : undefined)).forEach(f => {
|
||||
// Resolution
|
||||
if(f.resolution) {
|
||||
if(!stats.resolution[f.resolution]) stats.resolution[f.resolution] = 0;
|
||||
stats.resolution[f.resolution]++;
|
||||
}
|
||||
|
||||
// Container
|
||||
if(f.container) {
|
||||
if(!stats.container[f.container]) stats.container[f.container] = 0;
|
||||
stats.container[f.container]++;
|
||||
}
|
||||
|
||||
// Video codec
|
||||
if(f.videoCodec) {
|
||||
if(!stats.videoCodec[f.videoCodec]) stats.videoCodec[f.videoCodec] = 0;
|
||||
stats.videoCodec[f.videoCodec]++;
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
if(f.audioCodec) {
|
||||
if(!stats.audioCodec[f.audioCodec]) stats.audioCodec[f.audioCodec] = 0;
|
||||
stats.audioCodec[f.audioCodec]++;
|
||||
}
|
||||
|
||||
// Audio tracks
|
||||
f.audioTracks?.forEach(at => {
|
||||
if(!stats.audioLang[at]) stats.audioLang[at] = 0;
|
||||
stats.audioLang[at]++;
|
||||
});
|
||||
|
||||
// Subtitles
|
||||
f.subtitleTracks?.forEach(st => {
|
||||
if(!stats.subLang[st]) stats.subLang[st] = 0;
|
||||
stats.subLang[st]++;
|
||||
});
|
||||
|
||||
// Health
|
||||
stats.health[f.healthy == null ? 'unknown' : f.healthy ? 'healthy' : 'unhealthy']++;
|
||||
|
||||
// Filesize
|
||||
stats.size += f.size;
|
||||
|
||||
// Length
|
||||
stats.videos++;
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
async videos(library?: number) {
|
||||
// Check if library exists
|
||||
if(library != null) await this.read(library);
|
||||
return this.videoService.list(library != null ? {library} : undefined);
|
||||
}
|
||||
}
|
27
server/src/services/queue.service.ts
Normal file
27
server/src/services/queue.service.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import {videos} from '../main';
|
||||
|
||||
export class QueueService {
|
||||
private healthcheckJobs: number[] = [];
|
||||
private transcodeJobs: number[] = [];
|
||||
|
||||
mode: 'auto' | 'healthcheck' | 'transcode' = 'auto';
|
||||
|
||||
async getJob(type?: 'healthcheck' | 'transcode'): Promise<['healthcheck' | 'transcode', number]> {
|
||||
if((type || this.mode) == 'healthcheck')
|
||||
return ['healthcheck', this.healthcheckJobs.pop()];
|
||||
if((type || this.mode) == 'transcode')
|
||||
return ['transcode', this.transcodeJobs.pop()];
|
||||
|
||||
// Auto - Get next transcode job or it's healthcheck if it's needed still required
|
||||
const id = this.transcodeJobs.pop();
|
||||
const video = await videos.read(id);
|
||||
if(video.healthy != null)
|
||||
return [video.healthy ? 'transcode' : 'healthcheck', id]
|
||||
}
|
||||
|
||||
createJob(id: number, type: 'healthcheck' | 'transcode') {
|
||||
(type == 'healthcheck' ?
|
||||
this.healthcheckJobs :
|
||||
this.transcodeJobs).push(id);
|
||||
}
|
||||
}
|
25
server/src/services/socket.service.ts
Normal file
25
server/src/services/socket.service.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {Server as HTTP} from 'http';
|
||||
import {Server} from 'socket.io';
|
||||
|
||||
export class SocketService {
|
||||
private socket !: any;
|
||||
|
||||
constructor(server: HTTP) {
|
||||
this.socket = new Server(server);
|
||||
this.socket.on('connection', (socket) => {
|
||||
console.log('a user connected');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('user disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
scanStatus(library: string, complete: number, pending: number) {
|
||||
this.socket.emit('scan', {
|
||||
library,
|
||||
complete,
|
||||
pending,
|
||||
});
|
||||
}
|
||||
}
|
41
server/src/services/sqlite.service.ts
Normal file
41
server/src/services/sqlite.service.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {Library, Video} from '@transmute/common';
|
||||
import {Kysely, SqliteDialect} from 'kysely';
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
interface Schema {
|
||||
library: Library,
|
||||
video: Video
|
||||
}
|
||||
|
||||
export const db = new Kysely<Schema>({
|
||||
dialect: new SqliteDialect({
|
||||
database: new Database('db.sqlite3')
|
||||
})
|
||||
});
|
||||
|
||||
// export async function connectAndMigrate() {
|
||||
// const migrator = new Migrator({
|
||||
// db,
|
||||
// provider: new FileMigrationProvider({
|
||||
// fs,
|
||||
// path,
|
||||
// migrationFolder: 'dist/migrations',
|
||||
// })
|
||||
// });
|
||||
//
|
||||
// const { error, results } = await migrator.migrateToLatest()
|
||||
//
|
||||
// results?.forEach((it) => {
|
||||
// if (it.status === 'Success') {
|
||||
// console.log(`migration "${it.migrationName}" was executed successfully`);
|
||||
// } else if (it.status === 'Error') {
|
||||
// console.error(`failed to execute migration "${it.migrationName}"`);
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// if (error) {
|
||||
// console.error('failed to migrate');
|
||||
// console.error(error);
|
||||
// process.exit(1);
|
||||
// }
|
||||
// }
|
35
server/src/services/video.service.ts
Normal file
35
server/src/services/video.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {Video} from '@transmute/common';
|
||||
import fs from 'fs';
|
||||
import {CrudApiService} from '../modules/crud-api/service';
|
||||
import {getCodec, getResolution, getStreams} from '../utils/ffmpeg.utils';
|
||||
|
||||
export class VideoService extends CrudApiService<number, Video> {
|
||||
constructor() {
|
||||
super('video', 'id');
|
||||
}
|
||||
|
||||
async afterRead(value: Video, list: boolean): Promise<Video> {
|
||||
value.audioTracks = JSON.parse(<any>value.audioTracks);
|
||||
value.subtitleTracks = JSON.parse(<any>value.subtitleTracks);
|
||||
return value;
|
||||
}
|
||||
|
||||
async beforeWrite(value: Video, original: Video | null): Promise<Video> {
|
||||
value.audioTracks = <any>JSON.stringify(value.audioTracks);
|
||||
value.subtitleTracks = <any>JSON.stringify(value.subtitleTracks);
|
||||
return value;
|
||||
}
|
||||
|
||||
async afterWrite(value: Video, original: Video | null): Promise<void> { }
|
||||
|
||||
async scan(file: string): Promise<{resolution: string, videoCodec: string, audioCodec: string, audioTracks: string[], subtitleTracks: string[]}> {
|
||||
if(!fs.existsSync(file)) throw new Error(`File could not be found: ${file}`);
|
||||
return {
|
||||
resolution: getResolution(file),
|
||||
videoCodec: getCodec(file, 'video', 0),
|
||||
audioCodec: getCodec(file, 'audio', 0),
|
||||
audioTracks: getStreams(file, 'audio'),
|
||||
subtitleTracks: getStreams(file, 'subtitle')
|
||||
}
|
||||
}
|
||||
}
|
3
server/src/services/worker.service.ts
Normal file
3
server/src/services/worker.service.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class WorkerService {
|
||||
workers = [];
|
||||
}
|
77
server/src/utils/errors.ts
Normal file
77
server/src/utils/errors.ts
Normal file
@ -0,0 +1,77 @@
|
||||
export class CustomError extends Error {
|
||||
static code = 500;
|
||||
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static from(err: Error) {
|
||||
const newErr = new this(err.message);
|
||||
return Object.assign(newErr, err);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code != undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends CustomError {
|
||||
static code = 400;
|
||||
|
||||
constructor(message: string = 'Bad Request') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class UnauthorizedError extends CustomError {
|
||||
static code = 401;
|
||||
|
||||
constructor(message: string = 'Unauthorized') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends CustomError {
|
||||
static code = 403;
|
||||
|
||||
constructor(message: string = 'Forbidden') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends CustomError {
|
||||
static code = 404;
|
||||
|
||||
constructor(message: string = 'Not Found') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends CustomError {
|
||||
static code = 500;
|
||||
|
||||
constructor(message: string = 'Internal Server Error') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
67
server/src/utils/ffmpeg.utils.ts
Normal file
67
server/src/utils/ffmpeg.utils.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {Resolution} from '@transmute/common';
|
||||
import {execSync} from 'child_process';
|
||||
|
||||
/**
|
||||
* Use FFProbe to look up the encoding of a specific stream track
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* console.log(getEncoding('./sample.mp4', 'video'));
|
||||
* // Output: 'h264'
|
||||
*
|
||||
* console.log(getEncoding('./sample.mp4', 'audio', 1));
|
||||
* // Output: 'ACC'
|
||||
* ```
|
||||
*
|
||||
* @param {string} file Absolute or relative path to video file
|
||||
* @param {"audio" | "subtitle" | "video"} stream Type of stream to inspect
|
||||
* @param {number} track Index of stream (if multiple are available; for example, bilingual audio tracks)
|
||||
* @returns {string} The encoding algorithm used
|
||||
*/
|
||||
export function getCodec(file: string, stream: 'audio' | 'subtitle' | 'video', track: number = 0): string {
|
||||
return execSync(
|
||||
`ffprobe -v error -select_streams "${stream[0]}:${track}" -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "${file}"`,
|
||||
{encoding: 'utf-8', shell: 'cmd'}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch resolution of video. Can either provide the real resolution or the closest standard resolution.
|
||||
*
|
||||
* @param {string} file Absolute or relative path to file
|
||||
* @param {boolean} real Return the real resolution or the closest standard
|
||||
* @returns {string} If real enabled, will return XY (1920x1020), if disabled, will return closes resolution tag (1080p)
|
||||
*/
|
||||
export function getResolution(file: string, real?: boolean): string {
|
||||
const fileRes = execSync(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "${file}"`,
|
||||
{encoding: 'utf-8', shell: 'cmd'});
|
||||
if(real) return fileRes;
|
||||
const y = Number(fileRes.split(',')[1]);
|
||||
return <string>Object.keys(Resolution).reduce((best:[string, number], res: string) => {
|
||||
const diff = Math.abs(y - Resolution[res]);
|
||||
return (best == null || best[1] > diff) ? [res, diff] : best;
|
||||
}, null)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Use FFProbe to get an ordered list of available tracks for a given stream, identified by the tagged language
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* console.log(getStreams('./sample.mp4', 'audio'));
|
||||
* // Output: ['eng', 'fr', 'unk']
|
||||
* ```
|
||||
*
|
||||
* @param {string} file Absolute or relative path to video file
|
||||
* @param {"audio" | "subtitle" | "video"} stream Type of stream to inspect
|
||||
* @returns {string[]} Ordered list of available tracks & their respective tagged language
|
||||
*/
|
||||
export function getStreams(file: string, stream: 'audio' | 'subtitle' | 'video'): string[] {
|
||||
const resp = execSync(
|
||||
`ffprobe -v error -select_streams ${stream[0]} -show_entries stream=index:stream_tags=language -of csv=p=0 "${file}"`,
|
||||
{encoding: 'utf-8', shell: 'cmd'});
|
||||
return !resp ? [] : resp.trim().split('\n').map(text => {
|
||||
const split = text.split(',');
|
||||
return !!split[1] ? split[1].trim() : 'unk'
|
||||
});
|
||||
}
|
55
server/src/utils/file.utils.ts
Normal file
55
server/src/utils/file.utils.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import {join} from 'path';
|
||||
|
||||
/**
|
||||
* Calculate checksum of file
|
||||
*
|
||||
* @param {string} path Path to file
|
||||
* @returns {string} md5 checksum
|
||||
*/
|
||||
export function createChecksum(path: string): Promise<string> {
|
||||
return new Promise(function (res, rej) {
|
||||
const hash = crypto.createHash('md5');
|
||||
const input = fs.createReadStream(path);
|
||||
|
||||
input.on('error', rej);
|
||||
input.on('data', (chunk) => hash.update(chunk));
|
||||
input.on('close', () => res(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Break apart a relative or absolute path to a file into it's individual parts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* console.log(parseFilename('/some/path/sample.mp4'));
|
||||
* // Output: {path: '/some/path/', fileName: 'sample.mp4', baseName: 'sample', extension: 'mp4'}
|
||||
* ```
|
||||
*
|
||||
* @param {string} file Absolute or relative path to a file
|
||||
* @returns {{path: any, fileName: any, extension: any, baseName: any}} The components that makeup the given file path
|
||||
*/
|
||||
export function parseFilePath(file: string): {path: string, fileName: string, baseName: string, extension: string} {
|
||||
const matches: any = /^(?<path>.*?)(?<fileName>(?<baseName>[a-zA-Z0-9_\-\.\(\)\s]+)\.(?<extension>[a-zA-Z0-9]+))$/.exec(file);
|
||||
return {
|
||||
path: matches?.groups?.['path'],
|
||||
baseName: matches?.groups?.['baseName'],
|
||||
fileName: matches?.groups?.['fileName'],
|
||||
extension: matches?.groups?.['extension'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively search a directory for files
|
||||
*
|
||||
* @param {string} path Starting path
|
||||
* @returns {string[]} List of discovered files
|
||||
*/
|
||||
export function deepSearch(path: string): string[] {
|
||||
return fs.readdirSync(path).reduce((found, file) => {
|
||||
const filePath = join(path, file), isFile = fs.lstatSync(filePath).isFile();
|
||||
return [...found, ...(isFile ? [filePath] : deepSearch(filePath))];
|
||||
}, []);
|
||||
}
|
4
server/src/utils/sql.utils.ts
Normal file
4
server/src/utils/sql.utils.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function whereBuilder<T>(query: T, where: object): T {
|
||||
return !where ? query :
|
||||
Object.entries(where).reduce((qb, [key, val]) => (<any>query).where(key, '=', val), query);
|
||||
}
|
26
server/tsconfig.json
Normal file
26
server/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"module": "CommonJS",
|
||||
"esModuleInterop": true,
|
||||
"target": "es6",
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "node",
|
||||
"inlineSourceMap": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": [
|
||||
"node_modules/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"dom",
|
||||
"es2021"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"migrations/**/*"
|
||||
]
|
||||
}
|
0
worker/.gitkeep
Normal file
0
worker/.gitkeep
Normal file
Loading…
Reference in New Issue
Block a user