diff --git a/app/src/components/History/ClipFolderTree.tsx b/app/src/components/History/ClipFolderTree.tsx new file mode 100644 index 000000000..26cd25158 --- /dev/null +++ b/app/src/components/History/ClipFolderTree.tsx @@ -0,0 +1,318 @@ +import { + ChevronDown, + ChevronRight, + FolderPlus, + Inbox, + Layers, + MoreHorizontal, + Pencil, + Trash2, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import type { FolderResponse } from '@/lib/api/types'; +import { + useCreateFolder, + useDeleteFolder, + useDetachFolder, + useFolders, + useUpdateFolder, +} from '@/lib/hooks/useFolders'; +import { cn } from '@/lib/utils/cn'; +import { useUIStore } from '@/stores/uiStore'; + +/** + * What the clip list is currently filtered to. + * + * `uncategorised` is its own selection rather than a null folderId, because + * "no filter" and "clips in no folder" are different requests — the server + * distinguishes them too. + */ +export type ClipFolderSelection = + | { kind: 'all' } + | { kind: 'uncategorised' } + | { kind: 'folder'; folderId: string }; + +interface ClipFolderTreeProps { + selection: ClipFolderSelection; + onSelect: (selection: ClipFolderSelection) => void; +} + +/** A folder plus its children, built once per folder list change. */ +interface TreeNode { + folder: FolderResponse; + children: TreeNode[]; +} + +function buildTree(folders: FolderResponse[]): TreeNode[] { + const nodes = new Map(); + for (const folder of folders) nodes.set(folder.id, { folder, children: [] }); + + const roots: TreeNode[] = []; + for (const node of nodes.values()) { + const parentId = node.folder.parent_id; + // A parent that isn't in the list (deleted concurrently) would otherwise + // make the node unreachable — surface it at the root instead. + const parent = parentId ? nodes.get(parentId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; +} + +export function ClipFolderTree({ selection, onSelect }: ClipFolderTreeProps) { + const { t } = useTranslation(); + const { data: folders } = useFolders('generation'); + + const collapsedIds = useUIStore((state) => state.collapsedFolderIds.generation); + const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); + + const createFolder = useCreateFolder('generation'); + const updateFolder = useUpdateFolder('generation'); + const detachFolder = useDetachFolder('generation'); + const deleteFolder = useDeleteFolder('generation'); + + const [dialog, setDialog] = useState< + | { mode: 'create'; parentId: string | null } + | { mode: 'rename'; folder: FolderResponse } + | { mode: 'delete'; folder: FolderResponse } + | null + >(null); + const [draftName, setDraftName] = useState(''); + + const tree = useMemo(() => buildTree(folders ?? []), [folders]); + + const openCreate = (parentId: string | null) => { + setDraftName(''); + setDialog({ mode: 'create', parentId }); + }; + + const submitDialog = () => { + const trimmed = draftName.trim(); + if (!dialog) return; + + if (dialog.mode === 'create' && trimmed) { + createFolder.mutate({ name: trimmed, parentId: dialog.parentId }); + } else if (dialog.mode === 'rename' && trimmed && trimmed !== dialog.folder.name) { + updateFolder.mutate({ folderId: dialog.folder.id, data: { name: trimmed } }); + } + setDialog(null); + }; + + const renderNode = (node: TreeNode, depth: number) => { + const { folder, children } = node; + const collapsed = collapsedIds.includes(folder.id); + const isSelected = selection.kind === 'folder' && selection.folderId === folder.id; + const Chevron = collapsed ? ChevronRight : ChevronDown; + + return ( +
+
+ {children.length > 0 ? ( + + ) : ( + // Keeps leaf labels aligned with their expandable siblings. + + )} + + + + + + + + + openCreate(folder.id)}> + + {t('folders.clip.newSubfolder')} + + { + setDraftName(folder.name); + setDialog({ mode: 'rename', folder }); + }} + > + + {t('folders.rename')} + + {folder.parent_id && ( + detachFolder.mutate(folder.id)}> + + {t('folders.clip.moveToRoot')} + + )} + + setDialog({ mode: 'delete', folder })} + > + + {t('folders.delete')} + + + +
+ + {!collapsed && children.map((child) => renderNode(child, depth + 1))} +
+ ); + }; + + return ( +
+
+ + {t('folders.clip.filterTitle')} + + +
+ + + + {tree.map((node) => renderNode(node, 0))} + + + + setDialog(null)} + > + + + + {dialog?.mode === 'rename' + ? t('folders.renameDialog.title') + : t('folders.newDialog.title')} + + + setDraftName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitDialog(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + + + setDialog(null)}> + + + {t('folders.deleteDialog.title')} + + {dialog?.mode === 'delete' && + t('folders.deleteDialog.body', { name: dialog.folder.name })} + + + + + + + + +
+ ); +} diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index aeeae4ece..595f4f43f 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -4,6 +4,7 @@ import { AudioLines, Download, FileArchive, + FolderInput, Loader2, MoreHorizontal, Play, @@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { AudioBars } from '@/components/AudioBars'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; +import { type ClipFolderSelection, ClipFolderTree } from '@/components/History/ClipFolderTree'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -31,6 +33,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -45,6 +52,7 @@ import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { useFolders, useSetGenerationFolder } from '@/lib/hooks/useFolders'; import { useClearFailedGenerations, useDeleteGeneration, @@ -86,6 +94,18 @@ export function HistoryTable() { const { toast } = useToast(); const queryClient = useQueryClient(); + const [folderSelection, setFolderSelection] = useState({ kind: 'all' }); + const { data: clipFolders } = useFolders('generation'); + const setGenerationFolder = useSetGenerationFolder(); + + // Invalidating the query is not enough on its own: allHistory accumulates + // pages, and past page 0 the refreshed first page is appended rather than + // replacing it — so a clip moved out of the current folder stays visible. + // Dropping back to page 0 makes the next response replace the list. + const moveToFolder = (generationId: string, folderId: string | null) => { + setGenerationFolder.mutate({ generationId, folderId }, { onSuccess: () => setPage(0) }); + }; + const { data: historyData, isLoading, @@ -93,6 +113,8 @@ export function HistoryTable() { } = useHistory({ limit, offset: page * limit, + folder_id: folderSelection.kind === 'folder' ? folderSelection.folderId : undefined, + uncategorised_only: folderSelection.kind === 'uncategorised' || undefined, }); const deleteGeneration = useDeleteGeneration(); @@ -144,6 +166,15 @@ export function HistoryTable() { } }, [historyData, page]); + // Changing the folder filter changes what page 0 even means, so the + // accumulated pages have to be dropped — otherwise clips from the previous + // filter stay on screen underneath the new results. + // biome-ignore lint/correctness/useExhaustiveDependencies: folderSelection is the trigger, not a value the effect reads + useEffect(() => { + setPage(0); + setAllHistory([]); + }, [folderSelection]); + // Reset to page 0 when deletions, imports, or generation completions occur const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size); const prevPendingCountRef = useRef(pendingCount); @@ -430,9 +461,13 @@ export function HistoryTable() { return (
+ {/* Rendered outside the empty-state branch below: filtering to an empty + folder must not remove the only control that can clear the filter. */} + + {history.length === 0 ? (
- {t('history.empty')} + {folderSelection.kind === 'all' ? t('history.empty') : t('folders.clip.emptyFilter')}
) : ( <> @@ -678,6 +713,39 @@ export function HistoryTable() { {t('history.actions.regenerate')} + + + + {t('folders.clip.moveTo')} + + + {t('folders.clip.label')} + + + moveToFolder(gen.id, null) + } + > + {t('folders.uncategorised')} + + {(clipFolders ?? []).map((folder) => ( + + moveToFolder(gen.id, folder.id) + } + > + {folder.name} + + ))} + {(clipFolders ?? []).length === 0 && ( + {t('folders.none')} + )} + + + handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx new file mode 100644 index 000000000..31ba98827 --- /dev/null +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -0,0 +1,166 @@ +import { ChevronDown, ChevronRight, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; + +interface FolderSectionProps { + /** Null renders the Uncategorised bucket, which has no menu and no id. */ + folderId: string | null; + name: string; + count: number; + collapsed: boolean; + onToggle: () => void; + onRename?: (name: string) => void; + onDelete?: () => void; + children: React.ReactNode; +} + +/** + * A collapsible group header with its members underneath. + * + * The delete copy is explicit that only the folder goes — the server + * releases members to Uncategorised rather than cascading, and a header + * that just says "Delete" over a group of voices reads far more alarming + * than what actually happens. + */ +export function FolderSection({ + folderId, + name, + count, + collapsed, + onToggle, + onRename, + onDelete, + children, +}: FolderSectionProps) { + const { t } = useTranslation(); + const [renameOpen, setRenameOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [draftName, setDraftName] = useState(name); + + const Chevron = collapsed ? ChevronRight : ChevronDown; + + const submitRename = () => { + const trimmed = draftName.trim(); + if (trimmed && trimmed !== name) onRename?.(trimmed); + setRenameOpen(false); + }; + + return ( +
+
+ + + {folderId && ( + + + + + + { + setDraftName(name); + setRenameOpen(true); + }} + > + + {t('folders.rename')} + + + setDeleteOpen(true)} + > + + {t('folders.delete')} + + + + )} +
+ + {!collapsed &&
{children}
} + + + + + {t('folders.renameDialog.title')} + + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename(); + }} + aria-label={t('folders.renameDialog.title')} + /> + + + + + + + + + + + {t('folders.deleteDialog.title')} + {t('folders.deleteDialog.body', { name })} + + + + + + + +
+ ); +} diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e9042a571..7c905a081 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,4 +1,4 @@ -import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react'; +import { Copy, Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; @@ -14,7 +14,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import type { VoiceProfileResponse } from '@/lib/api/types'; -import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; import { cn } from '@/lib/utils/cn'; import { useUIStore } from '@/stores/uiStore'; @@ -35,6 +35,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { const deleteProfile = useDeleteProfile(); const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedProfileId = useUIStore((state) => state.selectedProfileId); @@ -126,11 +127,18 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { {profile.effects_chain && profile.effects_chain.length > 0 && ( )} - {profile.personality?.trim() && ( - - )} + {profile.personality?.trim() && }
+ { + e.stopPropagation(); + duplicateProfile.mutate({ profileId: profile.id }); + }} + disabled={duplicateProfile.isPending} + aria-label={t('profiles.card.duplicate')} + /> state.setProfileDialogOpen); const selectedEngine = useUIStore((state) => state.selectedEngine); const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const viewMode = useUIStore((state) => state.voiceViewMode); + const setViewMode = useUIStore((state) => state.setVoiceViewMode); + const collapsedIds = useUIStore((state) => state.collapsedFolderIds.voice); + const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); + + const createFolder = useCreateFolder('voice'); + const updateFolder = useUpdateFolder('voice'); + const deleteFolder = useDeleteFolder('voice'); + + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const cardRefs = useRef>(new Map()); // Scroll to the selected profile after engine/sort changes + // biome-ignore lint/correctness/useExhaustiveDependencies: selectedEngine reorders the list, so it must re-trigger the scroll even though the effect never reads it useEffect(() => { if (!selectedProfileId) return; let timeoutId: ReturnType | null = null; @@ -40,6 +76,42 @@ export function ProfileList() { }; }, [selectedProfileId, selectedEngine]); + const allProfiles = useMemo(() => profiles || [], [profiles]); + const voiceFolders = useMemo(() => folders || [], [folders]); + const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + + /** Whether a profile is supported by the currently selected engine. */ + const isSupported = useMemo( + () => (p: VoiceProfileResponse) => + isPresetEngine + ? p.voice_type === 'preset' && p.preset_engine === selectedEngine + : p.voice_type !== 'preset', + [isPresetEngine, selectedEngine], + ); + + // Sort so supported profiles come first, then bucket by folder. Sorting + // before grouping keeps the supported-first ordering inside each folder. + const grouped = useMemo(() => { + const sorted = [...allProfiles].sort( + (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), + ); + + const buckets = new Map(); + buckets.set(UNCATEGORISED, []); + for (const folder of voiceFolders) buckets.set(folder.id, []); + + for (const profile of sorted) { + // A folder_id can outlive its folder if another window deleted it + // between renders — fall back rather than dropping the voice. + const key = + profile.folder_id && buckets.has(profile.folder_id) ? profile.folder_id : UNCATEGORISED; + buckets.get(key)?.push(profile); + } + return buckets; + }, [allProfiles, voiceFolders, isSupported]); + + const hasUnsupported = allProfiles.some((p) => !isSupported(p)); + if (isLoading) { return null; } @@ -54,21 +126,52 @@ export function ProfileList() { ); } - const allProfiles = profiles || []; - const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + const renderProfiles = (items: VoiceProfileResponse[]) => { + if (items.length === 0) { + return ( +

{t('folders.emptyFolder')}

+ ); + } - /** Whether a profile is supported by the currently selected engine. */ - const isSupported = (p: (typeof allProfiles)[number]) => - isPresetEngine - ? p.voice_type === 'preset' && p.preset_engine === selectedEngine - : p.voice_type !== 'preset'; - - // Sort so supported profiles come first - const sortedProfiles = [...allProfiles].sort( - (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), - ); + return ( +
+ {items.map((profile) => ( +
{ + if (el) cardRefs.current.set(profile.id, el); + else cardRefs.current.delete(profile.id); + }} + > + {viewMode === 'card' ? ( + + ) : ( + + )} +
+ ))} +
+ ); + }; - const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); + const submitNewFolder = () => { + const trimmed = newFolderName.trim(); + if (!trimmed) return; + createFolder.mutate({ name: trimmed }); + setNewFolderName(''); + setNewFolderOpen(false); + }; return (
@@ -85,21 +188,67 @@ export function ProfileList() { ) : ( -
- {sortedProfiles.map((profile) => ( -
{ - if (el) cardRefs.current.set(profile.id, el); - else cardRefs.current.delete(profile.id); - }} +
+
+
+ + {t('folders.new')} + + +
+ + {voiceFolders.map((folder) => ( + toggleCollapsed('voice', folder.id)} + onRename={(name) => updateFolder.mutate({ folderId: folder.id, data: { name } })} + onDelete={() => deleteFolder.mutate(folder.id)} + > + {renderProfiles(grouped.get(folder.id) ?? [])} + ))} + + {/* Only worth a header once folders exist to contrast it with. */} + {voiceFolders.length > 0 ? ( + toggleCollapsed('voice', UNCATEGORISED)} + > + {renderProfiles(grouped.get(UNCATEGORISED) ?? [])} + + ) : ( + renderProfiles(grouped.get(UNCATEGORISED) ?? []) + )} + {hasUnsupported && ( -
+
{t('profiles.list.unsupportedNote')}
@@ -108,6 +257,31 @@ export function ProfileList() { )}
+ + + + {t('folders.newDialog.title')} + + setNewFolderName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitNewFolder(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + +
); diff --git a/app/src/components/VoiceProfiles/ProfileRow.tsx b/app/src/components/VoiceProfiles/ProfileRow.tsx new file mode 100644 index 000000000..268ad25cc --- /dev/null +++ b/app/src/components/VoiceProfiles/ProfileRow.tsx @@ -0,0 +1,274 @@ +import { + Copy, + Download, + Edit, + FolderInput, + MoreHorizontal, + Sparkles, + Trash2, + Wand2, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useToast } from '@/components/ui/use-toast'; +import type { FolderResponse, VoiceProfileResponse } from '@/lib/api/types'; +import { useSetProfileFolder } from '@/lib/hooks/useFolders'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; +import { useUIStore } from '@/stores/uiStore'; + +/** Human-readable display names for preset engine badges. */ +const ENGINE_DISPLAY_NAMES: Record = { + kokoro: 'Kokoro', + qwen_custom_voice: 'CustomVoice', +}; + +interface ProfileRowProps { + profile: VoiceProfileResponse; + /** Not usable by the selected engine — dimmed but still selectable. */ + disabled?: boolean; + /** Voice folders, for the "Move to" submenu. */ + folders: FolderResponse[]; +} + +/** + * One voice as a two-line row: name, language and trait icons on the first + * line, description on the second. + * + * Row actions live behind a menu rather than always-visible buttons — + * at list density a row is ~48px tall, too tight for four icon buttons + * without crowding the text. + */ +export function ProfileRow({ profile, disabled, folders }: ProfileRowProps) { + const { t } = useTranslation(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { toast } = useToast(); + + const deleteProfile = useDeleteProfile(); + const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); + const setProfileFolder = useSetProfileFolder(); + + const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); + const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); + + const isSelected = selectedProfileId === profile.id; + + const handleSelect = () => { + // Re-selecting a disabled voice re-fires the selection so the generate + // form can surface its unsupported-engine hint again. + if (disabled && isSelected) { + setSelectedProfileId(null); + setTimeout(() => setSelectedProfileId(profile.id), 0); + return; + } + setSelectedProfileId(isSelected ? null : profile.id); + }; + + const handleDuplicate = () => { + duplicateProfile.mutate( + { profileId: profile.id }, + { + onSuccess: (copy) => { + toast({ + title: t('profiles.duplicate.successTitle'), + description: t('profiles.duplicate.successDescription', { name: copy.name }), + }); + }, + onError: (error) => { + toast({ + title: t('profiles.duplicate.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + + const selectLabel = t( + isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel', + { name: profile.name, language: profile.language }, + ); + + return ( + <> + {/* The row is a plain container rather than role="button": the actions + menu is itself a button, and nesting interactive elements is invalid. + The selectable area is a real + + + + + + e.stopPropagation()}> + { + setEditingProfileId(profile.id); + setProfileDialogOpen(true); + }} + > + + {t('profiles.card.edit')} + + + + {t('profiles.card.duplicate')} + + + + + + {t('profiles.row.moveTo')} + + + {t('folders.voice.label')} + + setProfileFolder.mutate({ profileId: profile.id, folderId: null })} + > + {t('folders.uncategorised')} + + {folders.map((folder) => ( + + setProfileFolder.mutate({ profileId: profile.id, folderId: folder.id }) + } + > + {folder.name} + + ))} + {folders.length === 0 && ( + {t('folders.none')} + )} + + + + + exportProfile.mutate(profile.id)} + disabled={exportProfile.isPending} + > + + {t('profiles.card.export')} + + + setDeleteDialogOpen(true)} + > + + {t('profiles.card.delete')} + + + +
+ + + + + {t('profiles.deleteDialog.title')} + + {t('profiles.deleteDialog.body', { name: profile.name })} + + + + + + + + + + ); +} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 7f96d9d05..be830ae55 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -9,7 +9,8 @@ "loading": "Loading…", "error": "Error", "unknown": "Unknown", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "create": "Create" }, "nav": { "generate": "Generate", @@ -399,14 +400,28 @@ "export": "Export profile", "edit": "Edit profile", "delete": "Delete profile", + "duplicate": "Duplicate profile", "selectLabel": "{{name}}, {{language}}. Select as voice for generation.", "selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation." }, + "row": { + "actions": "Actions for {{name}}", + "moveTo": "Move to folder", + "hasEffects": "Has an effects chain", + "hasPersonality": "Has a personality" + }, "list": { "errorLoading": "Error loading profiles: {{message}}", "empty": "No voice profiles yet. Create your first profile to get started.", "createVoice": "Create Voice", - "unsupportedNote": "Only supported voice profiles can be selected for the current model." + "unsupportedNote": "Only supported voice profiles can be selected for the current model.", + "showCards": "Show voices as cards", + "showList": "Show voices as a list" + }, + "duplicate": { + "successTitle": "Voice duplicated", + "successDescription": "Created \"{{name}}\" with the same samples, personality and effects.", + "failedTitle": "Could not duplicate voice" }, "deleteDialog": { "title": "Delete Profile", @@ -414,6 +429,39 @@ "deleting": "Deleting…" } }, + "folders": { + "new": "New folder", + "none": "No folders yet", + "uncategorised": "Uncategorised", + "emptyFolder": "Empty", + "rename": "Rename", + "delete": "Delete folder", + "actions": "Actions for folder {{name}}", + "voice": { + "label": "Voice folders" + }, + "clip": { + "label": "Clip folders", + "newSubfolder": "New subfolder", + "moveTo": "Move to folder", + "moveToRoot": "Move to top level", + "filterTitle": "Folders", + "allClips": "All clips", + "emptyFilter": "No clips in this folder yet." + }, + "newDialog": { + "title": "New folder", + "placeholder": "Folder name" + }, + "renameDialog": { + "title": "Rename folder" + }, + "deleteDialog": { + "title": "Delete folder", + "body": "Delete \"{{name}}\"? Everything inside it is kept — items move to Uncategorised and any subfolders move up one level.", + "confirm": "Delete folder" + } + }, "effects": { "title": "Effects", "newPreset": "New Preset", diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index f89a17a3f..442ad8214 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -8,6 +8,10 @@ import type { EffectConfig, EffectPresetCreate, EffectPresetResponse, + FolderCreate, + FolderKind, + FolderResponse, + FolderUpdate, GenerationRequest, GenerationResponse, GenerationVersionResponse, @@ -134,6 +138,76 @@ class ApiClient { }); } + /** + * Copy a voice, including its samples, avatar, personality and effects. + * Not an export/import round-trip — that transfer format drops everything + * except name, description and language. + */ + async duplicateProfile(profileId: string, name?: string): Promise { + return this.request(`/profiles/${profileId}/duplicate`, { + method: 'POST', + body: JSON.stringify(name ? { name } : {}), + }); + } + + // ── Folders ──────────────────────────────────────────────────────── + + async listFolders(kind: FolderKind): Promise { + return this.request(`/folders?kind=${kind}`); + } + + async createFolder(data: FolderCreate): Promise { + return this.request('/folders', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateFolder(folderId: string, data: FolderUpdate): Promise { + return this.request(`/folders/${folderId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + /** Move a folder back to the root. Separate from updateFolder because a + * null parent_id there is indistinguishable from an omitted field. */ + async detachFolder(folderId: string): Promise { + return this.request(`/folders/${folderId}/detach`, { + method: 'POST', + }); + } + + /** Deletes the folder only — members become uncategorised and child + * folders rise to this folder's parent. */ + async deleteFolder(folderId: string): Promise { + await this.request(`/folders/${folderId}`, { + method: 'DELETE', + }); + } + + /** Pass null to move the voice out of any folder. */ + async setProfileFolder( + profileId: string, + folderId: string | null, + ): Promise { + return this.request(`/profiles/${profileId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + + /** Pass null to move the clip out of any folder. */ + async setGenerationFolder( + generationId: string, + folderId: string | null, + ): Promise<{ id: string; folder_id: string | null }> { + return this.request(`/history/${generationId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + // ── Personality-driven text generation ───────────────────────────── // Compose produces a fresh in-character utterance the UI drops into // the generate textarea. Rewrite now happens server-side inside @@ -301,6 +375,10 @@ class ApiClient { const params = new URLSearchParams(); if (query?.profile_id) params.append('profile_id', query.profile_id); if (query?.search) params.append('search', query.search); + if (query?.folder_id) params.append('folder_id', query.folder_id); + if (query?.uncategorised_only) params.append('uncategorised_only', 'true'); + // Server-side default is true, so only the opt-out needs sending. + if (query?.include_subfolders === false) params.append('include_subfolders', 'false'); if (query?.limit) params.append('limit', query.limit.toString()); if (query?.offset) params.append('offset', query.offset.toString()); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index d360ed1c4..8115e10c4 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -29,12 +29,42 @@ export interface VoiceProfileResponse { design_prompt?: string; default_engine?: string; personality?: string | null; + /** null / undefined means the voice sits in the Uncategorised bucket. */ + folder_id?: string | null; generation_count: number; sample_count: number; created_at: string; updated_at: string; } +/** What a folder groups. Voice folders are flat; clip folders nest. */ +export type FolderKind = 'voice' | 'generation'; + +export interface FolderResponse { + id: string; + name: string; + kind: FolderKind; + /** Always null for voice folders. */ + parent_id?: string | null; + position: number; + /** Direct members only — a parent does not count its children's items. */ + item_count: number; + created_at: string; + updated_at: string; +} + +export interface FolderCreate { + name: string; + kind: FolderKind; + parent_id?: string | null; +} + +export interface FolderUpdate { + name?: string; + parent_id?: string; + position?: number; +} + /** Response returned by /profiles/{id}/compose. */ export interface PersonalityTextResponse { text: string; @@ -121,6 +151,15 @@ export interface GenerationResponse { export interface HistoryQuery { profile_id?: string; search?: string; + /** Show only this folder's clips. Ignored when uncategorised_only is set. */ + folder_id?: string; + /** + * Show only clips in no folder at all. Distinct from an absent folder_id, + * which means "no folder filter" rather than "the Uncategorised bucket". + */ + uncategorised_only?: boolean; + /** Whether folder_id also matches clips in that folder's descendants. */ + include_subfolders?: boolean; limit?: number; offset?: number; } @@ -129,6 +168,8 @@ export interface HistoryResponse extends GenerationResponse { profile_name: string; versions?: GenerationVersionResponse[]; active_version_id?: string; + /** null / undefined means the clip sits in the Uncategorised bucket. */ + folder_id?: string | null; } export interface HistoryListResponse { diff --git a/app/src/lib/hooks/useFolders.ts b/app/src/lib/hooks/useFolders.ts new file mode 100644 index 000000000..08e7281b5 --- /dev/null +++ b/app/src/lib/hooks/useFolders.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; +import type { FolderKind, FolderUpdate } from '@/lib/api/types'; + +/** + * Folders for voices and clips. + * + * Both kinds live in one table server-side, so every query is keyed by kind + * — otherwise the voice panel and the clip panel would evict each other's + * cache entry on every mutation. + */ + +export function useFolders(kind: FolderKind) { + return useQuery({ + queryKey: ['folders', kind], + queryFn: () => apiClient.listFolders(kind), + }); +} + +export function useCreateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, parentId }: { name: string; parentId?: string | null }) => + apiClient.createFolder({ name, kind, parent_id: parentId ?? null }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useUpdateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ folderId, data }: { folderId: string; data: FolderUpdate }) => + apiClient.updateFolder(folderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useDetachFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.detachFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Detaching moves a folder out of its parent, and member lists include + // subfolders by default — so anyone filtered by the former parent is + // still being shown the detached child's items. + queryClient.invalidateQueries({ + queryKey: [kind === 'voice' ? 'profiles' : 'history'], + }); + }, + }); +} + +export function useDeleteFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.deleteFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Deleting a folder releases its members, so whichever list holds them + // is now stale too. + queryClient.invalidateQueries({ + queryKey: [kind === 'voice' ? 'profiles' : 'history'], + }); + }, + }); +} + +export function useSetProfileFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, folderId }: { profileId: string; folderId: string | null }) => + apiClient.setProfileFolder(profileId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // item_count changes on both the old and new folder. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + +export function useSetGenerationFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ generationId, folderId }: { generationId: string; folderId: string | null }) => + apiClient.setGenerationFolder(generationId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }); + queryClient.invalidateQueries({ queryKey: ['folders', 'generation'] }); + }, + }); +} diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts index f05fd999a..74d8eb261 100644 --- a/app/src/lib/hooks/useProfiles.ts +++ b/app/src/lib/hooks/useProfiles.ts @@ -55,6 +55,25 @@ export function useDeleteProfile() { }); } +/** + * Copy a voice in one step. Unlike export-then-import this keeps the + * personality, effects chain, default engine and preset fields, and works + * for voices with no samples. + */ +export function useDuplicateProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, name }: { profileId: string; name?: string }) => + apiClient.duplicateProfile(profileId, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // The copy lands in the source's folder, changing that folder's count. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + export function useProfileSamples(profileId: string) { return useQuery({ queryKey: ['profiles', profileId, 'samples'], diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts index dfaf6d2ff..11926a05e 100644 --- a/app/src/stores/uiStore.ts +++ b/app/src/stores/uiStore.ts @@ -1,8 +1,12 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import type { FolderKind } from '@/lib/api/types'; export type Theme = 'light' | 'dark' | 'system'; +/** How the Generate tab lists voices. */ +export type VoiceViewMode = 'list' | 'card'; + function resolveTheme(theme: Theme): 'light' | 'dark' { if (theme !== 'system') return theme; if (typeof window === 'undefined') return 'dark'; @@ -58,6 +62,16 @@ interface UIStore { profileFormDraft: ProfileFormDraft | null; setProfileFormDraft: (draft: ProfileFormDraft | null) => void; + // How the Generate tab renders voices + voiceViewMode: VoiceViewMode; + setVoiceViewMode: (mode: VoiceViewMode) => void; + + // Collapsed folder ids, keyed by folder kind. Collapsed rather than + // expanded ids so a newly created folder starts open without having to + // touch this set. + collapsedFolderIds: Record; + toggleFolderCollapsed: (kind: FolderKind, folderId: string) => void; + // Theme theme: Theme; setTheme: (theme: Theme) => void; @@ -89,6 +103,19 @@ export const useUIStore = create()( profileFormDraft: null, setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), + voiceViewMode: 'list', + setVoiceViewMode: (mode) => set({ voiceViewMode: mode }), + + collapsedFolderIds: { voice: [], generation: [] }, + toggleFolderCollapsed: (kind, folderId) => + set((state) => { + const current = state.collapsedFolderIds[kind] ?? []; + const next = current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId]; + return { collapsedFolderIds: { ...state.collapsedFolderIds, [kind]: next } }; + }), + theme: 'system', setTheme: (theme) => { set({ theme }); @@ -100,9 +127,15 @@ export const useUIStore = create()( partialize: (state) => ({ selectedProfileId: state.selectedProfileId, theme: state.theme, + voiceViewMode: state.voiceViewMode, + collapsedFolderIds: state.collapsedFolderIds, }), onRehydrateStorage: () => (state) => { if (state) applyTheme(state.theme); + // Persisted before folders existed, so an older store has no map. + if (state && !state.collapsedFolderIds) { + state.collapsedFolderIds = { voice: [], generation: [] }; + } }, }, ), diff --git a/backend/config.py b/backend/config.py index cb6bc168c..0cb47caf3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -18,8 +18,10 @@ os.environ["HF_HUB_CACHE"] = _custom_models_dir logger.info("Model download path set to: %s", _custom_models_dir) -# Default data directory (used in development) -_data_dir = Path("data").resolve() +# Default data directory (used in development). VOICEBOX_DATA_DIR lets a bare +# `uvicorn backend.main:app` point at the packaged app's data dir without a CLI +# flag. The --data-dir argument still wins: it calls set_data_dir() after import. +_data_dir = Path(os.environ.get("VOICEBOX_DATA_DIR") or "data").resolve() def _path_relative_to_any_data_dir(path: Path) -> Path | None: diff --git a/backend/database/migrations.py b/backend/database/migrations.py index d353b58c8..149f5a7ca 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -43,6 +43,7 @@ def run_migrations(engine) -> None: _migrate_generation_versions(engine, inspector, tables) _migrate_capture_settings(engine, inspector, tables) _migrate_mcp_bindings(engine, inspector, tables) + _migrate_folders(engine, inspector, tables) _normalize_storage_paths(engine, tables) @@ -334,3 +335,24 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None: if total_fixed > 0: conn.commit() logger.info("Normalized %d stored file paths", total_fixed) + + +def _migrate_folders(engine, inspector, tables: set[str]) -> None: + """Add folder_id to profiles and generations. + + The ``folders`` table itself is left to ``Base.metadata.create_all()``, + which runs straight after migrations and creates missing tables. Only + the columns on pre-existing tables need adding by hand. + + Declared without a REFERENCES clause, matching story_items.version_id: + SQLite cannot add a column with a foreign key to a table that does not + exist yet, and on a fresh database ``folders`` is created after this + runs. The relationship is still declared on the ORM models. + """ + if "profiles" in tables: + if "folder_id" not in _get_columns(inspector, "profiles"): + _add_column(engine, "profiles", "folder_id VARCHAR", "folder_id") + + if "generations" in tables: + if "folder_id" not in _get_columns(inspector, "generations"): + _add_column(engine, "generations", "folder_id VARCHAR", "folder_id") diff --git a/backend/database/models.py b/backend/database/models.py index b85a55b17..fb31fe6bd 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -14,6 +14,31 @@ Base = declarative_base() +class Folder(Base): + """A user-created folder for organising voices or generated clips. + + One table serves both, discriminated by ``kind``: + - "voice" — groups profiles. Flat: parent_id is always NULL. + - "generation" — groups generations. Nests to arbitrary depth. + + The asymmetry is a product decision, not a schema limit — voices are a + small, stable set that reads better as one level, while clips accumulate + per project and need real hierarchy. Nesting is enforced in the routes + rather than here so the constraint can relax without a migration. + """ + + __tablename__ = "folders" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + kind = Column(String, nullable=False, default="voice") # "voice" | "generation" + parent_id = Column(String, ForeignKey("folders.id"), nullable=True) + # Manual ordering within a parent. Ties break by name in the routes. + position = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class VoiceProfile(Base): """Voice profile. @@ -44,6 +69,11 @@ class VoiceProfile(Base): # cloning metadata above). personality = Column(Text, nullable=True) + # NULL means "Uncategorised" — the absence of a folder, not a missing + # reference. Deleting a folder nulls this rather than cascading, so a + # folder is never a way to lose voices. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -82,6 +112,8 @@ class Generation(Base): # profile's personality LLM before TTS. Future sources (bulk import, # agent replies, etc.) can extend this. source = Column(String, nullable=False, default="manual") + # NULL means "Uncategorised". See VoiceProfile.folder_id. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/models.py b/backend/models.py index 7970ce41e..c9aaf321d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -2,7 +2,8 @@ Pydantic models for request/response validation. """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StringConstraints +from typing_extensions import Annotated from typing import Optional, List from datetime import datetime @@ -12,6 +13,71 @@ ) +FOLDER_KIND_PATTERN = "^(voice|generation)$" + +# Names arrive from text inputs, so a value of " " passes a raw +# min_length check and then stores as an empty label once stripped. +# Strip first, then length-check the result. +TrimmedName = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100) +] + + +class FolderCreate(BaseModel): + """Request model for creating a folder.""" + + name: TrimmedName + kind: str = Field(default="voice", pattern=FOLDER_KIND_PATTERN) + # Only meaningful for kind="generation"; voice folders are flat and the + # route rejects a non-null parent for them. + parent_id: Optional[str] = None + + +class FolderUpdate(BaseModel): + """Request model for renaming or reparenting a folder. + + Every field is optional so a rename doesn't have to restate the parent. + ``parent_id`` therefore can't distinguish "unset" from "move to root" — + use the dedicated move endpoint to detach a folder to the root. + """ + + name: Optional[TrimmedName] = None + parent_id: Optional[str] = None + position: Optional[int] = Field(None, ge=0) + + +class FolderResponse(BaseModel): + """Response model for a folder.""" + + id: str + name: str + kind: str + parent_id: Optional[str] = None + position: int = 0 + # Direct members only — a parent folder does not count its children's items. + item_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FolderAssign(BaseModel): + """Request model for moving an item into a folder (null = uncategorised).""" + + folder_id: Optional[str] = None + + +class ProfileDuplicateRequest(BaseModel): + """Optional overrides when duplicating a profile. + + Omit entirely to accept the default " (copy)" name. + """ + + name: Optional[TrimmedName] = None + + class VoiceProfileCreate(BaseModel): """Request model for creating a voice profile.""" @@ -26,6 +92,7 @@ class VoiceProfileCreate(BaseModel): design_prompt: Optional[str] = Field(None, max_length=2000) default_engine: Optional[str] = Field(None, max_length=50) personality: Optional[str] = Field(None, max_length=2000) + folder_id: Optional[str] = None class VoiceProfileResponse(BaseModel): @@ -43,6 +110,7 @@ class VoiceProfileResponse(BaseModel): design_prompt: Optional[str] = None default_engine: Optional[str] = None personality: Optional[str] = None + folder_id: Optional[str] = None generation_count: int = 0 sample_count: int = 0 created_at: datetime @@ -119,6 +187,7 @@ class GenerationResponse(BaseModel): error: Optional[str] = None is_favorited: bool = False source: str = "manual" + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None @@ -132,6 +201,14 @@ class HistoryQuery(BaseModel): profile_id: Optional[str] = None search: Optional[str] = None + # Folder filter. A plain absent/None folder_id means "no filter, show + # everything"; selecting the Uncategorised bucket is a distinct request + # that a nullable field can't express, hence the explicit flag below. + folder_id: Optional[str] = None + uncategorised_only: bool = False + # Whether folder_id also matches clips in that folder's descendants. + # Clip folders nest, so a parent should be able to show the whole subtree. + include_subfolders: bool = True limit: int = Field(default=50, ge=1, le=100) offset: int = Field(default=0, ge=0) @@ -153,6 +230,7 @@ class HistoryResponse(BaseModel): status: str = "completed" error: Optional[str] = None is_favorited: bool = False + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index 42999d2d1..dab0aa13a 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -25,9 +25,11 @@ def register_routers(app: FastAPI) -> None: from .mcp_bindings import router as mcp_bindings_router from .events import router as events_router from .cloud import router as cloud_router + from .folders import router as folders_router app.include_router(health_router) app.include_router(profiles_router) + app.include_router(folders_router) app.include_router(channels_router) app.include_router(generations_router) app.include_router(history_router) diff --git a/backend/routes/folders.py b/backend/routes/folders.py new file mode 100644 index 000000000..cb2978eb5 --- /dev/null +++ b/backend/routes/folders.py @@ -0,0 +1,266 @@ +"""REST endpoints for organising voices and generated clips into folders. + +One ``folders`` table backs both, discriminated by ``kind``: + + - ``voice`` — flat. A voice folder never has a parent, because the + voice list reads better as one level (see Folder in database/models.py). + - ``generation`` — nests to arbitrary depth, since clips accumulate per + project and need real hierarchy. + +Deleting a folder never deletes its contents. Members are moved to +Uncategorised and child folders are re-parented to the deleted folder's +parent, so a folder is only ever a view over items, never an owner of them. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db +from ..database.models import Folder, Generation, ProfileSample, VoiceProfile +from ..services.folders import folder_and_descendants +from ..services.profiles import _profile_to_response + +router = APIRouter() + +# Maps a folder kind to the table whose rows it groups. +_MEMBER_MODEL = { + "voice": VoiceProfile, + "generation": Generation, +} + + +def _get_folder_or_404(folder_id: str, db: Session) -> Folder: + folder = db.query(Folder).filter(Folder.id == folder_id).first() + if folder is None: + raise HTTPException(status_code=404, detail="Folder not found") + return folder + + +def _member_counts(kind: str, db: Session) -> dict[str, int]: + """Direct member count per folder id, for one kind. + + One grouped query rather than a count per folder — the folder list is + rendered on every voice/clip panel render. + """ + model = _MEMBER_MODEL[kind] + rows = ( + db.query(model.folder_id, func.count(model.id)) + .filter(model.folder_id.isnot(None)) + .group_by(model.folder_id) + .all() + ) + return {folder_id: count for folder_id, count in rows} + + +def _to_response(folder: Folder, counts: dict[str, int]) -> models.FolderResponse: + return models.FolderResponse( + id=folder.id, + name=folder.name, + kind=folder.kind, + parent_id=folder.parent_id, + position=folder.position, + item_count=counts.get(folder.id, 0), + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.get("/folders", response_model=list[models.FolderResponse]) +async def list_folders(kind: str = "voice", db: Session = Depends(get_db)): + """List folders of one kind, ordered for direct rendering.""" + if kind not in _MEMBER_MODEL: + raise HTTPException(status_code=400, detail=f"Unknown folder kind: {kind}") + + folders = ( + db.query(Folder) + .filter(Folder.kind == kind) + .order_by(Folder.position, Folder.name) + .all() + ) + counts = _member_counts(kind, db) + return [_to_response(f, counts) for f in folders] + + +@router.post("/folders", response_model=models.FolderResponse) +async def create_folder(data: models.FolderCreate, db: Session = Depends(get_db)): + """Create a folder. Voice folders must be top-level.""" + if data.parent_id is not None: + if data.kind == "voice": + raise HTTPException( + status_code=400, detail="Voice folders cannot be nested" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != data.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + + folder = Folder( + name=data.name.strip(), + kind=data.kind, + parent_id=data.parent_id, + position=_next_position(data.kind, data.parent_id, db), + ) + db.add(folder) + db.commit() + db.refresh(folder) + return _to_response(folder, {}) + + +@router.patch("/folders/{folder_id}", response_model=models.FolderResponse) +async def update_folder( + folder_id: str, + data: models.FolderUpdate, + db: Session = Depends(get_db), +): + """Rename, reposition, or reparent a folder.""" + folder = _get_folder_or_404(folder_id, db) + + if data.name is not None: + folder.name = data.name.strip() + + if data.position is not None: + folder.position = data.position + + if data.parent_id is not None: + if folder.kind == "voice": + raise HTTPException( + status_code=400, detail="Voice folders cannot be nested" + ) + if data.parent_id == folder_id: + raise HTTPException( + status_code=400, detail="A folder cannot be its own parent" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != folder.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + # Reparenting under your own descendant would detach the whole + # subtree from the root and make it unreachable in the tree UI. + if data.parent_id in folder_and_descendants(folder_id, db): + raise HTTPException( + status_code=400, detail="Cannot move a folder inside itself" + ) + folder.parent_id = data.parent_id + + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.post("/folders/{folder_id}/detach", response_model=models.FolderResponse) +async def detach_folder(folder_id: str, db: Session = Depends(get_db)): + """Move a folder back to the root. + + Separate from PATCH because FolderUpdate.parent_id=None is + indistinguishable from "field omitted". + """ + folder = _get_folder_or_404(folder_id, db) + folder.parent_id = None + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.delete("/folders/{folder_id}") +async def delete_folder(folder_id: str, db: Session = Depends(get_db)): + """Delete a folder, preserving everything inside it. + + Members become uncategorised; child folders rise to this folder's + parent. Nothing the user made is removed. + """ + folder = _get_folder_or_404(folder_id, db) + model = _MEMBER_MODEL[folder.kind] + + released = ( + db.query(model) + .filter(model.folder_id == folder_id) + .update({model.folder_id: None}, synchronize_session=False) + ) + reparented = ( + db.query(Folder) + .filter(Folder.parent_id == folder_id) + .update({Folder.parent_id: folder.parent_id}, synchronize_session=False) + ) + + db.delete(folder) + db.commit() + return { + "deleted": folder_id, + "items_released": released, + "folders_reparented": reparented, + } + + +def _next_position(kind: str, parent_id: str | None, db: Session) -> int: + """Append position — one past the highest sibling.""" + highest = ( + db.query(func.max(Folder.position)) + .filter(Folder.kind == kind, Folder.parent_id == parent_id) + .scalar() + ) + return 0 if highest is None else highest + 1 + + +# ── Membership ─────────────────────────────────────────────────────── + + +@router.put("/profiles/{profile_id}/folder", response_model=models.VoiceProfileResponse) +async def set_profile_folder( + profile_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a voice into a folder, or out of one when folder_id is null.""" + profile = db.query(VoiceProfile).filter(VoiceProfile.id == profile_id).first() + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "voice": + raise HTTPException( + status_code=400, detail="Target folder does not hold voices" + ) + + profile.folder_id = data.folder_id + db.commit() + db.refresh(profile) + + generation_count = ( + db.query(func.count(Generation.id)) + .filter(Generation.profile_id == profile.id) + .scalar() + ) + sample_count = ( + db.query(func.count(ProfileSample.id)) + .filter(ProfileSample.profile_id == profile.id) + .scalar() + ) + return _profile_to_response(profile, generation_count, sample_count) + + +@router.put("/history/{generation_id}/folder") +async def set_generation_folder( + generation_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a clip into a folder, or out of one when folder_id is null.""" + generation = db.query(Generation).filter(Generation.id == generation_id).first() + if generation is None: + raise HTTPException(status_code=404, detail="Generation not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "generation": + raise HTTPException( + status_code=400, detail="Target folder does not hold clips" + ) + + generation.folder_id = data.folder_id + db.commit() + return {"id": generation_id, "folder_id": generation.folder_id} diff --git a/backend/routes/history.py b/backend/routes/history.py index 694d35beb..428c4f69e 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -2,7 +2,7 @@ import io -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session @@ -18,14 +18,23 @@ async def list_history( profile_id: str | None = None, search: str | None = None, - limit: int = 50, - offset: int = 0, + folder_id: str | None = None, + uncategorised_only: bool = False, + include_subfolders: bool = True, + # Bounds mirror HistoryQuery so FastAPI rejects out-of-range values with + # a 422 before the model is constructed. Building HistoryQuery from raw + # ints instead surfaced its ValidationError as an opaque 500. + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): """List generation history with optional filters.""" query = models.HistoryQuery( profile_id=profile_id, search=search, + folder_id=folder_id, + uncategorised_only=uncategorised_only, + include_subfolders=include_subfolders, limit=limit, offset=offset, ) diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 68e5f2af0..eb273dc1e 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -134,6 +134,26 @@ async def update_profile( raise HTTPException(status_code=400, detail=str(e)) +@router.post("/profiles/{profile_id}/duplicate", response_model=models.VoiceProfileResponse) +async def duplicate_profile( + profile_id: str, + data: models.ProfileDuplicateRequest | None = None, + db: Session = Depends(get_db), +): + """Duplicate a voice profile, including its samples, avatar and settings. + + Unlike an export/import round-trip this preserves personality, effects + chain, default engine and preset/designed fields, and works for profiles + with no samples. Defaults the new name to " (copy)". + """ + try: + return await profiles.duplicate_profile( + profile_id, db, name=data.name if data else None + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @router.delete("/profiles/{profile_id}") async def delete_profile( profile_id: str, diff --git a/backend/services/export_import.py b/backend/services/export_import.py index 514eaacda..3c49223e7 100644 --- a/backend/services/export_import.py +++ b/backend/services/export_import.py @@ -14,32 +14,14 @@ from ..models import VoiceProfileResponse from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion -from .profiles import create_profile, add_profile_sample +from .profiles import create_profile, add_profile_sample, get_unique_profile_name from ..models import VoiceProfileCreate from .. import config - -def _get_unique_profile_name(name: str, db: Session) -> str: - """ - Get a unique profile name by appending a number if needed. - - Args: - name: Original profile name - db: Database session - - Returns: - Unique profile name - """ - base_name = name - counter = 1 - - while True: - existing = db.query(DBVoiceProfile).filter_by(name=name).first() - if not existing: - return name - - name = f"{base_name} ({counter})" - counter += 1 +# Kept as a module-level alias: this helper started life here, and moved to +# services.profiles so profile duplication could reuse it without importing +# the export/import layer. +_get_unique_profile_name = get_unique_profile_name def export_profile_to_zip(profile_id: str, db: Session) -> bytes: diff --git a/backend/services/folders.py b/backend/services/folders.py new file mode 100644 index 000000000..fe54ccf1a --- /dev/null +++ b/backend/services/folders.py @@ -0,0 +1,29 @@ +"""Folder tree helpers shared by the folders routes and history queries. + +Clip folders nest, so both "list this folder's clips" and "don't let a +folder be moved inside itself" need the same subtree walk. It lives here +so the two callers can't drift apart. +""" + +from sqlalchemy.orm import Session + +from ..database.models import Folder + + +def folder_and_descendants(folder_id: str, db: Session) -> set[str]: + """Return ``folder_id`` plus every folder beneath it. + + Breadth-first over ``parent_id``. The seen-set both prevents revisiting + shared subtrees and stops a cycle -- which the reparent guard should make + impossible, but which a hand-edited database could still contain -- from + looping forever. + """ + seen = {folder_id} + frontier = [folder_id] + + while frontier: + rows = db.query(Folder.id).filter(Folder.parent_id.in_(frontier)).all() + frontier = [child_id for (child_id,) in rows if child_id not in seen] + seen.update(frontier) + + return seen diff --git a/backend/services/history.py b/backend/services/history.py index 3062f7d6a..dac081aff 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -13,6 +13,7 @@ from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile from .. import config +from .folders import folder_and_descendants def _get_versions_for_generation(generation_id: str, db: Session) -> tuple: @@ -192,7 +193,18 @@ async def list_generations( if query.search: search_pattern = f"%{query.search}%" q = q.filter(DBGeneration.text.like(search_pattern)) - + + # Apply folder filter. uncategorised_only is checked first because it + # is the one folder request a plain folder_id cannot express. + if query.uncategorised_only: + q = q.filter(DBGeneration.folder_id.is_(None)) + elif query.folder_id: + if query.include_subfolders: + folder_ids = folder_and_descendants(query.folder_id, db) + q = q.filter(DBGeneration.folder_id.in_(folder_ids)) + else: + q = q.filter(DBGeneration.folder_id == query.folder_id) + # Get total count before pagination total_count = q.count() @@ -224,6 +236,7 @@ async def list_generations( status=generation.status or "completed", error=generation.error, is_favorited=bool(generation.is_favorited), + folder_id=generation.folder_id, created_at=generation.created_at, versions=versions, active_version_id=active_version_id, diff --git a/backend/services/profiles.py b/backend/services/profiles.py index d7d32fa0f..21c4a561c 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -12,6 +12,7 @@ from .. import config from ..database import Generation as DBGeneration, ProfileSample as DBProfileSample, VoiceProfile as DBVoiceProfile +from ..database.models import Folder as DBFolder from ..models import ( EffectConfig, ProfileSampleResponse, @@ -55,6 +56,7 @@ def _profile_to_response( design_prompt=getattr(profile, "design_prompt", None), default_engine=getattr(profile, "default_engine", None), personality=getattr(profile, "personality", None), + folder_id=getattr(profile, "folder_id", None), generation_count=generation_count, sample_count=sample_count, created_at=profile.created_at, @@ -135,6 +137,24 @@ def validate_profile_engine(profile, engine: str) -> None: raise ValueError(f"Engine '{engine}' does not support cloned voice profiles") +def get_unique_profile_name(name: str, db: Session) -> str: + """Return ``name``, or the first free "name (n)" variant. + + ``profiles.name`` is UNIQUE, so anything that creates a profile from an + existing one -- import, duplicate -- has to resolve collisions first. + """ + base_name = name + counter = 1 + + while True: + existing = db.query(DBVoiceProfile).filter_by(name=name).first() + if not existing: + return name + + name = f"{base_name} ({counter})" + counter += 1 + + async def create_profile( data: VoiceProfileCreate, db: Session, @@ -172,6 +192,16 @@ async def create_profile( if validation_error: raise ValueError(validation_error) + # The folder-assignment endpoint enforces that a voice only lands in a + # voice folder; creation has to enforce the same contract, or a client can + # file a new profile straight into a clip folder and bypass it. + if data.folder_id is not None: + folder = db.query(DBFolder).filter_by(id=data.folder_id).first() + if folder is None: + raise ValueError(f"Folder not found: {data.folder_id}") + if folder.kind != "voice": + raise ValueError("Target folder does not hold voices") + db_profile = DBVoiceProfile( id=str(uuid.uuid4()), name=data.name, @@ -183,6 +213,7 @@ async def create_profile( design_prompt=data.design_prompt, default_engine=default_engine, personality=data.personality, + folder_id=data.folder_id, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) @@ -708,3 +739,94 @@ async def delete_avatar( db.commit() return True + + +async def duplicate_profile( + profile_id: str, + db: Session, + name: str | None = None, +) -> VoiceProfileResponse: + """Copy a profile, its samples, and its avatar. + + Deliberately not implemented as export-then-import. The transfer format + carries only name/description/language (see export_import.py), so a + round-trip silently drops personality, effects_chain, default_engine and + every preset/designed field -- and refuses profiles with no samples, + which is every preset voice. Copying the row directly keeps all of it. + + Sample audio is copied byte-for-byte rather than re-encoded through + add_profile_sample(): the source files were already validated when they + were first added, and a duplicate should be identical, not resampled. + """ + source = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not source: + raise ValueError(f"Profile {profile_id} not found") + + new_id = str(uuid.uuid4()) + new_name = get_unique_profile_name(name.strip() if name else f"{source.name} (copy)", db) + + duplicate = DBVoiceProfile( + id=new_id, + name=new_name, + description=source.description, + language=source.language, + effects_chain=source.effects_chain, + voice_type=source.voice_type, + preset_engine=source.preset_engine, + preset_voice_id=source.preset_voice_id, + design_prompt=source.design_prompt, + default_engine=source.default_engine, + personality=source.personality, + folder_id=source.folder_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(duplicate) + + new_dir = config.get_profiles_dir() / new_id + new_dir.mkdir(parents=True, exist_ok=True) + + # Samples: copy each file under a fresh id, then point a new row at it. + samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all() + for sample in samples: + source_audio = config.resolve_storage_path(sample.audio_path) + if source_audio is None or not source_audio.exists(): + # A profile can outlive its audio (moved data dir, manual + # cleanup). Skip the orphan rather than fail the whole copy. + logger.warning( + "Skipping sample %s while duplicating %s: audio missing at %s", + sample.id, + profile_id, + sample.audio_path, + ) + continue + + new_sample_id = str(uuid.uuid4()) + dest = new_dir / f"{new_sample_id}{source_audio.suffix}" + shutil.copy2(source_audio, dest) + db.add( + DBProfileSample( + id=new_sample_id, + profile_id=new_id, + audio_path=config.to_storage_path(dest), + reference_text=sample.reference_text, + ) + ) + + if source.avatar_path: + source_avatar = config.resolve_storage_path(source.avatar_path) + if source_avatar is not None and source_avatar.exists(): + dest_avatar = new_dir / source_avatar.name + shutil.copy2(source_avatar, dest_avatar) + duplicate.avatar_path = config.to_storage_path(dest_avatar) + + db.commit() + db.refresh(duplicate) + + sample_count = ( + db.query(func.count(DBProfileSample.id)) + .filter(DBProfileSample.profile_id == new_id) + .scalar() + ) + # A fresh copy has no generations of its own. + return _profile_to_response(duplicate, generation_count=0, sample_count=sample_count) diff --git a/backend/tests/test_data_dir_env.py b/backend/tests/test_data_dir_env.py new file mode 100644 index 000000000..cef81949e --- /dev/null +++ b/backend/tests/test_data_dir_env.py @@ -0,0 +1,78 @@ +""" +Unit tests for the ``VOICEBOX_DATA_DIR`` environment variable. + +``backend/README.md`` documents ``VOICEBOX_DATA_DIR`` alongside ``--data-dir``, +but the default was previously hardcoded to ``./data``, so a bare +``uvicorn backend.main:app`` (how ``just dev`` starts the backend) always wrote +to the repo instead of the app data dir. + +The variable is read at import time, so each test reloads the module under a +patched environment. + +NOTE: These tests reload ``config``, which rebinds its module-global +``_data_dir``. Import the module fresh rather than holding a reference across +reloads. +""" + +import importlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import config # noqa: E402 + + +@pytest.fixture +def reload_config(monkeypatch): + """Reload ``config`` with a patched environment, restoring it afterwards.""" + + def _reload(data_dir: str | None): + if data_dir is None: + monkeypatch.delenv("VOICEBOX_DATA_DIR", raising=False) + else: + monkeypatch.setenv("VOICEBOX_DATA_DIR", data_dir) + return importlib.reload(config) + + yield _reload + # Leave the module matching the real process environment for later tests. + importlib.reload(config) + + +def test_env_var_sets_data_dir(reload_config, tmp_path): + target = tmp_path / "appdata" + cfg = reload_config(str(target)) + + assert cfg.get_data_dir() == target.resolve() + + +def test_defaults_to_local_data_dir_when_unset(reload_config): + cfg = reload_config(None) + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_empty_env_var_falls_back_to_default(reload_config): + cfg = reload_config("") + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_relative_env_var_is_resolved_to_absolute(reload_config): + cfg = reload_config("relative/data") + + assert cfg.get_data_dir().is_absolute() + assert cfg.get_data_dir() == Path("relative/data").resolve() + + +def test_set_data_dir_still_wins_over_env_var(reload_config, tmp_path): + """``--data-dir`` calls set_data_dir() after import, so it must take + precedence over the environment variable.""" + cfg = reload_config(str(tmp_path / "from-env")) + explicit = tmp_path / "from-flag" + + cfg.set_data_dir(explicit) + + assert cfg.get_data_dir() == explicit.resolve() diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py new file mode 100644 index 000000000..9faef6e2b --- /dev/null +++ b/backend/tests/test_folders.py @@ -0,0 +1,271 @@ +""" +Tests for folder organisation of voices and generated clips. + +Covers the asymmetry between the two folder kinds: voice folders are flat, +clip folders nest. Also covers the guarantee that deleting a folder never +deletes what is inside it. + +VOICEBOX_DATA_DIR is set before importing the app so the whole suite runs +against a throwaway data directory and never touches a real install. + +Usage: + python -m pytest backend/tests/test_folders.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-folders-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + # Context-manager form triggers the lifespan, which runs init_db(). + with TestClient(app) as c: + yield c + + +@pytest.fixture +def voice_folder(client): + r = client.post("/folders", json={"name": "Podcast voices", "kind": "voice"}) + assert r.status_code == 200, r.text + folder = r.json() + yield folder + client.delete(f"/folders/{folder['id']}") + + +@pytest.fixture +def profile(client): + """A preset profile — deliberately one with no samples, since those are + exactly the profiles export/import cannot round-trip.""" + r = client.post( + "/profiles", + json={ + "name": "Folder Test Voice", + "description": "fixture", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Speaks in short, dry sentences.", + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + client.delete(f"/profiles/{created['id']}") + + +# ── Folder CRUD ────────────────────────────────────────────────────── + + +def test_create_and_list_voice_folder(client, voice_folder): + assert voice_folder["kind"] == "voice" + assert voice_folder["parent_id"] is None + + listed = client.get("/folders", params={"kind": "voice"}).json() + assert voice_folder["id"] in [f["id"] for f in listed] + + +def test_voice_folders_are_excluded_from_generation_listing(client, voice_folder): + listed = client.get("/folders", params={"kind": "generation"}).json() + assert voice_folder["id"] not in [f["id"] for f in listed] + + +def test_unknown_kind_is_rejected(client): + assert client.get("/folders", params={"kind": "nonsense"}).status_code == 400 + + +def test_rename_folder(client, voice_folder): + r = client.patch(f"/folders/{voice_folder['id']}", json={"name": "Renamed"}) + assert r.status_code == 200 + assert r.json()["name"] == "Renamed" + + +def test_folder_name_is_trimmed(client): + r = client.post("/folders", json={"name": " Padded ", "kind": "voice"}) + assert r.json()["name"] == "Padded" + client.delete(f"/folders/{r.json()['id']}") + + +def test_missing_folder_is_404(client): + assert client.patch("/folders/nope", json={"name": "x"}).status_code == 404 + assert client.delete("/folders/nope").status_code == 404 + + +# ── Nesting rules ──────────────────────────────────────────────────── + + +def test_voice_folders_cannot_nest(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Child", "kind": "voice", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + assert "nested" in r.json()["detail"].lower() + + +def test_generation_folders_nest(client): + parent = client.post("/folders", json={"name": "Season 1", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Episode 1", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + assert child["parent_id"] == parent["id"] + + grandchild = client.post( + "/folders", + json={"name": "Takes", "kind": "generation", "parent_id": child["id"]}, + ) + assert grandchild.status_code == 200 + + client.delete(f"/folders/{grandchild.json()['id']}") + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_parent_must_share_kind(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Mismatched", "kind": "generation", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + + +def test_folder_cannot_be_its_own_parent(client): + folder = client.post("/folders", json={"name": "Loop", "kind": "generation"}).json() + r = client.patch(f"/folders/{folder['id']}", json={"parent_id": folder["id"]}) + assert r.status_code == 400 + client.delete(f"/folders/{folder['id']}") + + +def test_folder_cannot_move_into_its_own_descendant(client): + """The cycle that would orphan a whole subtree from the root.""" + parent = client.post("/folders", json={"name": "Outer", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Inner", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + r = client.patch(f"/folders/{parent['id']}", json={"parent_id": child["id"]}) + assert r.status_code == 400 + assert "inside itself" in r.json()["detail"].lower() + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_detach_moves_folder_to_root(client): + parent = client.post("/folders", json={"name": "P", "kind": "generation"}).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.post(f"/folders/{child['id']}/detach") + assert r.status_code == 200 + assert r.json()["parent_id"] is None + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +# ── Membership ─────────────────────────────────────────────────────── + + +def test_assign_and_unassign_profile(client, voice_folder, profile): + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]} + ) + assert r.status_code == 200 + assert r.json()["folder_id"] == voice_folder["id"] + + r = client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": None}) + assert r.json()["folder_id"] is None + + +def test_profile_cannot_go_into_a_clip_folder(client, profile): + clip_folder = client.post( + "/folders", json={"name": "Clips", "kind": "generation"} + ).json() + + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": clip_folder["id"]} + ) + assert r.status_code == 400 + + client.delete(f"/folders/{clip_folder['id']}") + + +def test_item_count_reflects_members(client, voice_folder, profile): + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]}) + + listed = client.get("/folders", params={"kind": "voice"}).json() + entry = next(f for f in listed if f["id"] == voice_folder["id"]) + assert entry["item_count"] == 1 + + +# ── Deletion preserves contents ────────────────────────────────────── + + +def test_deleting_folder_releases_members_but_keeps_them(client, profile): + folder = client.post("/folders", json={"name": "Temp", "kind": "voice"}).json() + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": folder["id"]}) + + r = client.delete(f"/folders/{folder['id']}") + assert r.status_code == 200 + assert r.json()["items_released"] == 1 + + # The voice itself must survive, now uncategorised. + survivor = client.get(f"/profiles/{profile['id']}") + assert survivor.status_code == 200 + assert survivor.json()["folder_id"] is None + + +def test_deleting_parent_reparents_children_rather_than_orphaning(client): + grandparent = client.post("/folders", json={"name": "GP", "kind": "generation"}).json() + parent = client.post( + "/folders", json={"name": "P", "kind": "generation", "parent_id": grandparent["id"]} + ).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.delete(f"/folders/{parent['id']}") + assert r.json()["folders_reparented"] == 1 + + listed = client.get("/folders", params={"kind": "generation"}).json() + moved = next(f for f in listed if f["id"] == child["id"]) + assert moved["parent_id"] == grandparent["id"] + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{grandparent['id']}") + + +# ── History filtering ──────────────────────────────────────────────── + + +def test_history_rejects_out_of_range_limit_with_422(client): + """Previously surfaced as a 500 — HistoryQuery was built from raw ints + inside the handler, so its ValidationError escaped as a server error.""" + assert client.get("/history", params={"limit": 500}).status_code == 422 + + +def test_history_accepts_folder_filters(client): + folder = client.post("/folders", json={"name": "F", "kind": "generation"}).json() + + assert client.get("/history", params={"folder_id": folder["id"]}).status_code == 200 + assert client.get("/history", params={"uncategorised_only": True}).status_code == 200 + + client.delete(f"/folders/{folder['id']}") diff --git a/backend/tests/test_profile_duplicate.py b/backend/tests/test_profile_duplicate.py new file mode 100644 index 000000000..fb4d9b267 --- /dev/null +++ b/backend/tests/test_profile_duplicate.py @@ -0,0 +1,294 @@ +""" +Tests for POST /profiles/{id}/duplicate. + +The point of the endpoint is that it is *not* an export/import round-trip. +export_import.py writes only name/description/language into its manifest, so +importing an exported profile silently drops personality, effects_chain, +default_engine and the preset/designed fields -- and export refuses profiles +with no samples, which is every preset voice. These tests pin the fields a +round-trip would have lost. + +Usage: + python -m pytest backend/tests/test_profile_duplicate.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-duplicate-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + +EFFECTS = [{"type": "reverb", "params": {"room_size": 0.4}}] +PERSONALITY = "Speaks in short, dry sentences. Never uses exclamation marks." + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _delete(client, profile_id: str) -> None: + client.delete(f"/profiles/{profile_id}") + + +@pytest.fixture +def preset_profile(client): + """A preset (Kokoro) profile: rich metadata, zero samples.""" + r = client.post( + "/profiles", + json={ + "name": "Duplicate Source", + "description": "original description", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": PERSONALITY, + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + _delete(client, created["id"]) + + +def test_duplicate_preserves_personality(client, preset_profile): + """The field an export/import round-trip loses most damagingly.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["personality"] == PERSONALITY + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_preset_fields(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["voice_type"] == "preset" + assert copy["preset_engine"] == "kokoro" + assert copy["preset_voice_id"] == "af_bella" + # default_engine is auto-derived from preset_engine on create. + assert copy["default_engine"] == "kokoro" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_works_for_profiles_without_samples(client, preset_profile): + """Export raises ValueError for a sample-less profile, so this case is + unreachable via export/import.""" + r = client.post(f"/profiles/{preset_profile['id']}/duplicate") + assert r.status_code == 200, r.text + try: + assert r.json()["sample_count"] == 0 + finally: + _delete(client, r.json()["id"]) + + +def test_duplicate_preserves_description_and_language(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["description"] == "original description" + assert copy["language"] == "en" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_gets_a_new_id_and_copy_suffixed_name(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["id"] != preset_profile["id"] + assert copy["name"] == "Duplicate Source (copy)" + finally: + _delete(client, copy["id"]) + + +def test_repeated_duplicates_get_distinct_names(client, preset_profile): + """profiles.name is UNIQUE, so the second copy must not collide.""" + first = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + second = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert first["name"] == "Duplicate Source (copy)" + assert second["name"] == "Duplicate Source (copy) (1)" + finally: + _delete(client, first["id"]) + _delete(client, second["id"]) + + +def test_duplicate_accepts_an_explicit_name(client, preset_profile): + copy = client.post( + f"/profiles/{preset_profile['id']}/duplicate", json={"name": "Chosen Name"} + ).json() + try: + assert copy["name"] == "Chosen Name" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_starts_with_no_generations(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["generation_count"] == 0 + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_effects_chain(client, preset_profile): + set_effects = client.put( + f"/profiles/{preset_profile['id']}/effects", json={"effects_chain": EFFECTS} + ) + assert set_effects.status_code == 200, set_effects.text + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["effects_chain"], "effects chain was dropped by duplicate" + assert copy["effects_chain"][0]["type"] == "reverb" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_inherits_folder(client, preset_profile): + folder = client.post("/folders", json={"name": "Dup Folder", "kind": "voice"}).json() + client.put(f"/profiles/{preset_profile['id']}/folder", json={"folder_id": folder["id"]}) + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["folder_id"] == folder["id"] + finally: + _delete(client, copy["id"]) + client.delete(f"/folders/{folder['id']}") + + +def test_editing_the_copy_leaves_the_original_untouched(client, preset_profile): + """A duplicate must be independent, not a shared reference.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + client.put( + f"/profiles/{copy['id']}", + json={ + "name": "Edited Copy", + "description": "changed", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Totally different.", + }, + ) + original = client.get(f"/profiles/{preset_profile['id']}").json() + assert original["personality"] == PERSONALITY + assert original["description"] == "original description" + finally: + _delete(client, copy["id"]) + + +def test_duplicating_a_missing_profile_is_404(client): + assert client.post("/profiles/does-not-exist/duplicate").status_code == 404 + + +# ── Sample copying ─────────────────────────────────────────────────── + + +def _write_reference_wav(path: Path) -> None: + """A 3s tone: long enough for the 2s minimum, loud enough for the RMS floor.""" + import numpy as np + import soundfile as sf + + sr = 24000 + t = np.linspace(0, 3.0, int(sr * 3.0), endpoint=False) + tone = (0.3 * np.sin(2 * np.pi * 220 * t)).astype("float32") + sf.write(str(path), tone, sr) + + +@pytest.fixture +def cloned_profile_with_sample(client, tmp_path): + r = client.post( + "/profiles", + json={"name": "Cloned Source", "description": "has a sample", "language": "en"}, + ) + assert r.status_code == 200, r.text + created = r.json() + + # Delete unconditionally: if the upload below fails, the profile would + # otherwise survive and collide with the next test's UNIQUE name. + try: + wav = tmp_path / "reference.wav" + _write_reference_wav(wav) + with wav.open("rb") as fh: + upload = client.post( + f"/profiles/{created['id']}/samples", + files={"file": ("reference.wav", fh, "audio/wav")}, + data={"reference_text": "This is the reference transcript."}, + ) + assert upload.status_code == 200, upload.text + + yield created + finally: + _delete(client, created["id"]) + + +def test_duplicate_copies_samples(client, cloned_profile_with_sample): + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + assert copy["sample_count"] == 1 + + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + assert copy_samples[0]["reference_text"] == original_samples[0]["reference_text"] + # Distinct rows pointing at distinct files — not a shared reference. + assert copy_samples[0]["id"] != original_samples[0]["id"] + assert copy_samples[0]["audio_path"] != original_samples[0]["audio_path"] + finally: + _delete(client, copy["id"]) + + +def test_duplicated_sample_audio_is_byte_identical(client, cloned_profile_with_sample): + """Copied rather than re-encoded, so the clone sounds like the original.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + original_bytes = config.resolve_storage_path( + original_samples[0]["audio_path"] + ).read_bytes() + copy_bytes = config.resolve_storage_path( + copy_samples[0]["audio_path"] + ).read_bytes() + + assert copy_bytes == original_bytes + finally: + _delete(client, copy["id"]) + + +def test_deleting_the_copy_leaves_the_originals_audio_intact(client, cloned_profile_with_sample): + """Copied files must be independent — deleting one must not take the + other's audio with it.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + original_audio = config.resolve_storage_path(original_samples[0]["audio_path"]) + + _delete(client, copy["id"]) + + assert original_audio.exists()