initial commit

This commit is contained in:
Pierre
2026-01-11 17:26:49 +01:00
commit 888790fd11
240 changed files with 48691 additions and 0 deletions
@@ -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.js";
import {mimeTypeMappings} from "./config/MimeTypeMapping.js";
import {FileNameVideoMetaExtractor, VideoFileMetadata} from "./FileNameVideoMetaExtractor.js";
import {FileTypeConfigurable} from "./tools/FileTypeConfigurable.js";
/**
* 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,342 @@
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 && folderList.length > 0) {
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));
}
/**
* Check if a filename contains a season/episode pattern (indicates TV show)
*/
private filenameHasSeasonEpisodePattern(filename: string): boolean {
const cleanedFilename = this.serieFilesNameAnalysisTool.removeKeywordsFromFilename(filename);
const cleanedFilename2 = this.serieFilesNameAnalysisTool.removeSFV(cleanedFilename);
const cleanedFilename3 = this.fileNameCleaner.cleanSpecialChars(cleanedFilename2);
const cleanedFilename4 = this.fileNameCleaner.cleanSpace(cleanedFilename3);
const [season, episode] = this.serieFilesNameAnalysisTool.findSeasonAndEpisode(cleanedFilename4);
return season >= 0 || episode >= 0;
}
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]);
// Check if filename contains season/episode pattern (indicates TV show)
const isTvShow = this.filenameHasSeasonEpisodePattern(filename);
let selectedTitle;
if (isTvShow) {
// For TV shows, prefer the longer title between filename and folder
// This handles cases like "S01E01-The Fool.mkv" where "The Fool" is the episode name
// but "Lord of Mysteries" in the parent folder is the actual series title
const filenameTitleValid = titleFromFileName && titleFromFileName.length > MIN_TITLE_LENGTH;
const folderTitleValid = titleFromParentFolder && titleFromParentFolder.length > MIN_TITLE_LENGTH;
if (filenameTitleValid && folderTitleValid) {
// Both valid - prefer the longer one (series names are typically longer than episode names)
// Use strict > to avoid preferring folder when lengths are equal (e.g., "Dexter" vs "Series")
selectedTitle = titleFromParentFolder.length > titleFromFileName.length
? titleFromParentFolder
: titleFromFileName;
} else if (folderTitleValid) {
selectedTitle = titleFromParentFolder;
} else if (filenameTitleValid) {
selectedTitle = titleFromFileName;
} else if (titleFromGrandParentFolder && titleFromGrandParentFolder.length > MIN_TITLE_LENGTH) {
selectedTitle = titleFromGrandParentFolder;
} else {
selectedTitle = simpleTitle;
}
} else {
// For non-TV shows (movies, etc.), use original logic: prefer filename
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.js";
import {extensionMappings} from "./config/ExtentionsMapping.js";
import {mimeTypeMappings} from "./config/MimeTypeMapping.js";
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.js";
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.js";
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,19 @@
export * from './FileNameMetaExtractor.js';
export * from './FileNameVideoMetaExtractor.js';
export * from './SimpleFileType.js';
export * from './FileType.js';
export * from './tools/folder/FolderTool.js';
export * from './tools/lang/LanguageTool.js';
export * from './tools/video/SerieFilesNamaAnalysisTool.js';
export * from './tools/FileNameCleaner.js';
export * from './tools/FileNameStringOperation.js';
export * from './tools/FileTypeConfigurable.js';
export * from './tools/StringArrayOperation.js';
export * from './tools/TwoStringsOperation.js';
export * from './config/AnimeKeywords.js';
export * from './config/ExtentionsMapping.js';
export * from './config/KeywordsArray.js';
export * from './config/MimeTypeMapping.js';
export * from './config/SeasonAndEpisodePatterns.js';
@@ -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.js";
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,241 @@
import {describe, expect, it} from 'vitest';
import {FileNameMetaExtractor} from "../lib/FileNameMetaExtractor.js";
// Watch folder list for test paths
const watchFolders = [
'/data/watch/test-files/',
'/public/static_library/Media/Anime/',
'/public/static_library/Media/Movies/',
'/public/static_library/Media/Series/',
'/public/bitTorrent/media/'
];
// Helper to test series/anime (title, season, episode)
async function titleSeasonEpTester(title: string, season: string, episode: string, filePath: string) {
const extractor = new FileNameMetaExtractor(watchFolders);
const data = await extractor.extractMetadata(filePath);
expect(data.originalTitle).toEqual(title);
expect(data.season).toEqual(season);
expect(data.episode).toEqual(episode);
}
// Helper to test movies (title, year)
async function movieTester(title: string, year: string, filePath: string) {
const extractor = new FileNameMetaExtractor(watchFolders);
const data = await extractor.extractMetadata(filePath);
expect(data.originalTitle).toEqual(title);
expect(data.movieYear).toEqual(year);
expect(data.season).toBeUndefined();
expect(data.episode).toBeUndefined();
}
describe('FileNameMetaExtractor', () => {
describe('Basic parsing', () => {
it('filename and extension', async () => {
const filePath = '/data/watch/test-files/Series/The.Big.Bang.Theory.S01E01.mkv';
const extractor = new FileNameMetaExtractor(watchFolders);
const data = await extractor.extractMetadata(filePath);
expect(data.fileName).toEqual('The.Big.Bang.Theory.S01E01.mkv');
expect(data.extension).toEqual('mkv');
expect(data.originalTitle).toEqual('The Big Bang Theory');
expect(data.season).toEqual("1");
expect(data.episode).toEqual("1");
});
});
describe('Series patterns', () => {
it('The.Big.Bang.Theory.S01E01.mkv', async () => {
await titleSeasonEpTester('The Big Bang Theory', '1', '1',
'/data/watch/test-files/Series/The.Big.Bang.Theory.S01E01.mkv');
});
it('handles lower case with underscores', async () => {
await titleSeasonEpTester('Breaking Bad', '4', '2',
'/data/watch/test-files/Series/breaking_bad_s04e02.avi');
});
it('handles season and episode without leading zeros', async () => {
await titleSeasonEpTester('Friends', '10', '5',
'/data/watch/test-files/Series/friends_10x5.mp4');
});
it('processes files with year and resolution info', async () => {
await titleSeasonEpTester('Game of Thrones', '8', '1',
'/data/watch/test-files/Series/Game.of.Thrones.S08E01.2019.1080p.HDTV.x264.mkv');
});
it('parses title with dots and dashes', async () => {
await titleSeasonEpTester('Mr Robot', '1', '1',
'/data/watch/test-files/Series/Mr.Robot-S01E01.mkv');
});
it('handles files with extra text before season and episode', async () => {
await titleSeasonEpTester('Sherlock', '2', '3',
'/data/watch/test-files/Series/Sherlock - A Scandal in Belgravia S02E03.mkv');
});
it('interprets files with non-standard episode naming', async () => {
await titleSeasonEpTester('The Mandalorian', '1', '1',
'/data/watch/test-files/Series/The Mandalorian - Chapter 1 S01E01.mkv');
});
it('extracts info from files with mixed separators', async () => {
await titleSeasonEpTester('Dexter', '7', '12',
'/data/watch/test-files/Series/Dexter - S07E12_final-episode.avi');
});
it('handles verbose season episode notation', async () => {
await titleSeasonEpTester('The Crown', '4', '10',
'/data/watch/test-files/Series/The Crown Season 4 Episode 10.mkv');
});
it('handles files with multiple season and episode patterns', async () => {
await titleSeasonEpTester('Westworld', '3', '8',
'/data/watch/test-files/Series/Westworld S03 E08 - Crisis Theory.mp4');
});
it('handles files named with only episode numbers assuming season 1', async () => {
await titleSeasonEpTester('MythBusters', '1', '1',
'/data/watch/test-files/Series/MythBusters/MythBusters - Episode 1.avi');
});
it('Star Trek Enterprise', async () => {
await titleSeasonEpTester('Star Trek Enterprise', '1', '4',
'/data/watch/test-files/Series/Star Trek/Star Trek Enterprise/S1/(1x04) Star Trek Enterprise - Le peuple de la grotte.avi');
});
});
describe('Anime patterns', () => {
it('[DB]Haibane Renmei_-_13_(Dual Audio_10bit_BD1080p_x265).mkv', async () => {
await titleSeasonEpTester('Haibane Renmei', '1', '13',
'/data/watch/test-files/Anime/[DB]Haibane Renmei_-_13_(Dual Audio_10bit_BD1080p_x265).mkv');
});
it('One Piece with episode title', async () => {
await titleSeasonEpTester('One Piece', '20', '897',
'/data/watch/test-files/Anime/One Piece - S20E897 - Luffy vs. Kaido.mkv');
});
it('[SubsPlease] Shangri-La Frontier - 07 (1080p) [FC412C51].mkv', async () => {
await titleSeasonEpTester('Shangri-La Frontier', '1', '7',
'/data/watch/test-files/Anime/[SubsPlease] Shangri-La Frontier - 07 (1080p) [FC412C51].mkv');
});
it('[SubsPlease] Fumetsu no Anata e S2 - 07 (1080p) [9FF46A4E].mkv', async () => {
await titleSeasonEpTester('Fumetsu No Anata E', '2', '7',
'/data/watch/test-files/Anime/[SubsPlease] Fumetsu no Anata e S2 - 07 (1080p) [9FF46A4E].mkv');
});
it('_AnimeServ__Dakara_07__8bits__1A8C2326_.mp4', async () => {
await titleSeasonEpTester('Dakara', '1', '7',
'/data/watch/test-files/Anime/_AnimeServ__Dakara_07__8bits__1A8C2326_.mp4');
});
it('[Erai-raws] Xian Wang de Richang Shenghuo 3 - 01 [1080p][Multiple Subtitle][F822C9C7].mkv', async () => {
await titleSeasonEpTester('Xian Wang De Richang Shenghuo', '3', '1',
'/data/watch/test-files/Anime/[Erai-raws] Xian Wang de Richang Shenghuo 3 - 01 [1080p][Multiple Subtitle][F822C9C7].mkv');
});
it('[Erai-raws] Xian Wang de Richang Shenghuo 3 - 01.5 [1080p][Multiple Subtitle][F822C9C7].mkv', async () => {
await titleSeasonEpTester('Xian Wang De Richang Shenghuo', '3', '1.5',
'/data/watch/test-files/Anime/[Erai-raws] Xian Wang de Richang Shenghuo 3 - 01.5 [1080p][Multiple Subtitle][F822C9C7].mkv');
});
it('[Erai-raws] Mob Psycho 100 - 01 [1080p][FB7288A9].mkv', async () => {
await titleSeasonEpTester('Mob Psycho', '1', '1',
'/data/watch/test-files/Anime/[Erai-raws] Mob Psycho 100 - 01 [1080p][FB7288A9].mkv');
});
});
describe('Movie patterns', () => {
it('The.Wave.2008.1080p.BluRay.x264.AAC5.1.mkv', async () => {
await movieTester('The Wave', '2008',
'/data/watch/test-files/Movies/The.Wave.2008.1080p.BluRay.x264.AAC5.1.mkv');
});
it('Le.Prenom.2012.720p.BluRay.x264-SEiGHT.mkv', async () => {
await movieTester('Le Prenom -SEiGHT', '2012',
'/data/watch/test-files/Movies/Le.Prenom.2012.720p.BluRay.x264-SEiGHT.mkv');
});
});
describe('Edge cases - title from folder (skipped)', () => {
// These tests are skipped because the parser doesn't extract title from folder name
// when the filename doesn't contain the show title
// Pattern: FolderWithTitle/S01E01-EpisodeName.mkv should extract title from folder
it('Lord of Mysteries - title from folder, filename is S01E01-EpisodeName pattern', async () => {
// Real-world case: folder contains title + quality info, filename is S01E01-EpisodeName format
// Expected: extract "Lord of Mysteries" from folder, not "The Fool" from filename
await titleSeasonEpTester('Lord of Mysteries', '1', '1',
'/data/watch/test-files/Anime/Lord of Mysteries S01 1080p Dual Audio WEBRip DD+ x265-EMBER/S01E01-The Fool [8A4E8B1F].mkv');
});
it.skip('handles files named with season in folder', async () => {
await titleSeasonEpTester('MythBusters', '2', '1',
'/data/watch/test-files/Series/MythBusters/Season 2/MythBusters - Episode 1.avi');
});
it.skip('Ascendance of a Bookworm - title from parent folder', async () => {
await titleSeasonEpTester('Ascendance Of A Bookworm', '1', '7',
'/data/watch/test-files/Anime/Ascendance of a Bookworm/S01/S01E07-Seeds of Suspicion.mkv');
});
it.skip('Le Depart - title from parent folder, filename starts with number', async () => {
await titleSeasonEpTester('Le Depart', '1', '13',
'/data/watch/test-files/Anime/Le Depart/13 it a new start.mkv');
});
it('Akudama Drive - title from folder', async () => {
await titleSeasonEpTester('Akudama Drive', '1', '2',
'/data/watch/test-files/Anime/Akudama Drive/S01E02-RESERVOIR DOGS [F1D64557].mkv');
});
it.skip('Excel Saga VF - title from folder', async () => {
await titleSeasonEpTester('Excel Saga', '1', '2',
'/data/watch/test-files/Anime/Excel Saga VF/02 - la fille qui vient de mars.avi');
});
it.skip('Gundam Wing - title from folder', async () => {
await titleSeasonEpTester('Gundam Wing', '1', '2',
'/data/watch/test-files/Anime/Gundam/Gundam wing/02 - Gundam Wing - Face cachée.mp4');
});
it.skip('Hanaukyo Maids La Verité - title from folder', async () => {
await titleSeasonEpTester('Hanaukyo Maids La Verité', '1', '2',
'/data/watch/test-files/Anime/Hanaukyo Maids La Verité/02 Hanaukyo Maids La Verité [Todai].fullanimes.free.fr.avi');
});
it.skip('K-Project - title from parent folder', async () => {
await titleSeasonEpTester('K-Project', '1', '2',
'/data/watch/test-files/Anime/K-Project/K - Return of Kings/[HorribleSubs] K - Return of Kings - 02 [1080p].mkv');
});
it.skip('K-Project - Kitten - title from folder', async () => {
await titleSeasonEpTester('K-Project', '1', '2',
'/data/watch/test-files/Anime/K-Project/[CBM]_K_-_02_-_Kitten_[1080p-FLAC]_[A83DF131].mkv');
});
it('Made in Abyss - title from folder', async () => {
await titleSeasonEpTester('Made in Abyss', '1', '2',
'/data/watch/test-files/Anime/Made in Abyss/S01E02-Resurrection Festival [AE07AD68].mkv');
});
it.skip('The Mysterious Cities of Gold - title from folder', async () => {
await titleSeasonEpTester('The Mysterious Cities Of Gold', '1', '2',
'/data/watch/test-files/Anime/The Mysterious Cities of Gold/2 - Crossing the Atlantic.avi');
});
it.skip('Star Trek The Original - title from folder', async () => {
await titleSeasonEpTester('Star Trek The Original Series', '2', '1',
'/data/watch/test-files/Series/Star Trek/Star Trek The Original Series/Saison 2/2x01 Star Trek The Original - Mal Du Pays.avi');
});
it.skip('Star Trek TNG - title from folder', async () => {
await titleSeasonEpTester('Star Trek Tng', '3', '3',
'/data/watch/test-files/Series/Star Trek/Star Trek TNG/saison 3/03 - Les survivants.avi');
});
});
});
@@ -0,0 +1,7 @@
import {describe, expect, it} from 'vitest';
describe('add', () => {
it('1+1=2', async () => {
expect(1+1).toEqual(2);
});
});