added chapter support
This commit is contained in:
parent
ce3f4dcfdc
commit
2e621a476a
@ -1,6 +1,6 @@
|
|||||||
import { messageBox } from '../../../electron/background'
|
import { messageBox } from '../../../electron/background'
|
||||||
import { server } from '../../api'
|
import { server } from '../../api'
|
||||||
import { VideoPlaylist, VideoPlaylistNoGEO } from '../../types/crunchyroll'
|
import { VideoMetadata, VideoPlaylist, VideoPlaylistNoGEO } from '../../types/crunchyroll'
|
||||||
import { useFetch } from '../useFetch'
|
import { useFetch } from '../useFetch'
|
||||||
import { parse as mpdParse } from 'mpd-parser'
|
import { parse as mpdParse } from 'mpd-parser'
|
||||||
import { checkProxies, loggedInCheck } from '../service/service.service'
|
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() {
|
export async function getAccountInfo() {
|
||||||
const account = await loggedInCheck('CR')
|
const account = await loggedInCheck('CR')
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { concatenateTSFiles } from '../../services/concatenate'
|
|||||||
import { checkFileExistence, createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
|
import { checkFileExistence, createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
|
||||||
import { downloadADNSub, downloadCRSub } from '../../services/subs'
|
import { downloadADNSub, downloadCRSub } from '../../services/subs'
|
||||||
import { CrunchyEpisode } from '../../types/crunchyroll'
|
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'
|
import fs from 'fs'
|
||||||
var cron = require('node-cron')
|
var cron = require('node-cron')
|
||||||
import { Readable } from 'stream'
|
import { Readable } from 'stream'
|
||||||
@ -21,6 +21,7 @@ const mp4e = getMP4DecryptPath()
|
|||||||
import util from 'util'
|
import util from 'util'
|
||||||
import settings from 'electron-settings'
|
import settings from 'electron-settings'
|
||||||
import { server } from '../../api'
|
import { server } from '../../api'
|
||||||
|
import { createChapterFile } from '../../services/chapter'
|
||||||
const exec = util.promisify(require('child_process').exec)
|
const exec = util.promisify(require('child_process').exec)
|
||||||
|
|
||||||
// Get All Accounts
|
// Get All Accounts
|
||||||
@ -446,7 +447,7 @@ export async function downloadADNPlaylist(
|
|||||||
return
|
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')
|
await updatePlaylistByID(downloadID, 'completed')
|
||||||
|
|
||||||
@ -525,6 +526,8 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
const videoFolder = await createFolder()
|
const videoFolder = await createFolder()
|
||||||
|
|
||||||
|
const chapterFolder = await createFolder()
|
||||||
|
|
||||||
var seasonFolderNaming = (await settings.get('SeasonTemp')) as string
|
var seasonFolderNaming = (await settings.get('SeasonTemp')) as string
|
||||||
|
|
||||||
if (!seasonFolderNaming) {
|
if (!seasonFolderNaming) {
|
||||||
@ -695,6 +698,18 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
|
|
||||||
await updatePlaylistByID(downloadID, 'downloading video')
|
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 subDownload = async () => {
|
||||||
const sbs: Array<string> = []
|
const sbs: Array<string> = []
|
||||||
for (const sub of subDownloadList) {
|
for (const sub of subDownloadList) {
|
||||||
@ -1046,7 +1061,7 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
return file
|
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
|
if (!audios) return
|
||||||
|
|
||||||
@ -1068,13 +1083,14 @@ export async function downloadCrunchyrollPlaylist(
|
|||||||
.replace('{episodeNumberDD}', episode ? episode.toString().padStart(2, '0') : episode_string)
|
.replace('{episodeNumberDD}', episode ? episode.toString().padStart(2, '0') : episode_string)
|
||||||
.replace('{quality}', quality.toString() + 'p')
|
.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 updatePlaylistByID(downloadID, 'completed')
|
||||||
|
|
||||||
await deleteFolder(videoFolder)
|
await deleteFolder(videoFolder)
|
||||||
await deleteFolder(subFolder)
|
await deleteFolder(subFolder)
|
||||||
await deleteFolder(audioFolder)
|
await deleteFolder(audioFolder)
|
||||||
|
await deleteFolder(chapterFolder)
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
@ -1257,7 +1273,7 @@ async function mergeParts(parts: { filename: string; url: string }[], downloadID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeVideoFile(video: string, audios: Array<string>, subs: Array<string>, path: string, filename: string, format: 'mp4' | 'mkv', downloadID: number) {
|
async function mergeVideoFile(video: string, chapter: string | null, audios: Array<string>, subs: Array<string>, path: string, filename: string, format: 'mp4' | 'mkv', downloadID: number) {
|
||||||
const locales: Array<{
|
const locales: Array<{
|
||||||
locale: string
|
locale: string
|
||||||
name: string
|
name: string
|
||||||
@ -1290,7 +1306,11 @@ async function mergeVideoFile(video: string, audios: Array<string>, subs: Array<
|
|||||||
var output = Ffmpeg().setFfmpegPath(ffmpegP.ffmpeg).setFfprobePath(ffmpegP.ffprobe)
|
var output = Ffmpeg().setFfmpegPath(ffmpegP.ffmpeg).setFfprobePath(ffmpegP.ffprobe)
|
||||||
var ffindex = 1
|
var ffindex = 1
|
||||||
output.addInput(video)
|
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') {
|
if (format === 'mp4') {
|
||||||
options.push('-c:s mov_text')
|
options.push('-c:s mov_text')
|
||||||
}
|
}
|
||||||
|
63
src/api/services/chapter.ts
Normal file
63
src/api/services/chapter.ts
Normal file
@ -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
|
||||||
|
}
|
@ -189,3 +189,45 @@ export interface VideoPlaylistNoGEO {
|
|||||||
}>
|
}>
|
||||||
geo: string | undefined
|
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
|
||||||
|
}
|
Reference in New Issue
Block a user