mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 19:46:42 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
339
apps-archived/reader/apps/mobile/services/audioService.ts
Normal file
339
apps-archived/reader/apps/mobile/services/audioService.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { supabase } from '~/utils/supabase';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Audio } from 'expo-av';
|
||||
import { AudioChunk } from '~/types/database';
|
||||
import { getVoiceById } from '~/constants/voices';
|
||||
|
||||
const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
|
||||
|
||||
export interface AudioGenerationProgress {
|
||||
chunksCompleted: number;
|
||||
totalChunks: number;
|
||||
currentChunk: string;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export class AudioService {
|
||||
private static instance: AudioService;
|
||||
private supabase = supabase;
|
||||
|
||||
public static getInstance(): AudioService {
|
||||
if (!AudioService.instance) {
|
||||
AudioService.instance = new AudioService();
|
||||
}
|
||||
return AudioService.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.initializeAudioDirectory();
|
||||
}
|
||||
|
||||
private async initializeAudioDirectory(): Promise<void> {
|
||||
try {
|
||||
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
}
|
||||
|
||||
// Generate audio for a text using Supabase Edge Function
|
||||
async generateAudioForText(
|
||||
textId: string,
|
||||
content: string,
|
||||
voice: string = 'de-DE',
|
||||
speed: number = 1.0,
|
||||
chunkSize: number = 1000,
|
||||
onProgress?: (progress: AudioGenerationProgress) => void,
|
||||
versionId?: string
|
||||
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
|
||||
try {
|
||||
// Estimate number of chunks for progress tracking
|
||||
const estimatedChunks = Math.ceil(content.length / chunkSize);
|
||||
|
||||
onProgress?.({
|
||||
chunksCompleted: 0,
|
||||
totalChunks: estimatedChunks,
|
||||
currentChunk: 'Starting generation...',
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
// Determine which provider to use based on the voice
|
||||
let provider = 'google';
|
||||
|
||||
try {
|
||||
const voiceInfo = getVoiceById(voice);
|
||||
if (voiceInfo) {
|
||||
provider = voiceInfo.provider;
|
||||
} else {
|
||||
console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting voice info:', error);
|
||||
// Continue with default Google provider
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('generate-audio', {
|
||||
body: {
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
provider,
|
||||
speed,
|
||||
chunkSize,
|
||||
versionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to generate audio');
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
chunksCompleted: data.chunksGenerated,
|
||||
totalChunks: data.chunksGenerated,
|
||||
currentChunk: 'Audio generation complete!',
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chunks: data.chunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Download audio chunks to local storage
|
||||
async downloadAudioChunks(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
|
||||
): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
|
||||
try {
|
||||
const localChunks: AudioChunk[] = [];
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
|
||||
onProgress?.({
|
||||
completed: i,
|
||||
total: chunks.length,
|
||||
currentChunk: chunk.id,
|
||||
});
|
||||
|
||||
// Get signed URL for the chunk
|
||||
const { data: urlData, error: urlError } = await supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get URL for chunk ${chunk.id}`);
|
||||
}
|
||||
|
||||
// Download the audio file
|
||||
const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
|
||||
const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
|
||||
|
||||
if (downloadResult.status !== 200) {
|
||||
throw new Error(`Failed to download chunk ${chunk.id}`);
|
||||
}
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(localFilePath);
|
||||
|
||||
localChunks.push({
|
||||
...chunk,
|
||||
filename: `${textId}_${chunk.id}.mp3`,
|
||||
size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
completed: chunks.length,
|
||||
total: chunks.length,
|
||||
currentChunk: 'Download complete!',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
localChunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error downloading audio chunks:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Play audio directly from Supabase Storage
|
||||
async playAudioFromSupabase(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
|
||||
try {
|
||||
// Calculate chunk positions if not already set
|
||||
let currentPosition = 0;
|
||||
const chunksWithPositions = chunks.map((chunk) => {
|
||||
const chunkStart = currentPosition;
|
||||
const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
|
||||
currentPosition = chunkEnd;
|
||||
return {
|
||||
...chunk,
|
||||
start: chunk.start ?? chunkStart,
|
||||
end: chunk.end ?? chunkEnd,
|
||||
};
|
||||
});
|
||||
|
||||
// Find the chunk that contains the start position
|
||||
const chunk =
|
||||
chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
|
||||
chunksWithPositions[0]; // Default to first chunk if position not found
|
||||
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
|
||||
// Get signed URL for the audio chunk
|
||||
const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Create and load the audio from signed URL
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
|
||||
|
||||
// Calculate position within the chunk
|
||||
const positionWithinChunk = Math.max(0, startPosition - chunk.start);
|
||||
await sound.setPositionAsync(positionWithinChunk);
|
||||
|
||||
return { sound, chunk, chunks: chunksWithPositions };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from Supabase:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Play audio from local cache (kept for backward compatibility)
|
||||
async playAudioFromCache(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; error?: string }> {
|
||||
try {
|
||||
// Find the chunk that contains the start position
|
||||
const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
|
||||
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Audio file not found locally');
|
||||
}
|
||||
|
||||
// Create and load the audio
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: filePath });
|
||||
|
||||
// Calculate position within the chunk
|
||||
const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
|
||||
const positionMillis = chunkProgress * chunk.duration * 1000;
|
||||
|
||||
await sound.setPositionAsync(positionMillis);
|
||||
|
||||
return { sound };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from cache:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear local audio cache for a text
|
||||
async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise<void> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
try {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
} catch (deleteError) {
|
||||
console.log(`Could not delete ${chunk.filename}:`, deleteError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing audio cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get total cache size
|
||||
async getCacheSize(): Promise<number> {
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
|
||||
let totalSize = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
|
||||
totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.error('Error calculating cache size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if audio is cached locally
|
||||
async isAudioCached(textId: string, chunks: AudioChunk[]): Promise<boolean> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get file path for a chunk
|
||||
getChunkFilePath(textId: string, chunkId: string): string {
|
||||
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
|
||||
}
|
||||
}
|
||||
131
apps-archived/reader/apps/mobile/services/urlExtractorService.ts
Normal file
131
apps-archived/reader/apps/mobile/services/urlExtractorService.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { supabase } from '~/utils/supabase';
|
||||
|
||||
export interface ExtractedContent {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
source: string;
|
||||
domain: string;
|
||||
author: string;
|
||||
publishDate: string;
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ExtractUrlError {
|
||||
message: string;
|
||||
code?: 'INVALID_URL' | 'FETCH_FAILED' | 'EXTRACTION_FAILED' | 'NETWORK_ERROR' | 'UNAUTHORIZED';
|
||||
}
|
||||
|
||||
class UrlExtractorService {
|
||||
async extractFromUrl(
|
||||
url: string
|
||||
): Promise<{ data: ExtractedContent | null; error: ExtractUrlError | null }> {
|
||||
try {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^https?:\/\/.+/;
|
||||
if (!urlPattern.test(url)) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Bitte gib eine gültige URL ein (http:// oder https://)',
|
||||
code: 'INVALID_URL',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('extract-url', {
|
||||
body: { url },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error extracting URL:', error);
|
||||
|
||||
// Handle specific error cases
|
||||
if (error.message?.includes('Unauthorized')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Nicht autorisiert. Bitte melde dich erneut an.',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message?.includes('Failed to fetch URL')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Die Webseite konnte nicht geladen werden. Überprüfe die URL.',
|
||||
code: 'FETCH_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message?.includes('Could not extract')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
'Der Text konnte nicht extrahiert werden. Die Seite ist möglicherweise nicht kompatibel.',
|
||||
code: 'EXTRACTION_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error.message || 'Ein Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Keine Daten empfangen', code: 'EXTRACTION_FAILED' },
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as ExtractedContent, error: null };
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in extractFromUrl:', error);
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Ein unerwarteter Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
validateUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return ['http:', 'https:'].includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
formatExtractedContent(extracted: ExtractedContent): string {
|
||||
// Format the extracted content with title and metadata
|
||||
let formatted = extracted.title + '\n\n';
|
||||
|
||||
if (extracted.author) {
|
||||
formatted += `Von: ${extracted.author}\n`;
|
||||
}
|
||||
|
||||
if (extracted.publishDate) {
|
||||
formatted += `Veröffentlicht: ${extracted.publishDate}\n`;
|
||||
}
|
||||
|
||||
if (extracted.domain) {
|
||||
formatted += `Quelle: ${extracted.domain}\n`;
|
||||
}
|
||||
|
||||
formatted += '\n' + extracted.content;
|
||||
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
export const urlExtractorService = new UrlExtractorService();
|
||||
Loading…
Add table
Add a link
Reference in a new issue