initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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,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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Filename Tool
|
||||
|
||||
TypeScript library for extracting metadata from media filenames, designed for the Meta Orbit ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
Filename Tool parses media filenames to extract structured metadata such as titles, seasons, episodes, quality, languages, and other relevant information. It's designed to work with various naming conventions used in media files.
|
||||
|
||||
## Features
|
||||
|
||||
- **Title Extraction**: Identifies show/movie titles from filenames
|
||||
- **Episode & Season Detection**: Parses episode and season numbers in various formats
|
||||
- **Quality Detection**: Identifies resolution (1080p, 720p, 4K) and quality indicators
|
||||
- **Language Detection**: Extracts language codes and audio track information
|
||||
- **Release Group**: Identifies fansub/release group tags
|
||||
- **Format Support**: Handles multiple filename patterns and conventions
|
||||
- **Platform Agnostic**: Dual builds for Node.js and browser environments
|
||||
|
||||
## Installation
|
||||
|
||||
From the monorepo root:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
When using as a dependency:
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@metazla/filename-tool": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { FilenameParser } from '@metazla/filename-tool';
|
||||
|
||||
// Parse a filename
|
||||
const result = FilenameParser.parse('[SubsPlease] Naruto - 01 (1080p) [12345678].mkv');
|
||||
|
||||
console.log(result);
|
||||
// {
|
||||
// title: 'Naruto',
|
||||
// episode: 1,
|
||||
// season: 1,
|
||||
// quality: '1080p',
|
||||
// releaseGroup: 'SubsPlease',
|
||||
// hash: '12345678',
|
||||
// extension: 'mkv'
|
||||
// }
|
||||
```
|
||||
|
||||
## Supported Filename Patterns
|
||||
|
||||
### TV Shows
|
||||
- `ShowName S01E01.mkv`
|
||||
- `ShowName - 01.mkv`
|
||||
- `[Group] ShowName - 01 (1080p).mkv`
|
||||
- `ShowName.S01E01.1080p.WEB-DL.mkv`
|
||||
|
||||
### Movies
|
||||
- `MovieName (2024) 1080p BluRay.mkv`
|
||||
- `MovieName.2024.1080p.BluRay.x264.mkv`
|
||||
- `[Group] MovieName (2024).mkv`
|
||||
|
||||
### Quality Detection
|
||||
- Resolution: `1080p`, `720p`, `2160p`, `4K`, `8K`
|
||||
- Source: `BluRay`, `WEB-DL`, `HDTV`, `DVDRip`
|
||||
- Codec: `x264`, `x265`, `HEVC`, `AV1`
|
||||
|
||||
### Language Detection
|
||||
- Audio languages: `eng`, `jpl`, `spa`, `fre`, etc.
|
||||
- Subtitle languages: `[eng sub]`, `[multiple subs]`
|
||||
- Dual audio: `[eng+jpl]`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Build Configuration
|
||||
Built with `tsup` for dual output:
|
||||
- **Node.js**: `dist/index.js` (ESM) and `dist/index.cjs` (CommonJS)
|
||||
- **Browser**: `dist/index-browser.js` (ESM) and `dist/index-browser.cjs` (CommonJS)
|
||||
|
||||
### Pattern Matching
|
||||
Uses regex patterns and heuristics to handle various filename formats:
|
||||
- Episode number detection with multiple patterns
|
||||
- Title cleanup (remove tags, group names)
|
||||
- Quality and resolution parsing
|
||||
- Hash/CRC extraction
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm run build
|
||||
|
||||
# From package directory
|
||||
cd packages/filename-tool
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cd packages/filename-tool
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Uses Vitest for unit tests with comprehensive filename pattern coverage.
|
||||
|
||||
## Integration
|
||||
|
||||
Used by:
|
||||
- **meta-mesh**: Primary metadata extraction from filenames
|
||||
- **meta-orbit**: Filename normalization for distributed queries
|
||||
- **meta-ui**: Display and search filename metadata
|
||||
|
||||
## Examples
|
||||
|
||||
### Anime Filenames
|
||||
```typescript
|
||||
const result = FilenameParser.parse('[HorribleSubs] Attack on Titan - 25 [1080p].mkv');
|
||||
// { title: 'Attack on Titan', episode: 25, quality: '1080p', releaseGroup: 'HorribleSubs' }
|
||||
```
|
||||
|
||||
### Movie Filenames
|
||||
```typescript
|
||||
const result = FilenameParser.parse('The.Matrix.1999.1080p.BluRay.x264-GROUP.mkv');
|
||||
// { title: 'The Matrix', year: 1999, quality: '1080p', source: 'BluRay', codec: 'x264' }
|
||||
```
|
||||
|
||||
### Multi-Episode
|
||||
```typescript
|
||||
const result = FilenameParser.parse('Show.Name.S01E01-E03.1080p.mkv');
|
||||
// { title: 'Show Name', season: 1, episodes: [1, 2, 3], quality: '1080p' }
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Meta Orbit monorepo. See the root README for contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT - Same as Meta Orbit monorepo
|
||||
+3170
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
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2341
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
|
||||
@@ -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/**"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+1637
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
Reference in New Issue
Block a user