diff --git a/apps/default.nix b/apps/default.nix index 62b603e5..17d8cb92 100644 --- a/apps/default.nix +++ b/apps/default.nix @@ -10,5 +10,6 @@ type = "app"; }; in { + extract-subs = mkApp ./extract-subs; updateFlake = mkApp ./update; } diff --git a/devices/nos/modules/subtitles/extract-subs/.envrc b/apps/extract-subs/.envrc similarity index 81% rename from devices/nos/modules/subtitles/extract-subs/.envrc rename to apps/extract-subs/.envrc index e74546be..ceec897a 100644 --- a/devices/nos/modules/subtitles/extract-subs/.envrc +++ b/apps/extract-subs/.envrc @@ -1 +1,2 @@ use flake $FLAKE#subtitles-dev +npm ci diff --git a/apps/extract-subs/default.nix b/apps/extract-subs/default.nix new file mode 100644 index 00000000..eabb774d --- /dev/null +++ b/apps/extract-subs/default.nix @@ -0,0 +1,32 @@ +{ + lib, + buildNpmPackage, + ffmpeg-full, + makeWrapper, + nodejs_latest, + ... +}: let + inherit (lib) concatMapStringsSep getBin; + + packageJSON = builtins.fromJSON (builtins.readFile ./package.json); +in + buildNpmPackage rec { + pname = packageJSON.name; + inherit (packageJSON) version; + + src = ./.; + npmDepsHash = "sha256-edIAvY03eA3hqPHjAXz8pq3M5NzekOAYAR4o7j/Wf5Y="; + + runtimeInputs = [ + ffmpeg-full + ]; + nativeBuildInputs = [makeWrapper]; + + postInstall = '' + wrapProgram $out/bin/${pname} \ + --prefix PATH : ${concatMapStringsSep ":" (p: getBin p) runtimeInputs} + ''; + + nodejs = nodejs_latest; + meta.mainProgram = pname; + } diff --git a/devices/nos/modules/subtitles/extract-subs/eslint.config.ts b/apps/extract-subs/eslint.config.ts similarity index 97% rename from devices/nos/modules/subtitles/extract-subs/eslint.config.ts rename to apps/extract-subs/eslint.config.ts index 0a3eea06..48b1f19a 100644 --- a/devices/nos/modules/subtitles/extract-subs/eslint.config.ts +++ b/apps/extract-subs/eslint.config.ts @@ -30,6 +30,13 @@ export default tseslint.config({ 'class-methods-use-this': 'off', '@stylistic/no-multiple-empty-lines': 'off', '@stylistic/jsx-indent-props': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'error', + '@stylistic/indent-binary-ops': 'off', + '@stylistic/max-statements-per-line': [ + 'error', + { max: 2 }, + ], // Pre-flat config '@typescript-eslint/no-unused-vars': [ @@ -64,12 +71,6 @@ export default tseslint.config({ ], }, ], - 'no-use-before-define': [ - 'error', - { - functions: false, - }, - ], 'block-scoped-var': [ 'error', ], @@ -255,6 +256,7 @@ export default tseslint.config({ '@stylistic/brace-style': [ 'warn', 'stroustrup', + { allowSingleLine: true }, ], '@stylistic/comma-dangle': [ 'warn', diff --git a/devices/nos/modules/subtitles/extract-subs/package-lock.json b/apps/extract-subs/package-lock.json similarity index 75% rename from devices/nos/modules/subtitles/extract-subs/package-lock.json rename to apps/extract-subs/package-lock.json index 06a022d5..91222e9f 100644 Binary files a/devices/nos/modules/subtitles/extract-subs/package-lock.json and b/apps/extract-subs/package-lock.json differ diff --git a/apps/extract-subs/package.json b/apps/extract-subs/package.json new file mode 100644 index 00000000..5369632c --- /dev/null +++ b/apps/extract-subs/package.json @@ -0,0 +1,22 @@ +{ + "name": "extract-subs", + "version": "0.0.0", + "bin": "out/bin/app.cjs", + "type": "module", + "scripts": { + "build": "node_ver=$(node -v); esbuild src/app.ts --bundle --platform=node --target=\"node${node_ver:1:2}\" --outfile=out/bin/app.cjs" + }, + "dependencies": { + "@eslint/js": "9.14.0", + "@stylistic/eslint-plugin": "2.10.1", + "@types/fluent-ffmpeg": "2.1.27", + "@types/node": "22.9.0", + "esbuild": "0.24.0", + "eslint": "9.14.0", + "eslint-plugin-jsdoc": "50.5.0", + "fluent-ffmpeg": "2.1.3", + "jiti": "2.4.0", + "typescript": "5.6.3", + "typescript-eslint": "8.14.0" + } +} diff --git a/apps/extract-subs/src/app.ts b/apps/extract-subs/src/app.ts new file mode 100644 index 00000000..21b7a716 --- /dev/null +++ b/apps/extract-subs/src/app.ts @@ -0,0 +1,157 @@ +import { spawnSync as spawn } from 'child_process'; + +import ffprobe from './ffprobe'; +import { ISO6393To1 } from './lang-codes'; + +/* Types */ +import { FfprobeStream } from 'fluent-ffmpeg'; + + +const SPAWN_OPTS = { + stdio: [process.stdin, process.stdout, process.stderr], +}; + + +/** + * These are the cli arguments + * + * @param videoPath the directory in which we want to sync the subtitles + * @param languages a comma-separated list of languages (3 letters) to sync the subtitles + */ +const video = process.argv[2]; +const languages = process.argv[3]?.split(','); + + +// Global Vars +const subIndexes: number[] = []; +let videoPath: string; +let baseName: string; + + +/** + * Gets the relative path to the subtitle file of a ffmpeg stream. + * + * @param sub the stream of the subtitles to extract + * @returns the path of the subtitle file + */ +const getSubPath = (sub: FfprobeStream): string => { + const language = ISO6393To1.get(sub.tags.language); + + const forced = sub.disposition?.forced === 0 ? + '' : + '.forced'; + + const hearingImpaired = sub.disposition?.hearing_impaired === 0 ? + '' : + '.sdh'; + + return `${baseName}${forced}.${language}${hearingImpaired}.srt`; +}; + +/** + * Removes all subtitles streams from the video file. + */ +const removeContainerSubs = (): void => { + spawn('mv', [ + videoPath, + `${videoPath}.bak`, + ], SPAWN_OPTS); + + spawn('ffmpeg', [ + '-i', `${videoPath}.bak`, + '-map', '0', + ...subIndexes.map((i) => ['-map', `-0:${i}`]).flat(), + '-c', 'copy', videoPath, + ], SPAWN_OPTS); + + spawn('rm', [ + `${videoPath}.bak`, + ], SPAWN_OPTS); +}; + +/** + * Extracts a sub of a video file to a subtitle file. + * + * @param sub the stream of the subtitles to extract + */ +const extractSub = (sub: FfprobeStream): void => { + const subFile = getSubPath(sub); + + spawn('ffmpeg', [ + '-i', videoPath, + '-map', `0:${sub.index}`, subFile, + ], SPAWN_OPTS); + + subIndexes.push(sub.index); +}; + +/** + * Sorts the list of streams to only keep subtitles + * that can be extracted. + * + * @param lang the language of the subtitles + * @param streams the streams + * @returns the streams that represent subtitles + */ +const findSubs = ( + lang: string, + streams: FfprobeStream[], +): FfprobeStream[] => { + const subs = streams.filter((s) => s.tags?.language && + s.tags.language === lang && + s.codec_type === 'subtitle'); + + const pgs = subs.filter((s) => s.codec_name === 'hdmv_pgs_subtitle'); + + // If we only have PGS subs, warn user + if (pgs.length === subs.length) { + console.warn(`No SRT subtitle tracks were found for ${lang}`); + } + + // Remove PGS streams from subs + return subs.filter((s) => s.codec_name !== 'hdmv_pgs_subtitle'); +}; + +/** + * Where the magic happens. + */ +const main = async(): Promise => { + // Get rid of video extension + baseName = videoPath.split('/').at(-1)!.replace(/\.[^.]*$/, ''); + + // ffprobe the video file to see available sub tracks + const data = await ffprobe(videoPath); + + if (!data?.streams) { + console.error('Couldn\'t find streams in video file'); + + return; + } + + // Check for languages wanted + languages.forEach((lang) => { + const subs = findSubs(lang, data.streams); + + if (subs.length === 0) { + console.warn(`No subtitle tracks were found for ${lang}`); + + return; + } + + // Extract all subs + subs.forEach((sub) => { extractSub(sub); }); + }); + + removeContainerSubs(); +}; + + +// Check if there are 2 params +if (video && languages) { + videoPath = video; + main(); +} +else { + console.error('Error: no argument passed'); + process.exit(1); +} diff --git a/apps/extract-subs/src/ffprobe.ts b/apps/extract-subs/src/ffprobe.ts new file mode 100644 index 00000000..efdc4df8 --- /dev/null +++ b/apps/extract-subs/src/ffprobe.ts @@ -0,0 +1,8 @@ +import Ffmpeg from 'fluent-ffmpeg'; + + +export default (videoPath: string) => new Promise((resolve) => { + Ffmpeg.ffprobe(videoPath, (_e, data) => { + resolve(data); + }); +}); diff --git a/devices/nos/modules/subtitles/extract-subs/lang-codes.ts b/apps/extract-subs/src/lang-codes.ts similarity index 100% rename from devices/nos/modules/subtitles/extract-subs/lang-codes.ts rename to apps/extract-subs/src/lang-codes.ts diff --git a/apps/extract-subs/tsconfig.json b/apps/extract-subs/tsconfig.json new file mode 100644 index 00000000..2c06e799 --- /dev/null +++ b/apps/extract-subs/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "includes": [ + "*.ts", + "**/*.ts", + "*.js", + "**/*.js" + ] +} diff --git a/apps/tsconfig.json b/apps/tsconfig.json new file mode 100644 index 00000000..e1cb9ba7 --- /dev/null +++ b/apps/tsconfig.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + // Env + "target": "ESNext", + "lib": [ + "ESNext" + ], + // Module + "module": "nodenext", + "moduleResolution": "bundler", + "baseUrl": ".", + // Emit + "noEmit": true, + "newLine": "LF", + // Interop + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + // Type Checking + "strict": true, + "noImplicitAny": false + } +} diff --git a/apps/update/.envrc b/apps/update/.envrc index c79171c6..fd3dddda 100644 --- a/apps/update/.envrc +++ b/apps/update/.envrc @@ -1 +1,2 @@ use flake $FLAKE#node +npm ci diff --git a/apps/update/src/app.ts b/apps/update/src/app.ts index 00e15031..24b6d179 100644 --- a/apps/update/src/app.ts +++ b/apps/update/src/app.ts @@ -1,12 +1,12 @@ import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; -import { parseArgs } from './lib.ts'; +import { parseArgs } from './lib'; -import { updateDocker } from './docker.ts'; -import { updateFirefoxAddons } from '././firefox.ts'; -import { updateFlakeInputs } from './flake.ts'; -import { updateCustomPackage, updateVuetorrent } from './misc.ts'; +import { updateDocker } from './docker'; +import { updateFirefoxAddons } from '././firefox'; +import { updateFlakeInputs } from './flake'; +import { updateCustomPackage, updateVuetorrent } from './misc'; /* Constants */ diff --git a/apps/update/src/misc.ts b/apps/update/src/misc.ts index 5b96c42d..9dbc501b 100644 --- a/apps/update/src/misc.ts +++ b/apps/update/src/misc.ts @@ -1,7 +1,7 @@ import { writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { parseFetchurl } from './lib.ts'; +import { parseFetchurl } from './lib'; /* Constants */ diff --git a/apps/update/tsconfig.json b/apps/update/tsconfig.json index 8d125129..2c06e799 100644 --- a/apps/update/tsconfig.json +++ b/apps/update/tsconfig.json @@ -1,27 +1,6 @@ { - "compilerOptions": { - // Env - "target": "ESNext", - "lib": ["ESNext"], - // Module - "module": "nodenext", - "moduleResolution": "nodenext", - "allowImportingTsExtensions": true, - "baseUrl": ".", - // Emit - "noEmit": true, - "newLine": "LF", - // Interop - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - // Type Checking - "strict": true, - "noImplicitAny": false, - "allowJs": true, - "checkJs": true - }, + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", "includes": [ "*.ts", "**/*.ts", diff --git a/devices/nos/modules/subtitles/extract-subs/default.nix b/devices/nos/modules/subtitles/extract-subs/default.nix deleted file mode 100644 index ba5abdf3..00000000 --- a/devices/nos/modules/subtitles/extract-subs/default.nix +++ /dev/null @@ -1,49 +0,0 @@ -{ - buildNpmPackage, - ffmpeg-full, - nodejs_20, - typescript, - writeShellApplication, - ... -}: let - pname = "extract-subs"; - - extract-subs = buildNpmPackage { - name = "${pname}-npm"; - src = ./.; - npmDepsHash = "sha256-WXkg4e5Nh3+haCbm+XJ1CB7rsA2uV/7eZUaOUl/NVk0="; - - nativeBuildInputs = [ - nodejs_20 - typescript - ]; - - buildPhase = '' - tsc -p tsconfig.json - ''; - - installPhase = '' - mkdir -p $out/bin - mv node_modules package.json $out - - echo '#!/usr/bin/env node' > $out/bin/${pname} - cat ./build/main.js >> $out/bin/${pname} - rm ./build/main.js - chmod +x $out/bin/${pname} - - mv ./build/**.js $out/bin - ''; - }; -in - writeShellApplication { - name = pname; - - runtimeInputs = [ - ffmpeg-full - extract-subs - ]; - - text = '' - exec ${pname} "$@" - ''; - } diff --git a/devices/nos/modules/subtitles/extract-subs/main.ts b/devices/nos/modules/subtitles/extract-subs/main.ts deleted file mode 100755 index 3467fd98..00000000 --- a/devices/nos/modules/subtitles/extract-subs/main.ts +++ /dev/null @@ -1,114 +0,0 @@ -import Ffmpeg from 'fluent-ffmpeg'; -import { spawnSync as spawn } from 'child_process'; - -import { ISO6393To1 } from './lang-codes'; - - -const SPAWN_OPTS = { - shell: true, - stdio: [process.stdin, process.stdout, process.stderr], -}; - -/** - * These are the cli arguments - * - * @param videoPath the directory in which we want to sync the subtitles - * @param languages a comma-separated list of languages (3 letters) to sync the subtitles - */ -const video = process.argv[2]; -const languages = process.argv[3]?.split(','); - - -const getSubPath = (baseName: string, sub: Ffmpeg.FfprobeStream): string => { - const language = ISO6393To1.get(sub.tags.language); - - const forced = sub.disposition?.forced === 0 ? - '' : - '.forced'; - - const hearingImpaired = sub.disposition?.hearing_impaired === 0 ? - '' : - '.sdh'; - - return `${baseName}${forced}.${language}${hearingImpaired}.srt`; -}; - -const main = (videoPath: string) => { - const subIndexes: number[] = []; - const baseName = videoPath.split('/').at(-1)!.replace(/\.[^.]*$/, ''); - - // ffprobe the video file to see available sub tracks - Ffmpeg.ffprobe(videoPath, (_e, data) => { - if (!data?.streams) { - console.error('Couldn\'t find streams in video file'); - - return; - } - - languages.forEach((lang) => { - let subs = data.streams.filter((s) => { - return s['tags'] && - s['tags']['language'] && - s['tags']['language'] === lang && - s.codec_type === 'subtitle'; - }); - - const pgs = subs.filter((s) => s.codec_name === 'hdmv_pgs_subtitle'); - - // If we only have PGS subs, warn user - if (pgs.length === subs.length) { - console.warn(`No SRT subtitle tracks were found for ${lang}`); - } - // Remove PGS streams from subs - subs = subs.filter((s) => s.codec_name !== 'hdmv_pgs_subtitle'); - - if (subs.length === 0) { - console.warn(`No subtitle tracks were found for ${lang}`); - - return; - } - - subs.forEach((sub) => { - const subFile = getSubPath(baseName, sub); - - // Extract subtitle - spawn('ffmpeg', [ - '-i', `'${videoPath}'`, - '-map', `"0:${sub.index}"`, `'${subFile}'`, - ], SPAWN_OPTS); - - subIndexes.push(sub.index); - }); - }); - - // Delete subtitles from video - spawn('mv', [ - `'${videoPath}'`, - `'${videoPath}.bak'`, - ], SPAWN_OPTS); - - spawn('ffmpeg', [ - '-i', `'${videoPath}.bak'`, - '-map', '0', - ...subIndexes.map((i) => [ - '-map', `-0:${i}`, - ]).flat(), - '-c', 'copy', `'${videoPath}'`, - ], SPAWN_OPTS); - - spawn('rm', [ - `'${videoPath}.bak'`, - ], SPAWN_OPTS); - }); -}; - -const escapePath = (p: string): string => p.replaceAll("'", "'\\''"); - -// Check if there are 2 params -if (video && languages) { - main(escapePath(video)); -} -else { - console.error('Error: no argument passed'); - process.exit(1); -} diff --git a/devices/nos/modules/subtitles/extract-subs/package.json b/devices/nos/modules/subtitles/extract-subs/package.json deleted file mode 100644 index 365d7343..00000000 --- a/devices/nos/modules/subtitles/extract-subs/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "devDependencies": { - "@eslint/js": "9.12.0", - "@stylistic/eslint-plugin": "2.9.0", - "@types/eslint__js": "8.42.3", - "@types/node": "22.7.5", - "eslint": "9.12.0", - "eslint-plugin-jsdoc": "50.3.2", - "fzf": "0.5.2", - "jiti": "2.3.3", - "typescript": "5.6.3", - "typescript-eslint": "8.8.1" - }, - "dependencies": { - "@types/fluent-ffmpeg": "2.1.27", - "fluent-ffmpeg": "2.1.3" - } -} diff --git a/devices/nos/modules/subtitles/extract-subs/tsconfig.json b/devices/nos/modules/subtitles/extract-subs/tsconfig.json deleted file mode 100644 index 41c8a16d..00000000 --- a/devices/nos/modules/subtitles/extract-subs/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNEXT", - "module": "commonjs", - "lib": [ - "ES2022" - ], - "outDir": "build", - "strict": true, - "moduleResolution": "node", - "baseUrl": ".", - "types": [ - "@types/fluent-ffmpeg" - ], - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true - } -}