Initial Arcana FX soundboard

This commit is contained in:
Hermes Agent
2026-05-20 23:51:18 +02:00
commit 232e6923e3
19 changed files with 2520 additions and 0 deletions

188
src/filestash.js Normal file
View File

@@ -0,0 +1,188 @@
'use strict';
/**
* Filestash client. Holds a single proof cookie obtained by POSTing to the
* share proof endpoint, refreshes it on auth-style failures, and exposes
* directory listing plus a streaming download for the media proxy.
*/
class FilestashClient {
constructor({ baseUrl, shareId, fetchImpl = globalThis.fetch }) {
if (!baseUrl || !shareId) {
throw new Error('FilestashClient requires baseUrl and shareId');
}
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.shareId = shareId;
this.fetch = fetchImpl;
this.proofCookie = null;
this.basePath = '/';
this.proofPromise = null;
}
_commonHeaders(extra = {}) {
const headers = {
'X-Requested-With': 'XmlHttpRequest',
'Accept': 'application/json',
...extra,
};
if (this.proofCookie) {
headers['Cookie'] = this.proofCookie;
}
return headers;
}
/**
* Parse Set-Cookie header(s) and keep cookies relevant to this share.
* Returns a single "name=value; name2=value2" string suitable for Cookie header.
*/
_captureCookies(response) {
// Node fetch exposes raw Set-Cookie via headers.getSetCookie() (Node 20+).
let raws = [];
if (typeof response.headers.getSetCookie === 'function') {
raws = response.headers.getSetCookie();
} else {
const single = response.headers.get('set-cookie');
if (single) raws = [single];
}
if (!raws.length) return;
const existing = new Map();
if (this.proofCookie) {
for (const pair of this.proofCookie.split(/;\s*/)) {
const eq = pair.indexOf('=');
if (eq > 0) existing.set(pair.slice(0, eq), pair.slice(eq + 1));
}
}
for (const raw of raws) {
const firstSemi = raw.indexOf(';');
const pair = firstSemi === -1 ? raw : raw.slice(0, firstSemi);
const eq = pair.indexOf('=');
if (eq <= 0) continue;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (!name) continue;
existing.set(name, value);
}
this.proofCookie = [...existing.entries()]
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
async _doProof() {
const url = `${this.baseUrl}/api/share/${encodeURIComponent(this.shareId)}/proof`;
const res = await this.fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XmlHttpRequest',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await safeText(res);
throw new Error(`Filestash proof failed: ${res.status} ${text}`);
}
this._captureCookies(res);
const body = await res.json().catch(() => ({}));
const result = body && body.result;
if (result && typeof result.path === 'string' && result.path.length) {
this.basePath = result.path;
}
return { basePath: this.basePath };
}
async ensureProof() {
if (this.proofCookie) return;
if (!this.proofPromise) {
this.proofPromise = this._doProof().finally(() => {
this.proofPromise = null;
});
}
await this.proofPromise;
}
_resetProof() {
this.proofCookie = null;
}
async _request(buildRequest) {
await this.ensureProof();
let res = await buildRequest();
if (res.status === 401 || res.status === 403) {
this._resetProof();
await this.ensureProof();
res = await buildRequest();
}
return res;
}
/**
* Lists a directory. `relPath` is share-relative — "/" is the share root,
* a sub-path like "/Ambiences/" lists that folder. The absolute filesystem
* path reported by /proof (basePath) is metadata only and is NOT passed
* to ls/cat — the Filestash share API rewrites paths internally.
* Returns array of entries { name, type: 'file'|'directory', size, time }.
*/
async listDir(relPath = '/') {
const sharePath = normalizeSharePath(relPath);
const url = new URL(`${this.baseUrl}/api/files/ls`);
url.searchParams.set('path', sharePath);
url.searchParams.set('share', this.shareId);
const res = await this._request(() =>
this.fetch(url.toString(), { method: 'GET', headers: this._commonHeaders() })
);
if (!res.ok) {
const text = await safeText(res);
throw new Error(`Filestash ls failed: ${res.status} ${text} (${sharePath})`);
}
const body = await res.json();
const results = (body && body.results) || [];
return results.map((entry) => ({
name: String(entry.name || ''),
type: entry.type === 'directory' ? 'directory' : 'file',
size: typeof entry.size === 'number' ? entry.size : null,
time: typeof entry.time === 'number' ? entry.time : null,
}));
}
/**
* Streams a single file by its share-relative path (must start with "/").
* Returns the underlying fetch Response so the caller can pipe headers + body.
*/
async streamFile(relPath, { range } = {}) {
const sharePath = normalizeSharePath(relPath);
const url = new URL(`${this.baseUrl}/api/files/cat`);
url.searchParams.set('path', sharePath);
url.searchParams.set('share', this.shareId);
const headers = this._commonHeaders();
if (range) headers['Range'] = range;
const res = await this._request(() =>
this.fetch(url.toString(), { method: 'GET', headers })
);
return res;
}
}
function normalizeSharePath(p) {
if (!p || p === '/') return '/';
let s = String(p);
if (!s.startsWith('/')) s = `/${s}`;
// Collapse any accidental double slashes.
s = s.replace(/\/{2,}/g, '/');
return s;
}
async function safeText(res) {
try {
return (await res.text()).slice(0, 200);
} catch {
return '';
}
}
module.exports = { FilestashClient };