2026-03-26 03:26:37 -07:00
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
2026-04-22 18:23:16 -07:00
hasThumbnail? : boolean
2026-03-26 03:26:37 -07:00
}
export type HydrusFileDetails = HydrusMediaInfo & {
fileId : number
extension? : string
sizeBytes? : number
width? : number
height? : number
durationMs? : number
tags : string [ ]
}
2026-04-23 13:38:16 -07:00
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
}
2026-03-26 03:26:37 -07:00
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 ( ) ,
2026-04-23 13:38:16 -07:00
name : ( cfg . name || '' ) . trim ( ) ,
host : ( cfg . host || '' ) . trim ( ) ,
port : sanitizePort ( cfg . port ) ,
apiKey : sanitizeApiKey ( cfg . apiKey ) ,
2026-03-26 03:26:37 -07:00
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 )
}
2026-04-22 18:23:16 -07:00
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 [ ]
}
2026-03-26 03:26:37 -07:00
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
2026-04-22 18:23:16 -07:00
let hasThumbnail = false
2026-03-26 03:26:37 -07:00
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 )
2026-04-22 18:23:16 -07:00
else if ( ( lowerKey === 'thumbnail_width' || lowerKey === 'thumbnail_height' ) && value > 0 ) hasThumbnail = true
2026-03-26 03:26:37 -07:00
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 )
2026-04-22 18:23:16 -07:00
const withThumbnail = ( info : HydrusMediaInfo ) : HydrusMediaInfo = > hasThumbnail ? { . . . info , hasThumbnail : true } : info
2026-03-26 03:26:37 -07:00
for ( const candidate of mimeCandidates ) {
const mimeType = this . normalizeMimeType ( candidate )
if ( mimeType ) {
2026-04-22 18:23:16 -07:00
return withThumbnail ( {
2026-03-26 03:26:37 -07:00
mimeType ,
isVideo : mimeType.startsWith ( 'video/' ) || mimeType === 'application/vnd.apple.mpegurl'
2026-04-22 18:23:16 -07:00
} )
2026-03-26 03:26:37 -07:00
}
const lower = candidate . toLowerCase ( )
2026-04-22 18:23:16 -07:00
if ( lower . includes ( 'video' ) ) return withThumbnail ( { isVideo : true } )
if ( lower . includes ( 'audio' ) ) return withThumbnail ( { isVideo : false } )
2026-03-26 03:26:37 -07:00
}
2026-04-22 18:23:16 -07:00
if ( ( width > 0 || height > 0 ) && ( frameCount > 1 || hasDuration ) ) return withThumbnail ( { isVideo : true } )
if ( hasDuration ) return withThumbnail ( { isVideo : false } )
2026-03-26 03:26:37 -07:00
2026-04-22 18:23:16 -07:00
return withThumbnail ( { } )
2026-03-26 03:26:37 -07:00
}
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
2026-04-22 18:23:16 -07:00
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 ) )
}
2026-03-26 03:26:37 -07:00
let idx = 0
2026-04-22 18:23:16 -07:00
const workers = new Array ( Math . min ( concurrency , batches . length ) ) . fill ( null ) . map ( async ( ) = > {
2026-03-26 03:26:37 -07:00
while ( true ) {
if ( signal ? . aborted ) throw createAbortError ( )
const i = idx
2026-04-22 18:23:16 -07:00
if ( i >= batches . length ) break
2026-03-26 03:26:37 -07:00
idx ++
2026-04-22 18:23:16 -07:00
const batch = batches [ i ]
2026-03-26 03:26:37 -07:00
try {
2026-04-22 18:23:16 -07:00
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 ) : { }
}
2026-03-26 03:26:37 -07:00
} catch ( error : any ) {
if ( error ? . name === 'AbortError' ) throw error
2026-04-22 18:23:16 -07:00
for ( const fid of batch ) out [ fid ] = { }
2026-03-26 03:26:37 -07:00
}
}
} )
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 ]
}