refactor(extract-subs): move to apps and split up code better
All checks were successful
Discord / discord commits (push) Has been skipped

This commit is contained in:
matt1432 2024-11-15 11:37:58 -05:00
parent b5d17e6307
commit 655eac9ad6
19 changed files with 273 additions and 235 deletions

View file

@ -10,5 +10,6 @@
type = "app";
};
in {
extract-subs = mkApp ./extract-subs;
updateFlake = mkApp ./update;
}

View file

@ -1 +1,2 @@
use flake $FLAKE#subtitles-dev
npm ci

View file

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

View file

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

View file

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

View file

@ -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<void> => {
// 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);
}

View file

@ -0,0 +1,8 @@
import Ffmpeg from 'fluent-ffmpeg';
export default (videoPath: string) => new Promise<Ffmpeg.FfprobeData>((resolve) => {
Ffmpeg.ffprobe(videoPath, (_e, data) => {
resolve(data);
});
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../tsconfig.json",
"includes": [
"*.ts",
"**/*.ts",
"*.js",
"**/*.js"
]
}

25
apps/tsconfig.json Normal file
View file

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

View file

@ -1 +1,2 @@
use flake $FLAKE#node
npm ci

View file

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

View file

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

View file

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

View file

@ -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} "$@"
'';
}

View file

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

View file

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

View file

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