initial commit
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import {FileNameCleaner} from "./tools/FileNameCleaner.js";
|
||||
import {FileType} from "./FileType.js";
|
||||
import path from 'path';
|
||||
|
||||
import {episodePatterns, seasonAndEpisodePatterns, seasonPatterns, soloEp} from "./config/SeasonAndEpisodePatterns.js";
|
||||
import {extraEpKeyWords, keywordsArray, substringArray} from "./config/KeywordsArray.js";
|
||||
import {existsSync, readFileSync} from "fs";
|
||||
import {extensionMappings} from "./config/ExtentionsMapping";
|
||||
import {mimeTypeMappings} from "./config/MimeTypeMapping";
|
||||
import {FileNameVideoMetaExtractor, VideoFileMetadata} from "./FileNameVideoMetaExtractor";
|
||||
import {FileTypeConfigurable} from "./tools/FileTypeConfigurable";
|
||||
|
||||
/**
|
||||
* Result of file metadata extraction
|
||||
*/
|
||||
export interface FileMetadata extends VideoFileMetadata {
|
||||
fileName?: string;
|
||||
extension?: string;
|
||||
fileType?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class FileNameMetaExtractor {
|
||||
fileNameVideoMetaExtractor: FileNameVideoMetaExtractor;
|
||||
fileNameCleaner: FileNameCleaner = new FileNameCleaner();
|
||||
private fileType: FileType;
|
||||
|
||||
constructor(watchFolderList: string[], jsonConfigFile?: string) {
|
||||
// Load the config file
|
||||
let config: any = {};
|
||||
if (jsonConfigFile && existsSync(jsonConfigFile)) {
|
||||
const rawData = readFileSync(jsonConfigFile, 'utf-8');
|
||||
config = JSON.parse(rawData);
|
||||
}
|
||||
|
||||
// Use the config values or the default ones
|
||||
this.fileType = new FileTypeConfigurable(
|
||||
config.extensionMappings || extensionMappings,
|
||||
config.mimeTypeMappings || mimeTypeMappings
|
||||
);
|
||||
|
||||
this.fileNameVideoMetaExtractor = new FileNameVideoMetaExtractor(
|
||||
watchFolderList,
|
||||
config.episodePatterns || episodePatterns,
|
||||
config.seasonAndEpisodePatterns || seasonAndEpisodePatterns,
|
||||
config.seasonPatterns || seasonPatterns,
|
||||
config.extraEpKeyWords || extraEpKeyWords,
|
||||
config.keywordsArray || keywordsArray,
|
||||
config.substringArray || substringArray,
|
||||
config.soloEp || soloEp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from a file path
|
||||
* @returns Plain object with extracted metadata
|
||||
*/
|
||||
async extractMetadata(filePath: string): Promise<FileMetadata> {
|
||||
const result: FileMetadata = {};
|
||||
|
||||
// Get file type using mime type and file extension
|
||||
const fileType = await this.fileType.getFileType(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
let fileExtension = path.extname(filePath);
|
||||
|
||||
// Remove the dot from the extension
|
||||
fileExtension = fileExtension.slice(1);
|
||||
|
||||
result.fileName = fileName;
|
||||
result.extension = fileExtension;
|
||||
result.fileType = fileType;
|
||||
|
||||
// Extract tags from brackets
|
||||
const tags = this.fileNameCleaner.extractTags(filePath);
|
||||
if (tags.length > 0) {
|
||||
result.tags = tags;
|
||||
}
|
||||
|
||||
// For video and subtitle files, extract video-specific metadata
|
||||
const supportedVideoType = ['video', 'subtitle'];
|
||||
if (supportedVideoType.includes(fileType)) {
|
||||
const videoMetadata = this.fileNameVideoMetaExtractor.extractVideoFileMetadata(filePath);
|
||||
Object.assign(result, videoMetadata);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use extractMetadata instead - returns plain object
|
||||
*/
|
||||
async processFile(filePath: string, metadata: any): Promise<void> {
|
||||
const result = await this.extractMetadata(filePath);
|
||||
|
||||
// Write to metadata object for backwards compatibility
|
||||
if (result.fileName) metadata.at("fileName").set(result.fileName);
|
||||
if (result.extension) metadata.at("extension").set(result.extension);
|
||||
if (result.tags) {
|
||||
for (const tag of result.tags) {
|
||||
metadata.at("tags").add(tag);
|
||||
}
|
||||
}
|
||||
if (result.originalTitle) metadata.at("originalTitle").set(result.originalTitle);
|
||||
if (result.movieYear) metadata.at("movieYear").set(result.movieYear);
|
||||
if (result.season) metadata.at("season").set(result.season);
|
||||
if (result.episode) metadata.at("episode").set(result.episode);
|
||||
if (result.increment) metadata.at("increment").set(result.increment);
|
||||
if (result.extra) metadata.at("extra").set(result.extra);
|
||||
if (result.videoType) metadata.at("videoType").set(result.videoType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import {FileNameCleaner} from "./tools/FileNameCleaner.js";
|
||||
import {StringArrayOperation} from "./tools/StringArrayOperation.js";
|
||||
import {SerieFilesNameAnalysisTool} from "./tools/video/SerieFilesNamaAnalysisTool.js";
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Result of video file metadata extraction
|
||||
*/
|
||||
export interface VideoFileMetadata {
|
||||
originalTitle?: string;
|
||||
season?: string;
|
||||
episode?: string;
|
||||
increment?: string;
|
||||
movieYear?: string;
|
||||
videoType?: 'tvshow' | 'movie';
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
const MIN_TITLE_LENGTH = 3;
|
||||
|
||||
export class FileNameVideoMetaExtractor {
|
||||
fileNameCleaner = new FileNameCleaner();
|
||||
stringArrayOperation = new StringArrayOperation();
|
||||
serieFilesNameAnalysisTool: SerieFilesNameAnalysisTool;
|
||||
|
||||
constructor(
|
||||
private watchFolderList: string[],
|
||||
private episodePatterns:string[],
|
||||
private seasonAndEpisodePatterns:string[],
|
||||
private seasonPatterns:string[],
|
||||
private extraEpKeyWords:string[],
|
||||
private keywordsArray:string[],
|
||||
private substringArray:string[],
|
||||
private soloEp,
|
||||
) {
|
||||
this.serieFilesNameAnalysisTool = new SerieFilesNameAnalysisTool(
|
||||
episodePatterns,
|
||||
seasonAndEpisodePatterns,
|
||||
seasonPatterns,
|
||||
extraEpKeyWords,
|
||||
keywordsArray,
|
||||
substringArray,
|
||||
soloEp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the folder at a specified parent level from the file path,
|
||||
* or null if the file is not under any of the media root folders defined in watchFolderList,
|
||||
* or if no media root folder is defined, calculates based on the file path directly.
|
||||
* @param filePath The file path to check.
|
||||
* @param parent The parent level to adjust the depth check (1 for parent folder, 2 for grandparent folder, etc.).
|
||||
* @return The name of the folder at the specified parent level or null.
|
||||
*/
|
||||
getParentFolder(filePath: string, parent: number): string | null {
|
||||
if (parent < 0 || !Number.isInteger(parent)) {
|
||||
throw new Error('Parent parameter must be a non-negative integer.');
|
||||
}
|
||||
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
|
||||
const folderList = this.watchFolderList;
|
||||
if (folderList) {
|
||||
for (let folder of folderList) {
|
||||
const mediaRootFolder = path.normalize(folder);
|
||||
if (normalizedFilePath.startsWith(mediaRootFolder)) {
|
||||
const pathRelative = path.relative(mediaRootFolder, normalizedFilePath);
|
||||
const pathParts = pathRelative.split(path.sep);
|
||||
pathParts.reverse(); // Reverse the array to make it easier to access the parent levels.
|
||||
|
||||
if (pathParts.length >= parent) {
|
||||
return pathParts[parent]; // Adjusted for zero-indexed arrays.
|
||||
}
|
||||
return null; // The specified parent level is too deep.
|
||||
}
|
||||
}
|
||||
return null; // File path is not under any of the media root folders.
|
||||
}
|
||||
|
||||
// General solution for any parent level when no watchFolderList is defined.
|
||||
let targetFolder = normalizedFilePath;
|
||||
for (let i = 0; i < parent; i++) {
|
||||
targetFolder = path.dirname(targetFolder);
|
||||
}
|
||||
if (targetFolder === path.sep || !targetFolder) {
|
||||
return null; // Reached the root or an invalid state.
|
||||
}
|
||||
return path.basename(targetFolder);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Title cleaning
|
||||
*/
|
||||
|
||||
cleanTitle(simpleTitle: string): string {
|
||||
if (!simpleTitle) {
|
||||
return null;
|
||||
}
|
||||
simpleTitle = this.fileNameCleaner.cleanSpecialChars(simpleTitle);
|
||||
simpleTitle = this.serieFilesNameAnalysisTool.removeKeywordsFromFilename(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.cleanTags(simpleTitle);
|
||||
simpleTitle = this.serieFilesNameAnalysisTool.removeBeyondHyphen(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.removeTagSeparator(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.removeYear(simpleTitle);
|
||||
simpleTitle = this.serieFilesNameAnalysisTool.removeSFV(simpleTitle);
|
||||
simpleTitle = this.serieFilesNameAnalysisTool.removeSeasonAndEpisode(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.cleanDot(simpleTitle);
|
||||
//simpleTitle = this.fileNameCleaner.removeNumbers(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.removeHyphenAtTheEndAndStart(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.cleanSpace(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.convertFirstLetterToUpperCase(simpleTitle);
|
||||
simpleTitle = this.fileNameCleaner.sanitizeFilename(simpleTitle);
|
||||
return simpleTitle;
|
||||
}
|
||||
|
||||
computeTitleFromFileName(fileName: string): string {
|
||||
return this.cleanTitle(fileName);
|
||||
}
|
||||
|
||||
computeTitleFromParentFolder(filePath: string): string {
|
||||
return this.cleanTitle(this.getParentFolder(filePath, 1));
|
||||
}
|
||||
|
||||
computeTitleFromGrandParentFolder(filePath: string): string {
|
||||
return this.cleanTitle(this.getParentFolder(filePath, 2));
|
||||
}
|
||||
|
||||
computeTitleFromFilePath(filePath: string): string | undefined {
|
||||
let filename = this.fileNameCleaner.getFilenameFromPath(filePath);
|
||||
let titleFromFileName = this.computeTitleFromFileName(filename);
|
||||
let titleFromParentFolder = this.computeTitleFromParentFolder(filePath);
|
||||
let titleFromGrandParentFolder = this.computeTitleFromGrandParentFolder(filePath);
|
||||
let simpleTitle = this.stringArrayOperation.longestString([titleFromFileName, titleFromParentFolder, titleFromGrandParentFolder]);
|
||||
let selectedTitle;
|
||||
if (titleFromFileName && titleFromFileName.length > MIN_TITLE_LENGTH) {
|
||||
selectedTitle = titleFromFileName;
|
||||
} else if (titleFromParentFolder && titleFromParentFolder.length > MIN_TITLE_LENGTH) {
|
||||
selectedTitle = titleFromParentFolder;
|
||||
} else if (titleFromGrandParentFolder && titleFromGrandParentFolder.length > MIN_TITLE_LENGTH) {
|
||||
selectedTitle = titleFromGrandParentFolder;
|
||||
} else {
|
||||
selectedTitle = simpleTitle;
|
||||
}
|
||||
|
||||
if (selectedTitle && selectedTitle.length > MIN_TITLE_LENGTH) {
|
||||
return selectedTitle;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Season Ep cleaning
|
||||
*/
|
||||
|
||||
computeSeasonFromParentFolder(filePath: string): number {
|
||||
let parentFolder = this.getParentFolder(filePath, 1);
|
||||
if (!parentFolder) {
|
||||
return -1;
|
||||
}
|
||||
let parentFolderName = path.basename(path.dirname(filePath));
|
||||
parentFolderName = this.serieFilesNameAnalysisTool.removeKeywordsFromFilename(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanTags(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanSpecialChars(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanDot(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanSpace(parentFolderName);
|
||||
return this.serieFilesNameAnalysisTool.findSeasonFromParentFolder(parentFolderName);
|
||||
}
|
||||
|
||||
computeSeasonAndEpisodeFromFilename(filename: string): [number, number] { // season , episode
|
||||
filename = this.serieFilesNameAnalysisTool.removeKeywordsFromFilename(filename);
|
||||
filename = this.serieFilesNameAnalysisTool.removeSFV(filename);
|
||||
filename = this.fileNameCleaner.cleanSpecialChars(filename);
|
||||
filename = this.fileNameCleaner.cleanSpace(filename);
|
||||
return this.serieFilesNameAnalysisTool.findSeasonAndEpisode(filename);
|
||||
}
|
||||
|
||||
computeSeasonAndEpisodeFromFilePath(filePath: string): { season?: string; episode?: string; increment?: string } {
|
||||
const result: { season?: string; episode?: string; increment?: string } = {};
|
||||
let filename = this.fileNameCleaner.getFilenameFromPath(filePath);
|
||||
const seasonFromParentFolder = this.computeSeasonFromParentFolder(filePath);
|
||||
let [sea, ep] = this.computeSeasonAndEpisodeFromFilename(filename);
|
||||
|
||||
//season and ep 0 can exist (but < 0 is not possible)
|
||||
// first trust file then trust parent folder
|
||||
if (sea >= 0) {
|
||||
result.season = "" + sea;
|
||||
} else if (seasonFromParentFolder >= 0) {
|
||||
sea = seasonFromParentFolder;
|
||||
result.season = "" + sea;
|
||||
}
|
||||
if (ep >= 0) {
|
||||
result.episode = "" + ep;
|
||||
}
|
||||
if (ep >= 0) {
|
||||
if (sea < 0) {
|
||||
sea = 1;
|
||||
//assume season 1
|
||||
result.season = "1";
|
||||
}
|
||||
//max ep is 9999
|
||||
result.increment = "" + (sea * 10000 + ep);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Movie Year cleaning
|
||||
*/
|
||||
computeMovieYearFromFilename(filename: string): number {
|
||||
//year are most probably in a tag so no cleaning eg "(2020) or [2020] or {2020} or 2020"
|
||||
return this.serieFilesNameAnalysisTool.findMovieYear(filename);
|
||||
}
|
||||
|
||||
computeMovieYearFromParentFolder(filePath: string): number {
|
||||
let parentFolder = this.getParentFolder(filePath, 1);
|
||||
if (!parentFolder) {
|
||||
return null;
|
||||
}
|
||||
let parentFolderName = path.basename(path.dirname(filePath));
|
||||
parentFolderName = this.serieFilesNameAnalysisTool.removeKeywordsFromFilename(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanSpecialChars(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanDot(parentFolderName);
|
||||
parentFolderName = this.fileNameCleaner.cleanSpace(parentFolderName);
|
||||
return this.serieFilesNameAnalysisTool.findMovieYear(parentFolderName);
|
||||
}
|
||||
|
||||
computeMovieYearFromFilePath(filePath: string): string | undefined {
|
||||
let filename = this.fileNameCleaner.getFilenameFromPath(filePath);
|
||||
let movieYear = this.computeMovieYearFromFilename(filename);
|
||||
let movieYearFromParentFolder = this.computeMovieYearFromParentFolder(filePath);
|
||||
//first trust file then trust parent folder
|
||||
if (movieYear > 0) {
|
||||
return "" + movieYear;
|
||||
} else if (movieYearFromParentFolder > 0) {
|
||||
return "" + movieYearFromParentFolder;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from a video file path
|
||||
* @returns Plain object with extracted metadata
|
||||
*/
|
||||
public extractVideoFileMetadata(filePath: string): VideoFileMetadata {
|
||||
const result: VideoFileMetadata = {};
|
||||
|
||||
// Extract title
|
||||
const title = this.computeTitleFromFilePath(filePath);
|
||||
if (title) {
|
||||
result.originalTitle = title;
|
||||
}
|
||||
|
||||
// Extract movie year
|
||||
const movieYear = this.computeMovieYearFromFilePath(filePath);
|
||||
if (movieYear) {
|
||||
result.movieYear = movieYear;
|
||||
}
|
||||
|
||||
// Extract season and episode
|
||||
const seasonEpisode = this.computeSeasonAndEpisodeFromFilePath(filePath);
|
||||
if (seasonEpisode.season) result.season = seasonEpisode.season;
|
||||
if (seasonEpisode.episode) result.episode = seasonEpisode.episode;
|
||||
if (seasonEpisode.increment) result.increment = seasonEpisode.increment;
|
||||
|
||||
// Check for extra flag
|
||||
if (this.serieFilesNameAnalysisTool.isExtra(filePath)) {
|
||||
result.extra = "true";
|
||||
}
|
||||
|
||||
// Compute video type
|
||||
const season = result.season ? parseInt(result.season) : -1;
|
||||
const episode = result.episode ? parseInt(result.episode) : -1;
|
||||
const year = result.movieYear ? parseInt(result.movieYear) : -1;
|
||||
|
||||
if (season > 0 || episode > 0) {
|
||||
result.videoType = "tvshow";
|
||||
} else if (year > 1800 && year < 2100) {
|
||||
result.videoType = "movie";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use extractVideoFileMetadata instead - returns plain object
|
||||
*/
|
||||
public processVideoFile(filePath: string, metadata: any): void {
|
||||
const result = this.extractVideoFileMetadata(filePath);
|
||||
|
||||
// Write to metadata object for backwards compatibility
|
||||
if (result.originalTitle) metadata.at("originalTitle").set(result.originalTitle);
|
||||
if (result.movieYear) metadata.at("movieYear").set(result.movieYear);
|
||||
if (result.season) metadata.at("season").set(result.season);
|
||||
if (result.episode) metadata.at("episode").set(result.episode);
|
||||
if (result.increment) metadata.at("increment").set(result.increment);
|
||||
if (result.extra) metadata.at("extra").set(result.extra);
|
||||
if (result.videoType) metadata.at("videoType").set(result.videoType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {FileTypeConfigurable} from "./tools/FileTypeConfigurable";
|
||||
import {extensionMappings} from "./config/ExtentionsMapping";
|
||||
import {mimeTypeMappings} from "./config/MimeTypeMapping";
|
||||
|
||||
export class FileType extends FileTypeConfigurable {
|
||||
|
||||
constructor() {
|
||||
super(extensionMappings,mimeTypeMappings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SimpleFileType =
|
||||
'audio'
|
||||
| 'video'
|
||||
| 'document'
|
||||
| 'archive'
|
||||
| 'subtitle'
|
||||
| 'torrent'
|
||||
| 'other'
|
||||
| 'undefined';
|
||||
@@ -0,0 +1,28 @@
|
||||
export const animeKeywords = [
|
||||
"[HorribleSubs]",
|
||||
"[DB]", // Dragon Ball fansubs, also generic for various sub groups
|
||||
"[Erai-raws]",
|
||||
"[SubsPlease]",
|
||||
"[GG]", // A known fansub group
|
||||
"[Commie]", // A fansubbing group
|
||||
"[Underwater]", // A fansubbing group
|
||||
"[Nyaa]", // A reference to the Nyaa Torrents site, often included in anime torrents
|
||||
"[FFF]", // A fansubbing group
|
||||
"[Coalgirls]", // A fansubbing group known for high-quality releases
|
||||
"[Chihiro]", // A fansubbing group
|
||||
"[DameDesuYo]", // A fansubbing group
|
||||
"[GJM]", // A fansubbing group (Good Job! Media)
|
||||
"[AhorribleSubs]", // A misspelling of HorribleSubs sometimes used
|
||||
"[AnimeRG]", // A group known for releasing anime torrents
|
||||
"[Judas]", // A group known for their anime releases
|
||||
"[Cleo]", // A group known for their anime encodes
|
||||
"[LostYears]", // A group known for dub and sub releases
|
||||
"[KaiDubs]", // A group known for dubbed anime releases
|
||||
"[AnimeKaizoku]", // A website known for anime downloads, sometimes included in filenames
|
||||
"[AnimeLand]", // A website known for anime downloads, sometimes included in filenames
|
||||
"[Beatrice-Raws]", // A group known for raw anime blu-ray encodes
|
||||
"[BakedFish]", // A less common but recognized release group
|
||||
"[DeadFish]", // A group known for re-encoding fansubs into hardsubbed mp4 format
|
||||
"[Golumpa]", // A group known for dubbed anime releases
|
||||
"[EMBER]", // A group known for their anime releases
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import {SimpleFileType} from "../SimpleFileType";
|
||||
|
||||
export const extensionMappings: { [key: string]: SimpleFileType } = {
|
||||
// Audio
|
||||
'mp3': 'audio',
|
||||
'wav': 'audio',
|
||||
'flac': 'audio',
|
||||
'aac': 'audio',
|
||||
'ogg': 'audio',
|
||||
'm4a': 'audio',
|
||||
// Video
|
||||
'mp4': 'video',
|
||||
'mkv': 'video',
|
||||
'webm': 'video',
|
||||
'avi': 'video',
|
||||
'mov': 'video',
|
||||
'wmv': 'video',
|
||||
'flv': 'video',
|
||||
'm4v': 'video',
|
||||
'mpg': 'video',
|
||||
'mpeg': 'video',
|
||||
'3gp': 'video',
|
||||
'3g2': 'video',
|
||||
'f4v': 'video',
|
||||
'm2ts': 'video',
|
||||
'mts': 'video',
|
||||
'ts': 'video',
|
||||
'vob': 'video',
|
||||
'ogm': 'video',
|
||||
'divx': 'video',
|
||||
'xvid': 'video',
|
||||
// Document
|
||||
'pdf': 'document',
|
||||
'doc': 'document',
|
||||
'docx': 'document',
|
||||
'ppt': 'document',
|
||||
'pptx': 'document',
|
||||
'xls': 'document',
|
||||
'xlsx': 'document',
|
||||
'txt': 'document',
|
||||
'rtf': 'document',
|
||||
// Images (are document)
|
||||
'jpg': 'document',
|
||||
'jpeg': 'document',
|
||||
'png': 'document',
|
||||
'gif': 'document',
|
||||
'bmp': 'document',
|
||||
'tif': 'document',
|
||||
'tiff': 'document',
|
||||
'svg': 'document',
|
||||
'webp': 'document',
|
||||
// Archive
|
||||
'zip': 'archive',
|
||||
'rar': 'archive',
|
||||
'7z': 'archive',
|
||||
'tar': 'archive',
|
||||
'gz': 'archive',
|
||||
'bz2': 'archive',
|
||||
'xz': 'archive',
|
||||
//subtitles
|
||||
'srt': 'subtitle',
|
||||
'sub': 'subtitle',
|
||||
'sbv': 'subtitle',
|
||||
'vtt': 'subtitle',
|
||||
//torrent
|
||||
'torrent': 'torrent',
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Convert the keywords string to an array manually for readability
|
||||
* Create regex from keyword, ensuring word boundaries, case insensitive ('i') and global ('g') to replace all occurrences
|
||||
* order matters longest first
|
||||
* Try not to put real word in order to avoid corrupting the title
|
||||
**/
|
||||
export const keywordsArray = [
|
||||
'VF', 'aXXo', 'STFR', 'SUBFORCED', 'BrRipx264', 'YIFY', 'TRUEFRENCH', 'VFF', 'ATVP','RenewalEX','BD','DD','8bits', '8 bits' ,'10bits','10 bits', 'part 1','part 2', 'part 3', 'part 4', 'part A', 'part B', 'part C', 'part D',
|
||||
'AAC5', 'AAC5.1','1080i', '1080p', '2160p', '480i', '480p', '4k', '576i', '576p', '720',
|
||||
'720i', '720p', 'aac', 'aac4', 'ac3', 'amzn', 'apple tv+', 'avi', 'bbc',
|
||||
'bd5', 'bdrip', 'blu-ray', 'bluray', 'brrip', 'cam', 'cw', 'dc', 'dcu', 'ddp5.1', "director's cut", 'disney+',
|
||||
'divx', 'divx5', 'dl', 'dsr', 'dsrip', 'dts', 'dual audio', 'dubbed', 'dvd', 'dvdivx',
|
||||
'dvdr', 'dvdrip', 'dvdscr', 'dvdscreener', 'eng', 'eng sub',
|
||||
'esp', 'fan edit', 'fhd', 'flv', 'fs', 'ger', 'german', 'h.264', 'h.265', 'h264',
|
||||
'h265', 'hardcoded', 'hbo', 'hd', 'hddvd', 'hdr', 'hdrip', 'hdtv', 'hdtvrip',
|
||||
'hevc', 'hq', 'hrhd', 'hrhdtv', 'hulu', 'imax', 'ita', 'jpn', 'ld',
|
||||
'md', 'mkv', 'mp3', 'mp4', 'mpeg', 'mpg', 'mq', 'multi',
|
||||
'multisubs', 'netflix', 'nf', 'nfofix', 'ntsc', 'ogg', 'ogm', 'ova',
|
||||
'pal', 'pdtv', 'r3', 'r5', 'rerip', 'rsvcd', 'screener', 'sd', 'se',
|
||||
'subbed', 'svcd', 'tc', 'telecine','telesync', 'ts', 'tv series', 'uhd', 'uhdtv',
|
||||
'uhdv', 'v2', 'vcd', 'vostfr', 'web', 'web-dl', 'webcast',
|
||||
'webrip', 'wmv', 'ws', 'www', 'x264', 'x265', 'xsvcd', 'xvid', 'xvidvd',
|
||||
'xxx','bits','AnimeServ'
|
||||
];
|
||||
|
||||
/**
|
||||
* Same as keywordsArray but for substring (aka no word boundary)
|
||||
* Needs to be filled with extra care to avoid corrupting the title
|
||||
*/
|
||||
export const substringArray = [
|
||||
'v1','v2', 'v3', 'v4', 'v5'
|
||||
];
|
||||
|
||||
export const extraEpKeyWords = [
|
||||
'Extras','OVA','OAV','SP','NCOP','NCED','OP','ED','PV','CM','NC','NCOPED','NCOP'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {SimpleFileType} from "../SimpleFileType";
|
||||
|
||||
export const mimeTypeMappings: { [key: string]: SimpleFileType } = {
|
||||
'text/plain': 'undefined',//text could be anything document or subtitle or other
|
||||
// Audio MIME types
|
||||
'audio/mpeg': 'audio',
|
||||
'audio/wav': 'audio',
|
||||
'audio/flac': 'audio',
|
||||
// Video MIME types
|
||||
'video/mp4': 'video',
|
||||
'video/mpeg': 'video',
|
||||
'video/quicktime': 'video',
|
||||
'video/x-msvideo': 'video',
|
||||
'video/x-flv': 'video',
|
||||
'video/x-ms-wmv': 'video',
|
||||
'video/x-matroska': 'video',
|
||||
'video/webm': 'video',
|
||||
// Document MIME types
|
||||
'application/pdf': 'document',
|
||||
'application/msword': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
|
||||
'application/vnd.ms-powerpoint': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document',
|
||||
'application/vnd.ms-excel': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document',
|
||||
'image/jpeg': 'document',
|
||||
'image/png': 'document',
|
||||
'image/gif': 'document',
|
||||
// Archive MIME types
|
||||
'application/zip': 'archive',
|
||||
'application/x-rar-compressed': 'archive',
|
||||
'application/x-7z-compressed': 'archive',
|
||||
'application/gzip': 'archive',
|
||||
'application/x-tar': 'archive',
|
||||
//torrent
|
||||
'application/x-bittorrent': 'torrent',
|
||||
//subtitles
|
||||
'text/vtt': 'subtitle',
|
||||
'text/srt': 'subtitle',
|
||||
'text/ssa': 'subtitle',
|
||||
'text/sub': 'subtitle',
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Definition of the patterns used to match season and episode numbers in file names (order matters longest first).
|
||||
const ep4 = "((?:\\d{1,4})(?:\\.5)?)" // Matches "1" up to "9999" and "1.5" up to "9999.5"
|
||||
const ep2 = "((?:\\d{1,2})(?:\\.5)?)" // Matches "1" up to "99" and "1.5" up to "99.5"
|
||||
const ep3 = "((?:\\d{1,3})(?:\\.5)?)" // Matches "1" up to "999" and "1.5" up to "999.5"
|
||||
const sea = "((?:\\d{1,2})(?:\\.5)?)" // Matches "1" up to "99"
|
||||
const ep4mY = "((?:\\d{1,3}|1[0-7]\\d{2}|1800)(?:\\.5)?)" // Matches "1" up to "1800"
|
||||
const sw = "(?:\\.|\\b)" // a dot or word boundary
|
||||
const end = "(?:(?:\\.|\\b).*)" // any character after a season pattern is matched to be removed
|
||||
const s = "(?:\\.|\\b|\\s)"
|
||||
|
||||
export const soloEp = `${sw}${ep3}${sw}` // Matches "1" up to "9999" and "1.5" up to "9999.5"
|
||||
|
||||
//mind the parenthesis
|
||||
export const seasonAndEpisodePatterns = [
|
||||
`${sw}Season${s}*${sea}${s}*Episode${s}*${ep4}${end}`, // Verbose "Season 1 Episode 02" and "Season 1 Episode 02.5" format
|
||||
`${sw}Saison${s}*${sea}${s}*Episode${s}*${ep4}${end}`, // French "Saison 1 Episode 02" and "Saison 1 Episode 02.5" format
|
||||
`${sw}\\[${sea}${s}*x${s}*${ep4}\\]${end}`, // Matches concise "[1x02]" and "[1x02.5]" format
|
||||
`${sw}\\(${sea}${s}*x${s}*${ep4}\\)${end}`, // Matches concise "(1x02)" and "(1x02.5)" format
|
||||
`${sw}${sea}${s}*x${s}*${ep4}${end}`, // Matches concise "1x02" and "1x02.5" format
|
||||
`${sw}S${sea}${s}*x${s}*${ep4}${end}`, // Matches "S1x02" and "S1x02.5" format
|
||||
`${sw}S${sea}${s}*E${ep4}${end}`, // Strict "S01E03" and "S01E03.5" format
|
||||
`${sw}${sea}${s}*E${ep4}${end}`, // Matches "03E04" and "03E04.5" format
|
||||
`${sw}S${s}*${sea}${s}*E${s}*${ep2}${end}`, // Spaced "S 3 E 06" and "S 3 E 06.5" format
|
||||
`${sw}S${s}*${sea}${s}+${ep2}${end}`, // Compact "S 03 04" and "S 03 04.5" spacing variant
|
||||
`${sw}S${sea}${s}*-${s}*${ep2}${end}`, // Hyphen-separated "S2 - 07" and "S2 - 07.5" format
|
||||
`${sw}${sea}${s}*-${s}*${ep2}${end}`, // General "2 - 07" and "2 - 07.5" hyphen-separated format
|
||||
];
|
||||
|
||||
|
||||
|
||||
// Episode only patterns E01 etc...
|
||||
export const episodePatterns = [
|
||||
//`${sw}${ep3}${end}`, // Allows "04" or "4.5" - Akiba General Election //NOTE too generic to be managed at this level
|
||||
`${sw}Episode${s}*${ep4}${end}`, // Matches "Episode 1" up to "Episode 9999" and "Episode 1.5"
|
||||
`${sw}Epizoda${s}*${ep4}${end}`, // Matches "Episode 1" up to "Episode 9999" and "Episode 1.5"
|
||||
`${sw}E${s}*${ep4mY}${end}`, // Matches " E 1" up to " E 1800" and " E 1.5"
|
||||
`${sw}Ep${ep4mY}${end}`, // Matches "Ep1" up to "Ep1800" and "Ep1.5"
|
||||
];
|
||||
|
||||
|
||||
//Season only paterns S01 etc...
|
||||
export const seasonPatterns: string[] = [
|
||||
`${sw}Season${s}*${sea}${end}`, // Matches "Season 1" (eng) up to "Season 99"
|
||||
`${sw}Saison${s}*${sea}${end}`, // Matches "Saison 1" (fra) up to "Saison 99"
|
||||
`${sw}S${s}*${sea}${end}`, // Matches "S 1" up to "S 99"
|
||||
`${sw}${sea}${end}`, // Matches "1" up to "99"
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './FileNameMetaExtractor';
|
||||
export * from './FileNameVideoMetaExtractor';
|
||||
export * from './SimpleFileType';
|
||||
export * from './FileType';
|
||||
|
||||
export * from './tools/folder/FolderTool';
|
||||
export * from "./tools/lang/LanguageTool";
|
||||
export * from './tools/video/SerieFilesNamaAnalysisTool';
|
||||
export * from './tools/FileNameCleaner';
|
||||
export * from './tools/FileNameStringOperation';
|
||||
export * from './tools/FileTypeConfigurable';
|
||||
export * from './tools/StringArrayOperation';
|
||||
export * from './tools/TwoStringsOperation';
|
||||
|
||||
export * from './config/AnimeKeywords';
|
||||
export * from './config/ExtentionsMapping';
|
||||
export * from './config/KeywordsArray';
|
||||
export * from './config/MimeTypeMapping';
|
||||
export * from './config/SeasonAndEpisodePatterns';
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
export class FileNameCleaner {
|
||||
/**
|
||||
* Extract the filename from the file path (remove extension and folder path)
|
||||
* @param filePath
|
||||
*/
|
||||
public getFilenameFromPath(filePath: string): string {
|
||||
// Find the last occurrence of both '/' and '\'
|
||||
let lastSlash = filePath.lastIndexOf('/');
|
||||
let lastBackSlash = filePath.lastIndexOf('\\');
|
||||
|
||||
// Use the greater index to support both Windows and Unix paths
|
||||
let fileNameIndex = Math.max(lastSlash, lastBackSlash) + 1;
|
||||
let fileName = filePath.substring(fileNameIndex);
|
||||
|
||||
// Remove the file extension from the file name
|
||||
let extensionIndex = fileName.lastIndexOf('.');
|
||||
if (extensionIndex != -1) {
|
||||
fileName = fileName.substring(0, extensionIndex);
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public cleanSpace(filename: string): string {
|
||||
// Remove double (multiple) spaces
|
||||
filename = filename.replaceAll(/\s+/g, " ");
|
||||
// Remove leading spaces
|
||||
filename = filename.replace(/^\s/, "");
|
||||
// Remove trailing spaces if any
|
||||
filename = filename.replace(/\s$/, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
//tags are strings that are enclosed in square brackets or parentheses or curly braces
|
||||
public cleanTags(filename: string): string {
|
||||
// Remove tags
|
||||
filename = filename.replaceAll(/\[.*?\]/g, "");
|
||||
filename = filename.replaceAll(/\(.*?\)/g, "");
|
||||
filename = filename.replaceAll(/\{.*?\}/g, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
// Remove special characters (eg underscore) except for hyphen(-) , (.), (') and Tag separator (, ), [, ], {, }
|
||||
public cleanSpecialChars(filename: string): string {
|
||||
// Remove special characters
|
||||
const specialChars = ["'", ",", "_", "@", "#", "$", "%", "^", "&", "*", "+", "=", "<", ">", "?", "|", ":", ";", "\"", "`", "~"];
|
||||
for (let char of specialChars) {
|
||||
filename = filename.replaceAll(char, " ");
|
||||
}
|
||||
// Remove double (multiple) spaces
|
||||
filename = filename.replaceAll(/\s+/g, " ");
|
||||
return filename;
|
||||
}
|
||||
|
||||
public cleanDot(filename: string): string {
|
||||
// Remove dot
|
||||
filename = filename.replaceAll(/\./g, " ");
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* remove the Tag separator "(", ")", "[", "]", "{", "}" from the filename
|
||||
* @param filename
|
||||
*/
|
||||
public removeTagSeparator(filename: string): string {
|
||||
// Remove tag separators
|
||||
filename = filename.replaceAll(/\(|\)|\[|\]|\{|\}/g, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
public extractTags(filename: string): string[] {
|
||||
// Extract tags
|
||||
const tags = filename.match(/\[.*?\]|\(.*?\)|\{.*?\}/g);
|
||||
return tags ? tags.map(tag => tag.slice(1, -1)) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* remove the remaining hyphens at the end "-" or "- "
|
||||
* @param filename
|
||||
*/
|
||||
removeHyphenAtTheEndAndStart(filename: string): string {
|
||||
// Remove hyphen at the end
|
||||
filename = filename.replace(/-\s*$/, "");
|
||||
// Remove hyphen at the start
|
||||
filename = filename.replace(/^\s*-/, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* year ar 4 digits 1800-2099
|
||||
* @param filename
|
||||
*/
|
||||
public removeYear(filename: string): string {
|
||||
// Remove year
|
||||
filename = filename.replaceAll(/\b(19|20)\d{2}\b/g, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert the first letter of each word to uppercase
|
||||
* with the exception of small world like "of"
|
||||
* Aims to get a movie title style
|
||||
* don't touch the original casing of others letters in the words
|
||||
* */
|
||||
public convertFirstLetterToUpperCase(filename: string): string {
|
||||
// List of small words to exclude from capitalization, all in lowercase
|
||||
const smallWords = ['of', 'in', 'the', 'on', 'at', 'to', 'a', 'an', 'and', 'but', 'or', 'for', 'nor', 'with'];
|
||||
|
||||
// Split the filename into words
|
||||
const words = filename.split(' ');
|
||||
|
||||
// Process each word
|
||||
const titleCasedFilename = words
|
||||
.map((word, index) => {
|
||||
// Check if word is in the list of small words (ignoring case)
|
||||
if (index !== 0 && smallWords.includes(word.toLowerCase())) {
|
||||
// If it's a small word (not the first word), return it as is
|
||||
return word;
|
||||
} else {
|
||||
// Otherwise, capitalize the first letter but keep the rest of the word as is
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
})
|
||||
.join(' '); // Join the words back into a string
|
||||
|
||||
return titleCasedFilename;
|
||||
}
|
||||
|
||||
public removeNumbers(filename: string): string {
|
||||
// Remove numbers
|
||||
filename = filename.replaceAll(/\d/g, "");
|
||||
return filename;
|
||||
}
|
||||
|
||||
public removeExtension(filename: string): string {
|
||||
// Find the last index of '.' to identify the extension
|
||||
let extensionIndex = filename.lastIndexOf('.');
|
||||
if (extensionIndex != -1) {
|
||||
// Extract the extension excluding the dot
|
||||
let extension = filename.substring(extensionIndex + 1);
|
||||
// Check if the length of the extension is 5 or fewer characters
|
||||
if (extension.length <= 5) {
|
||||
// Remove the extension if it meets the criteria
|
||||
filename = filename.substring(0, extensionIndex);
|
||||
}
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string to be used in a filename or a path - remove illegal characters and reserved names
|
||||
* @param value
|
||||
*/
|
||||
sanitizeFilename(value: string): string {
|
||||
const illegalRe = /[<>:"\/\\|?*\x00-\x1F]/g; // Regex for Windows' illegal characters
|
||||
const controlRe = /[\x00-\x1F\x80-\x9F]/g; // Control characters
|
||||
const reservedRe = /^\.+$/; // Reserved filenames in Windows like "." and ".."
|
||||
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i; // Windows reserved names
|
||||
const spaceEndRe = /[\. ]+$/; // Trailing dots or spaces
|
||||
|
||||
let sanitized = value
|
||||
.replace(illegalRe, '') // Remove illegal characters
|
||||
.replace(controlRe, '')
|
||||
.replace(reservedRe, '')
|
||||
.replace(windowsReservedRe, '')
|
||||
.replace(spaceEndRe, '');
|
||||
|
||||
// Ensure the filename is not too long
|
||||
const MAX_LENGTH = 255;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.substring(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class FileNameStringOperation {
|
||||
cleanSpace(dataString: string): string {
|
||||
// Remove double spaces
|
||||
dataString = dataString.replace(/\s+/g, " ");
|
||||
// Remove leading spaces
|
||||
dataString = dataString.replace(/^\s/, "");
|
||||
// Remove trailing spaces if any
|
||||
if (dataString.endsWith(" ")) {
|
||||
dataString = dataString.slice(0, -1);
|
||||
}
|
||||
return dataString;
|
||||
}
|
||||
|
||||
removeExt(filename: string): string {
|
||||
const lastDotPosition = filename.lastIndexOf(".");
|
||||
if (lastDotPosition === -1) return ""; // No extension found
|
||||
return filename.substring(0, lastDotPosition);
|
||||
}
|
||||
|
||||
getExt(filename: string): string {
|
||||
const lastDotPosition = filename.lastIndexOf(".");
|
||||
if (lastDotPosition === -1) return ""; // No extension found
|
||||
return filename.substring(lastDotPosition + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {fileTypeFromFile} from 'file-type';
|
||||
import {SimpleFileType} from "../SimpleFileType";
|
||||
|
||||
export class FileTypeConfigurable {
|
||||
|
||||
constructor(private extensionMappings: Record<string, SimpleFileType>,private mimeTypeMappings:Record<string,SimpleFileType>) {
|
||||
}
|
||||
|
||||
public async getMimeTypeFromFile(filepath: string, retries: number = 3): Promise<string | undefined> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const result = await fileTypeFromFile(filepath);
|
||||
return result?.mime;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Only retry on EINVAL errors (common with SMB/CIFS mounts)
|
||||
if (error.code === 'EINVAL' && attempt < retries - 1) {
|
||||
// Exponential backoff: 50ms, 100ms, 200ms
|
||||
const delay = 50 * Math.pow(2, attempt);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't retry on other errors
|
||||
if (error.code !== 'EINVAL') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only log warning if all retries failed
|
||||
if (lastError) {
|
||||
// For EINVAL errors on network mounts, use less verbose logging
|
||||
if (lastError.code === 'EINVAL' && filepath.includes('/smb-')) {
|
||||
console.warn(`MIME type detection failed for network file (EINVAL - likely concurrent access or stale handle): ${filepath}`);
|
||||
} else {
|
||||
console.warn(`Error reading MIME type from file ${filepath}:`, lastError);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file type based on the file extension
|
||||
* @param filepath
|
||||
*/
|
||||
public getFileTypeFromExtension(filepath: string): SimpleFileType {
|
||||
const extension = filepath.split('.').pop()?.toLowerCase();
|
||||
let extensionBasedType:SimpleFileType = 'undefined';//some files don't have extension (eg. README)
|
||||
if (extension) {
|
||||
extensionBasedType = this.extensionMappings[extension] || 'other';
|
||||
}
|
||||
return extensionBasedType;
|
||||
}
|
||||
|
||||
public async getFileType(filepath: string): Promise<SimpleFileType> {
|
||||
const mimeType = await this.getMimeTypeFromFile(filepath);
|
||||
let extensionBasedType:SimpleFileType = this.getFileTypeFromExtension(filepath);
|
||||
|
||||
if (mimeType) {
|
||||
const mimeBasedType = this.mimeTypeMappings[mimeType];
|
||||
if (mimeBasedType && mimeBasedType !== 'undefined') {
|
||||
if (extensionBasedType !== mimeBasedType) {
|
||||
console.warn(`Warning: Possible mismatch between extension and MIME type for file ${filepath}. Extension suggests ${extensionBasedType}, but MIME type suggests ${mimeBasedType}.`);
|
||||
return 'undefined';
|
||||
}
|
||||
return mimeBasedType;
|
||||
}
|
||||
}
|
||||
|
||||
return extensionBasedType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {TwoStringsOperation} from './TwoStringsOperation.js';
|
||||
|
||||
export class StringArrayOperation {
|
||||
private tso = new TwoStringsOperation();
|
||||
|
||||
longestString(strings: string[]): string {
|
||||
let longest = '';
|
||||
strings.forEach(str => {
|
||||
if (str && (str.length > longest.length)) {
|
||||
longest = str;
|
||||
}
|
||||
});
|
||||
return longest;
|
||||
}
|
||||
|
||||
findUniqueCommonSubstring(data: string[]): string {
|
||||
let sortedEntries = this.findCommonSubstring(data);
|
||||
return sortedEntries.length > 0 ? sortedEntries[0][0] : '';
|
||||
}
|
||||
|
||||
findCommonSubstring(data: string[]): [string,number][] {
|
||||
let minStringLength = 4;
|
||||
let finalData: Map<string, number> = new Map();
|
||||
|
||||
data.forEach(name1 => {
|
||||
data.forEach(name2 => {
|
||||
let strcmp = this.tso.longestCommonSubseq(name2, name1, false);
|
||||
if (strcmp.length >= minStringLength) {
|
||||
finalData.set(strcmp, (finalData.get(strcmp) || 0) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(finalData.entries()).sort((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
isStringsSimilar(strings: string[]): number {
|
||||
let distance = 0.0;
|
||||
let cmp = 0;
|
||||
|
||||
strings.forEach(af1 => {
|
||||
strings.forEach(af2 => {
|
||||
if (af1 !== af2) {
|
||||
distance += Math.abs(this.tso.getLevenshteinDistance(af1, af2));
|
||||
cmp++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cmp === 0 ? -1 : distance / cmp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export class TwoStringsOperation {
|
||||
longestCommonSubseq(a: string, b: string, caseSensitive: boolean): string {
|
||||
const lengths: number[][] = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
||||
let tmpA: string, tmpB: string;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
for (let j = 0; j < b.length; j++) {
|
||||
if (caseSensitive) {
|
||||
tmpA = a.charAt(i);
|
||||
tmpB = b.charAt(j);
|
||||
} else {
|
||||
tmpA = a.charAt(i).toLowerCase();
|
||||
tmpB = b.charAt(j).toLowerCase();
|
||||
}
|
||||
if (tmpA === tmpB) {
|
||||
lengths[i + 1][j + 1] = lengths[i][j] + 1;
|
||||
} else {
|
||||
lengths[i + 1][j + 1] = Math.max(lengths[i + 1][j], lengths[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read the substring out from the matrix
|
||||
let sb = '';
|
||||
for (let x = a.length, y = b.length; x !== 0 && y !== 0;) {
|
||||
if (lengths[x][y] === lengths[x - 1][y]) {
|
||||
x--;
|
||||
} else if (lengths[x][y] === lengths[x][y - 1]) {
|
||||
y--;
|
||||
} else {
|
||||
sb = a.charAt(x - 1) + sb;
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
}
|
||||
|
||||
return sb;
|
||||
}
|
||||
|
||||
getLevenshteinDistance(s: string, t: string): number {
|
||||
if (s === null || t === null) {
|
||||
throw new Error("Strings must not be null");
|
||||
}
|
||||
|
||||
const n = s.length; // length of s
|
||||
const m = t.length; // length of t
|
||||
|
||||
if (n === 0) {
|
||||
return m;
|
||||
} else if (m === 0) {
|
||||
return n;
|
||||
}
|
||||
|
||||
let p: number[] = Array.from({ length: n + 1 }, (_, i) => i); // 'previous' cost array, horizontally
|
||||
let d: number[] = new Array(n + 1); // cost array, horizontally
|
||||
let _d: number[]; // placeholder to assist in swapping p and d
|
||||
|
||||
let cost: number;
|
||||
|
||||
for (let j = 1; j <= m; j++) {
|
||||
const t_j = t.charAt(j - 1);
|
||||
d[0] = j;
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
cost = s.charAt(i - 1) === t_j ? 0 : 1;
|
||||
// minimum of cell to the left+1, to the top+1, diagonally left and up +cost
|
||||
d[i] = Math.min(Math.min(d[i - 1] + 1, p[i] + 1), p[i - 1] + cost);
|
||||
}
|
||||
|
||||
// copy current distance counts to 'previous row' distance counts
|
||||
_d = p;
|
||||
p = d;
|
||||
d = _d;
|
||||
}
|
||||
|
||||
// our last action in the above loop was to switch d and p, so p now
|
||||
// actually has the most recent cost counts
|
||||
return p[n];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
import {dirname, parse} from 'path';
|
||||
import {promises as fs} from 'fs';
|
||||
|
||||
/**
|
||||
* Return the sibling file associated to the nfo file.
|
||||
* Warning: the sibling file must be in the same folder. This function will look in the file system.
|
||||
* It will find any file with the same base name regardless of its extension, excluding the original .nfo file.
|
||||
* @param filePath The path to the .nfo file.
|
||||
* @returns The path to the sibling file if it exists, otherwise an empty string.
|
||||
*/
|
||||
export async function getSiblingFiles(filePath: string): Promise<string[]> {
|
||||
const fileDir = dirname(filePath);
|
||||
const fileNameWithoutExt = parse(filePath).name;
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(fileDir);
|
||||
return files.filter(file => {
|
||||
const {name, ext} = parse(file);
|
||||
return name === fileNameWithoutExt;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error searching for sibling file of ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import {iso6393To2T} from "iso-639-3";
|
||||
|
||||
/**
|
||||
* iso6393To1
|
||||
* Map of ISO 639-3 codes to ISO 639-1 codes (Record<string, string>).
|
||||
*
|
||||
* iso6393To2B
|
||||
* Map of ISO 639-3 codes to bibliographic ISO 639-2 codes (Record<string, string>).
|
||||
*
|
||||
* iso6393To2T
|
||||
* Map of ISO 639-3 codes to terminologic ISO 639-2 codes (Record<string, string>).
|
||||
*/
|
||||
|
||||
//reverse of iso6393To1
|
||||
const iso6391To3: Record<string, string> = {};//ISO 639-1 to ISO 639-3
|
||||
for (const key in iso6393To2T) {
|
||||
const value = iso6393To2T[key];
|
||||
iso6391To3[value] = key;
|
||||
}
|
||||
|
||||
export function anyTo_iso_639_3(languageName: string) : string | null {
|
||||
if (!languageName) {
|
||||
return null;
|
||||
}
|
||||
if (languageName.length === 3) {
|
||||
//could be iso 639-3 or iso 639-2T or iso 639-2B
|
||||
//all 3 are pretty similar just return the input
|
||||
return languageName;
|
||||
}
|
||||
if (languageName.length === 2) {
|
||||
//could be iso 639-1 => validate and convert
|
||||
return iso6391To3[languageName] || "und";
|
||||
}
|
||||
|
||||
// full language name convert to iso 639-3
|
||||
const code1 = ISO6391.getCode(languageName);
|
||||
if(iso6391To3[code1]){
|
||||
return iso6391To3[code1];
|
||||
}
|
||||
|
||||
return null; //undefined
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
export class SerieFilesNameAnalysisTool {
|
||||
|
||||
constructor(
|
||||
private episodePatterns:string[],
|
||||
private seasonAndEpisodePatterns:string[],
|
||||
private seasonPatterns:string[],
|
||||
private extraEpKeyWords:string[],
|
||||
private keywordsArray:string[],
|
||||
private substringArray:string[],
|
||||
private soloEp,
|
||||
) {
|
||||
//ensure keywords are sorted by length in descending order because we want to remove the longest keyword first
|
||||
keywordsArray.sort((a, b) => b.length - a.length); // Sort keywords by length in descending order
|
||||
substringArray.sort((a, b) => b.length - a.length); // Sort keywords by length in descending order
|
||||
extraEpKeyWords.sort((a, b) => b.length - a.length); // Sort keywords by length in descending order
|
||||
}
|
||||
|
||||
private getSeasonAndEpisodePatterns(): RegExp[] { // Patterns for matching season and episode
|
||||
return this.seasonAndEpisodePatterns.map(pattern => new RegExp(pattern, 'i')); // Apply 'i' flag for case-insensitive matching
|
||||
}
|
||||
|
||||
private getEpisodeOnlyPatterns(): RegExp[] {
|
||||
return this.episodePatterns.map(pattern => new RegExp(pattern, 'i')); // Apply 'i' flag for case-insensitive matching
|
||||
}
|
||||
|
||||
private getSeasonOnlyPatterns(): RegExp[] {
|
||||
return this.seasonPatterns.map(pattern => new RegExp(pattern, 'i')); // Apply 'i' flag for case-insensitive matching
|
||||
}
|
||||
|
||||
|
||||
findMovieYear(filename: string): number {
|
||||
const yearPattern = "\\b(19|20)\\d{2}\\b"; // Matches 1900-2099
|
||||
const match = filename.match(yearPattern);
|
||||
if (match) {
|
||||
return parseInt(match[0], 10);
|
||||
}
|
||||
|
||||
return -1; // Indicates that no year pattern was found
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the season and episode numbers in the filename
|
||||
* Must keep the "." for the float episode number
|
||||
* @param filename
|
||||
*/
|
||||
findSeasonAndEpisode(filename: string): [number, number] {
|
||||
// Combine season & episode and episode-only patterns
|
||||
const combinedPatterns = [...this.getSeasonAndEpisodePatterns(), ...this.getEpisodeOnlyPatterns()];
|
||||
filename = " "+filename+" "; // Add spaces to the beginning and end of the filename to ensure word boundaries (easier regex matching)
|
||||
const dxs = 1;//number of parentesis match in the regex (before the interesting one)
|
||||
for (const pattern of combinedPatterns) {
|
||||
const match = filename.match(pattern);
|
||||
if (match) {
|
||||
if(match.length==dxs+2) {
|
||||
// Extract season and episode from the match
|
||||
const season = parseFloat(match[dxs]);
|
||||
const episode = parseFloat(match[dxs+1]);
|
||||
return [season, episode];
|
||||
}else if(match.length==dxs+1){
|
||||
const season = -1;
|
||||
const episode = parseFloat(match[dxs]); // Assumes Season 1 if only an episode is found
|
||||
return [season, episode];
|
||||
}
|
||||
}
|
||||
}
|
||||
//last hope
|
||||
//Try to match the number the most on the right
|
||||
let number = this.extractRightmostNumber(filename);
|
||||
if(number){
|
||||
return [-1, number];
|
||||
}
|
||||
return [-1, -1]; // Indicates no season/episode pattern was found
|
||||
}
|
||||
|
||||
removeSeasonAndEpisode(filename: string): string {
|
||||
// Get both sets of patterns
|
||||
const allPatterns = [
|
||||
...this.getSeasonAndEpisodePatterns(),
|
||||
...this.getEpisodeOnlyPatterns(),
|
||||
...this.getSeasonOnlyPatterns()
|
||||
];
|
||||
|
||||
// Remove season and episode info from filename
|
||||
let match = false;
|
||||
for (const pattern of allPatterns) {
|
||||
if(filename.match(pattern) != null) {
|
||||
filename = filename.replace(pattern, '');
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!match){
|
||||
filename = this.removeRightmostNumber(filename);
|
||||
}
|
||||
|
||||
return filename.trim(); // Trim any leading/trailing whitespace that may have been left
|
||||
}
|
||||
|
||||
isExtra(filename: string): boolean {
|
||||
// Remove season and episode info from filename
|
||||
let match = false;
|
||||
for (const keyword of this.extraEpKeyWords) {
|
||||
// Prepare keyword for regex. Since we're focusing on strict word boundaries, we don't escape parentheses in keywords as before
|
||||
const preparedKeyword = keyword.replace(/([.*+?^=!:${}|\[\]\/\\])/g, "\\$1");
|
||||
// Create regex from keyword, ensuring word boundaries, case insensitive ('i') and global ('g') to replace all occurrences
|
||||
const pattern = new RegExp(`\\b${preparedKeyword}`, 'gi');
|
||||
if(filename.match(pattern) != null) {
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
|
||||
extractRightmostNumber (str: string): number | null {
|
||||
const matches = str.match(new RegExp(this.soloEp, 'g'));
|
||||
return matches ? parseFloat(matches[matches.length - 1]) : null;
|
||||
};
|
||||
|
||||
removeRightmostNumber(str: string): string {
|
||||
// This regex matches the rightmost number in the string, including optional negative sign and decimals
|
||||
const regex = new RegExp(this.soloEp);
|
||||
return str.replace(regex, '').trim();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Remove everything beyond the first hyphen in the filename but only if the hyphen is a word boundary
|
||||
* eg The Mandalorian - Chapter 1 S01E01.mkv -> The Mandalorian
|
||||
* eg Sherlock - A Scandal in Belgravia S02E03.mkv -> Sherlock
|
||||
* eg [SubsPlease] Shangri-La Frontier - 07 (1080p) [FC412C51].nfo -> [SubsPlease] Shangri-La Frontier - 07 (1080p) [FC412C51].mkv
|
||||
* Delimiter char around hyphen can be any non-word char " ",".","_"
|
||||
* @param filename
|
||||
*/
|
||||
removeBeyondHyphen(filename: string): string {
|
||||
// This pattern targets a hyphen that is acting as a delimiter, based on surrounding context (e.g., spaces).
|
||||
// It will match the first instance of a hyphen surrounded by spaces or a hyphen at the end before trimming.
|
||||
// The addition of '?\s*$' accounts for cases where the hyphen may be followed by optional spaces before the end of the string.
|
||||
const pattern = /^(.*?)(\s+-\s+|\s+-).*|\s*-\s*$/;
|
||||
|
||||
// Use the pattern to replace the content beyond the first delimiter hyphen or a trailing hyphen, if matched.
|
||||
const replacement = filename.replace(pattern, '$1').trim();
|
||||
|
||||
// Return the potentially trimmed filename, preserving internal hyphens but removing a trailing delimiter hyphen.
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple file verification (SFV) is a file format for storing CRC32 checksums
|
||||
* @param filename
|
||||
*/
|
||||
removeSFV(filename: string): string {
|
||||
// Regular expression to match an 8-character hexadecimal string
|
||||
const hexIdPattern = /\b[A-F0-9]{8}\b/i;
|
||||
|
||||
// Replace the hexadecimal ID with an empty string
|
||||
return filename.replace(hexIdPattern, '').trim();
|
||||
}
|
||||
|
||||
removeKeywordsFromFilename(filename: string): string {
|
||||
// Iterate over each keyword/pattern and remove it from the filename
|
||||
for (const keyword of this.keywordsArray) {
|
||||
// Prepare keyword for regex. Since we're focusing on strict word boundaries, we don't escape parentheses in keywords as before
|
||||
const preparedKeyword = keyword.replace(/([.*+?^=!:${}|\[\]\/\\])/g, "\\$1");
|
||||
// Create regex from keyword, ensuring word boundaries, case insensitive ('i') and global ('g') to replace all occurrences
|
||||
const regex = new RegExp(`\\b${preparedKeyword}\\b`, 'gi');
|
||||
// Replace occurrences of the keyword in the filename
|
||||
filename = filename.replace(regex, '');
|
||||
}
|
||||
|
||||
// Iterate over each substring and remove it from the filename
|
||||
for (const substring of this.substringArray) {
|
||||
// Prepare substring for regex. Since we're focusing on strict word boundaries, we don't escape parentheses in keywords as before
|
||||
const preparedSubstring = substring.replace(/([.*+?^=!:${}|\[\]\/\\])/g, "\\$1");
|
||||
// Create regex from keyword, ensuring word boundaries, case insensitive ('i') and global ('g') to replace all occurrences
|
||||
const regex = new RegExp(preparedSubstring, 'gi');
|
||||
// Replace occurrences of the keyword in the filename
|
||||
filename = filename.replace(regex, '');
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
|
||||
findSeasonFromParentFolder(parentFolderName: string): number {
|
||||
const minSeaNbLen = 1, maxSeaNbLen = 2;
|
||||
|
||||
const seaNumberString = `(\\d{${minSeaNbLen},${maxSeaNbLen}})`;
|
||||
|
||||
const patterns = [
|
||||
"\\bSeason\\s*(\\d{1,2})\\b", // Season 1 (eng)
|
||||
"\\bSaison\\s*(\\d{1,2})\\b", // Saison 1 (fra)
|
||||
"\\bS(\\d{1,2})\\b", // S01
|
||||
"\\b(\\d{1,2})\\b", // 01
|
||||
`\\b\\w+\\s*\\(${seaNumberString}\\)\\b`, // title then (number)
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = parentFolderName.match(pattern);
|
||||
if (match) {
|
||||
const season = match[1] ? parseInt(match[1], 10) : -1; // Assumes Season 1 for "Episode 2" format
|
||||
return season;
|
||||
}
|
||||
}
|
||||
|
||||
return -1; // Indicates that no season pattern was found
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {expect} from "chai";
|
||||
import {FileNameMetaExtractor} from "../lib/FileNameMetaExtractor.js";
|
||||
describe('FileNameMetaExtractor', () => {
|
||||
it('getParent', async () => {
|
||||
const list= [
|
||||
'/public/static_library/Media/Anime/',
|
||||
'/public/static_library/Media/Movies/',
|
||||
'/public/static_library/Media/Series/',
|
||||
'/public/bitTorrent/media/'];
|
||||
let fileNameMetaExtractor = new FileNameMetaExtractor(list);
|
||||
let folder = fileNameMetaExtractor.fileNameVideoMetaExtractor.getParentFolder("/public/static_library/Media/Anime/Series/AAAAAA/abcd.mkv",1);
|
||||
expect(folder).to.equal("AAAAAA");
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('add', () => {
|
||||
it('1+1=2', async () => {
|
||||
expect(1+1).toEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user