761 lines
27 KiB
TypeScript
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]
|
|
}
|
|
|