This commit is contained in:
Zakary Timson 2023-08-14 14:36:45 -04:00
commit b5966f98b2
94 changed files with 21124 additions and 0 deletions

16
.editorconfig Normal file
View 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
View File

@ -0,0 +1,6 @@
# IDEs and editors
.idea
.vscode
# Other
test

20
.gitmodules vendored Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
client/package.json Normal file
View 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"
]
}

View 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 {}

View File

@ -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>

View File

@ -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 { }
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -0,0 +1,6 @@
<h2>{{title}}</h2>
<canvas baseChart
[data]="dataset"
[options]="options"
type="doughnut">
</canvas>

View 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}]};
}
}
}

View File

@ -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>

View File

@ -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 { }
}

View 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>

View 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)'}});
}
}

View 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()
);
}
}

View 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();
}
}

View 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);
// }
// }

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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};
}
}

View 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 { }

View File

@ -0,0 +1,2 @@
export * from './form-helper.module';
export * from './components/form-boilerplate/form-boilerplate.component';

View File

@ -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();
}
}

View 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;

View 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 {}

View 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]}`;
}
}

View 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(); }
}

View 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>

View File

View 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(', ') : '';
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,4 @@
export const environment = {
apiUrl: '{{API_URL}}',
production: true,
};

View File

@ -0,0 +1,4 @@
export const environment = {
apiUrl: 'http://localhost:5000',
production: true,
}

18
client/src/index.html Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
# IDEs
.idea
.vscode
# Artifacts
coverage
dist
junit.xml
node_modules

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
common/package.json Normal file
View 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
View 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';

View 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
View File

@ -0,0 +1,8 @@
import {File} from 'buffer';
export type JobType = 'healthcheck' | 'transcode'
export type Job = {
type: JobType,
file: File
}

View File

@ -0,0 +1,6 @@
export enum Languages {
eng = 'English',
fre = 'French',
spa = 'Spanish',
unk = 'Unknown'
}

View 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;
}

View 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
}

View 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;
}

View 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);
}
}

View 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]));
}

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

13
server/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# IDEs
.idea
.vscode
# Artifacts
node_modules
dist
# Databases
*.db
*.db3
*.sqlite
*.sqlite3

2
server/.npmrc Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

30
server/package.json Normal file
View 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"
}
}

View 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());
}));
}
}

View 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'])))));
}
}

View 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
View 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}`));
})();

View 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()
})
}
}

View 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();
}

View 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]));
}));
}
}

View 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(() => {});
}
}

View 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}), {});
}
}

View 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);
}
}

View 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);
}
}

View 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,
});
}
}

View 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);
// }
// }

View 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')
}
}
}

View File

@ -0,0 +1,3 @@
export class WorkerService {
workers = [];
}

View 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;
}
}

View 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'
});
}

View 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))];
}, []);
}

View 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
View 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
View File