diff --git a/@types/crunchyChapters.d.ts b/@types/crunchyChapters.d.ts new file mode 100644 index 0000000..6a4ad37 --- /dev/null +++ b/@types/crunchyChapters.d.ts @@ -0,0 +1,16 @@ +export interface CrunchyChapters { + [key: string]: CrunchyChapter; + lastUpdate: Date; + mediaId: string; +} + +export interface CrunchyChapter { + approverId: string; + distributionNumber: string; + end: number; + start: number; + title: string; + seriesId: string; + new: boolean; + type: string; +} diff --git a/@types/crunchyTypes.d.ts b/@types/crunchyTypes.d.ts index 504cbc5..cd96708 100644 --- a/@types/crunchyTypes.d.ts +++ b/@types/crunchyTypes.d.ts @@ -90,6 +90,10 @@ export type DownloadedMedia = { lang: LanguageItem, path: string, isPrimary?: boolean +} | { + type: 'Chapters', + lang: LanguageItem, + path: string } | ({ type: 'Subtitle', cc: boolean diff --git a/crunchy.ts b/crunchy.ts index e03c95c..3b9e846 100644 --- a/crunchy.ts +++ b/crunchy.ts @@ -40,6 +40,7 @@ import { CrunchyAndroidStreams } from './@types/crunchyAndroidStreams'; import { CrunchyAndroidEpisodes } from './@types/crunchyAndroidEpisodes'; import { parse } from './modules/module.transform-mpd'; import { CrunchyAndroidObject } from './@types/crunchyAndroidObject'; +import { CrunchyChapters, CrunchyChapter } from './@types/crunchyChapters'; export type sxItem = { language: langsData.LanguageItem, @@ -1174,7 +1175,55 @@ export default class Crunchy implements ServiceClass { if (mediaId.includes(':')) mediaId = mediaId.split(':')[1]; - // /cms/v2/BUCKET/crunchyroll/videos/MEDIAID/streams + const chapterRequest = await this.req.getData(`https://static.crunchyroll.com/skip-events/production/${mMeta.mediaId}.json`); + let chapterData: CrunchyChapters; + const compiledChapters: string[] = []; + if(!chapterRequest.ok || !chapterRequest.res){ + console.warn('Chapter request failed'); + } else { + chapterData = JSON.parse(chapterRequest.res.body); + const chapters: CrunchyChapter[] = []; + for (const chapter in chapterData) { + if (typeof chapterData[chapter] == 'object') { + chapters.push(chapterData[chapter]); + } + } + if (chapters.length > 0) { + chapters.sort((a, b) => a.start - b.start); + for (const chapter of chapters) { + const startTime = new Date(0), endTime = new Date(0); + startTime.setSeconds(chapter.start); + endTime.setSeconds(chapter.end); + const startFormatted = startTime.toISOString().substring(11, 19)+'.00'; + const endFormatted = endTime.toISOString().substring(11, 19)+'.00'; + if (chapter.type == 'intro') { + if (chapter.start > 0) { + compiledChapters.push( + `CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`, + `CHAPTER${(compiledChapters.length/2)+1}NAME=Prologue` + ); + } + compiledChapters.push( + `CHAPTER${(compiledChapters.length/2)+1}=${startFormatted}`, + `CHAPTER${(compiledChapters.length/2)+1}NAME=Opening` + ); + compiledChapters.push( + `CHAPTER${(compiledChapters.length/2)+1}=${endFormatted}`, + `CHAPTER${(compiledChapters.length/2)+1}NAME=Episode` + ); + } else { + compiledChapters.push( + `CHAPTER${(compiledChapters.length/2)+1}=${startFormatted}`, + `CHAPTER${(compiledChapters.length/2)+1}NAME=${chapter.type.charAt(0).toUpperCase() + chapter.type.slice(1)} Start` + ); + compiledChapters.push( + `CHAPTER${(compiledChapters.length/2)+1}=${endFormatted}`, + `CHAPTER${(compiledChapters.length/2)+1}NAME=${chapter.type.charAt(0).toUpperCase() + chapter.type.slice(1)} End` + ); + } + } + } + } let pbData = { total: 0, data: {}, meta: {} } as PlaybackData; if (options.apiType == 'android') { @@ -1797,6 +1846,32 @@ export default class Crunchy implements ServiceClass { console.info('Downloading skipped!'); } + if (compiledChapters.length > 0) { + try { + fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep); + const outFile = parseFileName(options.fileName + '.' + mMeta.lang?.name, variables, options.numbers, options.override).join(path.sep); + tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile); + const split = outFile.split(path.sep).slice(0, -1); + split.forEach((val, ind, arr) => { + const isAbsolut = path.isAbsolute(outFile as string); + if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val))) + fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)); + }); + const lang = langsData.languages.find(a => a.code === curStream?.audio_lang); + if (!lang) { + console.error(`Unable to find language for code ${curStream.audio_lang}`); + return; + } + fs.writeFileSync(`${tsFile}.txt`, compiledChapters.join('\r\n')); + files.push({ + path: `${tsFile}.txt`, + lang: lang, + type: 'Chapters' + }); + } catch { + console.error('Failed to write chapter file'); + } + } if(options.dlsubs.indexOf('all') > -1){ options.dlsubs = ['all']; @@ -1912,6 +1987,8 @@ export default class Crunchy implements ServiceClass { throw new Error('Never'); if (a.type === 'Audio') throw new Error('Never'); + if (a.type === 'Chapters') + throw new Error('Never'); return { file: a.path, language: a.language, @@ -1929,6 +2006,18 @@ export default class Crunchy implements ServiceClass { path: a.path, }; }), + chapters: data.filter(a => a.type === 'Chapters').map((a) : MergerInput => { + if (a.type === 'Video') + throw new Error('Never'); + if (a.type === 'Audio') + throw new Error('Never'); + if (a.type === 'Subtitle') + throw new Error('Never'); + return { + path: a.path, + lang: a.lang + }; + }), videoTitle: options.videoTitle, options: { ffmpeg: options.ffmpegOptions, diff --git a/modules/module.merger.ts b/modules/module.merger.ts index 5cf98f5..989667f 100644 --- a/modules/module.merger.ts +++ b/modules/module.merger.ts @@ -37,6 +37,7 @@ export type MergerOptions = { onlyVid: MergerInput[], onlyAudio: MergerInput[], subtitles: SubtitleInput[], + chapters?: MergerInput[], ccTag: string, output: string, videoTitle?: string, @@ -162,7 +163,7 @@ class Merger { args.push(`-i "${sub.file}"`); } - if (this.options.output.split('.').pop() === 'mkv') + if (this.options.output.split('.').pop() === 'mkv') { if (this.options.fonts) { let fontIndex = 0; for (const font of this.options.fonts) { @@ -170,6 +171,9 @@ class Merger { fontIndex++; } } + } + + //TODO: Make it possible for chapters to work with ffmpeg merging args.push(...metaData); args.push(...this.options.subtitles.map((_, subIndex) => `-map ${subIndex + index}`)); @@ -296,6 +300,7 @@ class Merger { '--no-subtitles', ); } + if (this.options.fonts && this.options.fonts.length > 0) { for (const f of this.options.fonts) { args.push('--attachment-name', f.name); @@ -308,6 +313,10 @@ class Merger { ); } + if (this.options.chapters && this.options.chapters.length > 0) { + args.push(`--chapters "${this.options.chapters[0].path}"`); + } + return args.join(' '); }; @@ -405,6 +414,7 @@ class Merger { public cleanUp() { this.options.onlyAudio.concat(this.options.onlyVid).concat(this.options.videoAndAudio).forEach(a => fs.unlinkSync(a.path)); + this.options.chapters?.forEach(a => fs.unlinkSync(a.path)); this.options.subtitles.forEach(a => fs.unlinkSync(a.file)); }