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[] } 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 = {}) { this.cfg = { id: (cfg.id as string) || makeId(), name: cfg.name || '', host: cfg.host || '', port: cfg.port, apiKey: 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 = {} 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 = {}, 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() 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 { // 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 { 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 = {} 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 { const data = await this.getFileMetadataPayload(fileId, signal) if (!data) return [] return this.extractTagsFromMetadata(data, fileId) } async getFileMediaInfo(fileId: number, signal?: AbortSignal): Promise { const data = await this.getFileMetadataPayload(fileId, signal) if (!data) return {} return this.extractMediaInfoFromMetadata(data, fileId) } async getFileDetails(fileId: number, signal?: AbortSignal): Promise { 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> { const out: Record = {} 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> { const out: Record = {} 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() 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] }