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,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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
{
"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": {
"clean": "rm -rf dist",
"build": "npm run clean && 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"
}
}
@@ -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);
});
});
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"noImplicitAny": false,
"module": "NodeNext",
"target": "ESNext",
"allowJs": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src/lib",
"baseUrl": "./"
},
"include": [
"src/lib"
],
"exclude": ["node_modules/**"]
}
@@ -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: false, // Don't clean - tsc generates .d.ts files first
dts: false, // Let tsc handle declaration files for NodeNext compatibility
minify: false,
format: ['cjs', 'esm'],
})
@@ -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
},
});
+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
+31
View File
@@ -0,0 +1,31 @@
# 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
build
.pnpm-store/
+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.
+175
View File
@@ -0,0 +1,175 @@
# Meta-Hash
Meta-Hash is a TypeScript library designed to monitor a specified folder and compute hashes for any new files detected within it. This library leverages an index system to avoid recomputing hashes if the program is restarted, ensuring efficiency and continuity in hash computation. The hashes are computed in the Content Identifier (CID) format, making them compatible with systems like IPFS (InterPlanetary File System).
## Features
- **Platform Agnostic**: Dual builds for Node.js and browser environments
- **Efficient Hash Computation**: Computes hashes using multiple algorithms (SHA2-256, SHA1, MD5, SHA3-256, SHA3-384, CRC32)
- **Persistent Indexing**: CSV-based index system tracks file hashes, sizes, and modification times
- **CID Format**: Uses IPFS Content Identifiers (multiformat, multicodec, multihash)
- **Worker Thread Support**: Node.js build can use worker threads for parallel hashing
- **Incremental Hashing**: Only rehashes files when size or mtime changes
- **Multiple Hash Types**: Supports all major cryptographic and checksum algorithms
## CID Format and Inspection
The Content Identifier (CID) is a label used to point to material in IPFS. It doesn't indicate where the content is stored, but it forms a kind of address based on the content itself. CIDs are used as a standard way of pointing to pieces of information. A single CID can represent a piece of content that is distributed across multiple systems.
The list of CID formats used in this project can be found at the following link:
[Multicodecs Table](https://ipfs.io/ipfs/QmXec1jjwzxWJoNbxQF5KffL8q6hFXm9QwUGaa3wKGk6dT/#title=Multicodecs&src=https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv)
To inspect the details of a CID, including its version, codec, multihash and more, you can use the CID Inspector tool. This tool allows you to enter a CID and it will break it down into its component parts and provide a detailed explanation of each part.
[CID Inspector Tool](https://cid.ipfs.tech/)
## Installation
This package is part of the Meta Orbit monorepo and uses pnpm workspaces.
From the monorepo root:
```bash
pnpm install
```
When using as a dependency in other packages:
```json
{
"dependencies": {
"@metazla/meta-hash": "workspace:*"
}
}
```
## Usage of ComputeHashIndexCache
To integrate Meta-Hash into your application, follow these steps:
1. **Import the Library**: Import the necessary components from the library.
```typescript
import { ComputeHashIndexCache, CID_ALGORITHM_NAMES } from "./folder-watch-hasher";
```
2. **Initialize the Watcher**: Create an instance of `ComputeHashIndexCache`, specifying the path to the folder you want to monitor and the hash algorithms you wish to use.
```typescript
//note that the csv file will be derived from the path provided into multiple files (one for each algorithm)
const computer = new ComputeHashIndexCache("path/to/your/indexfile/index.csv", [CID_ALGORITHM_NAMES.sha256, CID_ALGORITHM_NAMES.sha1]);
```
3. **Compute Hashes**: Invoke the `computeMissingHash` method for each file you wish to hash. This method will automatically check the index for existing hashes and compute missing ones.
```typescript
let hash:MultiHashData = {};
await computer.computeMissingHash("path/to/your/file.txt", hash);
console.log(hash[CID_ALGORITHM_NAMES.sha256]);
```
## Example of Index File
An example index file (`index-cid_sha2-256.csv`) is shown below, demonstrating how file paths, sizes, modification times, and computed hashes are stored:
```
path,size,mtime,cid_sha2-256
file.mkv,230265591,2023-01-17T18:53:37.692Z,baejbeif45glg263244x7cbjzyd3ssp5edzae7bxurzxjhnogazcwmj32wa
...
```
## Contributing
Contributions to Meta-Hash are welcome! Please read our contributing guidelines for more information on how to participate.
## License
Meta-Hash is released under the [MIT License](LICENSE.md).
## Platform-Specific Builds
### Node.js Environment
```typescript
import { HashComputerIndexCache, CID_ALGORITHM_NAMES } from '@metazla/meta-hash';
import path from 'path';
// With worker threads for parallel processing
const packageEntryPath = path.dirname(import.meta.resolve("@metazla/meta-hash")) + "/worker.js";
const hashComputer = new HashComputerIndexCache(
'/path/to/index/folder',
[CID_ALGORITHM_NAMES.sha256, CID_ALGORITHM_NAMES.sha1],
packageEntryPath
);
// Hash a file
const hashes = {};
await hashComputer.computeMissingHash('/path/to/file.mkv', hashes);
console.log(hashes[CID_ALGORITHM_NAMES.sha256]);
```
### Browser Environment
```typescript
import { HashComputerIndexCache, CID_ALGORITHM_NAMES } from '@metazla/meta-hash/index-browser';
// Browser build (no worker threads, uses Web Crypto API where available)
const hashComputer = new HashComputerIndexCache(
'/virtual/index/path',
[CID_ALGORITHM_NAMES.sha256]
);
```
## 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)
### Worker Thread Support (Node.js)
The Node.js build includes `worker.js` for parallel hash computation:
```typescript
// Automatic worker path resolution
const workerPath = path.join(
path.dirname(import.meta.resolve('@metazla/meta-hash')),
'worker.js'
);
```
## Development
### Building
```bash
# From monorepo root
pnpm run build
# From package directory
cd packages/meta-hash
pnpm run build
```
### Testing
```bash
cd packages/meta-hash
pnpm test
```
Uses Vitest for unit tests.
## Integration
Used by:
- **meta-mesh**: File content hashing for deduplication
- **meta-orbit**: Content addressing for distributed storage
- **meta-ui**: Browser-based file verification
## Performance
- **Index Cache**: Avoids rehashing unchanged files (checks size + mtime)
- **Worker Threads**: Parallel hashing on multi-core systems (Node.js only)
- **Streaming**: Hashes large files without loading into memory
- **CSV Index**: Fast lookup and incremental updates
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@metazla/meta-hash",
"version": "1.0.0",
"description": "module that hash (cid) files and uses cache",
"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"
},
"./index-interface": {
"import": "./dist/index-interface.js",
"require": "./dist/index-interface.cjs"
},
"./index-browser": {
"import": "./dist/index-browser.js",
"require": "./dist/index-browser.cjs"
}
},
"scripts": {
"build": "tsup",
"test": "vitest"
},
"devDependencies": {
"@libp2p/crypto": "^4.0.5",
"@types/node": "^20.12.2",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
},
"dependencies": {
"bencodec": "^2.0.1",
"chokidar": "^3.6.0",
"crc-32": "^1.2.2",
"csv-parse": "^5.5.5",
"csv-stringify": "^6.4.6",
"debounce": "^2.0.0",
"hash-wasm": "^4.12.0",
"multiformats": "^13.1.0",
"p-queue": "^8.0.1",
"piscina": "^4.4.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
// Browser-compatible hash implementation
import {SimpleHash} from "@root/file-id/SimpleHash";
import {createMD5, createSHA1, createSHA256, createSHA3} from "hash-wasm";
export class BrowserHasher implements SimpleHash {
private algorithm: string;
private wasmHasher: any;
private isWasm: boolean;
constructor(algorithm: string) {
this.algorithm = algorithm;
this.isWasm = true;
}
async initialize(): Promise<void> {
if (this.isWasm) {
switch (this.algorithm) {
case 'md5':
this.wasmHasher = await createMD5();
break;
case 'sha-1':
this.wasmHasher = await createSHA1();
break;
case 'sha-256':
this.wasmHasher = await createSHA256();
break;
case 'sha3-256':
this.wasmHasher = await createSHA3(256);
break;
case 'sha3-384':
this.wasmHasher = await createSHA3(384);
break;
default:
throw new Error(`Unsupported algorithm: ${this.algorithm}`);
}
this.wasmHasher.init();
}
}
update(data: Uint8Array): SimpleHash {
this.wasmHasher.update(data);
return this;
}
async digest(): Promise<Uint8Array> {
const hash = await this.wasmHasher.digest('binary');
return new Uint8Array(hash);
}
}
@@ -0,0 +1,200 @@
import {createHash} from 'crypto';
import {SimpleHash} from './SimpleHash';
import {encode} from 'bencodec';
import {basename} from 'path';
/**
* BitTorrent v2 Info Hash Hasher (BEP 52)
*
* Implements BitTorrent v2 file hashing with:
* - Fixed 16KB block size
* - SHA-256 merkle tree
* - Info hash computation
*
* Architecture:
* 1. Streaming: Accumulates file data into 16KB blocks
* 2. Block hashing: SHA-256 hash each complete block
* 3. Merkle tree: Build balanced tree from block hashes
* 4. Info hash: SHA-256(bencode(info_dict))
*/
export class BtihV2Hasher implements SimpleHash {
private static readonly BLOCK_SIZE = 16 * 1024; // 16KB fixed by BEP 52
private blockHashes: Buffer[] = [];
private currentBlock: Buffer[] = [];
private currentBlockSize: number = 0;
private fileSize: number = 0;
private fileName: string = 'unknown';
/**
* Optional: Set filename for info dictionary
* If not set, defaults to 'unknown'
*/
setFileName(fileName: string): void {
this.fileName = basename(fileName);
}
/**
* Update hasher with new data chunk
* Accumulates chunks into 16KB blocks and hashes each complete block
*/
update(data: Uint8Array): SimpleHash {
const buffer = Buffer.from(data);
this.fileSize += buffer.length;
let offset = 0;
while (offset < buffer.length) {
const remaining = BtihV2Hasher.BLOCK_SIZE - this.currentBlockSize;
const toRead = Math.min(remaining, buffer.length - offset);
// Accumulate chunk into current block
this.currentBlock.push(buffer.subarray(offset, offset + toRead));
this.currentBlockSize += toRead;
offset += toRead;
// When block is complete, hash it
if (this.currentBlockSize === BtihV2Hasher.BLOCK_SIZE) {
this.finalizeBlock();
}
}
return this;
}
/**
* Finalize current block by hashing it and storing the hash
*/
private finalizeBlock(): void {
if (this.currentBlock.length === 0) return;
const blockData = Buffer.concat(this.currentBlock);
const blockHash = createHash('sha256').update(blockData).digest();
this.blockHashes.push(blockHash);
// Reset current block
this.currentBlock = [];
this.currentBlockSize = 0;
}
/**
* Compute final info hash
*
* Steps:
* 1. Finalize any partial block
* 2. Build merkle tree from block hashes
* 3. Construct info dictionary (name, length, pieces_root)
* 4. Bencode info dict
* 5. SHA-256 of bencoded data = info hash
*/
digest(): Buffer {
// Finalize any remaining partial block
if (this.currentBlockSize > 0) {
this.finalizeBlock();
}
// Handle empty file edge case
if (this.blockHashes.length === 0) {
// Empty file: hash empty block
const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest();
this.blockHashes.push(emptyHash);
}
// Build merkle tree to get pieces root
const piecesRoot = this.buildMerkleTree(this.blockHashes);
// Construct info dictionary (BEP 52 v2 format)
const infoDict = {
name: this.fileName,
length: this.fileSize,
'piece length': this.computePieceLength(this.fileSize),
'pieces root': piecesRoot,
'meta version': 2 // BitTorrent v2
};
// Bencode the info dictionary
const bencodedInfo = encode(infoDict);
// Info hash = SHA-256 of bencoded info dict
const infoHash = createHash('sha256').update(bencodedInfo).digest();
return infoHash;
}
/**
* Build balanced merkle tree from leaf hashes
*
* Algorithm (from BEP 52 reference implementation):
* 1. Pad to power of 2 with zero hashes
* 2. Recursively pair adjacent hashes
* 3. Hash each pair: SHA-256(left || right)
* 4. Repeat until single root hash remains
*/
private buildMerkleTree(hashes: Buffer[]): Buffer {
if (hashes.length === 0) {
throw new Error('Cannot build merkle tree from empty hash list');
}
// Pad to next power of 2 with zero hashes
let paddedHashes = [...hashes];
const targetSize = this.nextPowerOfTwo(paddedHashes.length);
const zeroHash = Buffer.alloc(32); // 32 bytes of zeros for SHA-256
while (paddedHashes.length < targetSize) {
paddedHashes.push(zeroHash);
}
// Build tree by repeatedly pairing and hashing
while (paddedHashes.length > 1) {
const parentHashes: Buffer[] = [];
for (let i = 0; i < paddedHashes.length; i += 2) {
const left = paddedHashes[i];
const right = paddedHashes[i + 1];
const combined = Buffer.concat([left, right]);
const parentHash = createHash('sha256').update(combined).digest();
parentHashes.push(parentHash);
}
paddedHashes = parentHashes;
}
return paddedHashes[0];
}
/**
* Compute piece length using BEP 52 formula
*
* Formula: piece_length = 16KB * 2^n
* where: file_size / piece_length < 2048
*
* This ensures each file has between 1024 and 2047 pieces
*/
private computePieceLength(fileSize: number): number {
if (fileSize === 0) {
return BtihV2Hasher.BLOCK_SIZE; // 16KB for empty files
}
let pieceLength = BtihV2Hasher.BLOCK_SIZE; // Start at 16KB
const maxPieces = 2048;
// Increase piece length until we have < 2048 pieces
while (Math.ceil(fileSize / pieceLength) >= maxPieces) {
pieceLength *= 2;
}
return pieceLength;
}
/**
* Find next power of 2 >= n
*/
private nextPowerOfTwo(n: number): number {
if (n === 0) return 1;
n--;
n |= n >> 1;
n |= n >> 2;
n |= n >> 4;
n |= n >> 8;
n |= n >> 16;
return n + 1;
}
}
@@ -0,0 +1,12 @@
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
export const codeTable = {
[CID_ALGORITHM_NAMES.crc32]: CID_ALGORITHM_CODES.crc32,
[CID_ALGORITHM_NAMES.md5]: CID_ALGORITHM_CODES.md5,
[CID_ALGORITHM_NAMES.sha1]: CID_ALGORITHM_CODES.sha1,
[CID_ALGORITHM_NAMES.sha256]: CID_ALGORITHM_CODES.sha256,
[CID_ALGORITHM_NAMES.sha3_256]: CID_ALGORITHM_CODES.sha3_256,
[CID_ALGORITHM_NAMES.sha3_384]: CID_ALGORITHM_CODES.sha3_384,
[CID_ALGORITHM_NAMES.midhash256]: CID_ALGORITHM_CODES.midhash256,
[CID_ALGORITHM_NAMES.btih_v2]: CID_ALGORITHM_CODES.btih_v2
}
@@ -0,0 +1,50 @@
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
import {create} from "multiformats/hashes/digest";
import {CID} from 'multiformats/cid';
import {SimpleHash} from "./SimpleHash";
import {codeTable} from "@root/file-id/CodeTable";
/**
* Compute the CIDs of a file using specific algorithms
* @param stream The Readable stream of the file
* @param algorithms Array of algorithms ('sha256', 'sha1', etc.)
* @returns Array of CIDs (in the order of the algorithms)
*/
export async function computeCIDs({stream, algorithms,createHasher}: {
stream: ReadableStream<Uint8Array>;
algorithms: CID_ALGORITHM_NAMES[];
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>
}): Promise<string[]> {
const hashers = await hasherDefiner(algorithms,createHasher);
const reader = stream.getReader();
while (true) {
const {done, value} = await reader.read();
if (done) break;
// Assuming the hasher can handle Uint8Array directly
for (const item of hashers) {
await item.hasher.update(value);
}
}
return cidFinalize(hashers);
}
async function hasherDefiner(algorithms: CID_ALGORITHM_NAMES[],
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>): Promise<{
hasher: SimpleHash,
code: CID_ALGORITHM_CODES
}[]> {
const hashers = algorithms.filter(algo => Object.values(CID_ALGORITHM_NAMES).includes(algo))
.map(async algo => ({
hasher: await createHasher(algo),
code: codeTable[algo]
}));
return await Promise.all(hashers);
}
async function cidFinalize(hashers: { hasher: SimpleHash, code: number }[]): Promise<string[]> {
return await Promise.all(hashers.map(async ({code, hasher}) => {
const hashBuffer = await hasher.digest();
const digest = create(code, hashBuffer);
return CID.createV1(code, digest).toString();
}));
}
@@ -0,0 +1,18 @@
import crc32 from "crc-32";
import {SimpleHash} from "@root/file-id/SimpleHash";
export class Crc32Hash implements SimpleHash {
private _crc: number = undefined;
update(data: Buffer): SimpleHash {
// Update the CRC-32 checksum with the new chunk of data
this._crc = crc32.buf(data, this._crc);
return this;
}
digest(): Buffer {
const buffer = Buffer.alloc(4); // Create a buffer of 4 bytes (32 bits)
buffer.writeInt32BE(this._crc, 0); // Write the unsigned integer to the buffer in big-endian format
return buffer;
}
}
@@ -0,0 +1,31 @@
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
import {SimpleHash} from "./SimpleHash";
import {createHash} from "crypto";
import {Crc32Hash} from "./Crc32Hash";
import {BtihV2Hasher} from "./BtihV2Hasher";
/**
* Compute the CIDs of a file using specific algorithms
* @returns Array of CIDs (in the order of the algorithms)
* @param algo
*/
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> {
switch (algo) {
case CID_ALGORITHM_NAMES.sha256:
return createHash("sha256");
case CID_ALGORITHM_NAMES.sha1:
return createHash("sha1");
case CID_ALGORITHM_NAMES.md5:
return createHash("md5");
case CID_ALGORITHM_NAMES.sha3_256:
return createHash("sha3-256");
case CID_ALGORITHM_NAMES.sha3_384:
return createHash("sha3-384");
case CID_ALGORITHM_NAMES.crc32:
return new Crc32Hash();
case CID_ALGORITHM_NAMES.btih_v2:
return new BtihV2Hasher();
default:
throw new Error(`Unsupported algorithm: ${algo}`);
}
}
@@ -0,0 +1,31 @@
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
import {SimpleHash} from "./SimpleHash";
import {Crc32Hash} from "./Crc32Hash";
import {BrowserHasher} from "@root/file-id/BrowserHasher";
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> {
const hasher = (() => {
switch (algo) {
case CID_ALGORITHM_NAMES.sha256:
return new BrowserHasher('sha-256');
case CID_ALGORITHM_NAMES.sha1:
return new BrowserHasher('sha-1');
case CID_ALGORITHM_NAMES.md5:
return new BrowserHasher('md5');
case CID_ALGORITHM_NAMES.sha3_256:
return new BrowserHasher('sha3-256');
case CID_ALGORITHM_NAMES.sha3_384:
return new BrowserHasher('sha3-384');
case CID_ALGORITHM_NAMES.crc32:
return new Crc32Hash();
default:
throw new Error(`Unsupported algorithm: ${algo}`);
}
})();
if (hasher instanceof BrowserHasher) {
await hasher.initialize();
}
return hasher;
}
@@ -0,0 +1,104 @@
import { createHash } from 'crypto';
import { open, stat } from 'fs/promises';
import { create } from 'multiformats/hashes/digest';
import { CID } from 'multiformats/cid';
import { CID_ALGORITHM_CODES } from '../hash-compute/MultiHashData.js';
/**
* Computes a midhash256 based on sampling the middle 1MB of a file
* plus the file size as a 64-bit integer prefix.
*
* Algorithm:
* - For files <= 1MB: Hashes entire file content + size prefix
* - For files > 1MB: Hashes middle 1MB + size prefix
*
* The size prefix ensures that files with identical middle content
* but different sizes produce different hashes.
*
* Performance: ~20ms regardless of file size (O(1) complexity)
*
* @param filePath - Path to file
* @returns CID string in proper CID v1 format with multicodec 0x1000
*/
export async function computeMidHash256(filePath: string): Promise<string> {
const SAMPLE_SIZE = 1024 * 1024; // 1MB
// Get file size (instant - no file read)
const stats = await stat(filePath);
const fileSize = stats.size;
// Create size buffer (64-bit big-endian)
const sizeBuffer = Buffer.allocUnsafe(8);
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
// Extract sample data
let sampleData: Buffer;
if (fileSize <= SAMPLE_SIZE) {
// Small file: read entire content
const fd = await open(filePath, 'r');
sampleData = Buffer.allocUnsafe(fileSize);
await fd.read(sampleData, 0, fileSize, 0);
await fd.close();
} else {
// Large file: read middle 1MB
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
const fd = await open(filePath, 'r');
sampleData = Buffer.allocUnsafe(SAMPLE_SIZE);
await fd.read(sampleData, 0, SAMPLE_SIZE, middleOffset);
await fd.close();
}
// Compute SHA-256 hash of [size + sample]
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
const hashBuffer = createHash('sha256')
.update(hashInput)
.digest();
// Wrap in CID format with custom multicodec 0x1000
const digest = create(CID_ALGORITHM_CODES.midhash256, hashBuffer);
return CID.createV1(CID_ALGORITHM_CODES.midhash256, digest).toString();
}
/**
* Synchronous version of computeMidHash256 for use in worker threads
* Uses synchronous fs operations for compatibility with Piscina workers
*/
export function computeMidHash256Sync(filePath: string): string {
const fs = require('fs');
const SAMPLE_SIZE = 1024 * 1024; // 1MB
// Get file size
const stats = fs.statSync(filePath);
const fileSize = stats.size;
// Create size buffer (64-bit big-endian)
const sizeBuffer = Buffer.allocUnsafe(8);
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
// Extract sample data
let sampleData: Buffer;
if (fileSize <= SAMPLE_SIZE) {
// Small file: read entire content
const fd = fs.openSync(filePath, 'r');
sampleData = Buffer.allocUnsafe(fileSize);
fs.readSync(fd, sampleData, 0, fileSize, 0);
fs.closeSync(fd);
} else {
// Large file: read middle 1MB
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
const fd = fs.openSync(filePath, 'r');
sampleData = Buffer.allocUnsafe(SAMPLE_SIZE);
fs.readSync(fd, sampleData, 0, SAMPLE_SIZE, middleOffset);
fs.closeSync(fd);
}
// Compute SHA-256 hash of [size + sample]
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
const hashBuffer = createHash('sha256')
.update(hashInput)
.digest();
// Wrap in CID format with custom multicodec 0x1000
const digest = create(CID_ALGORITHM_CODES.midhash256, hashBuffer);
return CID.createV1(CID_ALGORITHM_CODES.midhash256, digest).toString();
}
@@ -0,0 +1,5 @@
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
export interface FileIDComputerInterface{
computeCIDs(filePath: ReadableStream<Uint8Array>, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]>;
}
@@ -0,0 +1,31 @@
import {Piscina} from "piscina";
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
export class FileIDComputerWorker{
private piscina: Piscina;
constructor(workerPath?:string) {
if(!workerPath) {
// Construct the URL for the current module
let distFolder = import.meta.dirname;
distFolder = distFolder.replace('src', 'dist');
distFolder = distFolder + "/worker.js";
workerPath = new URL(distFolder,"file://").href;
}
this.piscina = new Piscina({
maxThreads: 4,
//filename: new URL('./ShaComputeWorker.ts', import.meta.url).href
filename: workerPath
});
}
/**
* Compute the CIDs of a file using specific algorithms
* @param filePath The path to the file
* @param algorithms Array of algorithms ('sha256', 'sha1')
* @returns Array of CIDs (in the order of the algorithms)
*/
public async computeCIDs(filePath: string, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]> {
return this.piscina.run({filePath, algorithms});
}
}
@@ -0,0 +1,183 @@
import {MultiHashData} from '../hash-compute/MultiHashData';
import {CID} from 'multiformats/cid';
/**
* Magnet Link Generator for BitTorrent v2
*
* Generates BitTorrent v2 magnet links from file metadata.
*
* Format: magnet:?xt=urn:btmh:1220<info-hash-hex>&dn=<filename>&xl=<filesize>
*
* Components:
* - xt: eXact Topic (torrent identifier)
* - btmh: BitTorrent Merkle Hash (v2 URN scheme)
* - 1220: Multihash prefix (0x12 = SHA-256, 0x20 = 32 bytes)
* - info-hash-hex: 64-character hex string (32 bytes SHA-256)
* - dn: Display Name (filename)
* - xl: eXact Length (file size in bytes)
*
* Reference: BEP 52, BEP 9 (magnet URIs)
*/
export class MagnetLinkGenerator {
/**
* Generate BitTorrent v2 magnet link from metadata
*
* @param metadata - File metadata containing cid_btih_v2
* @param fileName - Original filename for display name
* @param fileSize - File size in bytes
* @returns Magnet link string, or null if btih_v2 not available
*
* @example
* const metadata = {cid_btih_v2: 'bafyb...'};
* const magnet = MagnetLinkGenerator.generate(metadata, 'video.mp4', 1024000);
* // Returns: magnet:?xt=urn:btmh:1220abc...&dn=video.mp4&xl=1024000
*/
static generate(
metadata: MultiHashData,
fileName: string,
fileSize: number
): string | null {
const cidString = metadata.cid_btih_v2;
if (!cidString) {
return null; // BitTorrent v2 hash not computed
}
try {
// Decode CID to get raw info hash
const cid = CID.parse(cidString);
const infoHash = cid.multihash.digest; // Raw 32-byte SHA-256 hash
// Convert to hex for magnet link
const infoHashHex = Buffer.from(infoHash).toString('hex');
// Build magnet link components
const components = [
`xt=urn:btmh:1220${infoHashHex}`, // 1220 = multihash prefix (SHA-256, 32 bytes)
`dn=${encodeURIComponent(fileName)}`, // Display name (URL encoded)
`xl=${fileSize}` // Exact length in bytes
];
return `magnet:?${components.join('&')}`;
} catch (error) {
console.error('Failed to generate magnet link from CID:', error);
return null;
}
}
/**
* Extract info hash from magnet link
*
* @param magnetLink - Magnet link string
* @returns Info hash as hex string, or null if invalid
*
* @example
* const hash = MagnetLinkGenerator.extractInfoHash('magnet:?xt=urn:btmh:1220abc...');
* // Returns: 'abc...'
*/
static extractInfoHash(magnetLink: string): string | null {
try {
const match = magnetLink.match(/xt=urn:btmh:1220([0-9a-f]{64})/i);
return match ? match[1] : null;
} catch (error) {
return null;
}
}
/**
* Extract filename from magnet link
*
* @param magnetLink - Magnet link string
* @returns Decoded filename, or null if not present
*/
static extractFileName(magnetLink: string): string | null {
try {
const match = magnetLink.match(/dn=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
} catch (error) {
return null;
}
}
/**
* Extract file size from magnet link
*
* @param magnetLink - Magnet link string
* @returns File size in bytes, or null if not present
*/
static extractFileSize(magnetLink: string): number | null {
try {
const match = magnetLink.match(/xl=(\d+)/);
return match ? parseInt(match[1], 10) : null;
} catch (error) {
return null;
}
}
/**
* Validate BitTorrent v2 magnet link format
*
* @param magnetLink - Magnet link string
* @returns True if valid BitTorrent v2 magnet link
*/
static isValid(magnetLink: string): boolean {
if (!magnetLink.startsWith('magnet:?')) {
return false;
}
// Must have btmh (BitTorrent Merkle Hash) identifier
if (!magnetLink.includes('xt=urn:btmh:1220')) {
return false;
}
// Must have 64-character hex hash (32 bytes SHA-256)
const hashMatch = magnetLink.match(/1220([0-9a-f]{64})/i);
if (!hashMatch) {
return false;
}
return true;
}
/**
* Parse magnet link into components
*
* @param magnetLink - Magnet link string
* @returns Object with parsed components, or null if invalid
*/
static parse(magnetLink: string): {
infoHash: string;
fileName?: string;
fileSize?: number;
} | null {
if (!this.isValid(magnetLink)) {
return null;
}
const infoHash = this.extractInfoHash(magnetLink);
if (!infoHash) {
return null;
}
return {
infoHash,
fileName: this.extractFileName(magnetLink) || undefined,
fileSize: this.extractFileSize(magnetLink) || undefined
};
}
/**
* Generate magnet link from CID string directly
*
* @param cidString - CID string (e.g., 'bafyb...')
* @param fileName - Original filename
* @param fileSize - File size in bytes
* @returns Magnet link string, or null if CID invalid
*/
static fromCID(
cidString: string,
fileName: string,
fileSize: number
): string | null {
return this.generate({cid_btih_v2: cidString}, fileName, fileSize);
}
}
@@ -0,0 +1,19 @@
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
import fs from "fs";
import {Readable} from 'stream';
import {computeCIDs as computeCIDsInternal} from "./ComputeHash";
import {createHasher} from "@root/file-id/CreateHasher";
/**
* Compute the CIDs of a file using specific algorithms
* @param filePath The path to the file
* @param algorithms Array of algorithms ('sha256', 'sha1')
* @returns Array of CIDs (in the order of the algorithms)
*/
export default async function computeCIDs({filePath, algorithms}: {
filePath: string;
algorithms: CID_ALGORITHM_NAMES[]
}): Promise<string[]> {
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
return computeCIDsInternal({stream, algorithms, createHasher});
}
@@ -0,0 +1,5 @@
export interface SimpleHash {
update(data: Uint8Array): SimpleHash | Promise<SimpleHash>;
digest(): Uint8Array | Promise<Uint8Array>;
}
@@ -0,0 +1,50 @@
/**
* Interface for file processing implementations used by FolderWatcher.
*
* Provides hooks for different stages of the file processing pipeline:
* 1. markPending() - File discovered, waiting in queue
* 2. queueFile() - File starts processing (optional pre-processing phase)
* 3. processFile() - Main processing phase
* 4. finalize() - After all files processed (optional)
*/
export interface FileProcessorInterface {
/** Optional initialization before processing starts */
initialize?(): Promise<void>;
/**
* Main file processing method.
* @param current - Current file index in the queue
* @param queueSize - Total number of files to process
* @param nfoFilePath - Path to the file being processed
*/
processFile(current: number, queueSize: number, nfoFilePath: string): Promise<void>;
/**
* Determines if a file should be processed.
* @param filePath - Path to the file to check
* @returns true if the file should be processed
*/
canProcessFile(filePath: string): Promise<boolean>;
/** Optional callback when a file is deleted */
deleteFile?(filePath: string): Promise<void>;
/** Optional callback after all files have been processed */
finalize?(): Promise<void>;
/**
* Optional callback when a file is discovered and marked as pending.
* Called BEFORE the file enters the processing queue.
* Useful for tracking/monitoring pending files.
* @param filePath - Path to the file being marked as pending
*/
markPending?(filePath: string): void;
/**
* Optional callback when a file begins processing.
* Called WITHIN the queue (respects concurrency limits).
* Can be used for quick/light pre-processing before main processing.
* @param filePath - Path to the file being queued
*/
queueFile?(filePath: string): Promise<void>;
}
@@ -0,0 +1,170 @@
import * as path from 'path';
import chokidar from 'chokidar';
import {readdir} from "fs/promises";
/**
* Generic file discovery utility
* Discovers files in directory trees and emits paths as async generator
* No filtering logic - just pure file discovery
*/
export class FolderWatcher {
/**
* Discover all files in given directories
* Returns async generator that yields file paths as they're discovered
*
* @param directories - Array of directory paths to scan
* @returns AsyncGenerator yielding file paths
*/
async *discoverFiles(directories: string[]): AsyncGenerator<string> {
for (const dir of directories) {
const normalizedDir = path.normalize(dir);
yield* this.walkDirectory(normalizedDir);
}
}
/**
* Recursively walk directory tree
* Yields file paths as they're discovered
*
* @param directory - Directory to walk
* @returns AsyncGenerator yielding file paths
*/
private async *walkDirectory(directory: string): AsyncGenerator<string> {
try {
const entries = await readdir(directory, {withFileTypes: true});
// Collect subdirectories for parallel processing
const subdirs: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
subdirs.push(fullPath);
} else {
// Yield file path immediately
yield fullPath;
}
}
// Process subdirectories (yields naturally to event loop)
for (const subdir of subdirs) {
yield* this.walkDirectory(subdir);
}
} catch (error: any) {
// Handle permission errors gracefully
if (error.code === 'EACCES' || error.code === 'EPERM') {
console.warn(`[FolderWatcher] Permission denied: ${directory} - skipping`);
} else if (error.code === 'ENOENT') {
console.warn(`[FolderWatcher] Directory not found: ${directory} - skipping`);
} else if (error.code === 'ENOTDIR') {
console.warn(`[FolderWatcher] Not a directory: ${directory} - skipping`);
} else {
console.error(`[FolderWatcher] Error reading directory ${directory}:`, error.message);
}
// Continue processing - don't let one bad directory stop the scan
}
}
/**
* Watch directories for file changes using chokidar
*
* @param directories - Directories to watch
* @param callbacks - Event callbacks
* @param options - Chokidar options
*/
watch(directories: string[], callbacks: {
onAdd?: (filePath: string) => void | Promise<void>;
onChange?: (filePath: string) => void | Promise<void>;
onUnlink?: (filePath: string) => void | Promise<void>;
onReady?: () => void | Promise<void>;
onError?: (error: Error) => void | Promise<void>;
}, options?: {
interval?: number;
stabilityThreshold?: number;
pollInterval?: number;
}): chokidar.FSWatcher {
const chokidarConfig: chokidar.WatchOptions = {
ignoreInitial: true, // Don't emit events for initial scan
persistent: true,
depth: Infinity,
awaitWriteFinish: {
stabilityThreshold: options?.stabilityThreshold || 30000,
pollInterval: options?.pollInterval || 5000
}
};
let watcher: chokidar.FSWatcher;
if ((options?.interval || 0) <= 0) {
watcher = chokidar.watch(directories, chokidarConfig);
} else {
watcher = chokidar.watch(directories, {
...chokidarConfig,
usePolling: true,
interval: options.interval
});
}
if (callbacks.onAdd) {
watcher.on('add', async (filePath) => {
try {
await callbacks.onAdd!(filePath);
} catch (error) {
console.error(`[FolderWatcher] Error in onAdd callback for ${filePath}:`, error);
}
});
}
if (callbacks.onChange) {
watcher.on('change', async (filePath) => {
try {
await callbacks.onChange!(filePath);
} catch (error) {
console.error(`[FolderWatcher] Error in onChange callback for ${filePath}:`, error);
}
});
}
if (callbacks.onUnlink) {
watcher.on('unlink', async (filePath) => {
try {
await callbacks.onUnlink!(filePath);
} catch (error) {
console.error(`[FolderWatcher] Error in onUnlink callback for ${filePath}:`, error);
}
});
}
watcher.on('error', (error: Error) => {
if (callbacks.onError) {
callbacks.onError(error);
} else {
// Default error handling
const err = error as any;
if (err.code === 'EACCES' || err.code === 'EPERM') {
console.warn(`[FolderWatcher] Permission denied for file watcher:`, error.message);
} else if (err.code === 'ENOENT') {
console.warn(`[FolderWatcher] Watched path no longer exists:`, error.message);
} else if (err.code === 'ENOSPC') {
console.error(`[FolderWatcher] CRITICAL: No space left on device or inotify watch limit reached:`, error.message);
console.error(`[FolderWatcher] Try increasing fs.inotify.max_user_watches: sysctl fs.inotify.max_user_watches=524288`);
} else {
console.error(`[FolderWatcher] Watcher error:`, error.message || error);
}
}
});
if (callbacks.onReady) {
watcher.on('ready', async () => {
try {
console.log(`[FolderWatcher] Watching for file changes on ${directories.join(', ')}`);
await callbacks.onReady!();
} catch (error) {
console.error(`[FolderWatcher] Error in onReady callback:`, error);
}
});
}
return watcher;
}
}
@@ -0,0 +1,168 @@
import {readdir} from "fs/promises";
import * as path from 'path';
import {FileProcessorInterface} from "./FileProcessorInterface.js";
export interface PollingPathConfig {
path: string;
intervalMs: number;
}
/**
* PollingWatcher - Manual polling-based file watcher (no chokidar)
*
* Polls specific directories at configured intervals using simple setInterval + readdir.
* Perfect for network drives (SMB/NFS) where filesystem events don't work reliably.
*
* Usage:
* - Configure with array of {path, intervalMs} objects
* - Each path can have its own polling interval
* - Runs in parallel with FolderWatcher (chokidar)
*/
export class PollingWatcher {
private fileProcessor: FileProcessorInterface;
private pollingConfigs: PollingPathConfig[];
private intervals: NodeJS.Timeout[] = [];
private seenFiles: Set<string> = new Set();
private initialized = false;
constructor(fileProcessor: FileProcessorInterface, pollingConfigs: PollingPathConfig[]) {
this.fileProcessor = fileProcessor;
this.pollingConfigs = pollingConfigs;
}
/**
* Recursively scan directory and find all files
*/
private async scanDirectory(directory: string, allFiles: string[]): Promise<void> {
try {
const entries = await readdir(directory, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await this.scanDirectory(fullPath, allFiles);
} else {
allFiles.push(fullPath);
}
}
} catch (error: any) {
// Ignore permission errors and other scan errors
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error(`Error scanning directory ${directory}:`, error.message);
}
}
}
/**
* Process a single file (check if new, then queue for processing)
*/
private async processFile(filePath: string): Promise<void> {
// Skip if already seen
if (this.seenFiles.has(filePath)) {
return;
}
// Check if file type is supported
if (!await this.fileProcessor.canProcessFile(filePath)) {
return;
}
// Mark as seen
this.seenFiles.add(filePath);
console.log(`[PollingWatcher] New file detected: ${filePath}`);
// Queue for processing (same pipeline as chokidar)
if (this.fileProcessor.markPending) {
this.fileProcessor.markPending(filePath);
}
if (this.fileProcessor.queueFile) {
await this.fileProcessor.queueFile(filePath);
}
if (this.fileProcessor.processFile) {
this.fileProcessor.processFile(0, 0, filePath);
}
}
/**
* Poll a specific directory once
*/
private async pollDirectory(dirPath: string): Promise<void> {
console.log(`[PollingWatcher] Polling ${dirPath}...`);
const startTime = Date.now();
try {
const allFiles: string[] = [];
await this.scanDirectory(dirPath, allFiles);
console.log(`[PollingWatcher] Found ${allFiles.length} files in ${dirPath} (${Date.now() - startTime}ms)`);
// Process new files
const promises: Promise<void>[] = [];
for (const filePath of allFiles) {
promises.push(this.processFile(filePath));
}
await Promise.all(promises);
console.log(`[PollingWatcher] Poll completed for ${dirPath} (${Date.now() - startTime}ms)`);
} catch (error: any) {
console.error(`[PollingWatcher] Error polling ${dirPath}:`, error.message);
}
}
/**
* Start polling all configured paths
*/
async start(): Promise<void> {
if (this.pollingConfigs.length === 0) {
console.log('[PollingWatcher] No polling paths configured, skipping');
return;
}
console.log(`[PollingWatcher] Starting polling for ${this.pollingConfigs.length} paths`);
// Initialize file processor if needed
if (!this.initialized && this.fileProcessor.initialize) {
await this.fileProcessor.initialize();
this.initialized = true;
}
// Do initial scan for all paths
for (const config of this.pollingConfigs) {
console.log(`[PollingWatcher] Initial scan: ${config.path} (interval: ${config.intervalMs}ms)`);
await this.pollDirectory(config.path);
}
// Start interval polling for each path
for (const config of this.pollingConfigs) {
const interval = setInterval(async () => {
await this.pollDirectory(config.path);
}, config.intervalMs);
this.intervals.push(interval);
console.log(`[PollingWatcher] Polling started for ${config.path} every ${config.intervalMs}ms`);
}
console.log('[PollingWatcher] All polling intervals started');
}
/**
* Stop all polling
*/
stop(): void {
console.log('[PollingWatcher] Stopping all polling intervals');
for (const interval of this.intervals) {
clearInterval(interval);
}
this.intervals = [];
console.log('[PollingWatcher] All polling intervals stopped');
}
/**
* Clear the seen files cache (useful for testing/debugging)
*/
clearCache(): void {
console.log(`[PollingWatcher] Clearing seen files cache (${this.seenFiles.size} files)`);
this.seenFiles.clear();
}
}
@@ -0,0 +1,30 @@
import {CID_ALGORITHM_NAMES, MultiHashData} from "./MultiHashData";
import {computeCIDs} from "@root/file-id/ComputeHash";
import {SimpleHash} from "@root/file-id/SimpleHash";
export class HashComputer{
constructor(private targetHash: CID_ALGORITHM_NAMES[],
private createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>) {
}
async computeMissingHash(stream: ReadableStream<Uint8Array>, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing
if (neededHashes.length === 0) {
return;
}
// Compute only the needed CIDs
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher:this.createHasher});
// Map the computed CIDs back to their respective metadata properties
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
}
}
}
@@ -0,0 +1,32 @@
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
import {Readable} from "stream";
import {computeCIDs} from "@root/file-id/ComputeHash";
import fs from "fs";
import {createHasher} from "@root/file-id/CreateHasher";
export class HashComputerFile implements ComputeInterface{
constructor(private targetHash: CID_ALGORITHM_NAMES[]) {
}
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing
if (neededHashes.length === 0) {
return;
}
// Compute only the needed CIDs
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher});
// Map the computed CIDs back to their respective metadata properties
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
}
}
}
@@ -0,0 +1,37 @@
import {HashComputerWorker} from "./HashComputerWorker";
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
import {HashIndexManager} from "./HashIndexManager";
import {stat} from "fs/promises";
import path from "path";
export class HashComputerIndexCache implements ComputeInterface {
private hashIndexManager: HashIndexManager;
private hashComputer: HashComputerWorker;
constructor(indexFolderPath: string,private targetHash: CID_ALGORITHM_NAMES[] = [CID_ALGORITHM_NAMES.sha1, CID_ALGORITHM_NAMES.sha256],workerPath?:string) {
this.hashComputer = new HashComputerWorker(targetHash,workerPath);
this.hashIndexManager = new HashIndexManager(indexFolderPath,targetHash);
}
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
await this.hashIndexManager.init();
let stats = await stat(filePath);
if (this.hashIndexManager.hasFileInCache(path.basename(filePath),stats)) {
const indexLine = this.hashIndexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
if(indexLine) {
for (const hash of this.targetHash) {
if (!metadata[hash] && indexLine[hash]) {
metadata[hash] = indexLine[hash];
}
}
}
}
await this.hashComputer.computeMissingHash(filePath, metadata);
this.hashIndexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), metadata);
}
async getHashIndexManager(): Promise<HashIndexManager> {
await this.hashIndexManager.init();
return this.hashIndexManager;
}
}
@@ -0,0 +1,29 @@
import {FileIDComputerWorker} from "../file-id/FileIDComputerWorker";
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
export class HashComputerWorker implements ComputeInterface {
private fileIDComputer: FileIDComputerWorker;
constructor(private targetHash: CID_ALGORITHM_NAMES[], workerPath?: string) {
this.fileIDComputer = new FileIDComputerWorker(workerPath);
}
async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing
if (neededHashes.length === 0) {
return;
}
// Compute only the needed CIDs
const cids = await this.fileIDComputer.computeCIDs(filePath, neededHashes);
// Map the computed CIDs back to their respective metadata properties
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
}
}
}
@@ -0,0 +1,305 @@
import {createReadStream, promises as fs} from 'fs';
import {parse} from 'csv-parse';
import {parse as parseSync} from 'csv-parse/sync';
import {stringify} from 'csv-stringify/sync';
import {existsAsync} from "../utils/ExistsAsync";
import path from "path";
import {clearInterval} from "node:timers";
import {CID_ALGORITHM_NAMES} from "./MultiHashData";
import {stat} from "fs/promises";
import {Stats} from "node:fs";
interface IndexLine extends Partial<Record<CID_ALGORITHM_NAMES, string>> {
path: string;
size: string;
mtime: string;
}
export const INDEX_HEADERS = ['path', 'size', 'mtime'];
export class HashIndexManager {
private cache: Map<string, IndexLine> = new Map<string, IndexLine>();//the key is the file `${file_name}-${filesize}-${mtime_ISOstr}`
private intervalId: any;
private intervalTime: number = 30000;
private lastIndexFileSize: { [key in CID_ALGORITHM_NAMES]?: number } = {}; //size of the index file last time it was read
private lastCacheFile: { [key in CID_ALGORITHM_NAMES]?: IndexLine[] } = {}; //state of the file last time it was read
private indexOpsInProgress: boolean = false;
private hasChanged: boolean = false;
private initialLoad: Promise<void>;
private filePaths: { [key in CID_ALGORITHM_NAMES]?: string } = {};
constructor(private indexFolderPath: string,
private targetHash: CID_ALGORITHM_NAMES[]) {
}
hasFileInCache(filePath: string,stats:Stats): boolean {
return this.cache.has(`${path.basename(filePath)}-${stats.size}-${stats.mtime.toISOString()}`);
}
getCacheSize(): number {
return this.cache.size;
}
/**
* After init consecutively calls to this method will not reload the index
* readThe index for the first time and enable autosave
* @param autosave
*/
public async init(autosave = true) {
if (!this.initialLoad) {
this.initialLoad = new Promise<void>(async (resolve, reject) => {
// Create the index folder if it doesn't exist
await fs.mkdir(this.indexFolderPath, { recursive: true });
const stat = await fs.stat(this.indexFolderPath);
if (!stat.isDirectory()) {
throw new Error(`Invalid index folder path ${this.indexFolderPath}`);
}
for (const hash of this.targetHash) {
this.filePaths[hash] = path.join(this.indexFolderPath, `index-${hash}.csv`);
console.log(`Index file path for ${hash} is ${this.filePaths[hash]}`);
if (!this.filePaths[hash]) {
throw new Error(`Invalid index file path for ${hash}`);
}
}
try {
for (const hash of this.targetHash) {
if (!this.checkCSVHeaders(this.filePaths[hash], hash)) {
throw new Error(`Invalid index file headers for ${hash}`);
}
await this.loadIndex(hash);
}
if (autosave) {
this.startAutoSave()
}
resolve();
} catch (e) {
reject(e);
}
});
}
return this.initialLoad;
}
// Function to check CSV headers
private checkCSVHeaders(csvContent: string, hash: CID_ALGORITHM_NAMES): boolean {
const records = parseSync(csvContent, {
bom: true,
columns: true,
skip_empty_lines: true,
});
if (records.length === 0) {
//we will write the headers
return true;
}
// Extract headers
const headers = Object.keys(records[0]);
// Define required headers
//from the IndexLine interface
const requiredHeaders = [...INDEX_HEADERS, hash];
// Check if all required headers are present
return requiredHeaders.every(header => headers.includes(header));
};
public stopAutoSave() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
public startAutoSave(time: number = this.intervalTime) {
this.stopAutoSave();
this.intervalTime = time;
this.intervalId = setInterval(() => this.saveCacheToFile(), time);
}
public async loadIndex(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> {
if (await existsAsync(this.filePaths[hash])) {
// check the file size and if it did not change, do not read the file
const stats = await fs.stat(this.filePaths[hash]);
if (this.lastIndexFileSize[hash] !== stats.size) {
// Read existing file content and parse it
const records: IndexLine[] = await this.readCsv(hash);
for (const record of records) {
const cacheKey = `${record.path}-${record.size}-${record.mtime}`;
let indexLine = this.cache.get(cacheKey);
if (!indexLine) {
this.cache.set(cacheKey, record);
} else if(record[hash]){
//update the cache with the latest data
indexLine[hash] = record[hash];
}
}
this.lastIndexFileSize[hash] = stats.size;
this.lastCacheFile[hash] = records;
return records;
} else {
return this.lastCacheFile[hash];
}
}
return [];
}
private loadIndexFromCache(): IndexLine[] {
return Array.from(this.cache.values());
}
private async readCsv(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> {
if (!(await existsAsync(this.filePaths[hash]))) {
return [];
}
const start = performance.now();
const parser = parse({
columns: true,
skip_empty_lines: true,
});
const records: IndexLine[] = [];
return new Promise<IndexLine[]>((resolve, reject) => {
createReadStream(this.filePaths[hash])
.pipe(parser)
.on('data', (record) => {
records.push(record);
})
.on('end', () => {
resolve(records);
console.log(`Index read ${hash} time ${performance.now() - start}ms`);
})
.on('error', (err) => {
reject(err);
});
});
}
public async saveCacheToFile(): Promise<void> {
if (this.indexOpsInProgress || !this.hasChanged) {
return;
}
this.hasChanged = false;
this.indexOpsInProgress = true;
const start = performance.now();
let cacheRows: IndexLine[];
if (this.cache.size !== 0) {
cacheRows = this.loadIndexFromCache();
}
let didWrite=false;
for (const hash of this.targetHash) {
let existingRows: IndexLine[] = await this.loadIndex(hash);
let existingRowsAsMap: Map<string, IndexLine> = new Map(existingRows.map(row => [row.path + '-' + row.size + '-' + row.mtime, row]));
if (this.cache.size !== 0) {
// Filter out cacheRows that are already in the file
const newRows = cacheRows.filter(row => {
//to be added a row must not exist in the file and must exist in the cache (with a hash)
const newRow = !existingRowsAsMap.has(row.path + '-' + row.size + '-' + row.mtime);
return newRow && !!row[hash];
});
if (newRows.length !== 0) {
// Serialize new cacheRows to CSV string
const csvString = stringify(newRows, {
header: existingRows.length === 0, // Only add header if the file was empty
columns: [
{key: 'path', header: 'path'},
{key: 'size', header: 'size'},
{key: 'mtime', header: 'mtime'},
{key: hash, header: hash},
],
});
// Append new cacheRows to the file
await fs.appendFile(this.filePaths[hash], csvString);
didWrite = true;
}
}
}
if(didWrite) {
const totalTime = performance.now() - start;
console.log(`Index saved in ${totalTime}ms`);
// Check if the time to save the index is greater than the interval time. increase the interval time if needed
if (totalTime * 10 > this.intervalTime) {
this.startAutoSave(totalTime * 10);
console.log(`Index save interval increased to ${totalTime * 10}ms`);
}
}
this.indexOpsInProgress = false;
}
/**
* this.getCidForFile(fileName, stats.size, stats.mtime.toISOString())
* @param filePath
*/
public async getCidForFileAsync(filePath: string): Promise<IndexLine> {
const fileName = path.basename(filePath);
const stats = await stat(filePath);
return this.getCidForFile(fileName, stats.size, stats.mtime.toISOString());
}
public getCidForFile(filePath: string, fileSize: number, mtime: string): IndexLine {
const fileName = path.basename(filePath);
let fileNameIndex = this.cache.get(`${fileName}-${fileSize}-${mtime}`);
if (fileNameIndex) {
for (const hash of this.targetHash) {
if (!fileNameIndex[hash]) {
delete fileNameIndex[hash];
}
}
if (fileNameIndex.mtime) {
//if we have a mtime, we need to check it
if (fileNameIndex.size === (fileSize + "") && fileNameIndex.mtime === mtime) {
return fileNameIndex;
}
} else {
//mtime is optional
if (fileNameIndex.size === (fileSize + "")) {
return fileNameIndex;
}
}
}
// 3 - if not found, delete the entry (keeps the index clean)
/*if (fileNameIndex && fileNameIndex.size !== (fileSize + "") && pathIndex && pathIndex.size !== (fileSize + "")) {
this.cache.delete(fileName);
this.cache.delete(filePath);
}*/
return null;
}
public addFileCid(filePath: string, fileSize: number, mtime: string, hashs: Partial<Record<CID_ALGORITHM_NAMES, string>>): void {
if (!filePath || !fileSize || !mtime || !hashs) {
throw new Error('Invalid parameters');
}
for (const hash of this.targetHash) {
if (!hashs[hash]) {
throw new Error(`Missing hash ${hash}`);
}
}
const size = fileSize + "";
const baseName = path.basename(filePath);
const cacheKey = `${baseName}-${size}-${mtime}`;
let indexLine = this.cache.get(cacheKey);
if (!indexLine) {
//filter only the hashes we need
let filteredHash = {};
for (const hash of this.targetHash) {
filteredHash[hash] = hashs[hash];
}
const data = {path: baseName, size: size, mtime: mtime, ...filteredHash};
this.cache.set(cacheKey, data);
} else {
//update the cache with the latest data
for (const hash of this.targetHash) {
indexLine[hash] = hashs[hash];
}
}
this.hasChanged = true;
}
}
@@ -0,0 +1,51 @@
export interface MultiHashData {
cid_crc32?: string; //0x0132 CRC32 (SFV)
cid_md5?: string; // 0xd5 md5
cid_sha1?: string; // 0x11 sha1
"cid_sha2-256"?: string;//0x12 sha2-256
"cid_sha3-256"?: string; // 0x16 sha3-256
"cid_sha3_384"?: string; // 0x16 sha3-256
cid_midhash256?: string; // 0x1000 midhash256 (SHA-256 of middle 1MB + file size) - CID v1 format
cid_btih_v2?: string; // 0x10B7 BitTorrent v2 info hash (BEP 52) - CID v1 format
}
export interface ComputeInterface {
computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void>;
}
/**
* according to
* https://ipfs.io/ipfs/QmXec1jjwzxWJoNbxQF5KffL8q6hFXm9QwUGaa3wKGk6dT/#title=Multicodecs&src=https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv
*/
export enum CID_ALGORITHM_CODES {
crc32 = 0x0132,
md5 = 0xd5,
sha1 = 0x11,
sha256 = 0x12,
sha3_256 = 0x16,
sha3_384 = 0x15,
midhash256 = 0x1000, // Custom code for midhash256
btih_v2 = 0x10B7, // Custom code for BitTorrent v2 info hash
}
export enum CID_ALGORITHM_NAMES {
crc32 = 'cid_crc32',
md5 = 'cid_md5',
sha1 = 'cid_sha1',
sha256 = 'cid_sha2-256',
sha3_256 = 'cid_sha3-256',
sha3_384 = 'cid_sha3-384',
midhash256 = 'cid_midhash256',
btih_v2 = 'cid_btih_v2',
}
export enum CID_ALGORITHM {
crc32 = 'crc32',
md5 = 'md5',
sha1 = 'sha1',
sha256 = 'sha2-256',
sha3_256 = 'sha3-256',
sha3_384 = 'sha3-384',
midhash256 = 'midhash256',
btih_v2 = 'btih-v2',
}
@@ -0,0 +1,4 @@
export * from './index-interface.js';
export * from './file-id/CreateHasherBrowser.js';
export * from './hash-compute/HashComputer.js';
@@ -0,0 +1,3 @@
export * from './hash-compute/MultiHashData.js';
export * from './folder-watcher/FileProcessorInterface.js';
export * from './file-id/FileIDComputerInterface.js';
@@ -0,0 +1,20 @@
//fileID
export * from './file-id/FileIDComputerWorker.js';
export * from './file-id/ShaComputeWorker.js';//worker
export * from './file-id/FastHash.js'; // Fast-hash computation
export * from './file-id/BtihV2Hasher.js'; // BitTorrent v2 info hash
export * from './file-id/MagnetLinkGenerator.js'; // Magnet link generation
//hash-compute
export * from './hash-compute/HashComputerIndexCache.js';
export * from './hash-compute/HashComputerWorker.js';
export * from './hash-compute/HashIndexManager.js';
export * from './hash-compute/HashComputer.js';
export * from './hash-compute/HashComputerFile.js';
//folder-watcher
export * from './folder-watcher/FolderWatcher.js';
export * from './folder-watcher/PollingWatcher.js';
export * from './index-interface.js';
export * from './utils/ExistsAsync.js';
@@ -0,0 +1,12 @@
// Example of checking if a file exists asynchronously
// Note: `existsSync` does not have a direct async equivalent, so we use `access` instead.
import {access,constants} from "fs/promises";
export async function existsAsync(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true; // File exists
} catch {
return false; // File does not exist
}
}
@@ -0,0 +1,236 @@
import {describe, expect, it} from 'vitest';
import {BtihV2Hasher} from '../lib/file-id/BtihV2Hasher';
import {CID_ALGORITHM_NAMES, HashComputerWorker} from "../lib";
import {readFile} from 'fs/promises';
import {createHash} from 'crypto';
import * as process from "process";
process.env.WORKER_URL = "./dist/worker.js";
/**
* BitTorrent v2 Info Hash Tests (BEP 52)
*
* Tests verify:
* - 16KB block hashing
* - Merkle tree construction
* - Info hash computation
* - CID encoding format
* - Edge cases (empty files, partial blocks)
*/
describe('BitTorrent v2 Info Hash (BEP 52)', () => {
it('should compute info hash for small file', async () => {
const hasher = new BtihV2Hasher();
hasher.setFileName('test.txt');
// Read test file and feed to hasher
const testData = await readFile('./src/tests/test.txt');
hasher.update(testData);
// Get info hash
const infoHash = hasher.digest();
// Verify hash format
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32); // SHA-256 produces 32 bytes
// Verify it's non-zero
const isNonZero = infoHash.some(byte => byte !== 0);
expect(isNonZero).toBe(true);
console.log('Info hash (hex):', infoHash.toString('hex'));
});
it('should handle empty file', () => {
const hasher = new BtihV2Hasher();
hasher.setFileName('empty.txt');
// Don't update with any data (empty file)
const infoHash = hasher.digest();
// Verify hash format
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32);
// Should produce valid hash even for empty file
const isNonZero = infoHash.some(byte => byte !== 0);
expect(isNonZero).toBe(true);
console.log('Empty file info hash (hex):', infoHash.toString('hex'));
});
it('should handle file exactly one block (16KB)', () => {
const hasher = new BtihV2Hasher();
hasher.setFileName('exactly-16kb.bin');
// Create exactly 16KB of data
const oneBlock = Buffer.alloc(16 * 1024);
for (let i = 0; i < oneBlock.length; i++) {
oneBlock[i] = i % 256;
}
hasher.update(oneBlock);
const infoHash = hasher.digest();
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32);
console.log('16KB file info hash (hex):', infoHash.toString('hex'));
});
it('should handle streaming updates (partial blocks)', () => {
const hasher = new BtihV2Hasher();
hasher.setFileName('streamed.bin');
// Simulate streaming: feed data in small chunks
const chunkSize = 1024; // 1KB chunks
const totalSize = 50 * 1024; // 50KB total
const data = Buffer.alloc(totalSize);
for (let i = 0; i < data.length; i++) {
data[i] = i % 256;
}
// Stream in chunks
for (let offset = 0; offset < data.length; offset += chunkSize) {
const end = Math.min(offset + chunkSize, data.length);
hasher.update(data.subarray(offset, end));
}
const infoHash = hasher.digest();
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32);
console.log('Streamed 50KB info hash (hex):', infoHash.toString('hex'));
});
it('should compute consistent hash for same data', () => {
const testData = Buffer.from('Hello BitTorrent v2!');
// Compute hash twice
const hasher1 = new BtihV2Hasher();
hasher1.setFileName('hello.txt');
hasher1.update(testData);
const hash1 = hasher1.digest();
const hasher2 = new BtihV2Hasher();
hasher2.setFileName('hello.txt');
hasher2.update(testData);
const hash2 = hasher2.digest();
// Should produce identical hashes
expect(hash1.equals(hash2)).toBe(true);
});
it('should produce different hashes for different data', () => {
const data1 = Buffer.from('File A content');
const data2 = Buffer.from('File B content');
const hasher1 = new BtihV2Hasher();
hasher1.setFileName('fileA.txt');
hasher1.update(data1);
const hash1 = hasher1.digest();
const hasher2 = new BtihV2Hasher();
hasher2.setFileName('fileB.txt');
hasher2.update(data2);
const hash2 = hasher2.digest();
// Should produce different hashes
expect(hash1.equals(hash2)).toBe(false);
});
it('should produce different hashes for different filenames', () => {
const data = Buffer.from('Same content');
const hasher1 = new BtihV2Hasher();
hasher1.setFileName('nameA.txt');
hasher1.update(data);
const hash1 = hasher1.digest();
const hasher2 = new BtihV2Hasher();
hasher2.setFileName('nameB.txt');
hasher2.update(data);
const hash2 = hasher2.digest();
// Different filenames should produce different info hashes
expect(hash1.equals(hash2)).toBe(false);
});
it('should integrate with HashComputerWorker', async () => {
const hashComputer = new HashComputerWorker([
CID_ALGORITHM_NAMES.btih_v2,
CID_ALGORITHM_NAMES.sha256
]);
const metadata: any = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log('Computed metadata:', metadata);
// Verify btih_v2 CID was computed
expect(metadata).toHaveProperty('cid_btih_v2');
expect(typeof metadata.cid_btih_v2).toBe('string');
expect(metadata.cid_btih_v2.length).toBeGreaterThan(0);
// Verify CID format (should start with 'b' for base32 CIDv1)
expect(metadata.cid_btih_v2.startsWith('b')).toBe(true);
// Should also have SHA-256 for comparison
expect(metadata).toHaveProperty('cid_sha2-256');
});
it('should compute piece length correctly', () => {
// Test piece length formula: 16KB * 2^n where pieces < 2048
const testCases = [
{fileSize: 0, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // Empty file
{fileSize: 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 1KB
{fileSize: 16 * 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 16KB
{fileSize: 1024 * 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 1MB
{fileSize: 100 * 1024 * 1024, expectedMin: 64 * 1024, expectedMax: 128 * 1024}, // 100MB
];
for (const {fileSize, expectedMin, expectedMax} of testCases) {
const hasher = new BtihV2Hasher();
hasher.setFileName('size-test.bin');
// Feed dummy data of specified size
const chunkSize = 16 * 1024;
let remaining = fileSize;
while (remaining > 0) {
const toWrite = Math.min(chunkSize, remaining);
hasher.update(Buffer.alloc(toWrite));
remaining -= toWrite;
}
// Digest triggers piece length computation
const infoHash = hasher.digest();
// We can't directly access piece length from outside,
// but we can verify the hash was computed successfully
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32);
}
});
it('should produce valid CID format with codec 0x10B7', async () => {
const hashComputer = new HashComputerWorker([
CID_ALGORITHM_NAMES.btih_v2
]);
const metadata: any = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
const cid = metadata.cid_btih_v2;
console.log('BitTorrent v2 CID:', cid);
// Verify CID format
expect(cid).toBeDefined();
expect(typeof cid).toBe('string');
// CIDv1 base32 starts with 'b'
expect(cid.startsWith('b')).toBe(true);
// Should be longer than minimum CID length
expect(cid.length).toBeGreaterThan(10);
});
});
@@ -0,0 +1,175 @@
import {describe, expect, it} from 'vitest';
import {BtihV2Hasher, MagnetLinkGenerator} from '../lib';
import {readFile, stat} from 'fs/promises';
/**
* End-to-End Magnet Link Generation Tests
*
* Tests the complete workflow:
* 1. Compute BitTorrent v2 info hash from file
* 2. Generate magnet link
* 3. Parse and validate magnet link
*/
describe('Magnet Link Generation (End-to-End)', () => {
it('should generate valid magnet link from file', async () => {
const filePath = './src/tests/test.txt';
const fileName = 'test.txt';
// Step 1: Read file and compute info hash
const fileData = await readFile(filePath);
const fileStats = await stat(filePath);
const fileSize = fileStats.size;
const hasher = new BtihV2Hasher();
hasher.setFileName(fileName);
hasher.update(fileData);
const infoHash = hasher.digest();
// Verify info hash computed
expect(infoHash).toBeInstanceOf(Buffer);
expect(infoHash.length).toBe(32);
console.log('File:', fileName);
console.log('Size:', fileSize, 'bytes');
console.log('Info hash (hex):', infoHash.toString('hex'));
// Note: We can't test magnet link generation end-to-end without
// the CID encoding working properly through HashComputerWorker.
// This would require the worker integration to be fixed.
});
it('should generate magnet link from mock CID', () => {
// Mock CID format (this is a valid CIDv1 with our custom codec 0x10B7)
// In real usage, this would come from HashComputerWorker
const fileName = 'test-video.mp4';
const fileSize = 1024 * 1024 * 100; // 100MB
// Create a mock info hash (32 bytes of test data)
const mockInfoHash = Buffer.from(
'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf',
'hex'
);
// For this test, we'll construct the magnet link manually
// since we can't get a real CID without the worker
const infoHashHex = mockInfoHash.toString('hex');
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHashHex}&dn=${encodeURIComponent(fileName)}&xl=${fileSize}`;
console.log('\nGenerated magnet link:');
console.log(magnetLink);
// Verify magnet link format
expect(magnetLink.startsWith('magnet:?')).toBe(true);
expect(magnetLink.includes('xt=urn:btmh:1220')).toBe(true);
expect(magnetLink.includes(infoHashHex)).toBe(true);
expect(magnetLink.includes(`dn=${encodeURIComponent(fileName)}`)).toBe(true);
expect(magnetLink.includes(`xl=${fileSize}`)).toBe(true);
});
it('should validate magnet link format', () => {
const validLink = 'magnet:?xt=urn:btmh:1220' + 'a'.repeat(64) + '&dn=file.mp4&xl=1024';
const invalidLink1 = 'http://example.com';
const invalidLink2 = 'magnet:?xt=urn:btih:' + 'a'.repeat(40); // v1 format (SHA-1)
const invalidLink3 = 'magnet:?xt=urn:btmh:1220' + 'a'.repeat(32); // Too short
expect(MagnetLinkGenerator.isValid(validLink)).toBe(true);
expect(MagnetLinkGenerator.isValid(invalidLink1)).toBe(false);
expect(MagnetLinkGenerator.isValid(invalidLink2)).toBe(false);
expect(MagnetLinkGenerator.isValid(invalidLink3)).toBe(false);
});
it('should extract info hash from magnet link', () => {
const infoHash = 'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf';
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=file.mp4&xl=1024`;
const extracted = MagnetLinkGenerator.extractInfoHash(magnetLink);
expect(extracted).toBe(infoHash);
});
it('should extract filename from magnet link', () => {
const fileName = 'Big Buck Bunny.mp4';
const infoHash = 'a'.repeat(64);
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=1024`;
const extracted = MagnetLinkGenerator.extractFileName(magnetLink);
expect(extracted).toBe(fileName);
});
it('should extract file size from magnet link', () => {
const fileSize = 1024000;
const infoHash = 'a'.repeat(64);
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=file.mp4&xl=${fileSize}`;
const extracted = MagnetLinkGenerator.extractFileSize(magnetLink);
expect(extracted).toBe(fileSize);
});
it('should parse complete magnet link', () => {
const infoHash = 'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf';
const fileName = 'test-video.mp4';
const fileSize = 1024000;
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=${fileSize}`;
const parsed = MagnetLinkGenerator.parse(magnetLink);
expect(parsed).not.toBeNull();
expect(parsed!.infoHash).toBe(infoHash);
expect(parsed!.fileName).toBe(fileName);
expect(parsed!.fileSize).toBe(fileSize);
});
it('should handle special characters in filename', () => {
const fileName = 'My Video [1080p] (2024).mp4';
const infoHash = 'a'.repeat(64);
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=1024`;
const extracted = MagnetLinkGenerator.extractFileName(magnetLink);
expect(extracted).toBe(fileName);
// Verify round-trip
const parsed = MagnetLinkGenerator.parse(magnetLink);
expect(parsed?.fileName).toBe(fileName);
});
it('should demonstrate complete workflow (conceptual)', async () => {
// This test demonstrates the intended workflow once worker integration is fixed
const filePath = './src/tests/test.txt';
const fileStats = await stat(filePath);
console.log('\n=== Complete Workflow Demo ===');
console.log('1. File:', filePath);
console.log('2. Size:', fileStats.size, 'bytes');
// Step 1: Compute info hash
const fileData = await readFile(filePath);
const hasher = new BtihV2Hasher();
hasher.setFileName('test.txt');
hasher.update(fileData);
const infoHash = hasher.digest();
console.log('3. Info hash:', infoHash.toString('hex'));
// Step 2: In production, this would be the CID from metadata
// const cid = metadata.cid_btih_v2;
// const magnetLink = MagnetLinkGenerator.generate(metadata, 'test.txt', fileStats.size);
// Step 3: For demo, construct magnet link manually
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash.toString('hex')}&dn=test.txt&xl=${fileStats.size}`;
console.log('4. Magnet link:', magnetLink);
// Step 4: Verify it's valid
const isValid = MagnetLinkGenerator.isValid(magnetLink);
console.log('5. Valid:', isValid);
expect(isValid).toBe(true);
// Step 5: Parse it back
const parsed = MagnetLinkGenerator.parse(magnetLink);
console.log('6. Parsed:', parsed);
expect(parsed).not.toBeNull();
expect(parsed!.infoHash).toBe(infoHash.toString('hex'));
expect(parsed!.fileName).toBe('test.txt');
expect(parsed!.fileSize).toBe(fileStats.size);
});
});
@@ -0,0 +1,97 @@
import {CID_ALGORITHM_NAMES, HashComputerIndexCache, HashComputerWorker} from "../lib";
import {describe, expect, it} from 'vitest';
import * as process from "process";
import { mkdir, rm, rmdir } from "fs/promises";
process.env.WORKER_URL = "./dist/worker.js";
/**
* /!\ WARNING /!\
* Remember to build the worker each time you change the worker code.
*/
describe('add', () => {
it('meta data compute', async () => {
let hashComputer = new HashComputerWorker([
CID_ALGORITHM_NAMES.sha1,
CID_ALGORITHM_NAMES.sha256,
CID_ALGORITHM_NAMES.md5,
CID_ALGORITHM_NAMES.sha3_256,
CID_ALGORITHM_NAMES.crc32,
CID_ALGORITHM_NAMES.sha3_384
]);
let metadata = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log(metadata);
/**
* test.txt CF20B62A (CRC32)
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
*/
expect(metadata).toEqual({
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
});
});
it('meta data compute with index', async () => {
await rm("./tmp",{recursive:true});
await mkdir("./tmp",{recursive:true});
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[
CID_ALGORITHM_NAMES.sha1,
CID_ALGORITHM_NAMES.sha256,
CID_ALGORITHM_NAMES.md5,
CID_ALGORITHM_NAMES.sha3_256,
CID_ALGORITHM_NAMES.crc32,
CID_ALGORITHM_NAMES.sha3_384
]);
let metadata = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log(metadata);
/**
* test.txt CF20B62A (CRC32)
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
*/
expect(metadata).toEqual({
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
});
await (await hashComputer.getHashIndexManager()).saveCacheToFile();
});
it('cache', async () => {
await mkdir("./tmp",{recursive:true});
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[
CID_ALGORITHM_NAMES.sha1,
CID_ALGORITHM_NAMES.sha256,
CID_ALGORITHM_NAMES.md5,
CID_ALGORITHM_NAMES.sha3_256,
CID_ALGORITHM_NAMES.crc32,
CID_ALGORITHM_NAMES.sha3_384
]);
let indexLine = await (await hashComputer.getHashIndexManager()).getCidForFileAsync('./src/tests/test.txt');
delete indexLine['mtime'];
expect(indexLine).toEqual({
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga',
"path": "test.txt",
"size": "499",
});
});
});
@@ -0,0 +1,10 @@
this is a file that is just some data to be hashed
loreum ipsum dolor sit amet
consectetur adipiscing elit
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua
ut enim ad minim veniam
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat
duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur
excepteur sint occaecat cupidatat non proident
sunt in culpa qui officia deserunt mollit anim id est laborum
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"noImplicitAny": false,
"outDir": "build/",
"module": "esnext",
"target": "esnext",
"allowJs": true,
"sourceMap": true,
"declaration": true,
"esModuleInterop":true,
"skipLibCheck": true,
"emitDeclarationOnly": true,
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@root/*": ["src/lib/*"]
},
"plugins": [
{
//used to replace the alias by relative path
"transform": "typescript-transform-paths",
"afterDeclarations": true
}
]
},
"include": [
"src/lib"
],
"exclude": ["node_modules/**"]
}
+17
View File
@@ -0,0 +1,17 @@
import {defineConfig} from 'tsup'
export default defineConfig({
entry: {
'index': 'src/lib/index.ts',
'index-interface': 'src/lib/index-interface.ts',
'index-browser': 'src/lib/index-browser.ts',
'worker': 'src/lib/file-id/ShaComputeWorker.ts',
},
splitting: true,
treeshake: true,
sourcemap: true,
clean: true,
dts:true,
minify:false,
format: ['cjs', 'esm'],
})
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@root': path.resolve(__dirname, './src/lib')
}
},
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
},
});
@@ -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
@@ -0,0 +1,4 @@
{
"loader": "ts-node/esm",
"spec": "src/**/*.test.ts"
}
+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.
+176
View File
@@ -0,0 +1,176 @@
# Meta Interface
Shared TypeScript interfaces and types for the Meta Orbit ecosystem, providing type definitions used across all packages.
## Overview
Meta Interface is the foundational package that defines common TypeScript interfaces, types, and constants shared across the entire Meta Orbit monorepo. It has no dependencies and serves as the base layer for type safety.
## Features
- **Zero Dependencies**: Foundation package with no external dependencies
- **Type Definitions**: Comprehensive TypeScript interfaces for metadata structures
- **Shared Constants**: Common enums and constant values
- **Platform Agnostic**: Works in both Node.js and browser environments
- **Strict Typing**: Enables type safety across all packages
## Installation
This package is automatically available in the monorepo workspace:
```json
{
"dependencies": {
"@metazla/meta-interface": "workspace:*"
}
}
```
## Usage
```typescript
import type {
HashMeta,
FileMetadata,
VideoType,
MediaType
} from '@metazla/meta-interface';
// Use interfaces for type safety
const metadata: HashMeta = {
cid_sha256: 'baejbei...',
fileName: 'episode.mkv',
sizeByte: '1024000',
videoType: 'tvshow',
season: '1',
episode: '1',
titles: { eng: 'Show Name' }
};
```
## Key Interfaces
### HashMeta
Complete metadata structure for media files:
```typescript
interface HashMeta {
// Content identifiers
cid_sha1?: string;
cid_sha256?: string;
cid_md5?: string;
cid_sha3_256?: string;
cid_sha3_384?: string;
cid_crc32?: string;
// File information
fileName: string;
extension?: string;
sizeByte: string;
fileType?: MediaType;
// Video metadata
videoType?: VideoType;
season?: string;
episode?: string;
originalTitle?: string;
titles?: Record<string, string>;
// Classification
anime?: string;
genres?: string[];
languages?: string[];
tags?: string[];
// Paths
privateFilePath?: string;
publicFilePath?: string;
}
```
### VideoType
```typescript
type VideoType = 'movie' | 'tvshow' | 'anime' | 'documentary' | 'unknown';
```
### MediaType
```typescript
type MediaType = 'video' | 'audio' | 'image' | 'document' | 'unknown';
```
## Architecture
### Build Configuration
Built with `tsup` for dual output:
- **ESM**: `dist/index.js`
- **CommonJS**: `dist/index.cjs`
### Package Structure
```
meta-interface/
├── src/
│ └── lib/
│ ├── interfaces.ts # Core interface definitions
│ ├── types.ts # Type aliases and unions
│ ├── constants.ts # Shared constants
│ └── index.ts # Public exports
├── package.json
└── tsconfig.json
```
## Development
### Building
```bash
# From monorepo root
pnpm run build
# From package directory
cd packages/meta-interface
pnpm run build
```
### Testing
```bash
cd packages/meta-interface
pnpm test
```
Uses Mocha for type validation and interface tests.
## Used By
All packages in the monorepo depend on meta-interface:
- **meta-hash**: Hash metadata structures
- **meta-db**: Database record types
- **meta-mesh**: File processing interfaces
- **meta-orbit**: Network message types
- **meta-ui**: UI component prop types
- **filename-tool**: Parsing result types
## Dependency Flow
```
meta-interface (foundation, no deps)
↓
All other packages depend on it
```
This ensures:
- Consistent types across the entire codebase
- Single source of truth for interfaces
- Type safety in all inter-package communication
## Contributing
When adding new interfaces:
1. Add to appropriate file in `src/lib/`
2. Export from `src/lib/index.ts`
3. Document with JSDoc comments
4. Ensure backward compatibility
This package is part of the Meta Orbit monorepo. See the root README for contribution guidelines.
## License
MIT - Same as Meta Orbit monorepo
@@ -0,0 +1,40 @@
{
"name": "@metazla/meta-interface",
"version": "1.0.0",
"description": "List of interfaces for the Meta project",
"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": "mocha"
},
"devDependencies": {
"@types/chai": "^4.3.11",
"@types/mocha": "^10.0.6",
"@types/node": "^20.12.2",
"chai": "^5.0.3",
"cross-env": "^7.0.3",
"mocha": "^10.2.0",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"typescript": "^5.4.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import {describe, beforeEach, it} from 'mocha';
import {expect} from 'chai';
import {from, MetadataNode} from './MetaDataAPI.js';
import {VideoMeta} from "./metadata-type/VideoMeta";
import {GenreMeta} from "./metadata-type/GenreMeta";
type TypedMetaData = VideoMeta & GenreMeta;
describe('MetaData', () => {
let root: MetadataNode<TypedMetaData>;
let meta = {};
beforeEach(() => {
// Reset meta for each test to ensure no test pollution
meta = {};
root = from<TypedMetaData>(meta);
});
it('should set and get a simple key-value pair', () => {
root.at('originalTitle').set("Star Wars");
expect(root.at('originalTitle').get()).to.equal("Star Wars");
});
it('should set and get nested metadata using nested keys', () => {
root.at('titles').at('eng').set("Star Wars");
root.at('titles').at('jpn').set("Staru Warsu");
expect(root.at('titles').at('eng').get()).to.equal("Star Wars");
expect(root.at('titles').at('jpn').get()).to.equal("Staru Warsu");
expect(root.at('titles').get()).to.deep.equal({eng: "Star Wars", jpn: "Staru Warsu"});
expect(root.get()).to.deep.equal({
titles: {eng: "Star Wars", jpn: "Staru Warsu"}
});
});
it('should return undefined for non-existent keys', () => {
expect(root.at<any>('nonexistent').at<any>('nonexistent2').get()).to.equal(undefined);
});
it('should handle overriding existing keys with new values', () => {
root.at('genres').add("Sci-fi");
expect(root.at('genres').getKeysAsSet()).to.deep.equal(new Set(["Sci-fi"]));
// Override the value
root.at('genres').add("Adventure");
expect(root.at('genres').getKeysAsSet()).to.deep.equal(new Set(['Sci-fi', 'Adventure']));
root.at('genres').add("Sci-fi");
expect(root.at('genres').getKeysAsSet()).to.deep.equal(new Set(['Sci-fi', 'Adventure']));
//root.at('genres').remove('Sci-fi');
//expect(root.at('genres').getKeys()).to.deep.equal(new Set(['Adventure']));
});
});
@@ -0,0 +1,115 @@
export function from<T>(metadata: T): MetadataNode<T> {
return new MetadataNode<T>([], metadata);
}
export class MetadataNode<T> {
constructor(private path: string[], private rootData: any, private replacementRule?: (oldValue: T, newValue: T) => T) {
this.replacementRule = replacementRule || ((oldValue: T, newValue: T) => {
console.warn(`Key ${path.join('.')} already exists and no replacement rule defined (default to taking the new one).
Current value: ${oldValue}
New value: ${newValue}`);
return newValue;
});
}
// Navigate to a subnode without ensuring it exists for further operations
at<K extends keyof T>(key: K): MetadataNode<T[K]> {
if (!key) {
throw new Error('Key is required');
}
const newPath = [...this.path, key as string];
return new MetadataNode<T[K]>(newPath, this.rootData);
}
// Set the value at the current node, ensuring the path exists
set(value: T): void {
if (value === undefined || value === null) {
//we don't want to set undefined or null values
return;
}
const current = this.ensurePath(true);
if (this.path.length > 0) {
const lastKey = this.path[this.path.length - 1];
if (current[lastKey]) {
if (current[lastKey] === value) {
return;
}
current[lastKey] = this.replacementRule(current[lastKey], value);
} else {
current[lastKey] = value;
}
}
}
// Get the value at the current node
get(defaultValue?: T): T {
const current = this.ensurePath(false);
return current === undefined ? defaultValue : current;
}
// this is used to define set of values
add(key: string): void {
if (key === undefined || key === null) {
//we don't want to set undefined or null values
return;
}
let node = this.get();
if (node && node instanceof Set) {
const set = node as Set<string>;
if (set.has(key)) {
return;
} else {
set.add(key);
}
} else {
this.set(new Set([key]) as any);
}
}
// Remove a key from the node
/*remove<K extends keyof T>(key: K|string): void {
const current = this.ensurePath(false);
if (current && current[key] !== undefined) {
delete current[key];
}
}*/
// Get all keys at the current node
getKeysAsSet(): Set<string> {
const current = this.ensurePath(false);
if(current instanceof Set){
return current as Set<string>;
}else {
return new Set(current ? Object.keys(current) as Array<string> : []);
}
}
getKeysAsArray(): string[] {
const current = this.ensurePath(false);
if(!current){
return [];
}
if(current instanceof Set){
return Array.from(current as Set<string>);
}else {
return Object.keys(current);
}
}
private ensurePath(createPath: boolean): any {
let current = this.rootData;
const path = createPath ? this.path.slice(0, -1) : this.path;
for (const segment of path) {
if (current[segment] === undefined) {
if (createPath) {
current[segment] = {};
} else {
return undefined; // Return undefined early if the path segment doesn't exist and we're not creating it
}
}
current = current[segment];
}
return current;
}
}
@@ -0,0 +1,74 @@
export const FileMetaDataName = [
"fileType",
"mimeType",
"byteSize",
]
export const hashMetaDataName = [
"cid_crc32",
"cid_md5",
"cid_sha1",
"cid_sha2-256",
"cid_sha3-256",
"cid_sha3_384",
];
export const AnimeMetaData = [
"anime"
];
export const FFMpegMetaData = [
"fileinfo"
];
export const FileNameMetaData = [
"fileName",
"extension",
"videoType",
"extra",
"season",
"episode",
"increment",
"movieYear",
"title",
"titles",
];
export const TorrentMetaData = [
"trakers"
];
export const VideoMetaData = [
"subtitles"
];
export const JellyfinMetaData = [
"videoType", // Noted as optional and with specific types in your comment, but listed as a simple string here
"episode",
"season",
"title",
"year",
"originaltitle",
"imdbid",
"tmdbid",
"anidbid",
"premiered",
"genre",
"releasedate",
"plot",
"director",
"rating",
"runtime",
"studio",
"art",
"actor",
"showtitle",
"aired",
"customrating",
"dateadded",
"sorttitle",
"mpaa",
"aspectratio",
"collectionnumber",
"criticrating",
];
@@ -0,0 +1 @@
create a lib for that
@@ -0,0 +1,26 @@
export * from './MetaDataAPI';
export * from './MetaDataName';
export * from './metadata-type/basic/FileRef';
export * from './metadata-type/basic/LanguageData';
export * from './metadata-type/basic/RecordSet';
export * from './metadata-type/basic/SimpleFileType';
export * from './metadata-type/basic/StringBoolean';
export * from './metadata-type/basic/VideoType';
export * from "./metadata-type/basic/ArrayKey";
export * from './metadata-type/AnimeMeta';
export * from './metadata-type/DebugMeta';
export * from './metadata-type/FileNameMeta';
export * from './metadata-type/FileStatMeta';
export * from './metadata-type/GenreMeta';
export * from './metadata-type/HashMeta';
export * from './metadata-type/JellyfinMeta';
export * from './metadata-type/LanguagesMeta';
export * from './metadata-type/MediaStreamMeta';
export * from './metadata-type/SubtitleMeta';
export * from './metadata-type/TagsMeta';
export * from './metadata-type/TorrentMeta';
export * from './metadata-type/VideoMeta';
export * from './metadata-type/VersionMeta';
@@ -0,0 +1,9 @@
import {StringBoolean} from "./basic/StringBoolean.js";
export interface AnimeMeta {
anime?: StringBoolean;
}
export const AnimeMetaFields = [
"anime",
];
@@ -0,0 +1,7 @@
export interface DebugMeta {
privateFilePath?: string;
}
export const DebugMetaFields = [
"privateFilePath",
];
@@ -0,0 +1,9 @@
export interface FileNameMeta {
fileName?: string;
extension?: string;
}
export const FileNameMetaFields = [
"fileName",
"extension",
];
@@ -0,0 +1,12 @@
export interface FileStatMeta {
fileType?:string;
mimeType?:string;
sizeByte?:string;
}
export const FileStatMetaFields = [
"fileType",
"mimeType",
"sizeByte",
];
@@ -0,0 +1,9 @@
import {RecordSet} from "./basic/RecordSet.js";
export interface GenreMeta{
genres?: RecordSet;
}
export const GenreMetaFields = [
"genres",
];
@@ -0,0 +1,32 @@
/**
* HashMeta - Content identification hashes
*
* Primary Global ID: cid_midhash256 (SHA-256 hash of middle 1MB + file size)
* - Format: CID v1 with custom multicodec 0x1000
* - Example: bafkr4ih5kapbjzqvmj7jxr... (base32 CID encoding)
*
* Legacy IDs: cid_sha2-256, cid_sha1 (computed optionally for compatibility)
*
* Migration Note: cid_midhash256 is the new primary identifier replacing full SHA-256.
* Old hashes remain for backward compatibility and external system integration.
* All CIDs now use proper CID v1 format (IPFS-compatible structure).
*/
export interface HashMeta {
cid_crc32?: string; //0x0132 CRC32 (SFV)
cid_md5?: string; // 0xd5 md5
cid_sha1?: string; // 0x11 sha1
"cid_sha2-256"?: string;//0x12 sha2-256
"cid_sha3-256"?: string; // 0x16 sha3-256
"cid_sha3_384"?: string; // 0x16 sha3-256
cid_midhash256?: string; // 0x1000 midhash256 (CID v1 format with custom multicodec)
}
export const HashMetaFields = [
"cid_crc32",
"cid_md5",
"cid_sha1",
"cid_sha2-256",
"cid_sha3-256",
"cid_sha3_384",
"cid_midhash256",
];
@@ -0,0 +1,64 @@
import {LanguageData} from "./basic/LanguageData.js";
import {RecordSet} from "./basic/RecordSet.js";
import {VideoMeta} from "./VideoMeta.js";
import {TagsMeta} from "./TagsMeta.js";
import {LanguagesMeta} from "./LanguagesMeta.js";
import {GenreMeta} from "./GenreMeta.js";
import {FileRef} from "./basic/FileRef";
export interface IMDBMeta{
imdbid?: string;
criticrating?: string;
}
export interface TMDBMeta{
tmdbid?: string;
}
export interface AnidbMeta{
anidbid?: string;
}
export interface JellyfinMeta extends
IMDBMeta,
TMDBMeta,
AnidbMeta,
VideoMeta,
TagsMeta,
LanguagesMeta,
GenreMeta
{
releasedate?: string;
plot?: LanguageData<string>;//key is language ISO 639-1 code
rating?: string;
studio?: RecordSet;
art?:{
poster?: FileRef;
fanart?: FileRef;
};
mpaa?: string;
//TODO add more fields in the future
}
export const JellyfinMetaFields = [
"imdbid",
"tmdbid",
"anidbid",
"extra",
"season",
"episode",
"increment",
"movieYear",
"videoType",
"originalTitle",
"titles",
"tags",
"languages",
"genre",
"releasedate",
"plot",
"rating",
"studio",
"art",
"mpaa",
];
@@ -0,0 +1,10 @@
import {TypedRecordSet} from "./basic/RecordSet.js";
import {ISO639_1_CODE} from "./basic/LanguageData.js";
export interface LanguagesMeta{
languages?: TypedRecordSet<ISO639_1_CODE>;
}
export const LanguagesMetaFields = [
"languages",
];
@@ -0,0 +1,59 @@
import {StringBoolean} from "./basic/StringBoolean.js";
import {ArrayKey} from "./basic/ArrayKey";
export interface VideoStream {
width?: string;
height?: string;
bitrate?: string;
frameRate?: string;
rotation?: string;
language?: string;
}
export interface SubtitleStream {
language?: string;
title?: string;
}
export interface AudioStream {
sampleRate?: string;
bitrate?: string;
language?: string;
channelLayout?: string;
}
export interface CommonStream {
index?: string;
duration?: string;
codecType?: string;
codec?: string;
language?: string;
title?: string;
default?: StringBoolean;
forced?: StringBoolean;
}
export type Stream = VideoStream & SubtitleStream & AudioStream & CommonStream;
/**
* StreamList is a list of streams
* key is stream index in string "0","1","2" etc
*/
export type StreamList = Record<ArrayKey,Stream>;
export interface MediaStreamMeta {
fileinfo?: {
duration?: string;
formatName?: string;
streamdetails?: {
video: StreamList;
audio: StreamList;
subtitle: StreamList;
embeddedimage: StreamList;
}
}
}
export const MediaStreamMetaFields = [
"fileinfo",
];
@@ -0,0 +1,23 @@
import {LanguageData} from "./basic/LanguageData.js";
import {TypedRecordSet} from "./basic/RecordSet.js";
import {FileRef} from "./basic/FileRef.js";
//used by video files to store the subtitle files
export interface SubtitleMeta {
subtitles?:LanguageData<TypedRecordSet<FileRef>>;//key is the language code, value is a set of ipfs hash of the subtitle file
}
//used by subtitle files to store the video files
export interface SubtitleFileMeta {
videos?:TypedRecordSet<FileRef>;//a set of ipfs hash of the video file
subtitleLanguage?:string;//the language of the subtitle file
}
export const SubtitleMetaFields = [
"subtitles",
];
export const SubtitleFileMetaFields = [
"videos",
"subtitleLanguage",
];
@@ -0,0 +1,9 @@
import {RecordSet} from "./basic/RecordSet.js";
export interface TagsMeta {
tags?: RecordSet;
}
export const TagsMetaFields = [
"tags",
];
@@ -0,0 +1,25 @@
import {RecordSet} from "./basic/RecordSet.js";
export interface TorrentMeta {
announce?: string;
announceList?: RecordSet;
comment?: string;
createdBy?: string;
creationDate?: number;
info: {
files: Record<string, {
length: number;
path: string;
}>;// key is the "0" "1" "2" etc
name: string;
};
}
export const TorrentMetaFields = [
"announce",
"announceList",
"comment",
"createdBy",
"creationDate",
"info",
];
@@ -0,0 +1,7 @@
export interface VersionMeta {
version: string; //usually a V1 V2 V3 etc..
}
export const VersionMetaFields = [
"version"
];
@@ -0,0 +1,24 @@
import {LanguageData} from "./basic/LanguageData.js";
import {VideoType} from "./basic/VideoType";
export interface VideoMeta {
extra?: string;
season?: string;
episode?: string;
increment?: string;
movieYear?: string;
videoType?: VideoType;
originalTitle?: string;//title in original language
titles?: LanguageData<string>;// <ISO 639-3 lang codes, title>
}
export const VideoMetaFields = [
"extra",
"season",
"episode",
"increment",
"movieYear",
"videoType",
"originalTitle",
"titles",
];
@@ -0,0 +1,6 @@
// used to simulate array using map and object
export type ArrayKey = "id_0" | "id_1" | "id_2" | "id_3" | "id_4" | "id_5" | "id_6" | "id_7" | "id_8" | "id_9";
export function createArrayKey (index: number): ArrayKey {
return `id_${index}` as ArrayKey;
}
@@ -0,0 +1,4 @@
export type FileRef = URLString | FilePathString | CIDString;
export type URLString = string;
export type FilePathString = string;
export type CIDString = string;
@@ -0,0 +1,12 @@
/**
* key is language ISO 639-3 code
* https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes
*/
export type LanguageData<T> = Record<string, T>;
export type ISO639_1_CODE = string;
/**
* jpn, jpr and jpa already exist. Creating jpl is for japan-latin. Used for japanese romaji NON ISO 639-3 standard.
* */
export const romajiIsoCode = "jpl";
export const englishIsoCode = "eng";
@@ -0,0 +1,2 @@
export type RecordSet = Set<string>;
export type TypedRecordSet<T extends string> = Set<T>;
@@ -0,0 +1,2 @@
export type SimpleFileType = 'audio' | 'video' | 'document' | 'archive' | 'other' | 'undefined';

Some files were not shown because too many files have changed in this diff Show More