From 2e621a476a3725f11ec6a25dfbd8469b3db74a64 Mon Sep 17 00:00:00 2001 From: stratuma Date: Thu, 20 Jun 2024 15:16:42 +0200 Subject: [PATCH] added chapter support --- .../routes/crunchyroll/crunchyroll.service.ts | 34 +++++++++- src/api/routes/service/service.service.ts | 32 ++++++++-- src/api/services/chapter.ts | 63 +++++++++++++++++++ src/api/types/crunchyroll.ts | 42 +++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/api/services/chapter.ts diff --git a/src/api/routes/crunchyroll/crunchyroll.service.ts b/src/api/routes/crunchyroll/crunchyroll.service.ts index 7bbf9f8..2303b69 100644 --- a/src/api/routes/crunchyroll/crunchyroll.service.ts +++ b/src/api/routes/crunchyroll/crunchyroll.service.ts @@ -1,6 +1,6 @@ import { messageBox } from '../../../electron/background' import { server } from '../../api' -import { VideoPlaylist, VideoPlaylistNoGEO } from '../../types/crunchyroll' +import { VideoMetadata, VideoPlaylist, VideoPlaylistNoGEO } from '../../types/crunchyroll' import { useFetch } from '../useFetch' import { parse as mpdParse } from 'mpd-parser' import { checkProxies, loggedInCheck } from '../service/service.service' @@ -799,6 +799,38 @@ export async function crunchyGetPlaylistMPD(q: string, geo: string | undefined) } } +// Crunchyroll Metadata Fetch +export async function crunchyGetMetadata(q: string) { + const headers = { + 'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27' + } + + try { + const response = await fetch(`https://static.crunchyroll.com/skip-events/production/${q}.json`, { + method: 'GET', + headers: headers + }) + + if (response.ok) { + return await JSON.parse(await response.text()) as VideoMetadata + } else { + const error = await response.text() + + messageBox('error', ['Cancel'], 2, 'Failed to get Crunchyroll Metadata', 'Failed to get Crunchyroll Metadata', error) + server.logger.log({ + level: 'error', + message: 'Failed to get Crunchyroll Metadata', + error: error, + timestamp: new Date().toISOString(), + section: 'metadataCrunchyrollFetch' + }) + throw new Error(error) + } + } catch (e) { + throw new Error(e as string) + } +} + export async function getAccountInfo() { const account = await loggedInCheck('CR') diff --git a/src/api/routes/service/service.service.ts b/src/api/routes/service/service.service.ts index d36e151..8e5cb0f 100644 --- a/src/api/routes/service/service.service.ts +++ b/src/api/routes/service/service.service.ts @@ -4,7 +4,7 @@ import { concatenateTSFiles } from '../../services/concatenate' import { checkFileExistence, createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder' import { downloadADNSub, downloadCRSub } from '../../services/subs' import { CrunchyEpisode } from '../../types/crunchyroll' -import { checkAccountMaxStreams, crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service' +import { checkAccountMaxStreams, crunchyGetMetadata, crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service' import fs from 'fs' var cron = require('node-cron') import { Readable } from 'stream' @@ -21,6 +21,7 @@ const mp4e = getMP4DecryptPath() import util from 'util' import settings from 'electron-settings' import { server } from '../../api' +import { createChapterFile } from '../../services/chapter' const exec = util.promisify(require('child_process').exec) // Get All Accounts @@ -446,7 +447,7 @@ export async function downloadADNPlaylist( return } - await mergeVideoFile(file as string, [], subss, seasonFolder, `${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season} Episode ${episode}`, format, downloadID) + await mergeVideoFile(file as string, null, [], subss, seasonFolder, `${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season} Episode ${episode}`, format, downloadID) await updatePlaylistByID(downloadID, 'completed') @@ -525,6 +526,8 @@ export async function downloadCrunchyrollPlaylist( const videoFolder = await createFolder() + const chapterFolder = await createFolder() + var seasonFolderNaming = (await settings.get('SeasonTemp')) as string if (!seasonFolderNaming) { @@ -695,6 +698,18 @@ export async function downloadCrunchyrollPlaylist( await updatePlaylistByID(downloadID, 'downloading video') + const chapterDownload = async () => { + const metadata = await crunchyGetMetadata(e) + + if (!metadata.intro && !metadata.credits && !metadata.preview && !metadata.recap) { + return null + } + + const chapterPath = await createChapterFile(metadata, chapterFolder) + + return chapterPath + } + const subDownload = async () => { const sbs: Array = [] for (const sub of subDownloadList) { @@ -1046,7 +1061,7 @@ export async function downloadCrunchyrollPlaylist( return file } - const [subss, audios, file] = await Promise.all([subDownload(), audioDownload(), downloadVideo()]) + const [chapter, subss, audios, file] = await Promise.all([chapterDownload(), subDownload(), audioDownload(), downloadVideo()]) if (!audios) return @@ -1068,13 +1083,14 @@ export async function downloadCrunchyrollPlaylist( .replace('{episodeNumberDD}', episode ? episode.toString().padStart(2, '0') : episode_string) .replace('{quality}', quality.toString() + 'p') - await mergeVideoFile(file as string, audios, subss, seasonFolder, episodeNaming, format, downloadID) + await mergeVideoFile(file as string, chapter, audios, subss, seasonFolder, episodeNaming, format, downloadID) await updatePlaylistByID(downloadID, 'completed') await deleteFolder(videoFolder) await deleteFolder(subFolder) await deleteFolder(audioFolder) + await deleteFolder(chapterFolder) return playlist } @@ -1257,7 +1273,7 @@ async function mergeParts(parts: { filename: string; url: string }[], downloadID } } -async function mergeVideoFile(video: string, audios: Array, subs: Array, path: string, filename: string, format: 'mp4' | 'mkv', downloadID: number) { +async function mergeVideoFile(video: string, chapter: string | null, audios: Array, subs: Array, path: string, filename: string, format: 'mp4' | 'mkv', downloadID: number) { const locales: Array<{ locale: string name: string @@ -1290,7 +1306,11 @@ async function mergeVideoFile(video: string, audios: Array, subs: Array< var output = Ffmpeg().setFfmpegPath(ffmpegP.ffmpeg).setFfprobePath(ffmpegP.ffprobe) var ffindex = 1 output.addInput(video) - var options = ['-map_metadata -1', '-metadata:s:v:0 VENDOR_ID=', '-metadata:s:v:0 language=', '-c copy', '-map 0'] + if (chapter) { + output.addInput(chapter) + ffindex++ + } + var options = ['-map_metadata 1', '-metadata:s:v:0 VENDOR_ID=', '-metadata:s:v:0 language=', '-c copy', '-map 0'] if (format === 'mp4') { options.push('-c:s mov_text') } diff --git a/src/api/services/chapter.ts b/src/api/services/chapter.ts new file mode 100644 index 0000000..b684903 --- /dev/null +++ b/src/api/services/chapter.ts @@ -0,0 +1,63 @@ +import path from 'path' +import fs from 'fs/promises' +import { VideoMetadata } from '../types/crunchyroll' +import { server } from '../api' + +function formatTimeFFMPEG(seconds: number) { + return seconds * 1000 +} + +export async function createChapterFile(rawchapters: VideoMetadata, dir: string) { + const filepath = path.join(dir, 'chapters.txt') + + var chapters: string[] = [] + + chapters.push(';FFMETADATA1') + + if (rawchapters.intro && rawchapters.intro.type && rawchapters.intro.start && rawchapters.intro.end) { + chapters.push('[CHAPTER]') + chapters.push('TIMEBASE=1/1000') + chapters.push(`START=${formatTimeFFMPEG(rawchapters.intro.start)}`) + chapters.push(`END=${formatTimeFFMPEG(rawchapters.intro.end) - 1}`) + chapters.push('title=Intro') + } + + if (rawchapters.credits && rawchapters.credits.type && rawchapters.credits.start && rawchapters.credits.end) { + chapters.push('[CHAPTER]') + chapters.push('TIMEBASE=1/1000') + chapters.push(`START=${formatTimeFFMPEG(rawchapters.credits.start)}`) + chapters.push(`END=${formatTimeFFMPEG(rawchapters.credits.end) - 1}`) + chapters.push('title=Credits') + } + + if (rawchapters.preview && rawchapters.preview.type && rawchapters.preview.start && rawchapters.preview.end) { + chapters.push('[CHAPTER]') + chapters.push('TIMEBASE=1/1000') + chapters.push(`START=${formatTimeFFMPEG(rawchapters.preview.start)}`) + chapters.push(`END=${formatTimeFFMPEG(rawchapters.preview.end) - 1}`) + chapters.push('title=Preview') + } + + if (rawchapters.recap && rawchapters.recap.type && rawchapters.recap.start && rawchapters.recap.end) { + chapters.push('[CHAPTER]') + chapters.push('TIMEBASE=1/1000') + chapters.push(`START=${formatTimeFFMPEG(rawchapters.recap.start)}`) + chapters.push(`END=${formatTimeFFMPEG(rawchapters.recap.end) - 1}`) + chapters.push('title=Recap') + } + + try { + await fs.writeFile(filepath, chapters.join('\r\n'), 'utf-8') + } catch (e) { + console.error(`Error occurred during chapter writing:`, e) + server.logger.log({ + level: 'error', + message: `Error occurred during chapter writing:`, + error: e, + timestamp: new Date().toISOString(), + section: 'crunchyrollWritingProcessChapter' + }) + } + + return filepath +} diff --git a/src/api/types/crunchyroll.ts b/src/api/types/crunchyroll.ts index 9e18f81..bba6777 100644 --- a/src/api/types/crunchyroll.ts +++ b/src/api/types/crunchyroll.ts @@ -189,3 +189,45 @@ export interface VideoPlaylistNoGEO { }> geo: string | undefined } + + +export interface VideoMetadata { + intro: { + approverId: string, + distributionNumber: string, + end: number, + seriesId: string, + start: number, + title: string, + type: string + } + credits: { + approverId: string, + distributionNumber: string, + end: number, + seriesId: string, + start: number, + title: string, + type: string + } + preview: { + approverId: string, + distributionNumber: string, + end: number, + seriesId: string, + start: number, + title: string, + type: string + } + recap: { + approverId: string, + distributionNumber: string, + end: number, + seriesId: string, + start: number, + title: string, + type: string + } + lastUpdated: Date, + mediaId: string +} \ No newline at end of file