first commit
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
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
|
||||
}
|
||||
|
||||
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<ServerConfig> = {}) {
|
||||
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<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 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
|
||||
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.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)
|
||||
|
||||
for (const candidate of mimeCandidates) {
|
||||
const mimeType = this.normalizeMimeType(candidate)
|
||||
if (mimeType) {
|
||||
return {
|
||||
mimeType,
|
||||
isVideo: mimeType.startsWith('video/') || mimeType === 'application/vnd.apple.mpegurl'
|
||||
}
|
||||
}
|
||||
const lower = candidate.toLowerCase()
|
||||
if (lower.includes('video')) return { isVideo: true }
|
||||
if (lower.includes('audio')) return { isVideo: false }
|
||||
}
|
||||
|
||||
if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return { isVideo: true }
|
||||
if (hasDuration) return { isVideo: false }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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.getFileMediaInfo(fid, signal)
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') throw error
|
||||
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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user