feat: analysis providers, settings UI, song search, WAV duration fix

- Multi-provider AI analysis (Anthropic, OpenAI, Ollama, Algorithmic)
- server-only guards on all provider files; client bundle fix
- /settings page with provider status, Ollama model picker, preferences
- Song search box on /analyze replacing raw MBID input (debounced, keyboard nav)
- Auto-register song via MusicBrainz on POST /api/tracks (no more 404)
- Fix WAV duration bug: last section songEnd was double-counting elapsed time
- Registry sync comment updated for self-hosted HTTPS git servers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AJ Avezzano
2026-04-03 18:46:17 -04:00
parent 51f67f0aeb
commit 8b9d72bc9d
22 changed files with 1803 additions and 293 deletions

123
app/(web)/settings/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import type { ProviderInfo } from "@/lib/analysis/providers";
import ProviderStatus from "@/components/settings/ProviderStatus";
import OllamaModelPicker from "@/components/settings/OllamaModelPicker";
import PreferencesPanel from "@/components/settings/PreferencesPanel";
const PROVIDER_KEY = "clicktrack_analysis_provider";
const MODEL_KEY = "clicktrack_ollama_model";
interface ProvidersResponse {
providers: ProviderInfo[];
ollamaModels: string[];
}
export default function SettingsPage() {
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [defaultProvider, setDefaultProvider] = useState<string>("");
const [selectedOllamaModel, setSelectedOllamaModel] = useState<string>("");
const fetchProviders = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const res = await fetch("/api/analyze/providers");
const data = await res.json() as ProvidersResponse;
setProviders(data.providers);
setOllamaModels(data.ollamaModels);
// Initialise model selection
if (data.ollamaModels.length > 0) {
const saved = localStorage.getItem(MODEL_KEY);
if (saved && data.ollamaModels.includes(saved)) {
setSelectedOllamaModel(saved);
} else {
setSelectedOllamaModel(data.ollamaModels[0]);
}
}
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
const saved = localStorage.getItem(PROVIDER_KEY);
if (saved) setDefaultProvider(saved);
fetchProviders();
}, [fetchProviders]);
function handleSetDefault(id: string) {
setDefaultProvider(id);
localStorage.setItem(PROVIDER_KEY, id);
}
function handleModelChange(model: string) {
setSelectedOllamaModel(model);
localStorage.setItem(MODEL_KEY, model);
}
const ollamaProvider = providers.find((p) => p.id === "ollama");
const ollamaAvailable = ollamaProvider?.available === true;
if (loading) {
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-zinc-500">Loading</p>
</div>
);
}
return (
<div className="space-y-10 max-w-2xl">
<h1 className="text-2xl font-bold">Settings</h1>
{/* Analysis Providers */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Analysis Providers</h2>
<div className="space-y-3">
{providers.map((provider) => (
<ProviderStatus
key={provider.id}
provider={provider}
selectedOllamaModel={selectedOllamaModel}
isDefault={defaultProvider === provider.id}
onSetDefault={handleSetDefault}
/>
))}
</div>
</section>
{/* Ollama Models */}
{ollamaAvailable && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Ollama Models</h2>
<OllamaModelPicker
models={ollamaModels}
value={selectedOllamaModel}
onChange={handleModelChange}
onRefresh={() => fetchProviders(true)}
refreshing={refreshing}
/>
{ollamaProvider?.ollamaBaseUrl && (
<p className="text-xs text-zinc-600">
Base URL (operator-configured):{" "}
<code className="text-zinc-500">{ollamaProvider.ollamaBaseUrl}</code>
</p>
)}
</section>
)}
{/* Preferences */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Preferences</h2>
<PreferencesPanel />
</section>
</div>
);
}