refactor(extract-subs): move to apps and split up code better
All checks were successful
Discord / discord commits (push) Has been skipped
All checks were successful
Discord / discord commits (push) Has been skipped
This commit is contained in:
parent
b5d17e6307
commit
655eac9ad6
19 changed files with 273 additions and 235 deletions
|
@ -10,5 +10,6 @@
|
|||
type = "app";
|
||||
};
|
||||
in {
|
||||
extract-subs = mkApp ./extract-subs;
|
||||
updateFlake = mkApp ./update;
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
use flake $FLAKE#subtitles-dev
|
||||
npm ci
|
32
apps/extract-subs/default.nix
Normal file
32
apps/extract-subs/default.nix
Normal 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;
|
||||
}
|
|
@ -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',
|
Binary file not shown.
22
apps/extract-subs/package.json
Normal file
22
apps/extract-subs/package.json
Normal 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"
|
||||
}
|
||||
}
|
157
apps/extract-subs/src/app.ts
Normal file
157
apps/extract-subs/src/app.ts
Normal 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);
|
||||
}
|
8
apps/extract-subs/src/ffprobe.ts
Normal file
8
apps/extract-subs/src/ffprobe.ts
Normal 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);
|
||||
});
|
||||
});
|
10
apps/extract-subs/tsconfig.json
Normal file
10
apps/extract-subs/tsconfig.json
Normal 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
25
apps/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
use flake $FLAKE#node
|
||||
npm ci
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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} "$@"
|
||||
'';
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue