Files
api-HydrusNetwork/src/api/hydrusClient.ts
T

761 lines
27 KiB
TypeScript

export type ServerConfig = {
id: string
name?: string
host: string
port?: string | number
apiKey?: string
ssl?: boolean
forceApiKeyInQuery?: boolean
}
export type ConnectivityResult = {
ok: boolean
message: string
status?: number | null
searchOk?: boolean
rangeSupported?: boolean
}
export type HydrusMediaInfo = {
mimeType?: string
isVideo?: boolean
hasThumbnail?: boolean
}
export type HydrusFileDetails = HydrusMediaInfo & {
fileId: number
extension?: string
sizeBytes?: number
width?: number
height?: number
durationMs?: number
tags: string[]
}
function sanitizeApiKey(value?: string) {
return (value || '').replace(/\s+/g, '')
}
function sanitizePort(value?: string | number) {
if (value == null) return undefined
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
export function makeId() {
return Date.now().toString() + '-' + Math.random().toString(36).slice(2, 9)
}
export type HydrusSearchTag = string | HydrusSearchTags
export type HydrusSearchTags = HydrusSearchTag[]
const SYSTEM_PREDICATE_PATTERN = /^(system:[^<>!=]+?)\s*(<=|>=|!=|=|<|>)\s*(.+)$/i
const SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
function createAbortError() {
const error = new Error('Aborted')
error.name = 'AbortError'
return error
}
export class HydrusClient {
cfg: ServerConfig
constructor(cfg: Partial<ServerConfig> = {}) {
this.cfg = {
id: (cfg.id as string) || makeId(),
name: (cfg.name || '').trim(),
host: (cfg.host || '').trim(),
port: sanitizePort(cfg.port),
apiKey: sanitizeApiKey(cfg.apiKey),
ssl: !!cfg.ssl,
forceApiKeyInQuery: !!cfg.forceApiKeyInQuery
}
}
baseUrl(): string {
if (!this.cfg.host) throw new Error('Hydrus host not defined')
let url = this.cfg.host.trim().replace(/\/+$/, '')
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = (this.cfg.ssl ? 'https://' : 'http://') + url
}
try {
const parsed = new URL(url)
if (this.cfg.port && !parsed.port) {
parsed.port = String(this.cfg.port)
}
// preserve any configured pathname (e.g., if Hydrus is hosted under /hydrus)
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
return parsed.origin + path
} catch (e) {
// fallback
return url + (this.cfg.port ? `:${this.cfg.port}` : '')
}
}
getHeaders(includeApiKeyInHeader = true, includeContentType = false) {
const headers: Record<string, string> = {}
if (includeContentType) headers['Content-Type'] = 'application/json'
if (this.cfg.apiKey && includeApiKeyInHeader) headers['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey
return headers
}
private appendApiKeyToUrl(url: string) {
if (!this.cfg.apiKey) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}Hydrus-Client-API-Access-Key=${encodeURIComponent(this.cfg.apiKey)}`
}
private buildApiUrl(path: string, params: Record<string, string | number | undefined> = {}, includeApiKeyInQuery = false) {
const url = new URL(`${this.baseUrl()}${path}`)
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue
url.searchParams.set(key, String(value))
}
if (includeApiKeyInQuery && this.cfg.apiKey) {
url.searchParams.set('Hydrus-Client-API-Access-Key', this.cfg.apiKey)
}
return url.toString()
}
private async fetchWithAuthRetry(url: string, init: RequestInit = {}) {
const requestInit: RequestInit = { mode: 'cors', ...init }
let response = await fetch(url, requestInit)
if ((response.status === 401 || response.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) {
response = await fetch(this.appendApiKeyToUrl(url), {
...requestInit,
headers: undefined,
})
}
return response
}
private async getFileMetadataPayload(fileId: number, signal?: AbortSignal) {
const url = this.buildApiUrl('/get_files/file_metadata', { file_id: fileId }, this.cfg.forceApiKeyInQuery ?? false)
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
const res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal })
if (res.status === 404) {
console.warn('[HydrusClient] getFileMetadata 404', { url, status: res.status })
return null
}
if (!res.ok) {
console.warn('[HydrusClient] getFileMetadata Response Error', { status: res.status, statusText: res.statusText })
return null
}
return res.json().catch(() => null)
}
private async getFilesMetadataPayload(fileIds: number[], signal?: AbortSignal) {
if (!fileIds || fileIds.length === 0) return []
const url = this.buildApiUrl('/get_files/file_metadata', {
file_ids: JSON.stringify(fileIds),
include_services_object: 'false',
}, this.cfg.forceApiKeyInQuery ?? false)
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
const res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal })
if (res.status === 404) {
console.warn('[HydrusClient] getFilesMetadata 404', { url, status: res.status, fileCount: fileIds.length })
return []
}
if (!res.ok) {
console.warn('[HydrusClient] getFilesMetadata Response Error', { status: res.status, statusText: res.statusText, fileCount: fileIds.length })
return []
}
const data = await res.json().catch(() => null)
if (Array.isArray(data?.metadata)) return data.metadata
if (Array.isArray(data?.file_metadata)) return data.file_metadata
if (Array.isArray(data)) return data
return []
}
private getFileMetadataEntry(data: any, fileId: number) {
if (!data || typeof data !== 'object') return null
if (data.file_metadata && typeof data.file_metadata === 'object' && !Array.isArray(data.file_metadata)) {
const direct = data.file_metadata[String(fileId)]
if (direct && typeof direct === 'object') return direct
}
if (Array.isArray(data.file_metadata)) {
const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId))
if (found && typeof found === 'object') return found
}
if (String((data as any).file_id) === String(fileId)) return data
return null
}
private normalizeMimeType(value: string): string | undefined {
const normalized = value.trim().toLowerCase()
if (!normalized) return undefined
if (normalized.includes('mpegurl') || normalized.includes('m3u8')) return 'application/vnd.apple.mpegurl'
const directMimeMatch = normalized.match(/(?:video|audio|application)\/[a-z0-9.+-]+/)
if (directMimeMatch) return directMimeMatch[0]
const knownMimeMap: Array<[string, string]> = [
['quicktime', 'video/quicktime'],
['matroska', 'video/x-matroska'],
['mkv', 'video/x-matroska'],
['mp4', normalized.includes('audio') ? 'audio/mp4' : 'video/mp4'],
['webm', normalized.includes('audio') ? 'audio/webm' : 'video/webm'],
['mpeg', normalized.includes('audio') ? 'audio/mpeg' : 'video/mpeg'],
['avi', 'video/x-msvideo'],
['wmv', 'video/x-ms-wmv'],
['mov', 'video/quicktime'],
['ogg', normalized.includes('video') ? 'video/ogg' : 'audio/ogg'],
['mp3', 'audio/mpeg'],
['m4a', 'audio/mp4'],
['aac', 'audio/aac'],
['flac', 'audio/flac'],
['wav', 'audio/wav'],
]
const match = knownMimeMap.find(([token]) => normalized.includes(token))
return match ? match[1] : undefined
}
private extractMediaInfoFromMetadata(data: any, fileId: number): HydrusMediaInfo {
const metadata = this.getFileMetadataEntry(data, fileId) || data
if (!metadata || typeof metadata !== 'object') return {}
let width = 0
let height = 0
let frameCount = 0
let hasDuration = false
let hasThumbnail = false
const mimeCandidates: string[] = []
const visit = (value: any, keyHint = '') => {
if (value == null) return
if (typeof value === 'string') {
const lowerKey = keyHint.toLowerCase()
if (lowerKey.includes('mime') || lowerKey.includes('filetype') || lowerKey.includes('container') || lowerKey.includes('format')) {
mimeCandidates.push(value)
}
return
}
if (typeof value === 'number') {
const lowerKey = keyHint.toLowerCase()
if (lowerKey === 'width') width = Math.max(width, value)
else if (lowerKey === 'height') height = Math.max(height, value)
else if ((lowerKey === 'thumbnail_width' || lowerKey === 'thumbnail_height') && value > 0) hasThumbnail = true
else if (lowerKey.includes('frame')) frameCount = Math.max(frameCount, value)
else if (lowerKey.includes('duration')) hasDuration = hasDuration || value > 0
return
}
if (typeof value === 'boolean') {
if (keyHint.toLowerCase().includes('video') && value) mimeCandidates.push('video')
if (keyHint.toLowerCase().includes('audio') && value) mimeCandidates.push('audio')
return
}
if (Array.isArray(value)) {
for (const item of value) visit(item, keyHint)
return
}
if (typeof value === 'object') {
for (const [childKey, childValue] of Object.entries(value)) {
visit(childValue, childKey)
}
}
}
visit(metadata)
const withThumbnail = (info: HydrusMediaInfo): HydrusMediaInfo => hasThumbnail ? { ...info, hasThumbnail: true } : info
for (const candidate of mimeCandidates) {
const mimeType = this.normalizeMimeType(candidate)
if (mimeType) {
return withThumbnail({
mimeType,
isVideo: mimeType.startsWith('video/') || mimeType === 'application/vnd.apple.mpegurl'
})
}
const lower = candidate.toLowerCase()
if (lower.includes('video')) return withThumbnail({ isVideo: true })
if (lower.includes('audio')) return withThumbnail({ isVideo: false })
}
if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return withThumbnail({ isVideo: true })
if (hasDuration) return withThumbnail({ isVideo: false })
return withThumbnail({})
}
private extractTagsFromMetadata(data: any, fileId: number): string[] {
if (!data) return []
try {
if (data.file_metadata && typeof data.file_metadata === 'object' && data.file_metadata[String(fileId)]) {
const meta = data.file_metadata[String(fileId)]
if (Array.isArray(meta.tags)) return meta.tags
if (meta.service_keys_to_tags && typeof meta.service_keys_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(meta.service_keys_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
if (meta.service_names_to_tags && typeof meta.service_names_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(meta.service_names_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
}
if (Array.isArray(data.tags)) return data.tags
if (Array.isArray(data.file_tags)) return data.file_tags
if (Array.isArray(data.file_metadata)) {
const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId))
if (found) {
if (Array.isArray(found.tags)) return found.tags
if (found.service_keys_to_tags && typeof found.service_keys_to_tags === 'object') {
const merged: string[] = []
for (const value of Object.values(found.service_keys_to_tags)) {
if (Array.isArray(value)) merged.push(...value)
else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags)
}
if (merged.length) return Array.from(new Set(merged))
}
}
}
const foundArrays: string[][] = []
const walk = (obj: any) => {
if (!obj || typeof obj !== 'object') return
if (Array.isArray(obj)) {
if (obj.length > 0 && obj.every((item) => typeof item === 'string')) {
foundArrays.push(obj as string[])
return
}
for (const entry of obj) walk(entry)
return
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
if (value.length > 0 && value.every((item) => typeof item === 'string')) foundArrays.push(value as string[])
else for (const entry of value) walk(entry)
} else if (value && typeof value === 'object') {
walk(value)
}
}
}
walk(data)
if (foundArrays.length) {
const flattened = ([] as string[]).concat(...foundArrays)
return Array.from(new Set(flattened))
}
} catch {
// fall through
}
return []
}
private extractFileDetailsFromMetadata(data: any, fileId: number): HydrusFileDetails {
const metadata = this.getFileMetadataEntry(data, fileId) || data || {}
const mediaInfo = this.extractMediaInfoFromMetadata(data, fileId)
const tags = this.extractTagsFromMetadata(data, fileId)
let extension: string | undefined
let sizeBytes: number | undefined
let width: number | undefined
let height: number | undefined
let durationMs: number | undefined
const visit = (value: any, keyHint = '') => {
if (value == null) return
const lowerKey = keyHint.toLowerCase()
if (typeof value === 'string') {
if (!extension && (lowerKey === 'ext' || lowerKey === 'extension')) {
extension = value.replace(/^\./, '').trim().toLowerCase() || undefined
}
if (!extension && (lowerKey.includes('filename') || lowerKey === 'name')) {
const match = value.match(/\.([a-z0-9]{1,10})$/i)
if (match) extension = match[1].toLowerCase()
}
return
}
if (typeof value === 'number') {
if ((lowerKey === 'size' || lowerKey === 'file_size' || lowerKey === 'num_bytes' || lowerKey === 'bytes') && value > 0) {
sizeBytes = Math.max(sizeBytes || 0, value)
} else if (lowerKey === 'width' && value > 0) {
width = Math.max(width || 0, value)
} else if (lowerKey === 'height' && value > 0) {
height = Math.max(height || 0, value)
} else if ((lowerKey.includes('duration') || lowerKey === 'ms') && value > 0) {
durationMs = Math.max(durationMs || 0, value)
}
return
}
if (Array.isArray(value)) {
for (const item of value) visit(item, keyHint)
return
}
if (typeof value === 'object') {
for (const [childKey, childValue] of Object.entries(value)) {
visit(childValue, childKey)
}
}
}
visit(metadata)
return {
fileId,
...mediaInfo,
extension,
sizeBytes,
width,
height,
durationMs,
tags,
}
}
private cleanSearchTag(value: HydrusSearchTag | undefined | null): HydrusSearchTag | null {
if (!value) return null
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return null
return /^system:/i.test(trimmed) ? this.normalizeSystemPredicate(trimmed) : trimmed
}
if (Array.isArray(value)) {
const cleaned = value
.map((item) => this.cleanSearchTag(item))
.filter((item): item is HydrusSearchTag => item !== null)
return cleaned.length ? cleaned : null
}
return null
}
private normalizeSystemPredicate(value: string) {
const trimmed = value.trim().replace(/\s+/g, ' ')
const match = trimmed.match(SYSTEM_PREDICATE_PATTERN)
if (!match) return trimmed
const [, left, operator, right] = match
return `${left.trim()} ${operator} ${right.trim()}`
}
private buildSearchTags(searchableText: string | HydrusSearchTags | undefined | null): HydrusSearchTags {
const tags: HydrusSearchTags = []
if (!searchableText) return tags
const seen = new Set<string>()
const pushValue = (value: HydrusSearchTag | undefined | null) => {
const cleaned = this.cleanSearchTag(value)
if (!cleaned) return
const key = JSON.stringify(cleaned)
if (seen.has(key)) return
seen.add(key)
tags.push(cleaned)
}
if (typeof searchableText === 'string') {
const s = searchableText.trim()
if (!s) return tags
if (/^system:/i.test(s)) {
pushValue(this.normalizeSystemPredicate(s))
return tags
}
const tokens = s.match(SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
const wildcardize = (tok: string) => (tok.includes('*') ? tok : `*${tok}*`)
if (tokens.length === 1) {
const t = tokens[0]
if (t.includes(':')) {
// user explicitly used a namespace or special token
pushValue(t)
} else {
// match either as a plain tag OR as part of title (substring)
pushValue([t, `title:${wildcardize(t)}`])
}
} else {
// multi-token: try a full-phrase title match OR per-token matches
if (!s.includes(':')) {
pushValue([s, `title:${wildcardize(s)}`])
}
for (const t of tokens) {
if (t.includes(':')) pushValue(t)
else pushValue([t, `title:${wildcardize(t)}`])
}
}
} else {
for (const tag of searchableText) {
pushValue(tag)
}
}
return tags
}
async searchFiles(searchableText: string | HydrusSearchTags | null = '', resultsPerPage = 20, signal?: AbortSignal): Promise<number[]> {
// If server configured to prefer query param key, omit key from header
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
const tagsArr: HydrusSearchTags = this.buildSearchTags(searchableText)
tagsArr.push(this.normalizeSystemPredicate(`system:limit=${resultsPerPage}`))
const url = this.buildApiUrl('/get_files/search_files', {
tags: JSON.stringify(tagsArr),
return_file_ids: 'true'
}, this.cfg.forceApiKeyInQuery ?? false)
let res: Response
try {
res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal })
} catch (err: any) {
if (err && err.name === 'AbortError') throw err
throw err
}
if (res.status === 404) {
const text = await res.text().catch(() => '')
console.warn('[HydrusClient] searchFiles 404', { url, status: res.status, body: text })
throw new Error(`Search failed (404): ${text ? text : 'Not Found'} (request: ${url}). Note: /get_files/search_files expects GET with a 'tags' query parameter. Avoid POST fallback as this endpoint may not accept POST.`)
}
if (!res.ok) {
const text = await res.text().catch(() => '')
console.warn('[HydrusClient] searchFiles Response Error', { status: res.status, statusText: res.statusText, body: text })
throw new Error(`Search failed (${res.status})${text ? ': ' + (text.length > 1000 ? text.slice(0, 1000) + '...' : text) : ''} (request: ${url})`)
}
const data = await res.json()
if (Array.isArray(data)) return data as number[]
if (data && Array.isArray((data as any).file_ids)) return (data as any).file_ids
if (data && Array.isArray((data as any).results)) return (data as any).results
return []
}
getFileUrl(fileId: number, includeApiKeyInQuery = true) {
return this.buildApiUrl('/get_files/file', { file_id: fileId }, includeApiKeyInQuery)
}
getThumbnailUrl(fileId: number, includeApiKeyInQuery = true) {
return this.buildApiUrl('/get_files/thumbnail', { file_id: fileId }, includeApiKeyInQuery)
}
async testConnectivity(): Promise<ConnectivityResult> {
try {
// 1) Try a simple GET search with a small limit
const searchUrl = this.buildApiUrl('/get_files/search_files', {
tags: JSON.stringify([this.normalizeSystemPredicate('system:limit=1')]),
return_file_ids: 'true'
}, this.cfg.forceApiKeyInQuery ?? false)
const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false))
let res = await fetch(searchUrl, { method: 'GET', headers, mode: 'cors' })
if ((res.status === 401 || res.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) {
// Auth required; report it
return { ok: false, message: `Authentication required (status ${res.status})`, status: res.status }
}
if (res.status === 404) {
const text = await res.text().catch(() => '')
return { ok: false, message: `Search endpoint not found (404): ${text ? text : 'No response body'}`, status: 404 }
}
if (!res.ok) return { ok: false, message: `Search request failed (status ${res.status})`, status: res.status }
const json = await res.json()
const fileId = Array.isArray(json) && json.length > 0 ? json[0] : json?.file_ids?.[0] ?? null
const result: ConnectivityResult = { ok: true, message: 'Connected (search OK)', status: res.status, searchOk: true }
// 2) If we have a file, test range requests for streaming/seek
if (fileId) {
try {
// Try with header first
const fileUrl = `${this.baseUrl()}/get_files/file?file_id=${fileId}`
const headers2: Record<string, string> = {}
if (this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) headers2['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey
headers2['Range'] = 'bytes=0-0'
const rres = await fetch(fileUrl, { method: 'GET', headers: headers2, mode: 'cors' })
if (rres.status === 206 || (rres.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') {
result.rangeSupported = true
result.message += '; Range requests supported'
return result
}
// Fallback: if header approach didn't yield 206, try query-param API key (useful for HTML audio tags)
if (this.cfg.apiKey) {
const qUrl = `${this.getFileUrl(fileId, true)}`
const rres2 = await fetch(qUrl, { method: 'GET', headers: { Range: 'bytes=0-0' }, mode: 'cors' })
if (rres2.status === 206 || (rres2.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') {
result.rangeSupported = true
result.message += '; Range requests supported (via query param)'
return result
}
}
result.rangeSupported = false
result.message += '; Range request test failed (no 206)'
} catch (e: any) {
result.rangeSupported = false
result.message += `; Range test error: ${e?.message ?? String(e)}`
}
} else {
result.message += '; No files to test Range support'
}
return result
} catch (err: any) {
const msg = err?.message ?? String(err)
// A TypeError commonly indicates network or CORS blocks in the browser
if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('TypeError')) {
return { ok: false, message: `Network or CORS error: ${msg}` }
}
return { ok: false, message: `Error: ${msg}` }
}
}
async getFileTags(fileId: number, signal?: AbortSignal): Promise<string[]> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return []
return this.extractTagsFromMetadata(data, fileId)
}
async getFileMediaInfo(fileId: number, signal?: AbortSignal): Promise<HydrusMediaInfo> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return {}
return this.extractMediaInfoFromMetadata(data, fileId)
}
async getFileDetails(fileId: number, signal?: AbortSignal): Promise<HydrusFileDetails> {
const data = await this.getFileMetadataPayload(fileId, signal)
if (!data) return { fileId, tags: [] }
return this.extractFileDetailsFromMetadata(data, fileId)
}
async getFilesTags(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise<Record<number, string[]>> {
const out: Record<number, string[]> = {}
if (!fileIds || fileIds.length === 0) return out
let idx = 0
const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => {
while (true) {
if (signal?.aborted) throw createAbortError()
const i = idx
if (i >= fileIds.length) break
idx++
const fid = fileIds[i]
try {
out[fid] = await this.getFileTags(fid, signal)
} catch (error: any) {
if (error?.name === 'AbortError') throw error
out[fid] = []
}
}
})
await Promise.all(workers)
return out
}
async getFilesMediaInfo(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise<Record<number, HydrusMediaInfo>> {
const out: Record<number, HydrusMediaInfo> = {}
if (!fileIds || fileIds.length === 0) return out
const uniqueFileIds = Array.from(new Set(fileIds.filter((fileId) => Number.isFinite(fileId))))
const batchSize = 128
const batches: number[][] = []
for (let index = 0; index < uniqueFileIds.length; index += batchSize) {
batches.push(uniqueFileIds.slice(index, index + batchSize))
}
let idx = 0
const workers = new Array(Math.min(concurrency, batches.length)).fill(null).map(async () => {
while (true) {
if (signal?.aborted) throw createAbortError()
const i = idx
if (i >= batches.length) break
idx++
const batch = batches[i]
try {
const entries = await this.getFilesMetadataPayload(batch, signal)
const entryMap = new Map<number, any>()
for (const entry of entries) {
const entryFileId = Number(entry?.file_id)
if (Number.isFinite(entryFileId)) entryMap.set(entryFileId, entry)
}
for (const fid of batch) {
const entry = entryMap.get(fid)
out[fid] = entry ? this.extractMediaInfoFromMetadata(entry, fid) : {}
}
} catch (error: any) {
if (error?.name === 'AbortError') throw error
for (const fid of batch) out[fid] = {}
}
}
})
await Promise.all(workers)
return out
}
}
export function extractTitleFromTags(tags: string[] | null | undefined): string | null {
if (!tags || tags.length === 0) return null
const candidates: string[] = tags.filter((t) => /^title:/i.test(t))
if (candidates.length === 0) return null
const values = candidates
.map((t) => {
const m = t.match(/^title:(.*)$/i)
return m ? m[1].replace(/_/g, ' ').trim() : ''
})
.filter(Boolean)
if (values.length === 0) return null
// prefer the longest (most descriptive) title
values.sort((a, b) => b.length - a.length)
return values[0]
}