Files
AudioSort/src/main/services/SearchService.ts

181 lines
5.0 KiB
TypeScript

import Fuse from 'fuse.js';
import { AudioFileSummary } from '../../shared/models';
import { DatabaseService } from './DatabaseService';
import { TagService } from './TagService';
/**
* Lightweight snapshot of metadata required for advanced search filters.
*/
interface CachedMetadata {
author?: string;
}
/**
* Represents a structured filter derived from the freeform query.
*/
interface AdvancedFilter {
field: 'author';
value: string;
}
/**
* Provides fuzzy search capabilities backed by Fuse.js and the database content.
*/
export class SearchService {
private fuse: Fuse<AudioFileSummary> | null = null;
private cache: AudioFileSummary[] = [];
private readonly metadataCache = new Map<number, CachedMetadata>();
public constructor(
private readonly database: DatabaseService,
private readonly tagService: TagService
) {}
/**
* Rebuilds the in-memory index from the current database contents.
*/
public rebuildIndex(): void {
this.cache = this.database.listFiles();
this.metadataCache.clear();
this.fuse = new Fuse(this.cache, {
includeScore: true,
threshold: 0.35,
keys: [
{ name: 'displayName', weight: 0.3 },
{ name: 'fileName', weight: 0.2 },
{ name: 'relativePath', weight: 0.2 },
{ name: 'tags', weight: 0.2 },
{ name: 'categories', weight: 0.1 }
]
});
}
/**
* Executes a fuzzy search query, falling back to the full collection when the input is empty.
*/
public search(query: string): AudioFileSummary[] {
const trimmed = query.trim();
if (trimmed.length === 0) {
return this.getAll();
}
const { filters, remainingQuery } = this.extractAdvancedFilters(trimmed);
let candidates = this.getAll();
if (filters.length > 0) {
candidates = candidates.filter((file) => this.matchesAdvancedFilters(file, filters));
}
if (remainingQuery.length === 0) {
return candidates;
}
const fuseInstance = filters.length > 0
? new Fuse(candidates, this.createFuseOptions())
: this.ensureFuse();
return fuseInstance
.search(remainingQuery)
.map((result: Fuse.FuseResult<AudioFileSummary>) => result.item)
.filter((file) => (filters.length === 0 ? true : this.matchesAdvancedFilters(file, filters)));
}
/**
* Provides the cached collection, lazily rebuilding the index on first access.
*/
public getAll(): AudioFileSummary[] {
if (this.cache.length === 0) {
this.rebuildIndex();
}
return this.cache;
}
/**
* Ensures a Fuse instance is available, recreating it when the cache is stale.
*/
private ensureFuse(): Fuse<AudioFileSummary> {
if (!this.fuse) {
this.rebuildIndex();
}
return this.fuse ?? new Fuse(this.getAll(), this.createFuseOptions());
}
/**
* Provides the standard Fuse configuration used by the service.
*/
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
return {
includeScore: true,
threshold: 0.35,
keys: [
{ name: 'displayName', weight: 0.3 },
{ name: 'fileName', weight: 0.2 },
{ name: 'relativePath', weight: 0.2 },
{ name: 'tags', weight: 0.2 },
{ name: 'categories', weight: 0.1 }
]
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
}
/**
* Pulls out author filters and returns the remaining free text query.
*/
private extractAdvancedFilters(query: string): { filters: AdvancedFilter[]; remainingQuery: string } {
const tokens = query.split(/\s+/);
const filters: AdvancedFilter[] = [];
const remainingTokens: string[] = [];
for (const token of tokens) {
const lower = token.toLowerCase();
if (lower.startsWith('author:')) {
const value = token.slice('author:'.length).trim();
if (value.length > 0) {
filters.push({ field: 'author', value: value.toLowerCase() });
}
continue;
}
remainingTokens.push(token);
}
return {
filters,
remainingQuery: remainingTokens.join(' ').trim()
};
}
/**
* Evaluates whether a file satisfies all extracted advanced filters.
*/
private matchesAdvancedFilters(file: AudioFileSummary, filters: AdvancedFilter[]): boolean {
if (filters.length === 0) {
return true;
}
const metadata = this.getCachedMetadata(file);
return filters.every((filter) => {
const source = metadata.author;
if (!source) {
return false;
}
return source.toLowerCase().includes(filter.value);
});
}
/**
* Reads metadata once per file and caches it for reuse across searches in the same session.
*/
private getCachedMetadata(file: AudioFileSummary): CachedMetadata {
const cached = this.metadataCache.get(file.id);
if (cached) {
return cached;
}
const metadata = this.tagService.readMetadata(file.absolutePath);
const normalized: CachedMetadata = {
author: metadata.author?.trim() || undefined
};
this.metadataCache.set(file.id, normalized);
return normalized;
}
}