@tiahui/anitorrent-cli
Version:
CLI tool for video management with PeerTube and Storj S3
1,113 lines (957 loc) • 30.6 kB
JavaScript
const anitomy = require("anitomyscript");
const fs = require("fs").promises;
const path = require("path");
const { exec } = require("child_process");
const { promisify } = require("util");
const execAsync = promisify(exec);
class SubtitleService {
constructor() {
this.subtitlesFolderName = "subtitles";
}
async getVideoInfo(videoFile) {
const command = `ffprobe -v quiet -print_format json -show_streams -show_format "${videoFile}"`;
try {
const { stdout } = await execAsync(command);
const data = JSON.parse(stdout);
return data;
} catch (error) {
throw new Error(`Error getting video info: ${error.message}`);
}
}
async getMkvInfo(videoFile) {
const command = `mkvmerge -J "${videoFile}"`;
try {
const { stdout } = await execAsync(command);
const data = JSON.parse(stdout);
return data;
} catch (error) {
throw new Error(`Error getting MKV info with mkvmerge: ${error.message}`);
}
}
async listSubtitleTracks(videoFile) {
try {
// Try mkvmerge first for all video files, fallback to ffprobe
try {
return await this.listSubtitleTracksWithMkv(videoFile);
} catch (mkvError) {
// mkvmerge failed, fallback to ffprobe
return await this.listSubtitleTracksWithFfprobe(videoFile);
}
} catch (error) {
throw new Error(`Error listing subtitle tracks: ${error.message}`);
}
}
async listSubtitleTracksWithMkv(videoFile) {
const mkvData = await this.getMkvInfo(videoFile);
const tracks = mkvData.tracks || [];
const subtitleTracks = tracks.filter((track) => track.type === "subtitles");
return subtitleTracks.map((track, index) => {
const props = track.properties || {};
const langCode = props.language || "und";
const trackName = props.track_name || "";
const codec = track.codec || "";
const languageInfo = this.parseMkvLanguageInfo(
langCode,
trackName,
index,
tracks
);
return {
index: track.id,
trackNumber: index,
mkvTrackId: track.id,
codec: codec,
language: languageInfo.language,
languageDetail: languageInfo.detail,
title: trackName || languageInfo.title || `Subtitle Track ${index}`,
forced: props.forced_track === true,
default: props.default_track === true,
properties: props,
originalTrackName: trackName,
source: "mkvmerge",
};
});
}
async listSubtitleTracksWithFfprobe(videoFile) {
const data = await this.getVideoInfo(videoFile);
const subtitleStreams = data.streams.filter(
(stream) => stream.codec_type === "subtitle"
);
return subtitleStreams.map((stream, index) => {
const languageInfo = this.parseLanguageInfo(stream, index);
return {
index: stream.index,
trackNumber: index,
streamIndex: stream.index,
codec: stream.codec_name,
language: languageInfo.language,
languageDetail: languageInfo.detail,
title:
stream.tags?.title || languageInfo.title || `Subtitle Track ${index}`,
forced: stream.disposition?.forced === 1,
default: stream.disposition?.default === 1,
disposition: stream.disposition,
allTags: stream.tags,
source: "ffprobe",
};
});
}
parseMkvLanguageInfo(langCode, trackName, index, allTracks) {
const language = langCode || "unknown";
const name = trackName.toLowerCase();
let detail = "";
let displayTitle = trackName;
if (language === "spa" || language === "es") {
if (name.includes("es-419") || name.includes("latin")) {
detail = "Latino (es-419)";
displayTitle = displayTitle || "Español (Latino)";
} else if (
name.includes("es-es") ||
name.includes("españa") ||
name.includes("spain") ||
name.includes("castilian")
) {
detail = "España (es-ES)";
displayTitle = displayTitle || "Español (España)";
} else if (name.includes("forced")) {
detail = "Forced";
displayTitle = displayTitle || "Español (Forced)";
} else {
// Smart detection: check if there's already a Latino track
const spanishTracks = allTracks.filter(
(t) =>
(t.properties?.language === "spa" ||
t.properties?.language === "es") &&
t.type === "subtitles"
);
const hasLatinoTrack = spanishTracks.some((t) => {
const tName = (t.properties?.track_name || "").toLowerCase();
return tName.includes("es-419") || tName.includes("latin");
});
const hasEspañaTrack = spanishTracks.some((t) => {
const tName = (t.properties?.track_name || "").toLowerCase();
return (
tName.includes("es-es") ||
tName.includes("españa") ||
tName.includes("spain") ||
tName.includes("castilian")
);
});
// If there's already a Latino track and this is just "Spanish", assume it's España
if (hasLatinoTrack && !hasEspañaTrack && name === "cr_spanish") {
detail = "España (inferred)";
} else if (!hasLatinoTrack && !hasEspañaTrack) {
// If no specific variants, use order
detail = index === 0 ? "España (by order)" : "Latino (by order)";
} else {
detail = "Unknown variant";
}
displayTitle = displayTitle || `Español (${detail})`;
}
} else if (language === "por" || language === "pt") {
if (
name.includes("pt-br") ||
name.includes("brasil") ||
name.includes("brazil")
) {
detail = "Brasil (pt-BR)";
displayTitle = displayTitle || "Português (Brasil)";
} else {
detail = "Portugal";
displayTitle = displayTitle || "Português";
}
} else if (language === "eng" || language === "en") {
if (name.includes("en-us") || name.includes("american")) {
detail = "US (en-US)";
displayTitle = displayTitle || "English (US)";
} else {
detail = "English";
displayTitle = displayTitle || "English";
}
}
return {
language,
detail,
title: displayTitle,
};
}
parseLanguageInfo(stream, index) {
const tags = stream.tags || {};
const language = tags.language || "unknown";
const title = tags.title || tags.handler_name || "";
let detail = "";
let displayTitle = title;
if (language === "spa" || language === "es") {
if (
title.toLowerCase().includes("latin") ||
title.toLowerCase().includes("latino")
) {
detail = "Latino";
displayTitle = displayTitle || "Español (Latino)";
} else if (
title.toLowerCase().includes("spain") ||
title.toLowerCase().includes("españa") ||
title.toLowerCase().includes("castilian")
) {
detail = "España";
displayTitle = displayTitle || "Español (España)";
} else if (
title.toLowerCase().includes("forced") ||
stream.disposition?.forced === 1
) {
detail = "Forced";
displayTitle = displayTitle || "Español (Forced)";
} else {
detail = index === 0 ? "España (assumed)" : "Latino (assumed)";
displayTitle = displayTitle || `Español (${detail})`;
}
}
return {
language,
detail,
title: displayTitle,
};
}
async fetchPlaylistVideos(
playlistId,
apiUrl = "https://peertube.anitorrent.com/api/v1"
) {
const url = `${apiUrl}/video-playlists/${playlistId}/videos?count=100`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.data;
} catch (error) {
throw new Error(`Error fetching playlist: ${error.message}`);
}
}
async getLocalVideoFiles(directory = ".", recursive = false) {
const foundFiles = [];
async function scanDir(currentDir, relativePath = "") {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativeFilePath = path.join(relativePath, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
const videoExtensions = [
".mp4",
".mkv",
".avi",
".mov",
".wmv",
".flv",
".webm",
".m4v",
".ts",
".mts",
];
if (videoExtensions.includes(ext)) {
foundFiles.push(fullPath);
}
} else if (entry.isDirectory() && recursive) {
await scanDir(fullPath, relativeFilePath);
}
}
} catch (error) {
// Skip directories that can't be read
}
}
await scanDir(directory);
return foundFiles;
}
async parseVideoName(videoName) {
try {
if (
!videoName ||
typeof videoName !== "string" ||
videoName.trim() === ""
) {
return null;
}
const trimmedName = videoName.trim();
const result = await anitomy(trimmedName);
return result;
} catch (error) {
return null;
}
}
normalizeTitle(title) {
return title
.toLowerCase()
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim();
}
matchVideos(peertubeVideos, localFiles) {
const matches = [];
for (const peertubeVideo of peertubeVideos) {
const peertubeData = peertubeVideo.parsed;
if (!peertubeData) continue;
for (const localFile of localFiles) {
const localData = localFile.parsed;
if (!localData) continue;
const episodeMatch =
peertubeData.episode_number === localData.episode_number;
const seasonMatch =
(!peertubeData.anime_season && !localData.anime_season) ||
peertubeData.anime_season === localData.anime_season;
if (episodeMatch && seasonMatch) {
matches.push({
peertubeVideo: peertubeVideo.original,
localFile: localFile.filename,
peertubeData,
localData,
});
break;
}
}
}
return matches;
}
async ensureSubtitlesDirectory(directory = ".") {
const subtitlesDir = path.join(directory, this.subtitlesFolderName);
try {
await fs.access(subtitlesDir);
} catch {
await fs.mkdir(subtitlesDir);
}
return subtitlesDir;
}
async extractSubtitles(
videoFile,
outputFile,
subtitleTrack = 0,
directory = "."
) {
const subtitlesDir = await this.ensureSubtitlesDirectory(directory);
const subtitlesPath = path.join(subtitlesDir, outputFile);
// Validate and normalize subtitleTrack parameter
if (subtitleTrack === null || subtitleTrack === undefined) {
subtitleTrack = 0;
}
if (
typeof subtitleTrack !== "number" ||
isNaN(subtitleTrack) ||
subtitleTrack < 0
) {
return {
success: false,
error: `Invalid subtitle track number: ${subtitleTrack}`,
};
}
try {
const tracks = await this.listSubtitleTracks(videoFile);
if (tracks.length === 0) {
return {
success: false,
error: "No subtitle tracks found in video file",
};
}
if (subtitleTrack >= tracks.length) {
return {
success: false,
error: `Subtitle track ${subtitleTrack} not found (only ${tracks.length} tracks available)`,
};
}
} catch (error) {
return {
success: false,
error: `Failed to analyze video file: ${error.message}`,
};
}
try {
return await this.extractSubtitlesWithFfmpeg(
videoFile,
subtitlesPath,
subtitleTrack
);
} catch (ffmpegError) {
try {
return await this.extractSubtitlesWithMkv(
videoFile,
subtitlesPath,
subtitleTrack
);
} catch (mkvError) {
return {
success: false,
error: `Both ffmpeg and mkvextract failed. FFmpeg: ${ffmpegError.message}, MKV: ${mkvError.message}`,
};
}
}
}
async extractSubtitlesWithMkv(videoFile, outputPath, subtitleTrack) {
// Get track info to find the correct MKV track ID
const tracks = await this.listSubtitleTracksWithMkv(videoFile);
if (subtitleTrack >= tracks.length) {
return {
success: false,
error: `Subtitle track ${subtitleTrack} not found`,
};
}
const track = tracks[subtitleTrack];
const mkvTrackId = track.mkvTrackId;
const command = `mkvextract tracks "${videoFile}" ${mkvTrackId}:"${outputPath}"`;
try {
const { stdout, stderr } = await execAsync(command);
return { success: true, outputPath };
} catch (error) {
return { success: false, error: error.message };
}
}
async extractSubtitlesWithFfmpeg(videoFile, outputPath, subtitleTrack) {
// Validate input parameters
if (subtitleTrack === null || subtitleTrack === undefined) {
return {
success: false,
error: "Subtitle track cannot be null or undefined",
};
}
if (
typeof subtitleTrack !== "number" ||
isNaN(subtitleTrack) ||
subtitleTrack < 0
) {
return {
success: false,
error: `Invalid subtitle track number: ${subtitleTrack}`,
};
}
const tracks = await this.listSubtitleTracks(videoFile);
if (subtitleTrack >= tracks.length) {
return {
success: false,
error: `Subtitle track ${subtitleTrack} not found (only ${tracks.length} tracks available)`,
};
}
const track = tracks[subtitleTrack];
if (!track) {
return {
success: false,
error: `Track data not found for track ${subtitleTrack}`,
};
}
let streamMap;
if (track.source === "ffprobe" && track.streamIndex !== undefined) {
streamMap = `0:${track.streamIndex}`;
} else {
streamMap = `0:s:${subtitleTrack}`;
}
const command = `ffmpeg -i "${videoFile}" -map ${streamMap} "${outputPath}" -y`;
try {
const { stdout, stderr } = await execAsync(command);
return { success: true, outputPath };
} catch (error) {
return {
success: false,
error: `FFmpeg command failed: ${error.message}. Command: ${command}`,
};
}
}
async extractAllLocalSubtitles(
subtitleTrack = null,
directory = ".",
recursive = false
) {
const localFiles = await this.getLocalVideoFiles(directory, recursive);
if (localFiles.length === 0) {
throw new Error(`No video files found in directory: ${directory}`);
}
const results = [];
for (const filePath of localFiles) {
const nameWithoutExt = path.parse(filePath).name;
const tracks = await this.listSubtitleTracks(filePath);
let targetTrack = subtitleTrack;
// If no track specified, find Spanish Latino automatically
if (targetTrack === null) {
targetTrack = this.findDefaultSpanishTrack(tracks);
if (targetTrack === -1) {
// No Spanish track found, use first available track
targetTrack = 0;
}
}
// Ensure targetTrack is always a valid number
if (targetTrack === null || targetTrack === undefined) {
targetTrack = 0;
}
if (targetTrack >= tracks.length) {
results.push({
filename: filePath,
outputFile: `${nameWithoutExt}.ass`,
success: false,
error: `Track ${targetTrack} not found`,
});
continue;
}
let outputFile;
if (targetTrack < tracks.length) {
const track = tracks[targetTrack];
const langSuffix = this.getLanguageSuffix(track, tracks);
outputFile = langSuffix
? `${nameWithoutExt}_${langSuffix}.ass`
: `${nameWithoutExt}.ass`;
} else {
outputFile = `${nameWithoutExt}.ass`;
}
const result = await this.extractSubtitles(
filePath,
outputFile,
targetTrack,
directory
);
results.push({
filename: filePath,
outputFile,
trackUsed: targetTrack,
trackInfo: tracks[targetTrack],
...result,
});
}
return results;
}
findDefaultSpanishTrack(tracks) {
const spanishTracks = tracks.filter(
(t) => t.language === "spa" || t.language === "es"
);
if (spanishTracks.length === 0) {
return -1; // No Spanish tracks found
}
if (spanishTracks.length === 1) {
// Only one Spanish track, it's the default (latino)
const trackNumber = spanishTracks[0].trackNumber;
return trackNumber !== null && trackNumber !== undefined
? trackNumber
: 0;
}
// Multiple Spanish tracks - find Latino
for (const track of spanishTracks) {
const detail = track.languageDetail || "";
const title = track.title || "";
// Look for explicit Latino indicators
if (
detail.includes("Latino") ||
detail.includes("es-419") ||
title.toLowerCase().includes("latin") ||
title.toLowerCase().includes("419")
) {
const trackNumber = track.trackNumber;
return trackNumber !== null && trackNumber !== undefined
? trackNumber
: 0;
}
}
// If no explicit Latino found, look for non-España tracks
for (const track of spanishTracks) {
const detail = track.languageDetail || "";
const title = track.title || "";
// Skip España tracks
if (
detail.includes("España") ||
detail.includes("es-ES") ||
title.toLowerCase().includes("spain") ||
title.toLowerCase().includes("es-es")
) {
continue;
}
// This is likely Latino (not explicitly España)
const trackNumber = track.trackNumber;
return trackNumber !== null && trackNumber !== undefined
? trackNumber
: 0;
}
// Fallback: return first Spanish track
const trackNumber = spanishTracks[0].trackNumber;
return trackNumber !== null && trackNumber !== undefined ? trackNumber : 0;
}
async extractAllSubtitleTracks(videoFile, directory = ".") {
const tracks = await this.listSubtitleTracks(videoFile);
const nameWithoutExt = path.parse(videoFile).name;
if (tracks.length === 0) {
throw new Error("No subtitle tracks found in the video file");
}
const results = [];
for (const track of tracks) {
const langSuffix = this.getLanguageSuffix(track, tracks);
const outputFile = langSuffix
? `${nameWithoutExt}_${langSuffix}.ass`
: `${nameWithoutExt}.ass`;
const result = await this.extractSubtitles(
videoFile,
outputFile,
track.trackNumber,
directory
);
results.push({
track,
outputFile,
...result,
});
}
return results;
}
async extractAllSubtitlesFromFolder(directory = ".", recursive = false) {
const localFiles = await this.getLocalVideoFiles(directory, recursive);
if (localFiles.length === 0) {
throw new Error(`No video files found in directory: ${directory}`);
}
const results = [];
for (const filePath of localFiles) {
try {
const fileResults = await this.extractAllSubtitleTracks(
filePath,
directory
);
results.push(
...fileResults.map((r) => ({
filename: filePath,
...r,
}))
);
} catch (error) {
results.push({
filename: filePath,
success: false,
error: error.message,
});
}
}
return results;
}
getLanguageSuffix(track, tracks = null, customSuffix = null) {
if (customSuffix) {
return customSuffix;
}
const language = track.language;
const detail = track.languageDetail || "";
const title = track.title || "";
if (language === "spa" || language === "es") {
if (
detail.toLowerCase().includes("latin") ||
detail.toLowerCase().includes("es-419") ||
title.toLowerCase().includes("latin") ||
title.toLowerCase().includes("419")
) {
return null;
} else {
return "spa";
}
} else {
return language !== "unknown" && language !== "und" ? language : "unk";
}
}
async extractFromPlaylist(
playlistId,
subtitleTrack = 0,
apiUrl = "https://peertube.anitorrent.com/api/v1",
directory = ".",
offsetMs = 0,
recursive = false
) {
const peertubeVideos = await this.fetchPlaylistVideos(playlistId, apiUrl);
const localFiles = await this.getLocalVideoFiles(directory, recursive);
const parsedPeertubeVideos = [];
for (const video of peertubeVideos) {
const parsed = await this.parseVideoName(video.video.name);
parsedPeertubeVideos.push({
original: video,
parsed,
});
}
const parsedLocalFiles = [];
for (const filePath of localFiles) {
const nameWithoutExt = path.parse(filePath).name;
const parsed = await this.parseVideoName(nameWithoutExt);
parsedLocalFiles.push({
filename: filePath,
parsed,
});
}
const matches = this.matchVideos(parsedPeertubeVideos, parsedLocalFiles);
if (matches.length === 0) {
throw new Error(
"No matches found between PeerTube playlist and local files"
);
}
const results = [];
for (const match of matches) {
const outputFile = `${match.peertubeVideo.video.shortUUID}.ass`;
const result = await this.extractSubtitles(
match.localFile,
outputFile,
subtitleTrack,
directory
);
if (result.success && offsetMs && offsetMs !== 0) {
const offsetResult = await this.adjustSubtitleTiming(
result.outputPath,
offsetMs,
result.outputPath
);
result.offsetApplied = offsetResult.success;
result.offsetError = offsetResult.success ? null : offsetResult.error;
}
results.push({
match,
outputFile,
...result,
});
}
return { matches, results };
}
async translateSubtitleFile(subtitlePath, config, onProgress = null) {
const TranslationService = require("./translation-service");
try {
const translationService = new TranslationService(config);
const result = await translationService.translateSubtitles(subtitlePath, {
onProgress,
});
return result;
} catch (error) {
throw new Error(`Translation failed: ${error.message}`);
}
}
async extractAndTranslateSubtitles(
videoFile,
outputFile,
subtitleTrack = 0,
directory = ".",
translationConfig = null,
onProgress = null
) {
const extractResult = await this.extractSubtitles(
videoFile,
outputFile,
subtitleTrack,
directory
);
if (!extractResult.success) {
return extractResult;
}
if (!translationConfig) {
return extractResult;
}
try {
if (onProgress) {
onProgress({
type: "translation_start",
file: extractResult.outputPath,
});
}
const translationResult = await this.translateSubtitleFile(
extractResult.outputPath,
translationConfig,
onProgress
);
if (onProgress) {
onProgress({
type: "translation_complete",
originalFile: extractResult.outputPath,
translatedFile: translationResult.outputPath,
});
}
return {
...extractResult,
translationResult,
};
} catch (error) {
if (onProgress) {
onProgress({ type: "translation_error", error: error.message });
}
return {
...extractResult,
translationError: error.message,
};
}
}
async extractAllLocalSubtitlesWithTranslation(
subtitleTrack = null,
directory = ".",
translationConfig = null,
onProgress = null,
recursive = false
) {
const localFiles = await this.getLocalVideoFiles(directory, recursive);
if (localFiles.length === 0) {
throw new Error(`No video files found in directory: ${directory}`);
}
const results = [];
for (const filePath of localFiles) {
const nameWithoutExt = path.parse(filePath).name;
let targetTrack = subtitleTrack;
if (targetTrack === null) {
try {
const tracks = await this.listSubtitleTracks(filePath);
targetTrack = this.findDefaultSpanishTrack(tracks);
if (targetTrack === -1) {
targetTrack = 0;
}
} catch (error) {
results.push({
filename: filePath,
success: false,
error: `Failed to analyze tracks: ${error.message}`,
});
continue;
}
}
try {
const tracks = await this.listSubtitleTracks(filePath);
const spanishTracks = tracks.filter(
(t) => t.language === "spa" || t.language === "es"
);
if (targetTrack >= tracks.length) {
results.push({
filename: filePath,
success: false,
error: `Track ${targetTrack} not found`,
});
continue;
}
let outputFile;
if (targetTrack < tracks.length) {
const track = tracks[targetTrack];
const langSuffix = this.getLanguageSuffix(track, tracks);
outputFile = langSuffix
? `${nameWithoutExt}_${langSuffix}.ass`
: `${nameWithoutExt}.ass`;
} else {
outputFile = `${nameWithoutExt}.ass`;
}
const result = await this.extractAndTranslateSubtitles(
filePath,
outputFile,
targetTrack,
directory,
translationConfig,
onProgress
);
results.push({
filename: filePath,
outputFile,
trackUsed: targetTrack,
trackInfo: tracks[targetTrack],
...result,
});
} catch (error) {
results.push({
filename: filePath,
success: false,
error: error.message,
});
}
}
return results;
}
async extractAllSubtitleTracksWithTranslation(
videoFile,
directory = ".",
translationConfig = null,
onProgress = null
) {
const tracks = await this.listSubtitleTracks(videoFile);
const nameWithoutExt = path.parse(videoFile).name;
if (tracks.length === 0) {
throw new Error("No subtitle tracks found in the video file");
}
const results = [];
const spanishTracks = tracks.filter(
(t) => t.language === "spa" || t.language === "es"
);
for (const track of tracks) {
const langSuffix = this.getLanguageSuffix(track, tracks);
const outputFile = langSuffix
? `${nameWithoutExt}_${langSuffix}.ass`
: `${nameWithoutExt}.ass`;
const result = await this.extractAndTranslateSubtitles(
videoFile,
outputFile,
track.trackNumber,
directory,
translationConfig,
onProgress
);
results.push({
track,
outputFile,
...result,
});
}
return results;
}
async adjustSubtitleTiming(subtitleFile, offsetMs, outputFile = null) {
const fs = require("fs").promises;
const path = require("path");
try {
const content = await fs.readFile(subtitleFile, "utf8");
if (!outputFile) {
const parsed = path.parse(subtitleFile);
const offsetStr = offsetMs >= 0 ? `+${offsetMs}ms` : `${offsetMs}ms`;
outputFile = path.join(
parsed.dir,
`${parsed.name}_offset_${offsetStr}${parsed.ext}`
);
}
const adjustedContent = this.adjustAssTimings(content, offsetMs);
await fs.writeFile(outputFile, adjustedContent, "utf8");
return {
success: true,
inputFile: subtitleFile,
outputFile,
offsetMs,
message: `Timing adjusted by ${offsetMs}ms`,
};
} catch (error) {
return {
success: false,
inputFile: subtitleFile,
outputFile: outputFile || subtitleFile,
offsetMs,
error: error.message,
};
}
}
adjustAssTimings(content, offsetMs) {
const lines = content.split("\n");
const adjustedLines = lines.map((line) => {
if (line.startsWith("Dialogue:") || line.startsWith("Comment:")) {
const parts = line.split(",");
if (parts.length >= 10) {
const startTime = parts[1];
const endTime = parts[2];
const adjustedStartTime = this.adjustAssTime(startTime, offsetMs);
const adjustedEndTime = this.adjustAssTime(endTime, offsetMs);
parts[1] = adjustedStartTime;
parts[2] = adjustedEndTime;
return parts.join(",");
}
}
return line;
});
return adjustedLines.join("\n");
}
adjustAssTime(timeStr, offsetMs) {
const regex = /^(\d+):(\d{2}):(\d{2})\.(\d{2})$/;
const match = timeStr.match(regex);
if (!match) return timeStr;
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
const seconds = parseInt(match[3]);
const centiseconds = parseInt(match[4]);
const totalMs =
(hours * 3600 + minutes * 60 + seconds) * 1000 + centiseconds * 10;
const adjustedMs = Math.max(0, totalMs + offsetMs);
const newHours = Math.floor(adjustedMs / 3600000);
const newMinutes = Math.floor((adjustedMs % 3600000) / 60000);
const newSeconds = Math.floor((adjustedMs % 60000) / 1000);
const newCentiseconds = Math.floor((adjustedMs % 1000) / 10);
return `${newHours}:${newMinutes.toString().padStart(2, "0")}:${newSeconds
.toString()
.padStart(2, "0")}.${newCentiseconds.toString().padStart(2, "0")}`;
}
}
module.exports = SubtitleService;