add path-parser: extract movie/episode metadata from Radarr/Sonarr file paths
Parses title, year, provider IDs (imdb/tmdb/tvdb), season, episode number, and container from on-disk paths without requiring Jellyfin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parsePath } from "../path-parser";
|
||||
|
||||
const MOVIES = "/movies";
|
||||
const TV = "/tv";
|
||||
|
||||
describe("parsePath", () => {
|
||||
test("movie with imdb id", () => {
|
||||
const result = parsePath(
|
||||
"/movies/Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-tt0425112] - [Bluray-1080p][DTS 5.1][x264]-CtrlHD.mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("Hot Fuzz");
|
||||
expect(result!.year).toBe(2007);
|
||||
expect(result!.imdbId).toBe("tt0425112");
|
||||
expect(result!.tmdbId).toBeNull();
|
||||
expect(result!.tvdbId).toBeNull();
|
||||
expect(result!.container).toBe("mkv");
|
||||
expect(result!.seriesName).toBeNull();
|
||||
expect(result!.seasonNumber).toBeNull();
|
||||
expect(result!.episodeNumber).toBeNull();
|
||||
});
|
||||
|
||||
test("movie with tmdb id", () => {
|
||||
const result = parsePath(
|
||||
"/movies/Alien (1979)/Alien (1979) [tmdbid-348] - [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("Alien");
|
||||
expect(result!.year).toBe(1979);
|
||||
expect(result!.tmdbId).toBe("348");
|
||||
expect(result!.imdbId).toBeNull();
|
||||
});
|
||||
|
||||
test("movie with both imdb and tmdb ids", () => {
|
||||
const result = parsePath(
|
||||
"/movies/The Matrix (1999)/The Matrix (1999) [imdbid-tt0133093][tmdbid-603] - [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.imdbId).toBe("tt0133093");
|
||||
expect(result!.tmdbId).toBe("603");
|
||||
});
|
||||
|
||||
test("episode standard format", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Breaking Bad (2008)/Season 05/Breaking Bad (2008) - S05E03 - Hazard Pay [WEBDL-1080p][AC3 5.1][h264]-BS.mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Episode");
|
||||
expect(result!.seriesName).toBe("Breaking Bad");
|
||||
expect(result!.year).toBe(2008);
|
||||
expect(result!.seasonNumber).toBe(5);
|
||||
expect(result!.episodeNumber).toBe(3);
|
||||
expect(result!.name).toBe("Hazard Pay");
|
||||
expect(result!.tvdbId).toBeNull();
|
||||
expect(result!.container).toBe("mkv");
|
||||
});
|
||||
|
||||
test("episode with tvdb id in series folder", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Arrow (2012) [tvdbid-257655]/Season 01/Arrow (2012) - S01E01 - Pilot [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Episode");
|
||||
expect(result!.seriesName).toBe("Arrow");
|
||||
expect(result!.year).toBe(2012);
|
||||
expect(result!.seasonNumber).toBe(1);
|
||||
expect(result!.episodeNumber).toBe(1);
|
||||
expect(result!.name).toBe("Pilot");
|
||||
expect(result!.tvdbId).toBe("257655");
|
||||
});
|
||||
|
||||
test("multi-episode file uses first episode number", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Breaking Bad (2008)/Season 02/Breaking Bad (2008) - S02E01-E13 - Seven Thirty-Seven [info].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.seasonNumber).toBe(2);
|
||||
expect(result!.episodeNumber).toBe(1);
|
||||
expect(result!.name).toBe("Seven Thirty-Seven");
|
||||
});
|
||||
|
||||
test("mp4 container", () => {
|
||||
const result = parsePath(
|
||||
"/movies/Jaws (1975)/Jaws (1975) [imdbid-tt0073195] - [Bluray-1080p].mp4",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.container).toBe("mp4");
|
||||
});
|
||||
|
||||
test("non-video file returns null", () => {
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/Hot Fuzz (2007).nfo", MOVIES, TV)).toBeNull();
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/Hot Fuzz (2007).srt", MOVIES, TV)).toBeNull();
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/poster.jpg", MOVIES, TV)).toBeNull();
|
||||
});
|
||||
|
||||
test("file not under moviesRoot or tvRoot returns null", () => {
|
||||
expect(
|
||||
parsePath("/other/Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-tt0425112].mkv", MOVIES, TV),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("movie without provider ids (bare folder)", () => {
|
||||
const result = parsePath(
|
||||
"/movies/No Country for Old Men (2007)/No Country for Old Men (2007) - [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("No Country for Old Men");
|
||||
expect(result!.year).toBe(2007);
|
||||
expect(result!.imdbId).toBeNull();
|
||||
expect(result!.tmdbId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import path from "node:path";
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(["mkv", "mp4", "avi", "m4v", "ts", "wmv"]);
|
||||
|
||||
export interface ParsedPath {
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
year: number | null;
|
||||
seriesName: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
imdbId: string | null;
|
||||
tmdbId: string | null;
|
||||
tvdbId: string | null;
|
||||
container: string;
|
||||
}
|
||||
|
||||
function extractYear(str: string): number | null {
|
||||
const m = str.match(/\((\d{4})\)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
function extractId(str: string, prefix: string): string | null {
|
||||
const re = new RegExp(`\\[${prefix}-([^\\]]+)\\]`, "i");
|
||||
const m = str.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Strip trailing `(YYYY)` and any bracketed provider-id tokens from a folder name, return bare title. */
|
||||
function titleFromFolder(folderName: string): string {
|
||||
return folderName
|
||||
.replace(/\s*\(\d{4}\).*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parsePath(filePath: string, moviesRoot: string, tvRoot: string): ParsedPath | null {
|
||||
const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
|
||||
if (!VIDEO_EXTENSIONS.has(ext)) return null;
|
||||
|
||||
// Normalise roots so comparisons work regardless of trailing slash.
|
||||
const moviesPrefix = moviesRoot.replace(/\/+$/, "") + "/";
|
||||
const tvPrefix = tvRoot.replace(/\/+$/, "") + "/";
|
||||
|
||||
const fileName = path.basename(filePath, `.${ext}`);
|
||||
|
||||
if (filePath.startsWith(moviesPrefix)) {
|
||||
return parseMovie(filePath, moviesPrefix, fileName, ext);
|
||||
}
|
||||
|
||||
if (filePath.startsWith(tvPrefix)) {
|
||||
return parseEpisode(filePath, tvPrefix, fileName, ext);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMovie(filePath: string, moviesPrefix: string, fileName: string, ext: string): ParsedPath | null {
|
||||
// Path: /movies/{Folder}/{filename}.ext — folder is the immediate child of moviesRoot.
|
||||
const relative = filePath.slice(moviesPrefix.length); // e.g. "Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-...].mkv"
|
||||
const folderName = relative.split("/")[0];
|
||||
|
||||
const name = titleFromFolder(folderName);
|
||||
const year = extractYear(folderName);
|
||||
|
||||
// IDs can appear in filename or folder name.
|
||||
const searchStr = `${folderName} ${fileName}`;
|
||||
const imdbId = extractId(searchStr, "imdbid");
|
||||
const tmdbId = extractId(searchStr, "tmdbid");
|
||||
|
||||
return {
|
||||
type: "Movie",
|
||||
name,
|
||||
year,
|
||||
seriesName: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tvdbId: null,
|
||||
container: ext,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEpisode(filePath: string, tvPrefix: string, fileName: string, ext: string): ParsedPath | null {
|
||||
// Path: /tv/{Series Folder}/Season NN/{filename}.ext
|
||||
const relative = filePath.slice(tvPrefix.length);
|
||||
const parts = relative.split("/");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const seriesFolder = parts[0]; // e.g. "Arrow (2012) [tvdbid-257655]"
|
||||
|
||||
const seriesName = titleFromFolder(seriesFolder);
|
||||
const year = extractYear(seriesFolder);
|
||||
const tvdbId = extractId(seriesFolder, "tvdbid");
|
||||
|
||||
// Season/episode from S01E01 (or S02E01-E13 for multi-episode).
|
||||
const seMatch = fileName.match(/S(\d{2})E(\d{2})/i);
|
||||
if (!seMatch) return null;
|
||||
|
||||
const seasonNumber = parseInt(seMatch[1], 10);
|
||||
const episodeNumber = parseInt(seMatch[2], 10);
|
||||
|
||||
// Episode title: everything after "- S01E01... -" up to the first "[".
|
||||
// e.g. "Breaking Bad (2008) - S05E03 - Hazard Pay [WEBDL-...]"
|
||||
const epTitleMatch = fileName.match(/S\d{2}E\d{2}(?:-E\d{2})?\s*-\s*(.+?)(?:\s*\[|$)/i);
|
||||
const episodeTitle = epTitleMatch ? epTitleMatch[1].trim() : fileName;
|
||||
|
||||
return {
|
||||
type: "Episode",
|
||||
name: episodeTitle,
|
||||
year,
|
||||
seriesName,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
tvdbId,
|
||||
container: ext,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user