mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 14:20:42 -07:00
Add profile export and import functionality
- Implemented API endpoints for exporting and importing voice profiles as ZIP archives. - Enhanced the frontend with new hooks and components for profile export and import, including file handling and user dialogs. - Integrated Tauri plugins for file system access and dialog interactions to facilitate seamless user experience. - Updated ProfileCard and ProfileList components to support new export and import features, improving overall functionality. - Added necessary error handling and validation for file operations to ensure robustness.
This commit is contained in:
@@ -31,6 +31,8 @@
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.0.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
||||
@@ -74,7 +74,7 @@ export function HistoryTable() {
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2',
|
||||
isPlayerVisible && 'max-h-[calc(100vh-220px)]',
|
||||
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
|
||||
)}
|
||||
>
|
||||
{history.map((gen) => {
|
||||
@@ -169,21 +169,23 @@ export function HistoryTable() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page + 1} • {total} total
|
||||
{(total > limit || page > 0) && (
|
||||
<div className="flex justify-between items-center mt-4 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page + 1} • {total} total
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileDetail } from './ProfileDetail';
|
||||
@@ -26,6 +26,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
@@ -52,6 +53,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleExport = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
exportProfile.mutate(profile.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -87,6 +93,12 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
}}
|
||||
aria-label="View details"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Mic, Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useProfiles, useImportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
@@ -9,6 +18,44 @@ import { ProfileForm } from './ProfileForm';
|
||||
export function ProfileList() {
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const importProfile = useImportProfile();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
alert('Please select a valid .voicebox.zip file');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
if (selectedFile) {
|
||||
importProfile.mutate(selectedFile, {
|
||||
onSuccess: () => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Failed to import profile: ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -32,10 +79,23 @@ export function ProfileList() {
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
New Profile
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".voicebox.zip"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
@@ -48,7 +108,7 @@ export function ProfileList() {
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Profile
|
||||
Create Voice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -62,6 +122,37 @@ export function ProfileList() {
|
||||
</div>
|
||||
|
||||
<ProfileForm />
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the profile from "{selectedFile?.name}". This will create a new profile with all samples.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,40 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async exportProfile(profileId: string): Promise<Blob> {
|
||||
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async importProfile(file: File): Promise<VoiceProfileResponse> {
|
||||
const url = `${this.getBaseUrl()}/profiles/import`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Generation
|
||||
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>('/generate', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
|
||||
export function useProfiles() {
|
||||
return useQuery({
|
||||
@@ -96,3 +97,73 @@ export function useDeleteSample() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportProfile() {
|
||||
return useMutation({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const blob = await apiClient.exportProfile(profileId);
|
||||
|
||||
// Get profile name for filename
|
||||
const profile = await apiClient.getProfile(profileId);
|
||||
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `profile-${safeName}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => apiClient.importProfile(file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Voice profile export/import module.
|
||||
|
||||
Handles exporting profiles to ZIP archives and importing them back.
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import VoiceProfileResponse
|
||||
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample
|
||||
from .profiles import create_profile, add_profile_sample
|
||||
from .models import VoiceProfileCreate
|
||||
from . import config
|
||||
|
||||
|
||||
def _get_profiles_dir() -> Path:
|
||||
"""Get profiles directory from config."""
|
||||
return config.get_profiles_dir()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
|
||||
"""
|
||||
Export a voice profile to a ZIP archive.
|
||||
|
||||
Args:
|
||||
profile_id: Profile ID to export
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
ZIP file contents as bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If profile not found or has no samples
|
||||
"""
|
||||
# Get profile
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Get all samples
|
||||
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
||||
if not samples:
|
||||
raise ValueError(f"Profile {profile_id} has no samples")
|
||||
|
||||
# Create ZIP in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Create manifest.json
|
||||
manifest = {
|
||||
"version": "1.0",
|
||||
"profile": {
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"language": profile.language,
|
||||
}
|
||||
}
|
||||
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
|
||||
# Create samples.json mapping
|
||||
samples_data = {}
|
||||
profile_dir = _get_profiles_dir() / profile_id
|
||||
|
||||
for sample in samples:
|
||||
# Get filename from audio_path (should be {sample_id}.wav)
|
||||
audio_path = Path(sample.audio_path)
|
||||
filename = audio_path.name
|
||||
|
||||
# Read audio file
|
||||
if not audio_path.exists():
|
||||
raise ValueError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Add to samples directory in ZIP
|
||||
zip_path = f"samples/{filename}"
|
||||
zip_file.write(audio_path, zip_path)
|
||||
|
||||
# Map filename to reference text
|
||||
samples_data[filename] = sample.reference_text
|
||||
|
||||
zip_file.writestr("samples.json", json.dumps(samples_data, indent=2))
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer.read()
|
||||
|
||||
|
||||
async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfileResponse:
|
||||
"""
|
||||
Import a voice profile from a ZIP archive.
|
||||
|
||||
Args:
|
||||
file_bytes: ZIP file contents
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Created profile
|
||||
|
||||
Raises:
|
||||
ValueError: If ZIP is invalid or missing required files
|
||||
"""
|
||||
zip_buffer = io.BytesIO(file_bytes)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_buffer, 'r') as zip_file:
|
||||
# Validate ZIP structure
|
||||
namelist = zip_file.namelist()
|
||||
|
||||
if "manifest.json" not in namelist:
|
||||
raise ValueError("ZIP archive missing manifest.json")
|
||||
|
||||
if "samples.json" not in namelist:
|
||||
raise ValueError("ZIP archive missing samples.json")
|
||||
|
||||
# Read manifest
|
||||
manifest_data = json.loads(zip_file.read("manifest.json"))
|
||||
|
||||
if "version" not in manifest_data:
|
||||
raise ValueError("Invalid manifest.json: missing version")
|
||||
|
||||
if "profile" not in manifest_data:
|
||||
raise ValueError("Invalid manifest.json: missing profile")
|
||||
|
||||
profile_data = manifest_data["profile"]
|
||||
|
||||
# Read samples mapping
|
||||
samples_data = json.loads(zip_file.read("samples.json"))
|
||||
|
||||
if not isinstance(samples_data, dict):
|
||||
raise ValueError("Invalid samples.json: must be a dictionary")
|
||||
|
||||
# Get unique profile name
|
||||
original_name = profile_data.get("name", "Imported Profile")
|
||||
unique_name = _get_unique_profile_name(original_name, db)
|
||||
|
||||
# Create profile
|
||||
profile_create = VoiceProfileCreate(
|
||||
name=unique_name,
|
||||
description=profile_data.get("description"),
|
||||
language=profile_data.get("language", "en"),
|
||||
)
|
||||
|
||||
profile = await create_profile(profile_create, db)
|
||||
|
||||
# Extract and add samples
|
||||
profile_dir = _get_profiles_dir() / profile.id
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for filename, reference_text in samples_data.items():
|
||||
# Validate filename
|
||||
if not filename.endswith('.wav'):
|
||||
raise ValueError(f"Invalid sample filename: {filename} (must be .wav)")
|
||||
|
||||
# Extract audio file to temp location
|
||||
zip_path = f"samples/{filename}"
|
||||
|
||||
if zip_path not in namelist:
|
||||
raise ValueError(f"Sample file not found in ZIP: {zip_path}")
|
||||
|
||||
# Extract to temporary file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
tmp.write(zip_file.read(zip_path))
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Add sample to profile
|
||||
await add_profile_sample(
|
||||
profile.id,
|
||||
tmp_path,
|
||||
reference_text,
|
||||
db,
|
||||
)
|
||||
finally:
|
||||
# Clean up temp file
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
return profile
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise ValueError("Invalid ZIP file")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in archive: {e}")
|
||||
except Exception as e:
|
||||
if isinstance(e, ValueError):
|
||||
raise
|
||||
raise ValueError(f"Error importing profile: {str(e)}")
|
||||
+65
-2
@@ -6,7 +6,7 @@ Handles voice cloning, generation history, and server mode.
|
||||
|
||||
from fastapi import FastAPI, Depends, UploadFile, File, Form, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
@@ -14,10 +14,11 @@ import uvicorn
|
||||
import argparse
|
||||
import torch
|
||||
import tempfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from . import database, models, profiles, history, tts, transcribe, config
|
||||
from . import database, models, profiles, history, tts, transcribe, config, export_import
|
||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .utils.progress import get_progress_manager
|
||||
|
||||
@@ -227,6 +228,68 @@ async def delete_profile_sample(
|
||||
return {"message": "Sample deleted successfully"}
|
||||
|
||||
|
||||
@app.get("/profiles/{profile_id}/export")
|
||||
async def export_profile(
|
||||
profile_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Export a voice profile as a ZIP archive."""
|
||||
try:
|
||||
# Get profile to get name for filename
|
||||
profile = await profiles.get_profile(profile_id, db)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
# Export to ZIP
|
||||
zip_bytes = export_import.export_profile_to_zip(profile_id, db)
|
||||
|
||||
# Create safe filename
|
||||
safe_name = "".join(c for c in profile.name if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||
if not safe_name:
|
||||
safe_name = "profile"
|
||||
filename = f"profile-{safe_name}.voicebox.zip"
|
||||
|
||||
# Return as streaming response
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_bytes),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/profiles/import", response_model=models.VoiceProfileResponse)
|
||||
async def import_profile(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Import a voice profile from a ZIP archive."""
|
||||
# Validate file size (max 100MB)
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
|
||||
)
|
||||
|
||||
try:
|
||||
profile = await export_import.import_profile_from_zip(content, db)
|
||||
return profile
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================
|
||||
# GENERATION ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.0.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -543,6 +545,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="],
|
||||
|
||||
"@tauri-apps/plugin-fs": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA=="],
|
||||
|
||||
"@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
||||
|
||||
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="],
|
||||
|
||||
Generated
+68
@@ -571,6 +571,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2835,6 +2837,30 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3643,6 +3669,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.4"
|
||||
@@ -4296,6 +4362,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
|
||||
@@ -14,6 +14,8 @@ tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-fs = "2.0"
|
||||
tauri-plugin-shell = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for voicebox",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"remote": {
|
||||
"urls": ["http://localhost:*"]
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
@@ -11,6 +15,12 @@
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
"fs:read-all",
|
||||
"fs:write-all"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for voicebox","local":true,"permissions":["core:default","core:window:default","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions for voicebox","remote":{"urls":["http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default","dialog:default","dialog:allow-save","dialog:allow-open","fs:default","fs:read-all","fs:write-all"],"platforms":["linux","macOS","windows"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,8 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(ServerState {
|
||||
child: Mutex::new(None),
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "voicebox",
|
||||
"title": "",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
|
||||
Reference in New Issue
Block a user