|
|
|
@@ -1,10 +1,6 @@
|
|
|
|
import {TorrentType} from '@enums/torrent_types';
|
|
|
|
import {TorrentType} from '@enums/torrent_types';
|
|
|
|
import {ExtensionHelpers} from '@helpers/extension_helpers';
|
|
|
|
import {ExtensionHelpers} from '@helpers/extension_helpers';
|
|
|
|
import {PromiseHelpers} from '@helpers/promises_helpers';
|
|
|
|
|
|
|
|
import {ICommonVideoMetadata} from "@interfaces/common_video_metadata";
|
|
|
|
|
|
|
|
import {ILoggingService} from "@interfaces/logging_service";
|
|
|
|
import {ILoggingService} from "@interfaces/logging_service";
|
|
|
|
import {IMetaDataQuery} from "@interfaces/metadata_query";
|
|
|
|
|
|
|
|
import {IMetadataResponse} from "@interfaces/metadata_response";
|
|
|
|
|
|
|
|
import {IMetadataService} from "@interfaces/metadata_service";
|
|
|
|
import {IMetadataService} from "@interfaces/metadata_service";
|
|
|
|
import {IParsedTorrent} from "@interfaces/parsed_torrent";
|
|
|
|
import {IParsedTorrent} from "@interfaces/parsed_torrent";
|
|
|
|
import {ITorrentDownloadService} from "@interfaces/torrent_download_service";
|
|
|
|
import {ITorrentDownloadService} from "@interfaces/torrent_download_service";
|
|
|
|
@@ -14,27 +10,20 @@ import {IContentAttributes} from "@repository/interfaces/content_attributes";
|
|
|
|
import {IFileAttributes} from "@repository/interfaces/file_attributes";
|
|
|
|
import {IFileAttributes} from "@repository/interfaces/file_attributes";
|
|
|
|
import {configurationService} from '@services/configuration_service';
|
|
|
|
import {configurationService} from '@services/configuration_service';
|
|
|
|
import {IocTypes} from "@setup/ioc_types";
|
|
|
|
import {IocTypes} from "@setup/ioc_types";
|
|
|
|
import Bottleneck from 'bottleneck';
|
|
|
|
|
|
|
|
import {inject, injectable} from "inversify";
|
|
|
|
import {inject, injectable} from "inversify";
|
|
|
|
import moment from 'moment';
|
|
|
|
|
|
|
|
import {parse} from 'parse-torrent-title';
|
|
|
|
import {parse} from 'parse-torrent-title';
|
|
|
|
|
|
|
|
import { filenameParse } from '@ctrl/video-filename-parser';
|
|
|
|
|
|
|
|
import {ParsedShow} from "@ctrl/video-filename-parser/dist/src/filenameParse";
|
|
|
|
|
|
|
|
|
|
|
|
const MIN_SIZE: number = 5 * 1024 * 1024; // 5 MB
|
|
|
|
const MIN_SIZE: number = 5 * 1024 * 1024; // 5 MB
|
|
|
|
const MULTIPLE_FILES_SIZE = 4 * 1024 * 1024 * 1024; // 4 GB
|
|
|
|
const MULTIPLE_FILES_SIZE = 4 * 1024 * 1024 * 1024; // 4 GB
|
|
|
|
|
|
|
|
|
|
|
|
type SeasonEpisodeMap = Record<number, Record<number, ICommonVideoMetadata>>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@injectable()
|
|
|
|
@injectable()
|
|
|
|
export class TorrentFileService implements ITorrentFileService {
|
|
|
|
export class TorrentFileService implements ITorrentFileService {
|
|
|
|
@inject(IocTypes.IMetadataService) metadataService: IMetadataService;
|
|
|
|
@inject(IocTypes.IMetadataService) metadataService: IMetadataService;
|
|
|
|
@inject(IocTypes.ITorrentDownloadService) torrentDownloadService: ITorrentDownloadService;
|
|
|
|
@inject(IocTypes.ITorrentDownloadService) torrentDownloadService: ITorrentDownloadService;
|
|
|
|
@inject(IocTypes.ILoggingService) logger: ILoggingService;
|
|
|
|
@inject(IocTypes.ILoggingService) logger: ILoggingService;
|
|
|
|
|
|
|
|
|
|
|
|
private readonly imdb_limiter: Bottleneck = new Bottleneck({
|
|
|
|
|
|
|
|
maxConcurrent: configurationService.metadataConfig.IMDB_CONCURRENT,
|
|
|
|
|
|
|
|
minTime: configurationService.metadataConfig.IMDB_INTERVAL_MS
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async parseTorrentFiles(torrent: IParsedTorrent): Promise<ITorrentFileCollection> {
|
|
|
|
async parseTorrentFiles(torrent: IParsedTorrent): Promise<ITorrentFileCollection> {
|
|
|
|
if (!torrent.title) {
|
|
|
|
if (!torrent.title) {
|
|
|
|
return Promise.reject(new Error('Torrent title is missing'));
|
|
|
|
return Promise.reject(new Error('Torrent title is missing'));
|
|
|
|
@@ -44,25 +33,17 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
return Promise.reject(new Error('Torrent infoHash is missing'));
|
|
|
|
return Promise.reject(new Error('Torrent infoHash is missing'));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parsedTorrentName = parse(torrent.title);
|
|
|
|
let fileCollection: ITorrentFileCollection;
|
|
|
|
const query: IMetaDataQuery = {
|
|
|
|
|
|
|
|
id: torrent.kitsuId || torrent.imdbId,
|
|
|
|
|
|
|
|
type: torrent.type || TorrentType.Movie,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const metadata = await this.metadataService.getMetadata(query)
|
|
|
|
|
|
|
|
.then(meta => Object.assign({}, meta))
|
|
|
|
|
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (metadata === undefined || metadata instanceof Error) {
|
|
|
|
const isSeries = parse(torrent.title).seasons || this.isSeries(torrent.title);
|
|
|
|
return Promise.reject(new Error('Failed to retrieve metadata'));
|
|
|
|
|
|
|
|
|
|
|
|
if (!isSeries){
|
|
|
|
|
|
|
|
fileCollection = await this.parseMovieFiles(torrent);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
fileCollection = await this.parseSeriesFiles(torrent);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (torrent.type === TorrentType.Movie && (!parsedTorrentName.seasons ||
|
|
|
|
return fileCollection;
|
|
|
|
parsedTorrentName.season === 5 && [1, 5].includes(parsedTorrentName.episode || 0))) {
|
|
|
|
|
|
|
|
return this.parseMovieFiles(torrent, metadata);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return this.parseSeriesFiles(torrent, metadata)
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isPackTorrent(torrent: IParsedTorrent): boolean {
|
|
|
|
isPackTorrent(torrent: IParsedTorrent): boolean {
|
|
|
|
@@ -95,7 +76,7 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
return parsedVideos.map(video => ({...video, isMovie: this.isMovieVideo(torrent, video, parsedVideos, hasMovies)}));
|
|
|
|
return parsedVideos.map(video => ({...video, isMovie: this.isMovieVideo(torrent, video, parsedVideos, hasMovies)}));
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private parseMovieFiles = async (torrent: IParsedTorrent, metadata: IMetadataResponse): Promise<ITorrentFileCollection> => {
|
|
|
|
private parseMovieFiles = async (torrent: IParsedTorrent): Promise<ITorrentFileCollection> => {
|
|
|
|
const fileCollection: ITorrentFileCollection = await this.getMoviesTorrentContent(torrent);
|
|
|
|
const fileCollection: ITorrentFileCollection = await this.getMoviesTorrentContent(torrent);
|
|
|
|
if (fileCollection.videos === undefined || fileCollection.videos.length === 0) {
|
|
|
|
if (fileCollection.videos === undefined || fileCollection.videos.length === 0) {
|
|
|
|
return {...fileCollection, videos: this.getDefaultFileEntries(torrent)};
|
|
|
|
return {...fileCollection, videos: this.getDefaultFileEntries(torrent)};
|
|
|
|
@@ -110,26 +91,24 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
title: video.title || video.path || video.fileName || '',
|
|
|
|
title: video.title || video.path || video.fileName || '',
|
|
|
|
size: video.size || torrent.size,
|
|
|
|
size: video.size || torrent.size,
|
|
|
|
imdbId: torrent.imdbId?.toString() || metadata && metadata.imdbId?.toString(),
|
|
|
|
imdbId: torrent.imdbId?.toString(),
|
|
|
|
kitsuId: parseInt(torrent.kitsuId?.toString() || metadata && metadata.kitsuId?.toString() || '0')
|
|
|
|
kitsuId: parseInt(torrent.kitsuId?.toString() || '0')
|
|
|
|
}));
|
|
|
|
}));
|
|
|
|
return {...fileCollection, videos: parsedVideos};
|
|
|
|
return {...fileCollection, videos: parsedVideos};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parsedVideos = await PromiseHelpers.sequence(filteredVideos.map(video => () => this.isFeaturette(video)
|
|
|
|
const parsedVideos = filteredVideos.map(video => ({
|
|
|
|
? Promise.resolve(video)
|
|
|
|
infoHash: torrent.infoHash,
|
|
|
|
: this.findMovieImdbId(video.title).then(imdbId => ({...video, imdbId: imdbId?.toString() || ''}))))
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
.then(videos => videos.map((video: IFileAttributes) => ({
|
|
|
|
title: video.title || video.path,
|
|
|
|
infoHash: torrent.infoHash,
|
|
|
|
size: video.size,
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
imdbId: torrent.imdbId.toString() || ''
|
|
|
|
title: video.title || video.path,
|
|
|
|
}));
|
|
|
|
size: video.size,
|
|
|
|
|
|
|
|
imdbId: video.imdbId,
|
|
|
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
return {...fileCollection, videos: parsedVideos};
|
|
|
|
return {...fileCollection, videos: parsedVideos};
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private parseSeriesFiles = async (torrent: IParsedTorrent, metadata: IMetadataResponse): Promise<ITorrentFileCollection> => {
|
|
|
|
private parseSeriesFiles = async (torrent: IParsedTorrent): Promise<ITorrentFileCollection> => {
|
|
|
|
const fileCollection: ITorrentFileCollection = await this.getSeriesTorrentContent(torrent);
|
|
|
|
const fileCollection: ITorrentFileCollection = await this.getSeriesTorrentContent(torrent);
|
|
|
|
if (fileCollection.videos === undefined || fileCollection.videos.length === 0) {
|
|
|
|
if (fileCollection.videos === undefined || fileCollection.videos.length === 0) {
|
|
|
|
return {...fileCollection, videos: this.getDefaultFileEntries(torrent)};
|
|
|
|
return {...fileCollection, videos: this.getDefaultFileEntries(torrent)};
|
|
|
|
@@ -138,14 +117,13 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
const parsedVideos: IFileAttributes[] = await Promise.resolve(fileCollection.videos)
|
|
|
|
const parsedVideos: IFileAttributes[] = await Promise.resolve(fileCollection.videos)
|
|
|
|
.then(videos => videos.filter(video => videos?.length === 1 || video.size! > MIN_SIZE))
|
|
|
|
.then(videos => videos.filter(video => videos?.length === 1 || video.size! > MIN_SIZE))
|
|
|
|
.then(videos => this.parseSeriesVideos(torrent, videos))
|
|
|
|
.then(videos => this.parseSeriesVideos(torrent, videos))
|
|
|
|
.then(videos => this.decomposeEpisodes(torrent, videos, metadata))
|
|
|
|
|
|
|
|
.then(videos => this.assignKitsuOrImdbEpisodes(torrent, videos, metadata))
|
|
|
|
|
|
|
|
.then(videos => Promise.all(videos.map(video => video.isMovie
|
|
|
|
|
|
|
|
? this.mapSeriesMovie(torrent, video)
|
|
|
|
|
|
|
|
: this.mapSeriesEpisode(torrent, video, videos))))
|
|
|
|
|
|
|
|
.then(videos => videos
|
|
|
|
.then(videos => videos
|
|
|
|
.reduce((a, b) => a.concat(b), [])
|
|
|
|
.reduce((a, b) => a.concat(b), [])
|
|
|
|
.map(video => this.isFeaturette(video) ? this.clearInfoFields(video) : video));
|
|
|
|
.map(video => this.isFeaturette(video) ? this.clearInfoFields(video) : video))
|
|
|
|
|
|
|
|
.then(videos => Promise.all(videos.flatMap(video => this.mapSeriesEpisode(torrent, video, videos))))
|
|
|
|
|
|
|
|
.then(videos => videos.flat());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {...torrent.fileCollection, videos: parsedVideos};
|
|
|
|
return {...torrent.fileCollection, videos: parsedVideos};
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@@ -215,455 +193,6 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
})))
|
|
|
|
})))
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private mapSeriesMovie = async (torrent: IParsedTorrent, file: IFileAttributes): Promise<IFileAttributes[]> => {
|
|
|
|
|
|
|
|
const kitsuId = torrent.type === TorrentType.Anime ? await this.findMovieKitsuId(file)
|
|
|
|
|
|
|
|
.then(result => {
|
|
|
|
|
|
|
|
if (result instanceof Error) {
|
|
|
|
|
|
|
|
this.logger.warn(`Failed to retrieve kitsuId due to error: ${result.message}`);
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
|
|
}) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const imdbId = !kitsuId ? await this.findMovieImdbId(file) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const query: IMetaDataQuery = {
|
|
|
|
|
|
|
|
id: kitsuId || imdbId,
|
|
|
|
|
|
|
|
type: TorrentType.Movie
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const metadataOrError = await this.metadataService.getMetadata(query);
|
|
|
|
|
|
|
|
if (metadataOrError instanceof Error) {
|
|
|
|
|
|
|
|
this.logger.warn(`Failed to retrieve metadata due to error: ${metadataOrError.message}`);
|
|
|
|
|
|
|
|
// return default result or throw error, depending on your use case
|
|
|
|
|
|
|
|
return [{
|
|
|
|
|
|
|
|
infoHash: torrent.infoHash,
|
|
|
|
|
|
|
|
fileIndex: file.fileIndex,
|
|
|
|
|
|
|
|
title: file.path || file.title,
|
|
|
|
|
|
|
|
size: file.size,
|
|
|
|
|
|
|
|
imdbId: imdbId,
|
|
|
|
|
|
|
|
kitsuId: parseInt(kitsuId?.toString() || '0') || 0,
|
|
|
|
|
|
|
|
episodes: undefined,
|
|
|
|
|
|
|
|
imdbSeason: undefined,
|
|
|
|
|
|
|
|
imdbEpisode: undefined,
|
|
|
|
|
|
|
|
kitsuEpisode: undefined
|
|
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// at this point, TypeScript infers that metadataOrError is actually MetadataResponse
|
|
|
|
|
|
|
|
const metadata = metadataOrError;
|
|
|
|
|
|
|
|
const hasEpisode = metadata.videos && metadata.videos.length && (file.episode || metadata.videos.length === 1);
|
|
|
|
|
|
|
|
const episodeVideo = hasEpisode && metadata.videos && metadata.videos[(file.episode || 1) - 1];
|
|
|
|
|
|
|
|
return [{
|
|
|
|
|
|
|
|
infoHash: torrent.infoHash,
|
|
|
|
|
|
|
|
fileIndex: file.fileIndex,
|
|
|
|
|
|
|
|
title: file.path || file.title,
|
|
|
|
|
|
|
|
size: file.size,
|
|
|
|
|
|
|
|
imdbId: metadata.imdbId?.toString() || imdbId || '',
|
|
|
|
|
|
|
|
kitsuId: parseInt(metadata.kitsuId?.toString() || kitsuId?.toString() || '0') || 0,
|
|
|
|
|
|
|
|
imdbSeason: episodeVideo && metadata.imdbId ? episodeVideo.season : undefined,
|
|
|
|
|
|
|
|
imdbEpisode: episodeVideo && metadata.imdbId || episodeVideo && metadata.kitsuId ? episodeVideo.episode || episodeVideo.episode : undefined,
|
|
|
|
|
|
|
|
kitsuEpisode: episodeVideo && metadata.imdbId || episodeVideo && metadata.kitsuId ? episodeVideo.episode || episodeVideo.episode : undefined,
|
|
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private decomposeEpisodes = async (torrent: IParsedTorrent, files: IFileAttributes[], metadata: IMetadataResponse = {episodeCount: []}): Promise<IFileAttributes[]> => {
|
|
|
|
|
|
|
|
if (files.every(file => !file.episodes && !file.date)) {
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.preprocessEpisodes(files);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (torrent.type === TorrentType.Anime && torrent.kitsuId) {
|
|
|
|
|
|
|
|
if (this.needsCinemetaMetadataForAnime(files, metadata)) {
|
|
|
|
|
|
|
|
// In some cases anime could be resolved to wrong kitsuId
|
|
|
|
|
|
|
|
// because of imdb season naming/absolute per series naming/multiple seasons
|
|
|
|
|
|
|
|
// So in these cases we need to fetch cinemeta based metadata and decompose episodes using that
|
|
|
|
|
|
|
|
await this.updateToCinemetaMetadata(metadata);
|
|
|
|
|
|
|
|
if (files.some(file => Number.isInteger(file.season))) {
|
|
|
|
|
|
|
|
// sometimes multi season anime torrents don't include season 1 naming
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => !Number.isInteger(file.season) && file.episodes)
|
|
|
|
|
|
|
|
.forEach(file => file.season = 1);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// otherwise for anime type episodes are always absolute and for a single season
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => file.episodes && file.season !== 0)
|
|
|
|
|
|
|
|
.forEach(file => file.season = 1);
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sortedEpisodes = files
|
|
|
|
|
|
|
|
.map(file => !file.isMovie && file.episodes || [])
|
|
|
|
|
|
|
|
.reduce((a, b) => a.concat(b), [])
|
|
|
|
|
|
|
|
.sort((a, b) => a - b);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (this.isConcatSeasonAndEpisodeFiles(files, sortedEpisodes, metadata)) {
|
|
|
|
|
|
|
|
this.decomposeConcatSeasonAndEpisodeFiles(files, metadata);
|
|
|
|
|
|
|
|
} else if (this.isDateEpisodeFiles(files, metadata)) {
|
|
|
|
|
|
|
|
this.decomposeDateEpisodeFiles(files, metadata);
|
|
|
|
|
|
|
|
} else if (this.isAbsoluteEpisodeFiles(torrent, files, metadata)) {
|
|
|
|
|
|
|
|
this.decomposeAbsoluteEpisodeFiles(torrent, files, metadata);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// decomposeEpisodeTitleFiles(torrent, files, metadata);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private preprocessEpisodes = (files: IFileAttributes[]): void => {
|
|
|
|
|
|
|
|
// reverse special episode naming when they named with 0 episode, ie. S02E00
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => Number.isInteger(file.season) && file.episode === 0)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
file.episode = file.season
|
|
|
|
|
|
|
|
file.episodes = [file.season || 0];
|
|
|
|
|
|
|
|
file.season = 0;
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isConcatSeasonAndEpisodeFiles = (files: IFileAttributes[], sortedEpisodes: number[], metadata: IMetadataResponse): boolean => {
|
|
|
|
|
|
|
|
if (metadata.kitsuId !== undefined) {
|
|
|
|
|
|
|
|
// anime does not use this naming scheme in 99% of cases;
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// decompose concat season and episode files (ex. 101=S01E01) in case:
|
|
|
|
|
|
|
|
// 1. file has a season, but individual files are concatenated with that season (ex. path Season 5/511 - Prize
|
|
|
|
|
|
|
|
// Fighters.avi)
|
|
|
|
|
|
|
|
// 2. file does not have a season and the episode does not go out of range for the concat season
|
|
|
|
|
|
|
|
// episode count
|
|
|
|
|
|
|
|
const thresholdAbove = Math.max(Math.ceil(files.length * 0.05), 5);
|
|
|
|
|
|
|
|
const thresholdSorted = Math.max(Math.ceil(files.length * 0.8), 8);
|
|
|
|
|
|
|
|
const threshold = Math.max(Math.ceil(files.length * 0.8), 5);
|
|
|
|
|
|
|
|
const sortedConcatEpisodes = sortedEpisodes
|
|
|
|
|
|
|
|
.filter(ep => ep > 100)
|
|
|
|
|
|
|
|
.filter(ep => metadata.episodeCount && metadata.episodeCount[this.div100(ep) - 1] < ep)
|
|
|
|
|
|
|
|
.filter(ep => metadata.episodeCount && metadata.episodeCount[this.div100(ep) - 1] >= this.mod100(ep));
|
|
|
|
|
|
|
|
const concatFileEpisodes = files
|
|
|
|
|
|
|
|
.filter(file => !file.isMovie && file.episodes)
|
|
|
|
|
|
|
|
.filter(file => !file.season || file.episodes?.every(ep => this.div100(ep) === file.season));
|
|
|
|
|
|
|
|
const concatAboveTotalEpisodeCount = files
|
|
|
|
|
|
|
|
.filter(file => !file.isMovie && file.episodes && file.episodes.every(ep => ep > 100))
|
|
|
|
|
|
|
|
.filter(file => file.episodes?.every(ep => ep > metadata.totalCount!));
|
|
|
|
|
|
|
|
return sortedConcatEpisodes.length >= thresholdSorted && concatFileEpisodes.length >= threshold
|
|
|
|
|
|
|
|
|| concatAboveTotalEpisodeCount.length >= thresholdAbove;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isDateEpisodeFiles = (files: IFileAttributes[], metadata: IMetadataResponse): boolean => files.every(file => (!file.season || metadata.episodeCount && !metadata.episodeCount[file.season - 1]) && file.date);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isAbsoluteEpisodeFiles = (torrent: IParsedTorrent, files: IFileAttributes[], metadata: IMetadataResponse): boolean => {
|
|
|
|
|
|
|
|
const threshold = Math.ceil(files.length / 5);
|
|
|
|
|
|
|
|
const isAnime = torrent.type === TorrentType.Anime && torrent.kitsuId;
|
|
|
|
|
|
|
|
const nonMovieEpisodes = files.filter(file => !file.isMovie && file.episodes);
|
|
|
|
|
|
|
|
const absoluteEpisodes = files
|
|
|
|
|
|
|
|
.filter(file => file.season && file.episodes)
|
|
|
|
|
|
|
|
.filter(file => file.episodes?.every(ep =>
|
|
|
|
|
|
|
|
metadata.episodeCount && file.season && metadata.episodeCount[file.season - 1] < ep));
|
|
|
|
|
|
|
|
return nonMovieEpisodes.every(file => !file.season)
|
|
|
|
|
|
|
|
|| (isAnime && nonMovieEpisodes.every(file =>
|
|
|
|
|
|
|
|
metadata.episodeCount && file.season && file.season > metadata.episodeCount.length))
|
|
|
|
|
|
|
|
|| absoluteEpisodes.length >= threshold;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isNewEpisodeNotInMetadata = (torrent: IParsedTorrent, video: IFileAttributes, metadata: IMetadataResponse): boolean => {
|
|
|
|
|
|
|
|
const isAnime = torrent.type === TorrentType.Anime && torrent.kitsuId;
|
|
|
|
|
|
|
|
return !!(!isAnime && !video.isMovie && video.episodes && video.season !== 1
|
|
|
|
|
|
|
|
&& metadata.status && /continuing|current/i.test(metadata.status)
|
|
|
|
|
|
|
|
&& metadata.episodeCount && video.season && video.season >= metadata.episodeCount.length
|
|
|
|
|
|
|
|
&& video.episodes.every(ep => metadata.episodeCount && video.season && ep > (metadata.episodeCount[video.season - 1] || 0)));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private decomposeConcatSeasonAndEpisodeFiles = (files: IFileAttributes[], metadata: IMetadataResponse): void => {
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => file.episodes && file.season !== 0 && file.episodes.every(ep => ep > 100))
|
|
|
|
|
|
|
|
.filter(file => file.episodes && metadata?.episodeCount &&
|
|
|
|
|
|
|
|
((file.season || this.div100(file.episodes[0])) - 1) >= 0 &&
|
|
|
|
|
|
|
|
metadata.episodeCount[(file.season || this.div100(file.episodes[0])) - 1] < 100)
|
|
|
|
|
|
|
|
.filter(file => (file.season && file.episodes && file.episodes.every(ep => this.div100(ep) === file.season)) || !file.season)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
if (file.episodes) {
|
|
|
|
|
|
|
|
file.season = this.div100(file.episodes[0]);
|
|
|
|
|
|
|
|
file.episodes = file.episodes.map(ep => this.mod100(ep));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private decomposeAbsoluteEpisodeFiles = (torrent: IParsedTorrent, videos: IFileAttributes[], metadata: IMetadataResponse): void => {
|
|
|
|
|
|
|
|
if (metadata.episodeCount?.length === 0) {
|
|
|
|
|
|
|
|
videos
|
|
|
|
|
|
|
|
.filter(file => !Number.isInteger(file.season) && file.episodes && !file.isMovie)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
file.season = 1;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!metadata.episodeCount) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
videos
|
|
|
|
|
|
|
|
.filter(file => file.episodes && !file.isMovie && file.season !== 0)
|
|
|
|
|
|
|
|
.filter(file => !this.isNewEpisodeNotInMetadata(torrent, file, metadata))
|
|
|
|
|
|
|
|
.filter(file => {
|
|
|
|
|
|
|
|
if (!file.episodes || !metadata.episodeCount) return false;
|
|
|
|
|
|
|
|
return !file.season || (metadata.episodeCount[file.season - 1] || 0) < file.episodes[0];
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
if (!file.episodes || !metadata.episodeCount) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let seasonIdx = metadata.episodeCount
|
|
|
|
|
|
|
|
.map((_, i) => i)
|
|
|
|
|
|
|
|
.find(i => metadata.episodeCount && file.episodes && metadata.episodeCount.slice(0, i + 1).reduce((a, b) => a + b) >= file.episodes[0]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
seasonIdx = (seasonIdx || 1 || metadata.episodeCount.length) - 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
file.season = seasonIdx + 1;
|
|
|
|
|
|
|
|
file.episodes = file.episodes
|
|
|
|
|
|
|
|
.map(ep => ep - (metadata.episodeCount?.slice(0, seasonIdx).reduce((a, b) => a + b, 0) || 0));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private decomposeDateEpisodeFiles = (files: IFileAttributes[], metadata: IMetadataResponse): void => {
|
|
|
|
|
|
|
|
if (!metadata || !metadata.videos || !metadata.videos.length) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const timeZoneOffset = this.getTimeZoneOffset(metadata.country);
|
|
|
|
|
|
|
|
const offsetVideos: { [key: string]: ICommonVideoMetadata } = metadata.videos
|
|
|
|
|
|
|
|
.reduce((map: { [key: string]: ICommonVideoMetadata }, video: ICommonVideoMetadata) => {
|
|
|
|
|
|
|
|
const releaseDate = moment(video.released).utcOffset(timeZoneOffset).format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
map[releaseDate] = video;
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => file.date)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
const video = offsetVideos[file.date!];
|
|
|
|
|
|
|
|
if (video) {
|
|
|
|
|
|
|
|
file.season = video.season;
|
|
|
|
|
|
|
|
file.episodes = [video.episode || 0];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private getTimeZoneOffset = (country: string | undefined): string => {
|
|
|
|
|
|
|
|
switch (country) {
|
|
|
|
|
|
|
|
case 'United States':
|
|
|
|
|
|
|
|
case 'USA':
|
|
|
|
|
|
|
|
return '-08:00';
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
|
|
return '00:00';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private assignKitsuOrImdbEpisodes = (torrent: IParsedTorrent, files: IFileAttributes[], metadata: IMetadataResponse): IFileAttributes[] => {
|
|
|
|
|
|
|
|
if (!metadata || !metadata.videos || !metadata.videos.length) {
|
|
|
|
|
|
|
|
if (torrent.type === TorrentType.Anime) {
|
|
|
|
|
|
|
|
// assign episodes as kitsu episodes for anime when no metadata available for imdb mapping
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => file.season && file.episodes)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
file.season = undefined;
|
|
|
|
|
|
|
|
file.episodes = undefined;
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
if (metadata.type === TorrentType.Movie && files.every(file => !file.imdbId)) {
|
|
|
|
|
|
|
|
// sometimes a movie has episode naming, thus not recognized as a movie and imdbId not assigned
|
|
|
|
|
|
|
|
files.forEach(file => file.imdbId = metadata.imdbId?.toString());
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const seriesMapping = metadata.videos
|
|
|
|
|
|
|
|
.filter(video => video.season !== undefined && Number.isInteger(video.season) && video.episode !== undefined && Number.isInteger(video.episode))
|
|
|
|
|
|
|
|
.reduce<SeasonEpisodeMap>((map, video) => {
|
|
|
|
|
|
|
|
if (video.season !== undefined && video.episode !== undefined) {
|
|
|
|
|
|
|
|
const episodeMap = map[video.season] || {};
|
|
|
|
|
|
|
|
episodeMap[video.episode] = video;
|
|
|
|
|
|
|
|
map[video.season] = episodeMap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (metadata.videos.some(video => Number.isInteger(video.season)) || !metadata.imdbId) {
|
|
|
|
|
|
|
|
files.filter(file => file && Number.isInteger(file.season) && file.episodes)
|
|
|
|
|
|
|
|
.map(file => {
|
|
|
|
|
|
|
|
const seasonMapping = file && file.season && seriesMapping[file.season] || null;
|
|
|
|
|
|
|
|
const episodeMapping = seasonMapping && file && file.episodes && file.episodes[0] && seasonMapping[file.episodes[0]] || null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (episodeMapping && Number.isInteger(episodeMapping.season)) {
|
|
|
|
|
|
|
|
file.imdbId = metadata.imdbId?.toString() !== "NaN" ? metadata.imdbId?.toString() : file.imdbId;
|
|
|
|
|
|
|
|
file.season = episodeMapping.season;
|
|
|
|
|
|
|
|
file.episodes = file.episodes && file.episodes.map(ep => (seasonMapping && seasonMapping[ep]) ? Number(seasonMapping[ep].episode) : 0);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
file.episodes = undefined;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (file.imdbId && file.season && file.episodes?.length > 0 && file.imdbEpisode === undefined && file.episode === undefined) {
|
|
|
|
|
|
|
|
file.imdbEpisode = file.episodes[0];
|
|
|
|
|
|
|
|
file.episode = file.episodes[0];
|
|
|
|
|
|
|
|
file.imdbSeason = file.season;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.patchMissingImdbValues(file);
|
|
|
|
|
|
|
|
this.patchMissingKitsuValues(file);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else if (metadata.videos.some(video => video.episode)) {
|
|
|
|
|
|
|
|
// imdb episode info is base
|
|
|
|
|
|
|
|
files
|
|
|
|
|
|
|
|
.filter(file => Number.isInteger(file.season) && file.episodes)
|
|
|
|
|
|
|
|
.forEach(file => {
|
|
|
|
|
|
|
|
if (!file.season || !file.episodes) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (seriesMapping[file.season]) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const seasonMapping = seriesMapping[file.season];
|
|
|
|
|
|
|
|
file.imdbId = metadata.imdbId?.toString() !== "NaN" ? metadata.imdbId?.toString() : file.imdbId;
|
|
|
|
|
|
|
|
file.kitsuId = seasonMapping[file.episodes[0]] && parseInt(seasonMapping[file.episodes[0]].id || '0') || 0;
|
|
|
|
|
|
|
|
file.episodes = file.episodes.map(ep => seasonMapping[ep]?.episode)
|
|
|
|
|
|
|
|
.filter((ep): ep is number => ep !== undefined);
|
|
|
|
|
|
|
|
} else if (seriesMapping[file.season - 1]) {
|
|
|
|
|
|
|
|
// sometimes a second season might be a continuation of the previous season
|
|
|
|
|
|
|
|
const seasonMapping = seriesMapping[file.season - 1] as ICommonVideoMetadata;
|
|
|
|
|
|
|
|
const episodes = Object.values(seasonMapping);
|
|
|
|
|
|
|
|
const firstKitsuId = episodes.length && episodes[0];
|
|
|
|
|
|
|
|
const differentTitlesCount = new Set(episodes.map(ep => ep.id)).size
|
|
|
|
|
|
|
|
const skippedCount = episodes.filter(ep => ep.id === firstKitsuId).length;
|
|
|
|
|
|
|
|
const emptyArray: number[] = [];
|
|
|
|
|
|
|
|
const seasonEpisodes = files
|
|
|
|
|
|
|
|
.filter((otherFile: IFileAttributes) => otherFile.season === file.season && otherFile.episodes)
|
|
|
|
|
|
|
|
.reduce((a, b) => a.concat(b.episodes || []), emptyArray);
|
|
|
|
|
|
|
|
const isAbsoluteOrder = seasonEpisodes.every(ep => ep > skippedCount && ep <= episodes.length)
|
|
|
|
|
|
|
|
const isNormalOrder = seasonEpisodes.every(ep => ep + skippedCount <= episodes.length)
|
|
|
|
|
|
|
|
if (differentTitlesCount >= 1 && (isAbsoluteOrder || isNormalOrder)) {
|
|
|
|
|
|
|
|
const {season} = file;
|
|
|
|
|
|
|
|
const [episode] = file.episodes;
|
|
|
|
|
|
|
|
file.imdbId = metadata.imdbId?.toString() !== "NaN" ? metadata.imdbId?.toString() : file.imdbId;
|
|
|
|
|
|
|
|
file.season = file.season - 1;
|
|
|
|
|
|
|
|
file.episodes = file.episodes.map(ep => isAbsoluteOrder ? ep : ep + skippedCount);
|
|
|
|
|
|
|
|
const currentEpisode = seriesMapping[season][episode];
|
|
|
|
|
|
|
|
file.kitsuId = currentEpisode ? parseInt(currentEpisode.id || '0') : 0;
|
|
|
|
|
|
|
|
if (typeof season === 'number' && Array.isArray(file.episodes)) {
|
|
|
|
|
|
|
|
file.episodes = file.episodes.map(ep =>
|
|
|
|
|
|
|
|
seriesMapping[season]
|
|
|
|
|
|
|
|
&& seriesMapping[season][ep]
|
|
|
|
|
|
|
|
&& seriesMapping[season][ep].episode
|
|
|
|
|
|
|
|
|| ep);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else if (Object.values(seriesMapping).length === 1 && seriesMapping[1]) {
|
|
|
|
|
|
|
|
// sometimes series might be named with sequel season but it's not a season on imdb and a new title
|
|
|
|
|
|
|
|
// eslint-disable-next-line prefer-destructuring
|
|
|
|
|
|
|
|
const seasonMapping = seriesMapping[1];
|
|
|
|
|
|
|
|
file.imdbId = metadata.imdbId?.toString() !== "NaN" ? metadata.imdbId?.toString() : file.imdbId;
|
|
|
|
|
|
|
|
file.season = 1;
|
|
|
|
|
|
|
|
file.kitsuId = parseInt(seasonMapping[file.episodes[0]].id || '0') || 0;
|
|
|
|
|
|
|
|
file.episodes = file.episodes.map(ep => seasonMapping[ep] && seasonMapping[ep].episode)
|
|
|
|
|
|
|
|
.filter((ep): ep is number => ep !== undefined);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.patchMissingImdbValues(file);
|
|
|
|
|
|
|
|
this.patchMissingKitsuValues(file);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private patchMissingKitsuValues = (file: IFileAttributes) : void => {
|
|
|
|
|
|
|
|
if (file.kitsuId !== 0 && file.kitsuId !== undefined && file.season && file.episodes?.length > 0 && file.imdbEpisode === undefined && file.episode === undefined) {
|
|
|
|
|
|
|
|
file.kitsuEpisode = file.episodes[0];
|
|
|
|
|
|
|
|
file.episode = file.episodes[0];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private patchMissingImdbValues = (file: IFileAttributes) : void => {
|
|
|
|
|
|
|
|
if (file.imdbId && file.season && file.episodes?.length > 0 && file.imdbEpisode === undefined && file.episode === undefined) {
|
|
|
|
|
|
|
|
file.imdbEpisode = file.episodes[0];
|
|
|
|
|
|
|
|
file.episode = file.episodes[0];
|
|
|
|
|
|
|
|
file.imdbSeason = file.season;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private needsCinemetaMetadataForAnime = (files: IFileAttributes[], metadata: IMetadataResponse): boolean => {
|
|
|
|
|
|
|
|
if (!metadata || !metadata.imdbId || !metadata.videos || !metadata.videos.length) {
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const seasons = metadata.videos
|
|
|
|
|
|
|
|
.map(video => video.season)
|
|
|
|
|
|
|
|
.filter((season): season is number => season !== null && season !== undefined);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Using || 0 instead of || Number.MAX_VALUE to match previous logic
|
|
|
|
|
|
|
|
const minSeason = Math.min(...seasons) || 0;
|
|
|
|
|
|
|
|
const maxSeason = Math.max(...seasons) || 0;
|
|
|
|
|
|
|
|
const differentSeasons = new Set(seasons.filter(season => Number.isInteger(season))).size;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const total = metadata.totalCount || Number.MAX_VALUE;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return differentSeasons > 1 || files
|
|
|
|
|
|
|
|
.filter(file => !file.isMovie && file.episodes)
|
|
|
|
|
|
|
|
.some(file => file.season || 0 < minSeason || file.season || 0 > maxSeason || file.episodes?.every(ep => ep > total));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private updateToCinemetaMetadata = async (metadata: IMetadataResponse): Promise<IMetadataResponse> => {
|
|
|
|
|
|
|
|
const query: IMetaDataQuery = {
|
|
|
|
|
|
|
|
id: metadata.imdbId,
|
|
|
|
|
|
|
|
type: metadata.type
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return await this.metadataService.getMetadata(query)
|
|
|
|
|
|
|
|
.then((newMetadataOrError) => {
|
|
|
|
|
|
|
|
if (newMetadataOrError instanceof Error) {
|
|
|
|
|
|
|
|
// handle error
|
|
|
|
|
|
|
|
this.logger.warn(`Failed ${metadata.imdbId} metadata cinemeta update due: ${newMetadataOrError.message}`);
|
|
|
|
|
|
|
|
return metadata; // or throw newMetadataOrError to propagate error up the call stack
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// At this point TypeScript infers newMetadataOrError to be of type MetadataResponse
|
|
|
|
|
|
|
|
const newMetadata = newMetadataOrError;
|
|
|
|
|
|
|
|
if (!newMetadata.videos || !newMetadata.videos.length) {
|
|
|
|
|
|
|
|
return metadata;
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
metadata.videos = newMetadata.videos;
|
|
|
|
|
|
|
|
metadata.episodeCount = newMetadata.episodeCount;
|
|
|
|
|
|
|
|
metadata.totalCount = newMetadata.totalCount;
|
|
|
|
|
|
|
|
return metadata;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private findMovieImdbId = (title: IFileAttributes | string): Promise<string | undefined> => {
|
|
|
|
|
|
|
|
const parsedTitle = typeof title === 'string' ? parse(title) : title;
|
|
|
|
|
|
|
|
this.logger.debug(`Finding movie imdbId for ${title}`);
|
|
|
|
|
|
|
|
return this.imdb_limiter.schedule(async () => {
|
|
|
|
|
|
|
|
const imdbQuery = {
|
|
|
|
|
|
|
|
title: parsedTitle.title,
|
|
|
|
|
|
|
|
year: parsedTitle.year,
|
|
|
|
|
|
|
|
type: TorrentType.Movie
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return await this.metadataService.getImdbId(imdbQuery);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private findMovieKitsuId = async (title: IFileAttributes | string): Promise<number | Error | undefined> => {
|
|
|
|
|
|
|
|
const parsedTitle = typeof title === 'string' ? parse(title) : title;
|
|
|
|
|
|
|
|
const kitsuQuery = {
|
|
|
|
|
|
|
|
title: parsedTitle.title,
|
|
|
|
|
|
|
|
year: parsedTitle.year,
|
|
|
|
|
|
|
|
season: parsedTitle.season,
|
|
|
|
|
|
|
|
type: TorrentType.Movie
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return await this.metadataService.getKitsuId(kitsuQuery);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isDiskTorrent = (contents: IContentAttributes[]): boolean => contents.some(content => ExtensionHelpers.isDisk(content.path));
|
|
|
|
private isDiskTorrent = (contents: IContentAttributes[]): boolean => contents.some(content => ExtensionHelpers.isDisk(content.path));
|
|
|
|
|
|
|
|
|
|
|
|
private isSingleMovie = (videos: IFileAttributes[]): boolean => videos.length === 1 ||
|
|
|
|
private isSingleMovie = (videos: IFileAttributes[]): boolean => videos.length === 1 ||
|
|
|
|
@@ -674,61 +203,80 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
private isFeaturette = (video: IFileAttributes): boolean => /featurettes?\/|extras-grym/i.test(video.path!);
|
|
|
|
private isFeaturette = (video: IFileAttributes): boolean => /featurettes?\/|extras-grym/i.test(video.path!);
|
|
|
|
|
|
|
|
|
|
|
|
private parseSeriesVideo = (video: IFileAttributes): IFileAttributes => {
|
|
|
|
private parseSeriesVideo = (video: IFileAttributes): IFileAttributes => {
|
|
|
|
const videoInfo = parse(video.path);
|
|
|
|
let pathParts = video.path?.split('/');
|
|
|
|
// the episode may be in a folder containing season number
|
|
|
|
let fileName = pathParts[pathParts.length - 1];
|
|
|
|
if (!Number.isInteger(videoInfo.season) && video.path?.includes('/')) {
|
|
|
|
|
|
|
|
const folders = video.path?.split('/');
|
|
|
|
let regexList = [
|
|
|
|
const pathInfo = parse(folders[folders.length - 2]);
|
|
|
|
{ regex: /(saison|season|se|s)\s?(\d{2})/gi, format: (match: RegExpMatchArray) => `S${match[2]}` },
|
|
|
|
videoInfo.season = pathInfo.season;
|
|
|
|
{ regex: /(episode|ep|e)\s?(\d{2})/gi, format: (match: RegExpMatchArray) => `E${match[2]}` },
|
|
|
|
}
|
|
|
|
];
|
|
|
|
if (!Number.isInteger(videoInfo.season) && video.season) {
|
|
|
|
|
|
|
|
videoInfo.season = video.season;
|
|
|
|
let formattedValues: string[] = [];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!Number.isInteger(videoInfo.season) && videoInfo.seasons && videoInfo.seasons.length > 1) {
|
|
|
|
for (let i = 0; i < regexList.length; i++) {
|
|
|
|
// in case single file was interpreted as having multiple seasons
|
|
|
|
let regexEntry = regexList[i];
|
|
|
|
[videoInfo.season] = videoInfo.seasons;
|
|
|
|
let match = regexEntry.regex.exec(fileName);
|
|
|
|
}
|
|
|
|
if (match) {
|
|
|
|
if (!Number.isInteger(videoInfo.season) && video.path?.includes('/') && video.seasons
|
|
|
|
let formattedValue = regexEntry.format(match);
|
|
|
|
&& video.seasons.length > 1) {
|
|
|
|
fileName = fileName.replace(match[0], '');
|
|
|
|
// russian season are usually named with 'series name-2` i.e. Улицы разбитых фонарей-6/22. Одиночный выстрел.mkv
|
|
|
|
formattedValues.push(formattedValue);
|
|
|
|
const folderPathSeasonMatch = video.path?.match(/[\u0400-\u04ff]-(\d{1,2})(?=.*\/)/);
|
|
|
|
}
|
|
|
|
videoInfo.season = folderPathSeasonMatch && parseInt(folderPathSeasonMatch[1], 10) || undefined;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// sometimes video file does not have correct date format as in torrent title
|
|
|
|
|
|
|
|
if (!videoInfo.episodes && !videoInfo.date && video.date) {
|
|
|
|
|
|
|
|
videoInfo.date = video.date;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// limit number of episodes in case of incorrect parsing
|
|
|
|
|
|
|
|
if (videoInfo.episodes && videoInfo.episodes.length > 20) {
|
|
|
|
|
|
|
|
videoInfo.episodes = [videoInfo.episodes[0]];
|
|
|
|
|
|
|
|
[videoInfo.episode] = videoInfo.episodes;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// force episode to any found number if it was not parsed
|
|
|
|
|
|
|
|
if (!videoInfo.episodes && !videoInfo.date) {
|
|
|
|
|
|
|
|
const epMatcher = videoInfo.title.match(
|
|
|
|
|
|
|
|
/(?<!season\W*|disk\W*|movie\W*|film\W*)(?:^|\W|_)(\d{1,4})(?:a|b|c|v\d)?(?:_|\W|$)(?!disk|movie|film)/i);
|
|
|
|
|
|
|
|
videoInfo.episodes = epMatcher && [parseInt(epMatcher[1], 10)] || undefined;
|
|
|
|
|
|
|
|
videoInfo.episode = videoInfo.episodes && videoInfo.episodes[0];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!videoInfo.episodes && !videoInfo.date) {
|
|
|
|
|
|
|
|
const epMatcher = video.title.match(new RegExp(`(?:\\(${videoInfo.year}\\)|part)[._ ]?(\\d{1,3})(?:\\b|_)`, "i"));
|
|
|
|
|
|
|
|
videoInfo.episodes = epMatcher && [parseInt(epMatcher[1], 10)] || undefined;
|
|
|
|
|
|
|
|
videoInfo.episode = videoInfo.episodes && videoInfo.episodes[0];
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let response : IFileAttributes = {...video, ...videoInfo};
|
|
|
|
fileName = fileName.trim();
|
|
|
|
|
|
|
|
|
|
|
|
if (video.imdbId && video.imdbId !== "0") {
|
|
|
|
|
|
|
|
response = {...response, imdbEpisode: videoInfo.episode, imdbSeason: videoInfo.season};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (video.kitsuId && video.kitsuId !== 0) {
|
|
|
|
let splitFilename = fileName.split(/\.(?=[^.]*$)/);
|
|
|
|
response = {...response, kitsuEpisode: videoInfo.episode};
|
|
|
|
|
|
|
|
|
|
|
|
fileName = `${splitFilename[0]} ${formattedValues.join('')}.${splitFilename[1]}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const parsedInfo = filenameParse(fileName, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if ('isTv' in parsedInfo) {
|
|
|
|
|
|
|
|
const parsedShow = parsedInfo as ParsedShow;
|
|
|
|
|
|
|
|
return this.mapParsedShowToVideo(video, parsedShow);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
title: video.title,
|
|
|
|
|
|
|
|
path: video.path,
|
|
|
|
|
|
|
|
size: video.size,
|
|
|
|
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
|
|
|
|
imdbId: video.imdbId ? video.imdbId : undefined,
|
|
|
|
|
|
|
|
kitsuId: video.kitsuId && video.kitsuId !== 0 ? video.kitsuId : 0,
|
|
|
|
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isSeries = (title: string): boolean => {
|
|
|
|
|
|
|
|
const regexList = [
|
|
|
|
|
|
|
|
/(saison|season|se|s)\s?(\d{2})/gi,
|
|
|
|
|
|
|
|
/(episode|ep|e)\s?(\d{2})/gi,
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return regexList.some(regex => regex.test(title));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private mapParsedShowToVideo(video: IFileAttributes, parsedShow: ParsedShow & { isTv: true }) : IFileAttributes {
|
|
|
|
|
|
|
|
let response : IFileAttributes = {
|
|
|
|
|
|
|
|
title: video.title,
|
|
|
|
|
|
|
|
season: parsedShow.seasons[0],
|
|
|
|
|
|
|
|
episode: parsedShow.episodeNumbers[0],
|
|
|
|
|
|
|
|
path: video.path,
|
|
|
|
|
|
|
|
size: video.size,
|
|
|
|
|
|
|
|
fileIndex: video.fileIndex,
|
|
|
|
|
|
|
|
imdbId: video.imdbId ? video.imdbId : undefined,
|
|
|
|
|
|
|
|
kitsuId: video.kitsuId && video.kitsuId !== 0 ? video.kitsuId : 0,
|
|
|
|
|
|
|
|
imdbSeason: video.season,
|
|
|
|
|
|
|
|
imdbEpisode: video.episode,
|
|
|
|
|
|
|
|
kitsuEpisode: video.episode,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.imdbSeason && response.imdbEpisode) {
|
|
|
|
|
|
|
|
response.imdbSeason = 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private isMovieVideo = (torrent: IParsedTorrent, video: IFileAttributes, otherVideos: IFileAttributes[], hasMovies: boolean): boolean => {
|
|
|
|
private isMovieVideo = (torrent: IParsedTorrent, video: IFileAttributes, otherVideos: IFileAttributes[], hasMovies: boolean): boolean => {
|
|
|
|
if (Number.isInteger(torrent.season) && Array.isArray(torrent.episodes)) {
|
|
|
|
if (Number.isInteger(torrent.season) && Array.isArray(torrent.episodes)) {
|
|
|
|
// not movie if video has season
|
|
|
|
// not movie if video has season
|
|
|
|
@@ -761,8 +309,4 @@ export class TorrentFileService implements ITorrentFileService {
|
|
|
|
video.kitsuEpisode = undefined;
|
|
|
|
video.kitsuEpisode = undefined;
|
|
|
|
return video;
|
|
|
|
return video;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private div100 = (episode: number): number => (episode / 100 >> 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private mod100 = (episode: number): number => episode % 100;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|