import.ts
1 import { create, globSource } from 'kubo-rpc-client'; 2 import yargs from 'yargs'; 3 import { hideBin } from 'yargs/helpers'; 4 import { exec } from 'child_process'; 5 import { input, confirm, select } from '@inquirer/prompts'; 6 import { ITempDir, tempDir } from './utils/tempDIr'; 7 import chalk from 'chalk'; 8 import { partitionArray, Regexes } from 'ipmc-core'; 9 import path, { basename } from 'path'; 10 import srt2vtt from 'srt2vtt'; 11 import fs from 'fs'; 12 13 type TStream = 'Video' | 'Audio' | 'Text'; 14 15 interface IStream { 16 type: TStream; 17 id: number; 18 lang?: string; 19 file: string; 20 forced?: boolean; 21 } 22 23 async function detectStreams(packager: string, file: string): Promise<IStream[]> { 24 return new Promise((resolve, reject) => { 25 exec(`${packager} input="${file}" --dump_stream_info`, (error, stdout, stderr) => { 26 if (error) { 27 console.error(stderr); 28 reject(error); 29 } else { 30 //console.debug(stdout); 31 const regex = /Stream \[(\d+)\] type: (\w+)\r?\n((?: [\w_]+: .+\r?\n)+)\r?\n/gm; 32 const matches = stdout.matchAll(regex); 33 const streams: IStream[] = []; 34 35 for (const match of matches) { 36 const stream: IStream = { 37 type: match[2] as TStream, 38 id: parseInt(match[1]), 39 file: file 40 }; 41 42 const langResult = /language: (\w+)/.exec(match[0]); 43 if (langResult && langResult[1] !== 'und') { 44 stream.lang = langResult[1]; 45 } 46 47 streams.push(stream); 48 } 49 50 resolve(streams); 51 } 52 }); 53 }); 54 } 55 56 async function packageStreams(packager: string, title: string, streams: IStream[], workdir: ITempDir): Promise<void> { 57 const folder = workdir.getPath(); 58 function mapStream(s: IStream): string { 59 const segmentDir = `${folder}/${s.type.toLowerCase()}${s.type == 'Video' || !s.lang ? '' : '/' + s.lang + (s.forced ? '.forced' : '')}`; 60 const options: { [key: string]: string; } = { 61 in: s.file, 62 stream: s.type.toLowerCase(), 63 init_segment: `${segmentDir}/init.mp4`, 64 segment_template: `${segmentDir}/$Number$.mp4`, 65 }; 66 if (s.lang) { 67 options.lang = s.lang; 68 } 69 if (s.type === 'Text' && s.forced !== undefined) { 70 options.forced_subtitle = s.forced ? '1' : '0'; 71 } 72 return `"${Object.entries(options).map(([key, value]) => key + '=' + value).join(',')}"`; 73 } 74 return new Promise((resolve, reject) => { 75 exec(`${packager} ${streams.map(mapStream).join(' ')} --generate_static_live_mpd --mpd_output "${folder}/${title}.mpd"`, (error, stdout, stderr) => { 76 if (error) { 77 console.error(stderr); 78 reject(error); 79 } else { 80 console.log(stdout); 81 resolve(); 82 } 83 }); 84 }); 85 } 86 87 async function getMovieMetadata(files: string[]): Promise<{ year: string; title: string; fileName: string; }> { 88 const movieData = files.map(file => Regexes.VideoFile('mp4').exec(path.basename(file))).find(r => r != null); 89 90 const title = await input({ message: 'Movie title?', default: movieData != null ? movieData[1] : undefined, required: true }); 91 const year = await input({ message: 'Year?', default: movieData != null ? movieData[2] : undefined, required: true }); 92 93 return { 94 title, 95 year, 96 fileName: `${title} (${year})` 97 }; 98 } 99 100 async function getEpisodeMetadata(files: string[]): Promise<{ seriesTitle: string; fileName: string; season: string; episode: string; episodeTitle?: string; }> { 101 function zeroPad(input: string) { 102 return String(input).padStart(2, '0'); 103 } 104 const seriesTitle = await input({ message: 'Series title?', required: true }); 105 const season = await input({ message: 'Season?', default: '1', required: true }); 106 const episode = await input({ message: 'Episode?', default: '1', required: true }); 107 const episodeTitle = await input({ message: 'Episode title?', required: false }); 108 109 return { 110 seriesTitle, 111 season, 112 episode, 113 episodeTitle, 114 fileName: `${seriesTitle} - S${zeroPad(season)}E${zeroPad(episode)}${episodeTitle && episodeTitle !== '' ? ` - ${episodeTitle}` : ''}` 115 }; 116 } 117 118 async function importFiles(files: string[]) { 119 const temp = tempDir(); 120 const outDir = tempDir(); 121 122 try { 123 const streams: IStream[] = []; 124 for (const file of files) { 125 let actualFile = file; 126 if (file.endsWith('.srt')) { 127 const srtData = fs.readFileSync(file); 128 actualFile = await new Promise((resolve, reject) => { 129 srt2vtt(srtData, (err, vttData) => { 130 if (err) { 131 reject(err); 132 } else { 133 const fn = path.join(temp.getPath(), basename(file, 'srt') + 'vtt'); 134 fs.writeFileSync(fn, vttData); 135 resolve(fn); 136 } 137 }); 138 }); 139 } 140 141 const streamsFromFile = await detectStreams(args.packager, actualFile); 142 streams.push(...streamsFromFile); 143 } 144 145 console.log(`Detected ${streams.length} streams, from ${files.length} files!`); 146 147 const mediaType = await select({ 148 message: 'Media Type', 149 choices: [ 150 'Movie', 151 'Episode', 152 ] 153 }); 154 const metaData = await (mediaType === 'Movie' ? getMovieMetadata(files) : getEpisodeMetadata(files)); 155 156 for (const stream of streams) { 157 const streamDisplayName = `${stream.id}|${stream.type}|${path.basename(stream.file)}`; 158 159 if (stream.type !== 'Video') { 160 stream.lang = await input({ 161 message: `[${streamDisplayName}] Language?`, 162 default: stream.lang, 163 required: true, 164 validate: (input) => Regexes.LangCheck.exec(input) != null 165 }); 166 } 167 168 if (stream.type === 'Text') { 169 stream.forced = await confirm({ 170 message: `[${streamDisplayName}] Forced?`, 171 default: false, 172 }); 173 } 174 } 175 176 console.log('Packaging...'); 177 await packageStreams(args.packager, metaData.fileName, streams, outDir); 178 console.log('Streams packaged!'); 179 180 const node = create({ url: args.ipfs }); 181 for await (const file of node.addAll(globSource(outDir.getPath(), '**'), { 182 pin: false, 183 wrapWithDirectory: true 184 })) { 185 if (file.path === '') { 186 console.log(`Added to ipfs ${chalk.green(file.cid)}`); 187 console.log(`Item ${chalk.blue(metaData.fileName)}`); 188 } 189 } 190 } finally { 191 outDir.clean(); 192 temp.clean(); 193 } 194 } 195 196 const args = yargs(hideBin(process.argv)) 197 .option('packager', { 198 alias: 'p', 199 describe: 'packager executable to use', 200 default: 'shaka-packager', 201 }) 202 .option('ipfs', { 203 describe: 'ipfs api url', 204 default: 'http://127.0.0.1:5002/api/v0' 205 }) 206 .array('file') 207 .alias('file', 'f') 208 .demandOption(['file']) 209 .help() 210 .parseSync(); 211 212 (async () => { 213 const paths = args.file as string[]; 214 215 const [directories, files] = partitionArray(paths, path => fs.lstatSync(path).isDirectory()); 216 217 for (const dir of directories) { 218 await importFiles(fs.readdirSync(dir).map(name => path.join(dir, name))); 219 } 220 221 if (files.length < 0) { 222 await importFiles(files); 223 } 224 225 console.log('Done!'); 226 })(); 227