initial commit

This commit is contained in:
Pierre
2026-01-09 22:33:03 +01:00
commit ba42517a05
29 changed files with 4842 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
+29
View File
@@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules/**
/.pnp
.pnp.js
.pnp.cjs
.pnp.loader.mjs
.yarn
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.vscode
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/junit.xml
/.nyc_output/
/coverage/
/dist/
.idea
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Francisco Hodge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
# Filename Tool
TypeScript library for extracting metadata from media filenames, designed for the Meta Orbit ecosystem.
## Overview
Filename Tool parses media filenames to extract structured metadata such as titles, seasons, episodes, quality, languages, and other relevant information. It's designed to work with various naming conventions used in media files.
## Features
- **Title Extraction**: Identifies show/movie titles from filenames
- **Episode & Season Detection**: Parses episode and season numbers in various formats
- **Quality Detection**: Identifies resolution (1080p, 720p, 4K) and quality indicators
- **Language Detection**: Extracts language codes and audio track information
- **Release Group**: Identifies fansub/release group tags
- **Format Support**: Handles multiple filename patterns and conventions
- **Platform Agnostic**: Dual builds for Node.js and browser environments
## Installation
From the monorepo root:
```bash
pnpm install
```
When using as a dependency:
```json
{
"dependencies": {
"@metazla/filename-tool": "workspace:*"
}
}
```
## Usage
```typescript
import { FilenameParser } from '@metazla/filename-tool';
// Parse a filename
const result = FilenameParser.parse('[SubsPlease] Naruto - 01 (1080p) [12345678].mkv');
console.log(result);
// {
// title: 'Naruto',
// episode: 1,
// season: 1,
// quality: '1080p',
// releaseGroup: 'SubsPlease',
// hash: '12345678',
// extension: 'mkv'
// }
```
## Supported Filename Patterns
### TV Shows
- `ShowName S01E01.mkv`
- `ShowName - 01.mkv`
- `[Group] ShowName - 01 (1080p).mkv`
- `ShowName.S01E01.1080p.WEB-DL.mkv`
### Movies
- `MovieName (2024) 1080p BluRay.mkv`
- `MovieName.2024.1080p.BluRay.x264.mkv`
- `[Group] MovieName (2024).mkv`
### Quality Detection
- Resolution: `1080p`, `720p`, `2160p`, `4K`, `8K`
- Source: `BluRay`, `WEB-DL`, `HDTV`, `DVDRip`
- Codec: `x264`, `x265`, `HEVC`, `AV1`
### Language Detection
- Audio languages: `eng`, `jpl`, `spa`, `fre`, etc.
- Subtitle languages: `[eng sub]`, `[multiple subs]`
- Dual audio: `[eng+jpl]`
## Architecture
### Build Configuration
Built with `tsup` for dual output:
- **Node.js**: `dist/index.js` (ESM) and `dist/index.cjs` (CommonJS)
- **Browser**: `dist/index-browser.js` (ESM) and `dist/index-browser.cjs` (CommonJS)
### Pattern Matching
Uses regex patterns and heuristics to handle various filename formats:
- Episode number detection with multiple patterns
- Title cleanup (remove tags, group names)
- Quality and resolution parsing
- Hash/CRC extraction
## Development
### Building
```bash
# From monorepo root
pnpm run build
# From package directory
cd packages/filename-tool
pnpm run build
```
### Testing
```bash
cd packages/filename-tool
pnpm test
```
Uses Vitest for unit tests with comprehensive filename pattern coverage.
## Integration
Used by:
- **meta-mesh**: Primary metadata extraction from filenames
- **meta-orbit**: Filename normalization for distributed queries
- **meta-ui**: Display and search filename metadata
## Examples
### Anime Filenames
```typescript
const result = FilenameParser.parse('[HorribleSubs] Attack on Titan - 25 [1080p].mkv');
// { title: 'Attack on Titan', episode: 25, quality: '1080p', releaseGroup: 'HorribleSubs' }
```
### Movie Filenames
```typescript
const result = FilenameParser.parse('The.Matrix.1999.1080p.BluRay.x264-GROUP.mkv');
// { title: 'The Matrix', year: 1999, quality: '1080p', source: 'BluRay', codec: 'x264' }
```
### Multi-Episode
```typescript
const result = FilenameParser.parse('Show.Name.S01E01-E03.1080p.mkv');
// { title: 'Show Name', season: 1, episodes: [1, 2, 3], quality: '1080p' }
```
## Contributing
This package is part of the Meta Orbit monorepo. See the root README for contribution guidelines.
## License
MIT - Same as Meta Orbit monorepo
+3170
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@metazla/filename-tools",
"version": "1.0.0",
"description": "module that extract metadata from a given filename",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"dist/index.d.ts": [
"dist/index.d.ts"
],
"*": [
"dist/*"
]
}
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "cross-env tsc && tsup",
"test": "vitest"
},
"devDependencies": {
"@types/node": "^20.12.2",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
},
"dependencies": {
"file-type": "^19.0.0",
"franc": "^6.2.0",
"franc-all": "^7.2.0",
"iso-639-1": "^3.1.2",
"iso-639-3": "^3.0.1"
}
}
+110
View File
@@ -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);
}
}
+300
View File
@@ -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);
}
}
+10
View File
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
export type SimpleFileType =
'audio'
| 'video'
| 'document'
| 'archive'
| 'subtitle'
| 'torrent'
| 'other'
| 'undefined';
+28
View File
@@ -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
];
+67
View File
@@ -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',
};
+37
View File
@@ -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'
]
+42
View File
@@ -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"
];
+20
View File
@@ -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';
+177
View File
@@ -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;
}
}
+25
View File
@@ -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();
}
}
+77
View File
@@ -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;
}
}
+52
View File
@@ -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;
}
}
+80
View File
@@ -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];
}
}
+26
View File
@@ -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 [];
}
}
+43
View File
@@ -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
}
}
+15
View File
@@ -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");
});
});
+7
View File
@@ -0,0 +1,7 @@
import {describe, expect, it} from 'vitest';
describe('add', () => {
it('1+1=2', async () => {
expect(1+1).toEqual(2);
});
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"noImplicitAny": false,
"module": "esnext",
"target": "esnext",
"allowJs": true,
"sourceMap": true,
"esModuleInterop":true,
"skipLibCheck": true,
"noEmit": true,
"moduleResolution": "node",
"baseUrl": "./",
},
"include": [
"src/lib"
],
"exclude": ["node_modules/**"]
}
+15
View File
@@ -0,0 +1,15 @@
import {defineConfig} from 'tsup'
export default defineConfig({
entry: {
'index': 'src/lib/index.ts',
},
outDir: 'dist',
splitting: true,
treeshake: true,
sourcemap: true,
clean: true,
dts:true,
minify:false,
format: ['cjs', 'esm'],
})
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 300000, // Set global timeout to 30 seconds
// Use ts-jest or esbuild to handle TypeScript files
// Specify other configurations as needed
},
});