/ packages / tools / src / import.ts
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