feat: add sound scanning and normalization features to SettingsPage; implement UI components and state management

This commit is contained in:
JSC
2025-08-10 09:48:04 +02:00
parent f2772c392c
commit 0c1c420fd8
2 changed files with 286 additions and 5 deletions

View File

@@ -23,6 +23,36 @@ export interface MessageResponse {
message: string message: string
} }
export interface ScanResults {
added: number
updated: number
deleted: number
skipped: number
errors: string[]
files_added: string[]
files_updated: string[]
files_deleted: string[]
files_skipped: string[]
}
export interface NormalizationResults {
processed: number
normalized: number
skipped: number
errors: number
error_details: string[]
}
export interface ScanResponse {
message: string
results: ScanResults
}
export interface NormalizationResponse {
message: string
results: NormalizationResults
}
export class AdminService { export class AdminService {
async listUsers(limit = 100, offset = 0): Promise<User[]> { async listUsers(limit = 100, offset = 0): Promise<User[]> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, { return apiClient.get<User[]>(`/api/v1/admin/users/`, {
@@ -49,6 +79,33 @@ export class AdminService {
async listPlans(): Promise<Plan[]> { async listPlans(): Promise<Plan[]> {
return apiClient.get<Plan[]>(`/api/v1/admin/users/plans/list`) return apiClient.get<Plan[]>(`/api/v1/admin/users/plans/list`)
} }
// Sound Management
async scanSounds(): Promise<ScanResponse> {
return apiClient.post<ScanResponse>(`/api/v1/admin/sounds/scan`)
}
async normalizeAllSounds(force = false, onePass?: boolean): Promise<NormalizationResponse> {
const params = new URLSearchParams()
if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.toString()
const url = queryString ? `/api/v1/admin/sounds/normalize/all?${queryString}` : `/api/v1/admin/sounds/normalize/all`
return apiClient.post<NormalizationResponse>(url)
}
async normalizeSoundsByType(soundType: 'SDB' | 'TTS' | 'EXT', force = false, onePass?: boolean): Promise<NormalizationResponse> {
const params = new URLSearchParams()
if (force) params.append('force', 'true')
if (onePass !== undefined) params.append('one_pass', onePass.toString())
const queryString = params.toString()
const url = queryString ? `/api/v1/admin/sounds/normalize/type/${soundType}?${queryString}` : `/api/v1/admin/sounds/normalize/type/${soundType}`
return apiClient.post<NormalizationResponse>(url)
}
} }
export const adminService = new AdminService() export const adminService = new AdminService()

View File

@@ -1,6 +1,77 @@
import { useState } from 'react'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
import {
Scan,
Volume2,
Settings as SettingsIcon,
Loader2,
FolderSync,
AudioWaveform
} from 'lucide-react'
import { adminService, type ScanResponse, type NormalizationResponse } from '@/lib/api/services/admin'
export function SettingsPage() { export function SettingsPage() {
// Sound scanning state
const [scanningInProgress, setScanningInProgress] = useState(false)
const [lastScanResults, setLastScanResults] = useState<ScanResponse | null>(null)
// Sound normalization state
const [normalizationInProgress, setNormalizationInProgress] = useState(false)
const [normalizationOptions, setNormalizationOptions] = useState({
force: false,
onePass: false,
soundType: 'all' as 'all' | 'SDB' | 'TTS' | 'EXT'
})
const [lastNormalizationResults, setLastNormalizationResults] = useState<NormalizationResponse | null>(null)
const handleScanSounds = async () => {
setScanningInProgress(true)
try {
const response = await adminService.scanSounds()
setLastScanResults(response)
toast.success(`Sound scan completed! Added: ${response.results.added}, Updated: ${response.results.updated}, Deleted: ${response.results.deleted}`)
} catch (error) {
toast.error('Failed to scan sounds')
console.error('Sound scan error:', error)
} finally {
setScanningInProgress(false)
}
}
const handleNormalizeSounds = async () => {
setNormalizationInProgress(true)
try {
let response: NormalizationResponse
if (normalizationOptions.soundType === 'all') {
response = await adminService.normalizeAllSounds(
normalizationOptions.force,
normalizationOptions.onePass
)
} else {
response = await adminService.normalizeSoundsByType(
normalizationOptions.soundType,
normalizationOptions.force,
normalizationOptions.onePass
)
}
setLastNormalizationResults(response)
toast.success(`Sound normalization completed! Processed: ${response.results.processed}, Normalized: ${response.results.normalized}`)
} catch (error) {
toast.error('Failed to normalize sounds')
console.error('Sound normalization error:', error)
} finally {
setNormalizationInProgress(false)
}
}
return ( return (
<AppLayout <AppLayout
breadcrumb={{ breadcrumb={{
@@ -11,11 +82,164 @@ export function SettingsPage() {
] ]
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 space-y-6">
<h1 className="text-2xl font-bold mb-4">System Settings</h1> <div className="flex justify-between items-center">
<p className="text-muted-foreground"> <h1 className="text-3xl font-bold">System Settings</h1>
System administration interface coming soon... <SettingsIcon className="h-8 w-8" />
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Sound Scanning */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderSync className="h-5 w-5" />
Sound Scanning
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Scan the sound directories to synchronize new, updated, and deleted audio files with the database.
</p> </p>
<Button
onClick={handleScanSounds}
disabled={scanningInProgress}
className="w-full"
>
{scanningInProgress ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Scanning...
</>
) : (
<>
<Scan className="h-4 w-4 mr-2" />
Scan Sound Directory
</>
)}
</Button>
{lastScanResults && (
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium">Last Scan Results:</div>
<div className="text-xs text-muted-foreground space-y-1">
<div> Added: {lastScanResults.results.added}</div>
<div>🔄 Updated: {lastScanResults.results.updated}</div>
<div>🗑 Deleted: {lastScanResults.results.deleted}</div>
<div> Skipped: {lastScanResults.results.skipped}</div>
{lastScanResults.results.errors.length > 0 && (
<div> Errors: {lastScanResults.results.errors.length}</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Sound Normalization */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AudioWaveform className="h-5 w-5" />
Sound Normalization
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Normalize audio levels across all sounds using FFmpeg's loudnorm filter for consistent volume.
</p>
<div className="space-y-3">
<div className="space-y-2">
<Label>Sound Type</Label>
<Select
value={normalizationOptions.soundType}
onValueChange={(value: 'all' | 'SDB' | 'TTS' | 'EXT') =>
setNormalizationOptions(prev => ({ ...prev, soundType: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sounds</SelectItem>
<SelectItem value="SDB">Soundboard (SDB)</SelectItem>
<SelectItem value="TTS">Text-to-Speech (TTS)</SelectItem>
<SelectItem value="EXT">Extracted (EXT)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="force-normalize"
checked={normalizationOptions.force}
onCheckedChange={(checked) =>
setNormalizationOptions(prev => ({ ...prev, force: !!checked }))
}
/>
<Label htmlFor="force-normalize" className="text-sm">
Force re-normalization of already processed sounds
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="one-pass"
checked={normalizationOptions.onePass}
onCheckedChange={(checked) =>
setNormalizationOptions(prev => ({ ...prev, onePass: !!checked }))
}
/>
<Label htmlFor="one-pass" className="text-sm">
Use one-pass normalization (faster, lower quality)
</Label>
</div>
</div>
<Button
onClick={handleNormalizeSounds}
disabled={normalizationInProgress}
className="w-full"
>
{normalizationInProgress ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Normalizing...
</>
) : (
<>
<Volume2 className="h-4 w-4 mr-2" />
Normalize Sounds
</>
)}
</Button>
{lastNormalizationResults && (
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium">Last Normalization Results:</div>
<div className="text-xs text-muted-foreground space-y-1">
<div>🔄 Processed: {lastNormalizationResults.results.processed}</div>
<div> Normalized: {lastNormalizationResults.results.normalized}</div>
<div> Skipped: {lastNormalizationResults.results.skipped}</div>
<div> Errors: {lastNormalizationResults.results.errors}</div>
{lastNormalizationResults.results.error_details.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-red-600">View Error Details</summary>
<div className="mt-1 text-xs text-red-600 space-y-1">
{lastNormalizationResults.results.error_details.map((error, index) => (
<div key={index}> {error}</div>
))}
</div>
</details>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
</AppLayout> </AppLayout>
) )