Compare commits

...

8 commits

Author SHA1 Message Date
AnimeDL
d4d673a183 Minor fixes 2024-10-08 19:20:25 -07:00
AnimeDL
868c886449 Hopefully improve accuracy of --syncTiming 2024-10-08 18:47:24 -07:00
AnimeDL
8bb64a6612 Improve defaults for --syncTiming 2024-10-08 17:19:18 -07:00
AnimeDL
b623bf7fc0 Fix wording for Stream Request 2024-10-08 14:45:06 -07:00
AnimeDL
b788bc0639 Remove extra verbose debug logging
Also adds multithreading to cross correlation of audio, and minor code cleanup
2024-10-07 21:05:38 -07:00
AnimeDL
146c5f10a9 Allow --syncTiming to work on all files
Forgive me father for I have sinned (this code with it's "temporary" solutions).

This maps all downloaded "versions" so that each version can have it's delay applied as needed
2024-10-07 20:53:23 -07:00
AnimeDL
dfa0a31def Minor code cleanup 2024-10-07 19:45:59 -07:00
AnimeDL
561ccac6f1 Fix --syncTiming 2024-10-07 19:34:13 -07:00
11 changed files with 8287 additions and 6448 deletions

View file

@ -87,26 +87,6 @@ export type CrunchyEpMeta = {
image: string,
}
export type DownloadedMedia = {
type: 'Video',
lang: LanguageItem,
path: string,
isPrimary?: boolean
} | {
type: 'Audio',
lang: LanguageItem,
path: string,
isPrimary?: boolean
} | {
type: 'Chapters',
lang: LanguageItem,
path: string
} | ({
type: 'Subtitle',
signs: boolean,
cc: boolean
} & sxItem )
export type ParseItem = {
__class__?: string;
isSelected?: boolean,

37
@types/downloaderTypes.d.ts vendored Normal file
View file

@ -0,0 +1,37 @@
import type { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import type { LanguageItem } from '../modules/module.langsData';
export type sxItem = {
language: LanguageItem,
path: string,
file: string
title: string,
fonts: Font[]
}
export type DownloadedMedia = {
type: 'Video',
lang: LanguageItem,
path: string,
uncut?: boolean,
isPrimary?: boolean
} | {
type: 'Audio',
lang: LanguageItem,
path: string,
uncut?: boolean,
isPrimary?: boolean
} | {
type: 'Chapters',
lang: LanguageItem,
path: string
} | ({
type: 'Subtitle',
signs?: boolean,
cc: boolean
} & sxItem )
export type DownloadedMediaMap = {
version: string;
files: DownloadedMedia[];
}

View file

@ -48,14 +48,4 @@ export interface HidiveSubtitleInfo {
language: string;
cc: boolean;
url: string;
}
export type DownloadedMedia = {
type: 'Video',
lang: LanguageItem,
path: string,
uncut: boolean
} | ({
type: 'Subtitle',
cc: boolean
} & sxItem )
}

60
adn.ts
View file

@ -26,15 +26,14 @@ import parseFileName, { Variable } from './modules/module.filename';
import { AvailableFilenameVars } from './modules/module.args';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { sxItem } from './crunchy';
import { DownloadedMedia } from './@types/hidiveTypes';
import { ADNSearch, ADNSearchShow } from './@types/adnSearch';
import { ADNVideo, ADNVideos } from './@types/adnVideos';
import { ADNPlayerConfig } from './@types/adnPlayerConfig';
import { ADNStreams } from './@types/adnStreams';
import { ADNSubtitles } from './@types/adnSubtitles';
import type { ServiceClass } from './@types/serviceClassInterface';
import type { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
import type { ADNSearch, ADNSearchShow } from './@types/adnSearch';
import type { ADNVideo, ADNVideos } from './@types/adnVideos';
import type { ADNPlayerConfig } from './@types/adnPlayerConfig';
import type { ADNStreams } from './@types/adnStreams';
import type { ADNSubtitles } from './@types/adnSubtitles';
export default class AnimationDigitalNetwork implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
@ -316,7 +315,7 @@ export default class AnimationDigitalNetwork implements ServiceClass {
return { isOk: true, value: selEpsArr };
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], options: yargs.ArgvType) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
@ -325,9 +324,8 @@ export default class AnimationDigitalNetwork implements ServiceClass {
hasAudioStreams = true;
}
const merger = new Merger({
mediaMap,
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -337,8 +335,6 @@ export default class AnimationDigitalNetwork implements ServiceClass {
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -346,10 +342,6 @@ export default class AnimationDigitalNetwork implements ServiceClass {
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
@ -357,14 +349,10 @@ export default class AnimationDigitalNetwork implements ServiceClass {
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -417,7 +405,7 @@ export default class AnimationDigitalNetwork implements ServiceClass {
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
@ -446,6 +434,12 @@ export default class AnimationDigitalNetwork implements ServiceClass {
}
const files: DownloadedMedia[] = [];
const mediaMap: DownloadedMediaMap[] = [];
const fileMap: DownloadedMediaMap = {
version: data.id.toString(),
files: []
};
let dlFailed = false;
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
@ -710,6 +704,11 @@ export default class AnimationDigitalNetwork implements ServiceClass {
path: `${tsFile}.ts`,
lang: audDub
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: audDub
});
dlVideoOnce = true;
}
} else{
@ -773,7 +772,12 @@ export default class AnimationDigitalNetwork implements ServiceClass {
fs.writeFileSync(`${tsFile}.txt`, compiledChapters.join('\r\n'));
files.push({
path: `${tsFile}.txt`,
lang: langsData.languages.find(a=>a.code=='jpn'),
lang: langsData.languages.find(a=>a.code=='jpn')!,
type: 'Chapters'
});
fileMap.files.push({
path: `${tsFile}.txt`,
lang: langsData.languages.find(a=>a.code=='jpn')!,
type: 'Chapters'
});
} catch {
@ -899,6 +903,11 @@ export default class AnimationDigitalNetwork implements ServiceClass {
...sxData as sxItem,
cc: false
});
fileMap.files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
}
subIndex++;
}
@ -909,9 +918,12 @@ export default class AnimationDigitalNetwork implements ServiceClass {
console.info('Subtitles downloading skipped!');
}
mediaMap.push(fileMap);
return {
error: dlFailed,
data: files,
mediaMap,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}

66
ao.ts
View file

@ -27,14 +27,13 @@ import { AvailableFilenameVars } from './modules/module.args';
import { parse } from './modules/module.transform-mpd';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { AOSearchResult, AnimeOnegaiSearch } from './@types/animeOnegaiSearch';
import { AnimeOnegaiSeries } from './@types/animeOnegaiSeries';
import { AnimeOnegaiSeasons, Episode } from './@types/animeOnegaiSeasons';
import { DownloadedMedia } from './@types/hidiveTypes';
import { AnimeOnegaiStream } from './@types/animeOnegaiStream';
import { sxItem } from './crunchy';
import type { ServiceClass } from './@types/serviceClassInterface';
import type { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import type { AOSearchResult, AnimeOnegaiSearch } from './@types/animeOnegaiSearch';
import type { AnimeOnegaiSeries } from './@types/animeOnegaiSeries';
import type { AnimeOnegaiSeasons, Episode } from './@types/animeOnegaiSeasons';
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
import type { AnimeOnegaiStream } from './@types/animeOnegaiStream';
type parsedMultiDubDownload = {
data: {
@ -315,7 +314,7 @@ export default class AnimeOnegai implements ServiceClass {
return false;
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
@ -327,7 +326,7 @@ export default class AnimeOnegai implements ServiceClass {
return true;
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], options: yargs.ArgvType) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
@ -336,9 +335,8 @@ export default class AnimeOnegai implements ServiceClass {
hasAudioStreams = true;
}
const merger = new Merger({
mediaMap,
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -348,8 +346,6 @@ export default class AnimeOnegai implements ServiceClass {
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -357,10 +353,6 @@ export default class AnimeOnegai implements ServiceClass {
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
@ -368,14 +360,10 @@ export default class AnimeOnegai implements ServiceClass {
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -415,6 +403,7 @@ export default class AnimeOnegai implements ServiceClass {
public async downloadMediaList(medias: parsedMultiDubDownload, options: yargs.ArgvType) : Promise<{
data: DownloadedMedia[],
mediaMap: DownloadedMediaMap[],
fileName: string,
error: boolean
} | undefined> {
@ -434,6 +423,7 @@ export default class AnimeOnegai implements ServiceClass {
}
const files: DownloadedMedia[] = [];
const mediaMap: DownloadedMediaMap[] = [];
let subIndex = 0;
let dlFailed = false;
@ -441,6 +431,11 @@ export default class AnimeOnegai implements ServiceClass {
for (const media of medias.data) {
console.info(`Requesting: [E.${media.episode.ID}] ${mediaName}`);
const fileMap: DownloadedMediaMap = {
version: media.episode.ID.toString(),
files: []
};
const AuthHeaders = {
headers: {
@ -713,6 +708,11 @@ export default class AnimeOnegai implements ServiceClass {
path: `${tsFile}.video.mp4`,
lang: lang
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.video.mp4`,
lang: lang
});
}
}
@ -734,6 +734,11 @@ export default class AnimeOnegai implements ServiceClass {
path: `${tsFile}.audio.mp4`,
lang: lang
});
fileMap.files.push({
type: 'Audio',
path: `${tsFile}.audio.mp4`,
lang: lang
});
console.info('Decryption done for audio');
}
}
@ -747,6 +752,11 @@ export default class AnimeOnegai implements ServiceClass {
path: `${tsFile}.video.mp4`,
lang: lang
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.video.mp4`,
lang: lang
});
}
if (audioDownloaded) {
files.push({
@ -754,6 +764,11 @@ export default class AnimeOnegai implements ServiceClass {
path: `${tsFile}.audio.mp4`,
lang: lang
});
fileMap.files.push({
type: 'Audio',
path: `${tsFile}.audio.mp4`,
lang: lang
});
}
}
}
@ -795,6 +810,11 @@ export default class AnimeOnegai implements ServiceClass {
...sxData as sxItem,
cc: false
});
fileMap.files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
} else{
console.warn(`Failed to download subtitle: ${sxData.file}`);
}
@ -808,11 +828,13 @@ export default class AnimeOnegai implements ServiceClass {
else{
console.info('Subtitles downloading skipped!');
}
mediaMap.push(fileMap);
await this.sleep(options.waittime);
}
return {
error: dlFailed,
data: files,
mediaMap,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}

View file

@ -1,18 +1,17 @@
// build-in
// Built In
import path from 'path';
import fs from 'fs-extra';
import { randomUUID } from 'crypto';
// package program
// Package
import packageJson from './package.json';
// plugins
import { console } from './modules/log';
// Plugins
import shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
import streamdl, { M3U8Json } from './modules/hls-download';
import { exec } from './modules/sei-helper-fixes';
// custom modules
import { console } from './modules/log';
import * as fontsData from './modules/module.fontsData';
import * as langsData from './modules/module.langsData';
import * as yamlCfg from './modules/module.cfg-loader';
@ -20,40 +19,32 @@ import * as yargs from './modules/module.app-args';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import getKeys, { canDecrypt } from './modules/widevine';
//import vttConvert from './modules/module.vttconvert';
// args
// load req
import { domain, api } from './modules/module.api-urls';
import * as reqModule from './modules/module.fetch';
import { CrunchySearch } from './@types/crunchySearch';
import { CrunchyEpisodeList, CrunchyEpisode } from './@types/crunchyEpisodeList';
import { CrunchyDownloadOptions, CrunchyEpMeta, CrunchyMuxOptions, CrunchyMultiDownload, DownloadedMedia, ParseItem, SeriesSearch, SeriesSearchItem } from './@types/crunchyTypes';
import { ObjectInfo } from './@types/objectInfo';
import parseFileName, { Variable } from './modules/module.filename';
import { CrunchyStreams, PlaybackData, Subtitles } from './@types/playbackData';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import { AvailableFilenameVars, getDefault } from './modules/module.args';
import { AuthData, AuthResponse, Episode, ResponseBase, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { ServiceClass } from './@types/serviceClassInterface';
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, CrunchyOldChapter } from './@types/crunchyChapters';
import vtt2ass from './modules/module.vtt2ass';
import { CrunchyPlayStream } from './@types/crunchyPlayStreams';
import { CrunchyPlayStreams } from './@types/enums';
import { randomUUID } from 'node:crypto';
import streamdl, { M3U8Json } from './modules/hls-download';
import { exec } from './modules/sei-helper-fixes';
export type sxItem = {
language: langsData.LanguageItem,
path: string,
file: string
title: string,
fonts: Font[]
}
// Types
import type { CrunchyDownloadOptions, CrunchyEpMeta, CrunchyMuxOptions, CrunchyMultiDownload, ParseItem, SeriesSearch, SeriesSearchItem } from './@types/crunchyTypes';
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
import type { CrunchySearch } from './@types/crunchySearch';
import type { CrunchyEpisodeList, CrunchyEpisode } from './@types/crunchyEpisodeList';
import type { ObjectInfo } from './@types/objectInfo';
import type { AuthData, AuthResponse, Episode, ResponseBase, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import type { ServiceClass } from './@types/serviceClassInterface';
import type { CrunchyAndroidStreams } from './@types/crunchyAndroidStreams';
import type { CrunchyAndroidEpisodes } from './@types/crunchyAndroidEpisodes';
import type { CrunchyPlayStream } from './@types/crunchyPlayStreams';
import type { CrunchyAndroidObject } from './@types/crunchyAndroidObject';
import type { CrunchyChapters, CrunchyChapter, CrunchyOldChapter } from './@types/crunchyChapters';
import type { CrunchyStreams, PlaybackData, Subtitles } from './@types/playbackData';
import { CrunchyPlayStreams } from './@types/enums';
export default class Crunchy implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
@ -985,7 +976,7 @@ export default class Crunchy implements ServiceClass {
return false;
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
@ -1195,6 +1186,7 @@ export default class Crunchy implements ServiceClass {
public async downloadMediaList(medias: CrunchyEpMeta, options: CrunchyDownloadOptions) : Promise<{
data: DownloadedMedia[],
mediaMap: DownloadedMediaMap[],
fileName: string,
error: boolean
} | undefined> {
@ -1214,6 +1206,7 @@ export default class Crunchy implements ServiceClass {
}
const files: DownloadedMedia[] = [];
const mediaMap: DownloadedMediaMap[] = [];
if(medias.data.every(a => !a.playback)){
console.warn('Video not available!');
@ -1230,6 +1223,11 @@ export default class Crunchy implements ServiceClass {
// Make sure we have a media id without a : in it
const currentMediaId = (mMeta.mediaId.includes(':') ? mMeta.mediaId.split(':')[1] : mMeta.mediaId);
const fileMap: DownloadedMediaMap = {
version: currentMediaId,
files: []
};
//Make sure token is up-to-date
await this.refreshToken(true, true);
let currentVersion;
@ -1377,7 +1375,7 @@ export default class Crunchy implements ServiceClass {
if (options.cstream !== 'none') {
const playbackReq = await this.req.getData(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${currentVersion ? currentVersion.guid : currentMediaId}/${CrunchyPlayStreams[options.cstream]}/play`, AuthHeaders);
if (!playbackReq.ok || !playbackReq.res) {
console.error('Non-DRM Request Stream URLs FAILED!');
console.error('Request for Stream URLs FAILED!');
} else {
playStream = await playbackReq.res.json() as CrunchyPlayStream;
const derivedPlaystreams = {} as CrunchyStreams;
@ -1779,6 +1777,12 @@ export default class Crunchy implements ServiceClass {
lang: lang,
isPrimary: isPrimary
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.video.m4s`,
lang: lang,
isPrimary: isPrimary
});
}
}
@ -1801,6 +1805,12 @@ export default class Crunchy implements ServiceClass {
lang: lang,
isPrimary: isPrimary
});
fileMap.files.push({
type: 'Audio',
path: `${tsFile}.audio.m4s`,
lang: lang,
isPrimary: isPrimary
});
console.info('Decryption done for audio');
}
}
@ -1815,6 +1825,12 @@ export default class Crunchy implements ServiceClass {
lang: lang,
isPrimary: isPrimary
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.video.m4s`,
lang: lang,
isPrimary: isPrimary
});
}
if (audioDownloaded) {
files.push({
@ -1823,6 +1839,12 @@ export default class Crunchy implements ServiceClass {
lang: lang,
isPrimary: isPrimary
});
fileMap.files.push({
type: 'Audio',
path: `${tsFile}.audio.m4s`,
lang: lang,
isPrimary: isPrimary
});
}
}
} else if (!options.novids) {
@ -1980,6 +2002,12 @@ export default class Crunchy implements ServiceClass {
lang: lang,
isPrimary: isPrimary
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: lang,
isPrimary: isPrimary
});
dlVideoOnce = true;
}
} else{
@ -2017,6 +2045,11 @@ export default class Crunchy implements ServiceClass {
lang: lang,
type: 'Chapters'
});
fileMap.files.push({
path: `${tsFile}.txt`,
lang: lang,
type: 'Chapters'
});
} catch {
console.error('Failed to write chapter file');
}
@ -2104,6 +2137,12 @@ export default class Crunchy implements ServiceClass {
cc: isCC,
signs: isSigns,
});
fileMap.files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: isCC,
signs: isSigns,
});
}
else{
console.warn(`Failed to download subtitle: ${sxData.file}`);
@ -2118,16 +2157,19 @@ export default class Crunchy implements ServiceClass {
console.info('Subtitles downloading skipped!');
}
mediaMap.push(fileMap);
await this.sleep(options.waittime);
}
return {
error: dlFailed,
data: files,
mediaMap,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public async muxStreams(data: DownloadedMedia[], options: CrunchyMuxOptions) {
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], options: CrunchyMuxOptions) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
@ -2136,6 +2178,7 @@ export default class Crunchy implements ServiceClass {
hasAudioStreams = true;
}
const merger = new Merger({
mediaMap,
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
return {
lang: a.lang,

File diff suppressed because it is too large Load diff

View file

@ -1,44 +1,43 @@
// build-in
// Built In
import path from 'path';
import fs from 'fs-extra';
// package program
// Package
import packageJson from './package.json';
// plugins
import { console } from './modules/log';
// Plugins
import shlp from 'sei-helper';
import streamdl, { M3U8Json } from './modules/hls-download';
// custom modules
// Custom Modules
import { console } from './modules/log';
import streamdl, { M3U8Json } from './modules/hls-download';
import * as fontsData from './modules/module.fontsData';
import * as langsData from './modules/module.langsData';
import * as yamlCfg from './modules/module.cfg-loader';
import * as yargs from './modules/module.app-args';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import vtt2ass from './modules/module.vtt2ass';
// load req
import { domain, api } from './modules/module.api-urls';
import * as reqModule from './modules/module.req';
import { DownloadedMedia } from './@types/hidiveTypes';
import parseFileName, { Variable } from './modules/module.filename';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import { AvailableFilenameVars } from './modules/module.args';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { ServiceClass } from './@types/serviceClassInterface';
import { sxItem } from './crunchy';
import { Hit, NewHidiveSearch } from './@types/newHidiveSearch';
import { NewHidiveSeries } from './@types/newHidiveSeries';
import { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra } from './@types/newHidiveSeason';
import { NewHidiveEpisode } from './@types/newHidiveEpisode';
import { NewHidivePlayback, Subtitle } from './@types/newHidivePlayback';
import { MPDParsed, parse } from './modules/module.transform-mpd';
import getKeys, { canDecrypt } from './modules/widevine';
import { exec } from './modules/sei-helper-fixes';
import { KeyContainer } from './modules/license';
// Types
import type { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import type { ServiceClass } from './@types/serviceClassInterface';
import type { Hit, NewHidiveSearch } from './@types/newHidiveSearch';
import type { NewHidiveSeries } from './@types/newHidiveSeries';
import type { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra } from './@types/newHidiveSeason';
import type { NewHidiveEpisode } from './@types/newHidiveEpisode';
import type { NewHidivePlayback, Subtitle } from './@types/newHidivePlayback';
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
export default class Hidive implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
private token: Record<string, any>;
@ -547,7 +546,7 @@ export default class Hidive implements ServiceClass {
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName }, false);
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName }, false);
} else {
console.info('Skipping mux');
}
@ -636,7 +635,7 @@ export default class Hidive implements ServiceClass {
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName }, false);
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName }, false);
} else {
console.info('Skipping mux');
}
@ -651,6 +650,7 @@ export default class Hidive implements ServiceClass {
public async downloadMPD(streamPlaylists: MPDParsed, subs: Subtitle[], selectedEpisode: NewHidiveEpisodeExtra, options: Record<any, any>) {
//let fileName: string;
const files: DownloadedMedia[] = [];
const mediaMap: DownloadedMediaMap[] = [];
const variables: Variable[] = [];
let dlFailed = false;
const subsMargin = 0;
@ -658,6 +658,11 @@ export default class Hidive implements ServiceClass {
let encryptionKeys: KeyContainer[] = [];
if (!canDecrypt) console.warn('Decryption not enabled!');
const fileMap: DownloadedMediaMap = {
version: selectedEpisode.id.toString(),
files: []
};
if (!this.cfg.bin.ffmpeg)
this.cfg.bin = await yamlCfg.loadBinCfg();
@ -839,6 +844,12 @@ export default class Hidive implements ServiceClass {
lang: chosenAudios[0].language,
isPrimary: true
});
fileMap.files.push({
type: 'Video',
path: `${tsFile}.video.m4s`,
lang: chosenAudios[0].language,
isPrimary: true
});
}
} else {
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
@ -920,6 +931,12 @@ export default class Hidive implements ServiceClass {
lang: chosenAudioSegments.language,
isPrimary: chosenAudioSegments.default
});
fileMap.files.push({
type: 'Audio',
path: `${tsFile}.audio.m4s`,
lang: chosenAudioSegments.language,
isPrimary: chosenAudioSegments.default
});
console.info('Decryption done for audio');
}
} else {
@ -968,6 +985,11 @@ export default class Hidive implements ServiceClass {
...sxData as sxItem,
cc: false
});
fileMap.files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
} else{
console.warn(`Failed to download subtitle: ${sxData.file}`);
}
@ -981,14 +1003,17 @@ export default class Hidive implements ServiceClass {
console.info('Subtitles downloading skipped!');
}
mediaMap.push(fileMap);
return {
error: dlFailed,
data: files,
mediaMap,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public async muxStreams(data: DownloadedMedia[], options: Record<any, any>, inverseTrackOrder: boolean = true) {
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], options: Record<any, any>, inverseTrackOrder: boolean = true) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
@ -997,9 +1022,8 @@ export default class Hidive implements ServiceClass {
hasAudioStreams = true;
}
const merger = new Merger({
mediaMap,
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -1009,8 +1033,6 @@ export default class Hidive implements ServiceClass {
inverseTrackOrder: inverseTrackOrder,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
@ -1018,10 +1040,6 @@ export default class Hidive implements ServiceClass {
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
@ -1029,14 +1047,10 @@ export default class Hidive implements ServiceClass {
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,

View file

@ -1,20 +1,33 @@
import * as iso639 from 'iso-639';
import * as yamlCfg from './module.cfg-loader';
import { fontFamilies, fontMime } from './module.fontsData';
// Native
import path from 'path';
import fs from 'fs';
import { LanguageItem } from './module.langsData';
import { AvailableMuxer } from './module.args';
import { spawn } from 'child_process';
import os from 'os';
// Plugins
import * as iso639 from 'iso-639';
import { exec } from './sei-helper-fixes';
import { console } from './log';
import ffprobe from 'ffprobe';
import { OggOpusDecodedAudio, OggOpusDecoder } from 'ogg-opus-decoder';
import SynAudio, { MultipleClipMatch, MultipleClipMatchFirst } from 'synaudio';
// Modules
import { LanguageItem } from './module.langsData';
import { AvailableMuxer } from './module.args';
import * as yamlCfg from './module.cfg-loader';
import { fontFamilies, fontMime } from './module.fontsData';
// Types
import { DownloadedMediaMap } from '../@types/downloaderTypes';
export type MergerInput = {
path: string,
lang: LanguageItem,
duration?: number,
duration?: OggOpusDecodedAudio,
delay?: number,
isPrimary?: boolean,
totalDuration?: number,
}
export type SubtitleInput = {
@ -34,6 +47,7 @@ export type ParsedFont = {
}
export type MergerOptions = {
mediaMap?: DownloadedMediaMap[],
videoAndAudio: MergerInput[],
onlyVid: MergerInput[],
onlyAudio: MergerInput[],
@ -58,7 +72,7 @@ export type MergerOptions = {
}
class Merger {
constructor(private options: MergerOptions) {
if (this.options.skipSubMux)
this.options.subtitles = [];
@ -66,46 +80,146 @@ class Merger {
this.options.videoTitle = this.options.videoTitle.replace(/"/g, '\'');
}
public async createDelays() {
//Don't bother scanning it if there is only 1 vna stream
if (this.options.videoAndAudio.length > 1) {
const bin = await yamlCfg.loadBinCfg();
const vnas = this.options.videoAndAudio;
//get and set durations on each videoAndAudio Stream
for (const [vnaIndex, vna] of vnas.entries()) {
const streamInfo = await ffprobe(vna.path, { path: bin.ffprobe as string });
const videoInfo = streamInfo.streams.filter(stream => stream.codec_type == 'video');
vnas[vnaIndex].duration = parseInt(videoInfo[0].duration as string);
}
//Sort videoAndAudio streams by duration (shortest first)
vnas.sort((a,b) => {
if (!a.duration || !b.duration) return -1;
return a.duration - b.duration;
async convertFile(path: string, ffmpeg: string): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const options = [
'-t',
'60',
'-i',
path,
'-vn',
'-c:a',
'libopus',
'-filter:a',
'loudnorm',
'-f',
'ogg',
'pipe:1'
];
console.info(ffmpeg, options.join(' '));
const ffmpegSpawn = spawn(ffmpeg, options);
const data: number[] = [];
ffmpegSpawn.stdout.on('data', (chunk) => {
data.push(...chunk);
});
//Set Delays
const shortestDuration = vnas[0].duration;
for (const [vnaIndex, vna] of vnas.entries()) {
//Don't calculate the shortestDuration track
if (vnaIndex == 0) {
if (!vna.isPrimary && vna.isPrimary !== undefined)
console.warn('Shortest video isn\'t primary, this might lead to problems with subtitles. Please report on github or discord if you experience issues.');
continue;
ffmpegSpawn.stderr.on('data', (err) => {
//console.debug(err.toString().trimEnd());
});
ffmpegSpawn.on('close', (code) => {
if (code !== 0) {
console.error('FFmpeg exited with code: ', code);
return reject();
}
if (vna.duration && shortestDuration) {
//Calculate the tracks delay
vna.delay = Math.ceil((vna.duration-shortestDuration) * 1000) / 1000;
//TODO: set primary language for audio so it can be used to determine which track needs the delay
//The above is a problem in the event that it isn't the dub that needs the delay, but rather the sub.
//Alternatively: Might not work: it could be checked if there are multiple of the same video language, and if there is
//more than 1 of the same video language, then do the subtitle delay on CC, else normal language.
const subtitles = this.options.subtitles.filter(sub => sub.language.code == vna.lang.code);
for (const [subIndex, sub] of subtitles.entries()) {
if (vna.isPrimary) subtitles[subIndex].delay = vna.delay;
else if (sub.closedCaption) subtitles[subIndex].delay = vna.delay;
resolve(new Uint8Array(data));
});
});
}
async decodeAudio(data: Uint8Array): Promise<OggOpusDecodedAudio> {
const decoder = new OggOpusDecoder();
await decoder.ready;
return decoder.decode(data);
}
public async createDelays() {
const bin = await yamlCfg.loadBinCfg();
const allFiles = [...this.options.onlyAudio, ...this.options.videoAndAudio, ...this.options.onlyVid, ...this.options.subtitles];
if (this.options.chapters)
allFiles.push(...this.options.chapters);
const audios = [...this.options.onlyAudio, ...this.options.videoAndAudio];
if (audios.length < 2)
return;
if (bin.ffmpeg === undefined || bin.ffmpeg.trim().length === 0)
return console.error('Unable to sync timing without ffmpeg!');
for (const audio of audios) {
try {
const track = (
await this.decodeAudio(
await this.convertFile(audio.path, bin.ffmpeg)
)
);
if (track.errors.length > 0) {
console.error(`Unable to decode ${audio.path}: ${track.errors}`);
return;
}
audio.duration = track;
const streamInfo = await ffprobe(audio.path, { path: bin.ffprobe as string });
const compare = streamInfo.streams.find(s => s.codec_type === 'video') || streamInfo.streams.find(s => s.codec_type === 'audio');
audio.totalDuration = parseInt(compare!.duration!);
} catch (e) {
console.error(`Unable to generate sync timing because of file ${audio.path}: ${e}`);
return;
}
}
audios.sort((a, b) => a.totalDuration! - b.totalDuration!);
const synAudio = new SynAudio({
correlationSampleSize: 50000,
initialGranularity: 12,
correlationThreshold: 0.5
});
const audioArray = await synAudio.syncMultiple(
audios.map((audio) => {
return {
name: audio.path,
data: {
channelData: audio.duration!.channelData,
samplesDecoded: audio.duration!.samplesDecoded,
}
};
}),
os.cpus().length-1
);
//console.debug(audioArray);
// Find max sampleOffset value
const maxSampleOffset = Math.max(...audioArray[0].map(item => item.sampleOffset));
// Invert the sampleOffset values
const invertedArray = audioArray[0].map(item => ({
...item,
sampleOffset: maxSampleOffset - item.sampleOffset
}));
//Iterate over found matches
invertedArray.forEach((clip: MultipleClipMatch | MultipleClipMatchFirst) => {
const audio = audios.find(a => a.path === clip.name)!;
audio.delay = (clip.sampleOffset || 0 ) / audio.duration!.sampleRate;
// Iterate over each DownloadedMediaMap in the array
for (const mediaMap of this.options.mediaMap!) {
// Iterate over the files array in each DownloadedMediaMap
for (const media of mediaMap.files) {
// Check if the path matches
if (media.path === clip.name) {
//console.debug('Matching path found:', media.path);
// Re-iterate over the files array where the match was found
mediaMap.files.forEach((file) => {
const mergerFile = allFiles.find(a =>
('path' in a ? a.path : a.file) === file.path
);
if (mergerFile)
mergerFile.delay = audio.delay;
else
console.error(`File ${file.path} was not found in allFiles array, this shouldn't happen`);
});
// We only want the first match, so break out of the loop
break;
}
}
}
}
});
}
public FFmpeg() : string {
@ -118,8 +232,9 @@ class Merger {
for (const vid of this.options.videoAndAudio) {
if (vid.delay && hasVideo) {
console.info('Inserting delay vid', vid.delay);
args.push(
`-itsoffset -${Math.ceil(vid.delay*1000)}ms`
`-itsoffset ${Math.round(vid.delay * 1000) / 1000}`
);
}
args.push(`-i "${vid.path}"`);
@ -147,6 +262,10 @@ class Merger {
}
for (const aud of this.options.onlyAudio) {
if (aud.delay) {
console.info('Inserting delay', aud.delay);
args.push(`-itsoffset -${Math.ceil(aud.delay * 1000) / 1000}`);
}
args.push(`-i "${aud.path}"`);
metaData.push(`-map ${index}`);
metaData.push(`-metadata:s:a:${audioIndex} language=${aud.lang.code}`);
@ -158,7 +277,7 @@ class Merger {
const sub = this.options.subtitles[index];
if (sub.delay) {
args.push(
`-itsoffset -${Math.ceil(sub.delay*1000)}ms`
`-itsoffset -${Math.round(sub.delay*1000)}ms`
);
}
args.push(`-i "${sub.file}"`);
@ -229,7 +348,7 @@ class Merger {
const videoTrackNum = this.options.inverseTrackOrder ? '1' : '0';
if (vid.delay) {
args.push(
`--sync ${audioTrackNum}:-${Math.ceil(vid.delay*1000)}`
`--sync ${audioTrackNum}:-${Math.round(vid.delay*1000)}`
);
}
if (!hasVideo || this.options.keepAllVideos) {
@ -264,6 +383,11 @@ class Merger {
}
for (const aud of this.options.onlyAudio) {
if (aud.delay) {
args.push(
`--sync 0:-${Math.round(aud.delay*1000)}`
);
}
const trackName = aud.lang.name;
args.push('--track-name', `0:"${trackName}"`);
args.push(`--language 0:${aud.lang.code}`);
@ -283,7 +407,7 @@ class Merger {
for (const subObj of this.options.subtitles) {
if (subObj.delay) {
args.push(
`--sync 0:-${Math.ceil(subObj.delay*1000)}`
`--sync 0:-${Math.round(subObj.delay*1000)}`
);
}
args.push('--track-name', `0:"${(subObj.language.language || subObj.language.name) + `${subObj.closedCaption === true ? ` ${this.options.ccTag}` : ''}` + `${subObj.signs === true ? ' Signs' : ''}`}"`);
@ -380,7 +504,7 @@ class Merger {
path: fontPath,
mime: mime,
});
}
}
}
}
}

View file

@ -55,9 +55,11 @@
"lookpath": "^1.2.2",
"m3u8-parsed": "^1.3.0",
"mpd-parser": "^1.3.0",
"ogg-opus-decoder": "^1.6.12",
"open": "^8.4.2",
"protobufjs": "^7.3.2",
"sei-helper": "^3.3.0",
"synaudio": "^0.3.5",
"ws": "^8.17.1",
"yaml": "^2.4.5",
"yargs": "^17.7.2"

File diff suppressed because it is too large Load diff