Nightly: Kategorien eingeführt Persistenz (state.json), API (CRUD + Bulk-Assign), Sounds-Filter unterstützt categoryId

This commit is contained in:
vibe-bot 2025-08-09 17:16:37 +02:00
parent b11e7dd666
commit 3d1a6ca60b
3 changed files with 131 additions and 7 deletions

View file

@ -44,8 +44,15 @@ if (!DISCORD_TOKEN) {
fs.mkdirSync(SOUNDS_DIR, { recursive: true });
// Persistente Lautstärke und Play-Zähler speichern
type PersistedState = { volumes: Record<string, number>; plays: Record<string, number>; totalPlays: number };
// Persistenter Zustand: Lautstärke/Plays + Kategorien
type Category = { id: string; name: string; color?: string; sort?: number };
type PersistedState = {
volumes: Record<string, number>;
plays: Record<string, number>;
totalPlays: number;
categories?: Category[];
fileCategories?: Record<string, string[]>; // relPath or fileName -> categoryIds[]
};
// Neuer, persistenter Speicherort direkt im Sounds-Volume
const STATE_FILE_NEW = path.join(SOUNDS_DIR, 'state.json');
// Alter Speicherort (eine Ebene über SOUNDS_DIR). Wird für Migration gelesen, falls vorhanden.
@ -57,13 +64,25 @@ function readPersistedState(): PersistedState {
if (fs.existsSync(STATE_FILE_NEW)) {
const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8');
const parsed = JSON.parse(raw);
return { volumes: parsed.volumes ?? {}, plays: parsed.plays ?? {}, totalPlays: parsed.totalPlays ?? 0 } as PersistedState;
return {
volumes: parsed.volumes ?? {},
plays: parsed.plays ?? {},
totalPlays: parsed.totalPlays ?? 0,
categories: Array.isArray(parsed.categories) ? parsed.categories : [],
fileCategories: parsed.fileCategories ?? {}
} as PersistedState;
}
// 2) Fallback: alten Speicherort lesen und sofort nach NEW migrieren
if (fs.existsSync(STATE_FILE_OLD)) {
const raw = fs.readFileSync(STATE_FILE_OLD, 'utf8');
const parsed = JSON.parse(raw);
const migrated: PersistedState = { volumes: parsed.volumes ?? {}, plays: parsed.plays ?? {}, totalPlays: parsed.totalPlays ?? 0 };
const migrated: PersistedState = {
volumes: parsed.volumes ?? {},
plays: parsed.plays ?? {},
totalPlays: parsed.totalPlays ?? 0,
categories: Array.isArray(parsed.categories) ? parsed.categories : [],
fileCategories: parsed.fileCategories ?? {}
};
try {
fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true });
fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8');
@ -333,7 +352,7 @@ app.use(express.json());
app.use(cors());
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0 });
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length });
});
// --- Admin Auth ---
@ -397,6 +416,7 @@ app.get('/api/admin/status', (req: Request, res: Response) => {
app.get('/api/sounds', (req: Request, res: Response) => {
const q = String(req.query.q ?? '').toLowerCase();
const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__';
const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined;
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
const rootFiles = rootEntries
@ -472,7 +492,16 @@ app.get('/api/sounds', (req: Request, res: Response) => {
...folders
];
// isRecent-Flag für UI (Top 5 der neuesten)
// Kategorie-Filter (virtuell) anwenden, wenn gesetzt
let result = filteredItems;
if (categoryFilter) {
const fc = persistedState.fileCategories ?? {};
result = result.filter((it) => {
const key = it.relativePath ?? it.fileName;
const cats = fc[key] ?? [];
return cats.includes(categoryFilter);
});
}
if (folderFilter === '__top3__') {
const keys = new Set(top3.map(t => t.key.split(':')[1]));
result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName));
@ -483,7 +512,7 @@ app.get('/api/sounds', (req: Request, res: Response) => {
isRecent: recentTop5Set.has(it.relativePath ?? it.fileName)
}));
res.json({ items: withRecentFlag, total, folders: foldersOut });
res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} });
});
// --- Admin: Bulk-Delete ---
@ -528,6 +557,71 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response)
}
});
// --- Kategorien API ---
app.get('/api/categories', (_req: Request, res: Response) => {
res.json({ categories: persistedState.categories ?? [] });
});
app.post('/api/categories', requireAdmin, (req: Request, res: Response) => {
const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number };
const n = (name || '').trim();
if (!n) return res.status(400).json({ error: 'name erforderlich' });
const id = crypto.randomUUID();
const cat = { id, name: n, color, sort };
persistedState.categories = [...(persistedState.categories ?? []), cat];
writePersistedState(persistedState);
res.json({ ok: true, category: cat });
});
app.patch('/api/categories/:id', requireAdmin, (req: Request, res: Response) => {
const { id } = req.params;
const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number };
const cats = persistedState.categories ?? [];
const idx = cats.findIndex(c => c.id === id);
if (idx === -1) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
const updated = { ...cats[idx] } as any;
if (typeof name === 'string') updated.name = name;
if (typeof color === 'string') updated.color = color;
if (typeof sort === 'number') updated.sort = sort;
cats[idx] = updated;
persistedState.categories = cats;
writePersistedState(persistedState);
res.json({ ok: true, category: updated });
});
app.delete('/api/categories/:id', requireAdmin, (req: Request, res: Response) => {
const { id } = req.params;
const cats = persistedState.categories ?? [];
if (!cats.find(c => c.id === id)) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
persistedState.categories = cats.filter(c => c.id !== id);
// Zuordnungen entfernen
const fc = persistedState.fileCategories ?? {};
for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== id);
persistedState.fileCategories = fc;
writePersistedState(persistedState);
res.json({ ok: true });
});
// Bulk-Assign/Remove Kategorien zu Dateien
app.post('/api/categories/assign', requireAdmin, (req: Request, res: Response) => {
const { files, add, remove } = req.body as { files?: string[]; add?: string[]; remove?: string[] };
if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] erforderlich' });
const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
const toAdd = (add ?? []).filter(id => validCats.has(id));
const toRemove = (remove ?? []).filter(id => validCats.has(id));
const fc = persistedState.fileCategories ?? {};
for (const rel of files) {
const key = rel;
const old = new Set(fc[key] ?? []);
for (const a of toAdd) old.add(a);
for (const r of toRemove) old.delete(r);
fc[key] = Array.from(old);
}
persistedState.fileCategories = fc;
writePersistedState(persistedState);
res.json({ ok: true, fileCategories: fc });
});
app.get('/api/channels', (_req: Request, res: Response) => {
if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' });

View file

@ -2,15 +2,41 @@ import type { Sound, SoundsResponse, VoiceChannelInfo } from './types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
export async function fetchSounds(q?: string, folderKey?: string): Promise<SoundsResponse> {
export async function fetchSounds(q?: string, folderKey?: string, categoryId?: string): Promise<SoundsResponse> {
const url = new URL(`${API_BASE}/sounds`, window.location.origin);
if (q) url.searchParams.set('q', q);
if (folderKey !== undefined) url.searchParams.set('folder', folderKey);
if (categoryId) url.searchParams.set('categoryId', categoryId);
const res = await fetch(url.toString());
if (!res.ok) throw new Error('Fehler beim Laden der Sounds');
return res.json();
}
// Kategorien
export async function fetchCategories() {
const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' });
if (!res.ok) throw new Error('Fehler beim Laden der Kategorien');
return res.json();
}
export async function createCategory(name: string, color?: string) {
const res = await fetch(`${API_BASE}/categories`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ name, color })
});
if (!res.ok) throw new Error('Kategorie anlegen fehlgeschlagen');
return res.json();
}
export async function assignCategories(files: string[], add: string[], remove: string[] = []) {
const res = await fetch(`${API_BASE}/categories/assign`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ files, add, remove })
});
if (!res.ok) throw new Error('Zuordnung fehlgeschlagen');
return res.json();
}
export async function fetchChannels(): Promise<VoiceChannelInfo[]> {
const res = await fetch(`${API_BASE}/channels`);
if (!res.ok) throw new Error('Fehler beim Laden der Channels');

View file

@ -10,6 +10,8 @@ export type SoundsResponse = {
items: Sound[];
total: number;
folders: Array<{ key: string; name: string; count: number }>;
categories?: Category[];
fileCategories?: Record<string, string[]>;
};
export type VoiceChannelInfo = {
@ -19,6 +21,8 @@ export type VoiceChannelInfo = {
channelName: string;
};
export type Category = { id: string; name: string; color?: string; sort?: number };