sounds, better files

This commit is contained in:
2024-08-18 19:11:41 +00:00
parent 9ed2f70701
commit 08489ed67b
86 changed files with 4226 additions and 86 deletions

View File

@@ -0,0 +1,100 @@
import { extensionMimeType } from "../FileMeta/ExtensionMimeType";
import { CachePolicy, FsCache } from "./FsCache";
import { VirtualDirectory, VirtualFile, FileSystemSource, ListDirOptions } from "./types";
export type FileStashFsConfig = {
name: string;
domain: string;
share: string,
key: string;
}
interface LsResult {
results: Array<{
name: string,
size: number,
time: number,
type: 'file' | 'directory',
}>
status: "ok"
}
export class FileStashFs implements FileSystemSource<FileStashFsConfig> {
private fsCache: FsCache;
constructor(private options: FileStashFsConfig) {
this.fsCache = new FsCache(options.name);
}
get name() {
return this.options.name;
}
get root(): VirtualDirectory {
return {
type: 'directory',
source: this.options.name,
path: [],
name: this.options.name
}
}
private createUrl(type: 'cat' | 'ls', path: string[]) {
const url = new URL(this.options.domain);
url.pathname = `/api/files/${type}`;
const params = new URLSearchParams();
params.set('share', this.options.share);
params.set('key', this.options.key);
params.set('path', `/${path.join('/')}`);
url.search = params.toString();
return url;
}
async listDir(dir: VirtualDirectory, {cache = CachePolicy.useNetwork}: ListDirOptions = {}): Promise<(VirtualDirectory|VirtualFile)[]> {
const request = new Request(this.createUrl('ls', dir.path));
const response = await this.fsCache.get(request, cache);
if (!response.ok) {
throw new Error(`Failed to list directory ${dir.path.join('/')}`);
}
const data = await response.json() as LsResult;
if (data.status !== 'ok') {
throw new Error(`Failed to list directory ${dir.path.join('/')}`);
}
return data.results.map((result) => {
if (result.type === 'file') {
return <VirtualFile>{
type: 'file',
source: this.options.name,
name: result.name,
mimeType: extensionMimeType(result.name),
path: [...dir.path, result.name],
}
} else if (result.type === 'directory') {
return {
type: 'directory',
source: this.options.name,
name: result.name,
path: [...dir.path, result.name],
}
} else {
throw new Error(`Unknown type ${result.type}`);
}
});
}
async download(file: VirtualFile): Promise<File> {
const response = await fetch(this.createUrl('cat', file.path));
if (!response.ok) {
throw new Error(`Failed to download file ${file.path.join('/')}`);
}
return new File([await response.blob()], file.name);
}
getConfig() {
return {
...this.options,
type: 'filestash',
} as const;
}
}

View File

@@ -0,0 +1,52 @@
export const CachePolicy = {
useCache: 'use-cache',
useNetwork: 'use-network',
onlyCache: 'only-cache',
} as const;
export type CachePolicy = typeof CachePolicy[keyof typeof CachePolicy];
export class CacheError extends Error {}
export class NoCacheEntryError extends CacheError {}
export class FsCache {
private _cache?: Cache;
constructor(public readonly name: string) {}
public async cache() {
if (!this._cache) {
this._cache = await caches.open(this.name);
}
return this._cache;
}
public async fetch(request: RequestInfo): Promise<Response> {
const response = await fetch(request);
if (response.ok) {
const cache = await this.cache()
cache.put(request, response.clone());
}
return response;
}
public async get(request: RequestInfo, policy: CachePolicy): Promise<Response> {
if (policy === CachePolicy.useNetwork) {
return this.fetch(request);
}
const cache = await this.cache();
const response = await cache.match(request);
if (response && policy === CachePolicy.useCache) {
return response;
} else if (policy === CachePolicy.onlyCache) {
if (!response) {
throw new NoCacheEntryError('No cache entry found');
}
return response;
} else {
return this.fetch(request);
}
}
}

View File

@@ -0,0 +1,129 @@
import { FileSystemSource, ListDirOptions, VirtualDirectory, VirtualFile } from "./types";
import { NginxFS } from "./NginxFS";
import { FileStashFs } from "./FileStashFs";
import { CachePolicy, NoCacheEntryError } from "./FsCache";
type Config<T> = T extends FileSystemSource<any> ? ReturnType<T['getConfig']> : never;
const CACHE = Symbol('Cache');
export interface Fs {
get root(): VirtualDirectory[];
download(file: VirtualFile): Promise<File>;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
}
export class MountedFs implements Fs {
private filesystems: Map<string, FileSystemSource<any>> = new Map();
public readonly name: string = 'root';
constructor(filesystems: (Config<NginxFS> | Config<FileStashFs>)[]) {
for (const fsOptions of filesystems) {
if (fsOptions.type === 'nginx') {
this.filesystems.set(fsOptions.name, new NginxFS(fsOptions));
} else if (fsOptions.type === 'filestash') {
this.filesystems.set(fsOptions.name, new FileStashFs(fsOptions));
}
}
}
get root() {
return [...this.filesystems.values()].map(fs => fs.root);
}
download(file: VirtualFile) {
const fs = this.filesystems.get(file.source);
if (!fs) {
throw new Error(`Filesystem ${file.source} not found`);
}
return fs.download(file);
}
async listDir(dir: VirtualDirectory, options?: ListDirOptions) {
const fs = this.filesystems.get(dir.source);
if (!fs) {
throw new Error(`Filesystem ${dir.source} not found`);
}
const result = await fs.listDir(dir, options);
return result;
}
async *search(query: string, cancelToken?: AbortSignal) {
query = query.toLocaleLowerCase();
for (const fs of this.filesystems.values()) {
const dirQueue = [fs.root];
while (dirQueue.length > 0) {
const dir = dirQueue.shift()!;
try {
const children = await this.listDir(dir, {cache: CachePolicy.onlyCache});
for (const child of children) {
if (cancelToken?.aborted) {
return;
}
if (child.type === 'directory') {
dirQueue.push(child);
} else if (child.name.toLocaleLowerCase().includes(query)) {
yield child;
}
}
} catch (err) {
if (!(err instanceof NoCacheEntryError)) {
console.warn(`Failed to list directory ${dir.path.join('/')}`, err);
}
continue;
}
}
}
}
async *buildIndex() {
let counter = 0;
for (const fs of this.filesystems.values()) {
const dirQueue = [fs.root];
while (dirQueue.length > 0) {
const dir = dirQueue.shift()!;
for (const child of await this.listDir(dir)) {
if (child.type === 'directory') {
dirQueue.push(child);
}
counter++;
}
yield counter
}
}
}
}
// export class SearchableFilesystem implements Fs {
// public allFiles: VirtualFile[] = [];
// constructor(private fs: MountedFs) {}
// async precacheDir(dir: VirtualDirectory) {
// for (const child of await this.fs.listDir(dir, {cache: CachePolicy.useCache})) {
// if (child.type === 'file') {
// this.allFiles.push(child);
// } else {
// await this.precacheDir(child as VirtualDirectory);
// }
// }
// }
// async search(query: string) {
// await Promise.all(this.fs.root.map((root) => this.precacheDir(root)));
// }
// get root() {
// return this.fs.root;
// }
// async listDir(dir: VirtualDirectory) {
// return this.fs.listDir(dir);
// }
// async download(file: VirtualFile) {
// return this.fs.download(file);
// }
// }

View File

@@ -0,0 +1,102 @@
import { extensionMimeType } from "../FileMeta/ExtensionMimeType";
import { FileSystemSource, VirtualFile, VirtualDirectory } from "./types";
export type NginxFsConfig = {
name: string;
rootUrl: string;
auth?: {
username: string;
password: string;
};
}
type DateString = string & {};
interface NginxFile {
name: string;
type: 'file'
mtime: DateString;
size: number;
}
interface NginxDirectory {
name: string;
type: 'directory';
mtime: DateString;
}
export class NginxFS implements FileSystemSource<NginxFsConfig> {
constructor(private options: NginxFsConfig) {}
get name() {
return this.options.name;
}
get root():VirtualDirectory {
return {
type: 'directory',
source: this.options.name,
path: [],
name: this.options.name
}
}
private get sanitizedRootUrl() {
return this.options.rootUrl.endsWith("/") ? this.options.rootUrl : this.options.rootUrl + "/";
}
private get authHeaders(): {Authorization: string}|{} {
return this.options.auth ? {
Authorization: `Basic ${btoa(`${this.options.auth.username}:${this.options.auth.password}`)}`
} : {}
}
async listDir(dir: VirtualDirectory): Promise<(VirtualDirectory|VirtualFile)[]> {
const response = await fetch(`${this.sanitizedRootUrl}${dir.path.map((x) => `${x}/`).join("")}`, {
headers: {
...this.authHeaders,
}
});
if (!response.ok) {
console.error("Failed to list directory", response);
return [];
}
const data = await response.json() as Array<NginxFile | NginxDirectory>;
return data.map((item) => {
if (item.type === "directory") {
return {
type: "directory",
source: this.options.name,
path: [...dir.path, item.name],
name: item.name
}
} else {
return {
type: "file",
source: this.options.name,
path: [...dir.path, item.name],
name: item.name,
mimeType: extensionMimeType(item.name),
}
}
});
}
async download(file: VirtualFile): Promise<File> {
const response = await fetch(`${this.sanitizedRootUrl}${file.path.join("/")}`);
if (!response.ok) {
throw new Error("Failed to download file");
}
return new File([await response.blob()], file.name, { type: file.mimeType });
}
getConfig() {
return {
...this.options,
type: "nginx",
} as const;
}
}

View File

@@ -0,0 +1,27 @@
import {CachePolicy} from './FsCache';
export interface FileSystemSource<TOptions> {
name: string;
root: VirtualDirectory;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
download(file: VirtualFile): Promise<File>;
getConfig(): TOptions & { type: string };
}
export interface ListDirOptions {
cache?: CachePolicy
}
export interface VirtualDirectory {
type: 'directory';
source: string;
path: string[];
name: string;
}
export interface VirtualFile {
type: 'file';
source: string;
path: string[];
name: string;
mimeType: string;
}