detection improvement
This commit is contained in:
Generated
-5
@@ -961,7 +961,6 @@
|
|||||||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1327,7 +1326,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -1902,7 +1900,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1952,7 +1949,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2475,7 +2471,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {FileNameCleaner} from "./tools/FileNameCleaner.js";
|
import {FileNameCleaner} from "./tools/FileNameCleaner.js";
|
||||||
import {StringArrayOperation} from "./tools/StringArrayOperation.js";
|
import {StringArrayOperation} from "./tools/StringArrayOperation.js";
|
||||||
import {SerieFilesNameAnalysisTool} from "./tools/video/SerieFilesNamaAnalysisTool.js";
|
import {SerieFilesNameAnalysisTool} from "./tools/video/SerieFilesNamaAnalysisTool.js";
|
||||||
|
import {extractQuality, extractCodec, extractLanguages} from "./tools/video/ReleaseTagExtractor.js";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,12 @@ export interface VideoFileMetadata {
|
|||||||
movieYear?: string;
|
movieYear?: string;
|
||||||
videoType?: 'tvshow' | 'movie';
|
videoType?: 'tvshow' | 'movie';
|
||||||
extra?: string;
|
extra?: string;
|
||||||
|
/** Title-derived resolution tier (`"1080p"`, …). See ReleaseTagExtractor. */
|
||||||
|
quality?: string;
|
||||||
|
/** Title-derived normalized video codec (`"hevc"`/`"h264"`/…). */
|
||||||
|
codec?: string;
|
||||||
|
/** Title-derived audio/subtitle languages, ISO 639-2/B (`["fre","mul"]`). */
|
||||||
|
languages?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_TITLE_LENGTH = 3;
|
const MIN_TITLE_LENGTH = 3;
|
||||||
@@ -310,6 +317,17 @@ export class FileNameVideoMetaExtractor {
|
|||||||
result.extra = "true";
|
result.extra = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title-derived release tags (quality / codec / languages). Matched over
|
||||||
|
// the whole path so a tag in the parent folder ("Show.1080p/ep01.mkv")
|
||||||
|
// or the filename both count. Best-effort — a real-stream probe should
|
||||||
|
// take precedence when one is available (see ReleaseTagExtractor).
|
||||||
|
const quality = extractQuality(filePath);
|
||||||
|
if (quality) result.quality = quality;
|
||||||
|
const codec = extractCodec(filePath);
|
||||||
|
if (codec) result.codec = codec;
|
||||||
|
const languages = extractLanguages(filePath);
|
||||||
|
if (languages.length > 0) result.languages = languages;
|
||||||
|
|
||||||
// Compute video type
|
// Compute video type
|
||||||
const season = result.season ? parseInt(result.season) : -1;
|
const season = result.season ? parseInt(result.season) : -1;
|
||||||
const episode = result.episode ? parseInt(result.episode) : -1;
|
const episode = result.episode ? parseInt(result.episode) : -1;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './FileType.js';
|
|||||||
export * from './tools/folder/FolderTool.js';
|
export * from './tools/folder/FolderTool.js';
|
||||||
export * from './tools/lang/LanguageTool.js';
|
export * from './tools/lang/LanguageTool.js';
|
||||||
export * from './tools/video/SerieFilesNamaAnalysisTool.js';
|
export * from './tools/video/SerieFilesNamaAnalysisTool.js';
|
||||||
|
export * from './tools/video/ReleaseTagExtractor.js';
|
||||||
export * from './tools/FileNameCleaner.js';
|
export * from './tools/FileNameCleaner.js';
|
||||||
export * from './tools/FileNameStringOperation.js';
|
export * from './tools/FileNameStringOperation.js';
|
||||||
export * from './tools/FileTypeConfigurable.js';
|
export * from './tools/FileTypeConfigurable.js';
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* ReleaseTagExtractor — title-derived video release tags: resolution `quality`,
|
||||||
|
* `codec`, and audio/subtitle `languages`.
|
||||||
|
*
|
||||||
|
* These are a faithful TypeScript port of the gateway feeders' Rust
|
||||||
|
* `filename_meta.rs` (`extract_quality` / `extract_codec` / `extract_languages`).
|
||||||
|
* Keeping the two in lockstep means a record enriched by the meta-sort
|
||||||
|
* filename-parser plugin and one stamped by a gateway feeder agree on the same
|
||||||
|
* normalized vocabulary (METADATA_KEYS: `quality` §3, `codec` §12, `languages`
|
||||||
|
* §1 in the ISO 639-2/B `fre`/`ger`/`chi` space meta-share's language chips use).
|
||||||
|
*
|
||||||
|
* All three are **title-string guesses** — for a locally-ingested file the
|
||||||
|
* authoritative values come from probing the real stream (ffmpeg / language
|
||||||
|
* plugin), which a consumer should prefer (`real-probe > title-guess`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Resolution tier regex (progressive/interlaced collapse to the `p` tier). */
|
||||||
|
const QUALITY_TIER = /\b(2160p|4k|1080p|1080i|720p|720i|576p|480p)\b/i;
|
||||||
|
/** `WxH` token — mapped to a tier by height. */
|
||||||
|
const QUALITY_WXH = /\b\d{3,4}\s*x\s*(\d{3,4})\b/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized resolution tier (`"2160p"`/`"1080p"`/…) or `undefined`. `4k`→2160p,
|
||||||
|
* `1080i`→1080p, `720i`→720p; a `1920x1080` token maps by height.
|
||||||
|
*/
|
||||||
|
export function extractQuality(title: string): string | undefined {
|
||||||
|
const t = title.replace(/[._]/g, " ");
|
||||||
|
const tier = t.match(QUALITY_TIER);
|
||||||
|
if (tier) {
|
||||||
|
const tok = tier[1].toLowerCase();
|
||||||
|
switch (tok) {
|
||||||
|
case "4k":
|
||||||
|
return "2160p";
|
||||||
|
case "1080i":
|
||||||
|
return "1080p";
|
||||||
|
case "720i":
|
||||||
|
return "720p";
|
||||||
|
default:
|
||||||
|
return tok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wxh = t.match(QUALITY_WXH);
|
||||||
|
if (wxh) {
|
||||||
|
const h = parseInt(wxh[1], 10);
|
||||||
|
if (h >= 2000) return "2160p";
|
||||||
|
if (h >= 1000) return "1080p";
|
||||||
|
if (h >= 700) return "720p";
|
||||||
|
if (h >= 500) return "576p";
|
||||||
|
return "480p";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codec regex → ffmpeg-style normalized token. */
|
||||||
|
const CODEC_RE = /\b(x265|h\s?265|hevc|x264|h\s?264|avc|av1|xvid|divx|vp9)\b/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized video codec (`hevc`/`h264`/`av1`/`xvid`/`divx`/`vp9`) or
|
||||||
|
* `undefined`. `x265`/`h265`→`hevc`, `x264`/`avc`→`h264`.
|
||||||
|
*/
|
||||||
|
export function extractCodec(title: string): string | undefined {
|
||||||
|
const t = title.replace(/[._]/g, " ");
|
||||||
|
const m = t.match(CODEC_RE);
|
||||||
|
if (!m) return undefined;
|
||||||
|
const cleaned = m[1].toLowerCase().replace(/\s/g, "");
|
||||||
|
switch (cleaned) {
|
||||||
|
case "x265":
|
||||||
|
case "h265":
|
||||||
|
case "hevc":
|
||||||
|
return "hevc";
|
||||||
|
case "x264":
|
||||||
|
case "h264":
|
||||||
|
case "avc":
|
||||||
|
return "h264";
|
||||||
|
default:
|
||||||
|
return cleaned; // av1, xvid, divx, vp9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `www.<domain>` noise, stripped while the dots are still intact. */
|
||||||
|
const URL_STRIP = /\bwww\.\S+/gi;
|
||||||
|
/**
|
||||||
|
* Guard phrases removed before language matching because each contains a
|
||||||
|
* substring that would false-positive a tag (reproduces lookbehind guards
|
||||||
|
* without lookaround): `shang chi`→`chi`, `tel aviv`→`tel`, `web dl`/`webdl`→`dl`.
|
||||||
|
*/
|
||||||
|
const LANG_GUARD = /\b(?:shang\s+chi|tel\s+aviv|web\s+dl|webdl)\b/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release-title language tags → ISO 639-2/B (alpha-3). Codes match meta-share's
|
||||||
|
* UI vocabulary (`fre`/`ger`/`chi`, not 639-3 `fra`/`deu`/`zho`). `mul` is the
|
||||||
|
* honest marker for `MULTi`/dual-audio. Every matching pattern contributes its
|
||||||
|
* code, so the result is a set (a `MULTi VOSTFR` release → `{fre, mul}`).
|
||||||
|
*/
|
||||||
|
const LANGUAGE_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||||
|
[/\bmulti(?:lang)?\b|\bmultisubs?\b|\bdual\s?audio\b|\bdual\b|\bdl\b/i, "mul"],
|
||||||
|
[
|
||||||
|
/\btruefrench\b|\bvostfr\b|\bsubfrench\b|\bfrench\b|\bvf[fqi2]?\b|\bstfr\b|\bfra\b|\bfre\b/i,
|
||||||
|
"fre",
|
||||||
|
],
|
||||||
|
[/\benglish\b|\beng\s?sub\b|\beng\b/i, "eng"],
|
||||||
|
[/\bjapanese\b|\bjpn\b|\bjap\b/i, "jpn"],
|
||||||
|
[/\bitalian\b|\bita\b/i, "ita"],
|
||||||
|
[/\bgerman\b|\bger\b|\bdeu\b/i, "ger"],
|
||||||
|
[/\bspanish\b|\bcastellano\b|\bespa[nñ]ol\b|\blatino\b|\besp\b|\bspa\b/i, "spa"],
|
||||||
|
[/\bportuguese\b|\bdublado\b|\blegendado\b|\bpor\b/i, "por"],
|
||||||
|
[/\bkorean\b|\bkor\b/i, "kor"],
|
||||||
|
[/\bchinese\b|\bmandarin\b|\bcantonese\b|\bchs\b|\bcht\b|\bzho\b|\bchi\b/i, "chi"],
|
||||||
|
[/\brussian\b|\brus\b/i, "rus"],
|
||||||
|
[/\bdutch\b|\bflemish\b|\bnld\b|\bdut\b/i, "dut"],
|
||||||
|
[/\bpolish\b|\bpl\s?dub\b|\bdub\s?pl\b|\blektor\b|\bpol\b/i, "pol"],
|
||||||
|
[/\bczech\b|\bcze\b|\bces\b/i, "cze"],
|
||||||
|
[/\bslovak\b|\bslo\b|\bslk\b/i, "slo"],
|
||||||
|
[/\bhungarian\b|\bhundub\b|\bhun\b/i, "hun"],
|
||||||
|
[/\bromanian\b|\brosub(?:bed)?\b|\brodubbed\b|\brum\b|\bron\b/i, "rum"],
|
||||||
|
[/\bgreek\b|\bgre\b|\bell\b/i, "gre"],
|
||||||
|
[/\bukrainian\b|\bukr\b/i, "ukr"],
|
||||||
|
[/\bbulgarian\b|\bbgaudio\b|\bbul\b/i, "bul"],
|
||||||
|
[/\bswedish\b|\bswesub\b|\bswe\b/i, "swe"],
|
||||||
|
[/\bnorwegian\b|\bnorsk\b/i, "nor"],
|
||||||
|
[/\bdanish\b|\bdansk\b/i, "dan"],
|
||||||
|
[/\bfinnish\b|\bsuomi\b/i, "fin"],
|
||||||
|
[/\bturkish\b|\btur\b/i, "tur"],
|
||||||
|
[/\barabic\b|\bara\b/i, "ara"],
|
||||||
|
[/\bhindi\b|\bhin\b/i, "hin"],
|
||||||
|
[/\btamil\b|\btam\b/i, "tam"],
|
||||||
|
[/\btelugu\b|\btel\b/i, "tel"],
|
||||||
|
[/\bmalayalam\b|\bmal\b/i, "mal"],
|
||||||
|
[/\bvietnamese\b|\bvie\b/i, "vie"],
|
||||||
|
[/\bthai\b|\btha\b/i, "tha"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of ISO 639-2/B language codes detected from a release title, sorted
|
||||||
|
* (deterministic). Empty when no tag matches.
|
||||||
|
*/
|
||||||
|
export function extractLanguages(title: string): string[] {
|
||||||
|
const stripped = title.replace(URL_STRIP, " ");
|
||||||
|
// Normalize `.`/`_`/`-` to spaces so `\b`-anchored tags + guards match
|
||||||
|
// regardless of the original separator.
|
||||||
|
const norm = stripped.replace(/[._-]/g, " ");
|
||||||
|
const guarded = norm.replace(LANG_GUARD, " ");
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const [re, code] of LANGUAGE_TAGS) {
|
||||||
|
if (re.test(guarded)) out.add(code);
|
||||||
|
}
|
||||||
|
return Array.from(out).sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {describe, it, expect} from 'vitest';
|
||||||
|
import {extractQuality, extractCodec, extractLanguages} from '../lib/tools/video/ReleaseTagExtractor.js';
|
||||||
|
|
||||||
|
describe('extractQuality', () => {
|
||||||
|
it('reads resolution tiers across separators', () => {
|
||||||
|
expect(extractQuality('Show.S01E01.1080p.WEB.h264')).toBe('1080p');
|
||||||
|
expect(extractQuality('Movie_2008_720p')).toBe('720p');
|
||||||
|
expect(extractQuality('Doc 2160p HDR')).toBe('2160p');
|
||||||
|
});
|
||||||
|
it('collapses 4k and interlaced to the progressive tier', () => {
|
||||||
|
expect(extractQuality('Film 4K')).toBe('2160p');
|
||||||
|
expect(extractQuality('Broadcast 1080i')).toBe('1080p');
|
||||||
|
expect(extractQuality('Old 720i')).toBe('720p');
|
||||||
|
});
|
||||||
|
it('maps a WxH token by height', () => {
|
||||||
|
expect(extractQuality('BDRip 1920x1080 x264')).toBe('1080p');
|
||||||
|
expect(extractQuality('rip 1280x720')).toBe('720p');
|
||||||
|
});
|
||||||
|
it('returns undefined when there is no resolution', () => {
|
||||||
|
expect(extractQuality('Big Buck Bunny')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCodec', () => {
|
||||||
|
it('normalizes encoder spellings to ffmpeg tokens', () => {
|
||||||
|
expect(extractCodec('Show.x265.WEB')).toBe('hevc');
|
||||||
|
expect(extractCodec('Show H.265')).toBe('hevc');
|
||||||
|
expect(extractCodec('Movie x264')).toBe('h264');
|
||||||
|
expect(extractCodec('Movie AVC')).toBe('h264');
|
||||||
|
expect(extractCodec('Clip AV1')).toBe('av1');
|
||||||
|
expect(extractCodec('Old XviD rip')).toBe('xvid');
|
||||||
|
});
|
||||||
|
it('returns undefined when no codec tag', () => {
|
||||||
|
expect(extractCodec('Big Buck Bunny 1080p')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractLanguages', () => {
|
||||||
|
it('detects a single language tag', () => {
|
||||||
|
expect(extractLanguages('Movie.2008.FRENCH.1080p')).toEqual(['fre']);
|
||||||
|
expect(extractLanguages('Show.S01.German.WEB')).toEqual(['ger']);
|
||||||
|
});
|
||||||
|
it('returns a sorted set for multi-tag releases', () => {
|
||||||
|
// MULTi VOSTFR → both mul and fre (sorted).
|
||||||
|
expect(extractLanguages('Movie.MULTi.VOSTFR.1080p')).toEqual(['fre', 'mul']);
|
||||||
|
});
|
||||||
|
it('honors guard phrases (no false positives)', () => {
|
||||||
|
// "web dl" must not trigger the German `dl` → mul marker by itself.
|
||||||
|
expect(extractLanguages('Movie.2021.WEB-DL.1080p.x264')).toEqual([]);
|
||||||
|
// "Shang-Chi" must not trigger Chinese via `chi`.
|
||||||
|
expect(extractLanguages('Shang-Chi.2021.1080p')).toEqual([]);
|
||||||
|
});
|
||||||
|
it('returns empty when no language tag', () => {
|
||||||
|
expect(extractLanguages('Big Buck Bunny 2008 1080p')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user