logger = $logger; $upload_dir = wp_upload_dir(); $this->cache_dir = trailingslashit( $upload_dir['basedir'] ) . 'piperless'; $this->cache_url = trailingslashit( $upload_dir['baseurl'] ) . 'piperless'; } /** * Generate a cache key from text and voice parameters. * * @param string $text The text content. * @param string $model Model file path. * @param string $language Language code. * @param string $quality Quality tier. * @return string SHA-256 hash. */ public function cache_key( string $text, string $model, string $language, string $quality, string $bitrate = '' ): string { $seed = $text . '|' . $model . '|' . $language . '|' . $quality; if ( '' !== $bitrate ) { $seed .= '|br:' . $bitrate; } return hash( 'sha256', $seed ); } /** * Check if a cached audio file exists. * * @param string $cache_key The cache key. * @return bool */ public function exists( string $cache_key ): bool { return file_exists( $this->file_path( $cache_key ) ); } /** * Get the absolute file path for a cache key (MP3). * * @param string $cache_key Cache key. * @return string */ public function file_path( string $cache_key ): string { return $this->cache_dir . '/' . $cache_key . '.mp3'; } /** * Get the URL for a cache key. * * @param string $cache_key Cache key. * @return string */ public function file_url( string $cache_key ): string { return $this->proxy_url( $cache_key ); } /** * Store audio data in the cache (WAV in → MP3 stored). * * @param string $cache_key Cache key. * @param string $data Raw WAV data. * @return bool */ public function put( string $cache_key, string $data ): bool { $settings = get_option( 'piperless_settings', [] ); $ffmpeg = $this->find_ffmpeg(); if ( null !== $ffmpeg ) { // Write WAV to temp, convert to MP3, store MP3. $wav_tmp = tempnam( sys_get_temp_dir(), 'piperless_' ) . '.wav'; error_clear_last(); if ( false === @file_put_contents( $wav_tmp, $data, LOCK_EX ) ) { $this->logger->log_last_error( 'Temp WAV write' ); $this->logger->error( 'Failed to write temp WAV for conversion.' ); return false; } $mp3_path = $this->file_path( $cache_key ); $bitrate = $settings['piper_mp3_bitrate'] ?? '32k'; $cmd = sprintf( '%s -i %s -codec:a libmp3lame -b:a %s -ac 1 -y %s 2>&1', escapeshellarg( $ffmpeg ), escapeshellarg( $wav_tmp ), escapeshellarg( $bitrate ), escapeshellarg( $mp3_path ) ); $output = []; $ret = 0; exec( $cmd, $output, $ret ); @unlink( $wav_tmp ); if ( 0 === $ret && file_exists( $mp3_path ) && filesize( $mp3_path ) > 0 ) { $this->logger->info( 'Cached MP3: {key} ({size} bytes)', [ 'key' => $cache_key, 'size' => filesize( $mp3_path ), ] ); return true; } $this->logger->error( 'ffmpeg conversion failed for {key}, code {code}', [ 'key' => $cache_key, 'code' => $ret, ] ); // Fall through to store WAV if ffmpeg failed. } // No ffmpeg — store WAV as-is. $wav_path = $this->cache_dir . '/' . $cache_key . '.wav'; error_clear_last(); $written = @file_put_contents( $wav_path, $data, LOCK_EX ); if ( false === $written ) { $this->logger->log_last_error( 'Cache file write' ); $this->logger->error( 'Failed to write cache file: {path}', [ 'path' => $wav_path ] ); return false; } $this->logger->info( 'Cached WAV: {key} ({size} bytes) — ffmpeg unavailable', [ 'key' => $cache_key, 'size' => $written, ] ); return true; } /** * Retrieve cached audio data. * * @param string $cache_key Cache key. * @return string|null Raw WAV data or null. */ public function get( string $cache_key ): ?string { $path = $this->file_path( $cache_key ); if ( ! file_exists( $path ) ) { return null; } $data = @file_get_contents( $path ); return ( false === $data ) ? null : $data; } /** * Delete a specific cache entry. * * @param string $cache_key Cache key. * @return bool */ public function delete( string $cache_key ): bool { $deleted = false; // Delete MP3. $mp3_path = $this->file_path( $cache_key ); if ( file_exists( $mp3_path ) ) { if ( @unlink( $mp3_path ) ) { $deleted = true; } else { $this->logger->warning( 'Failed to unlink: {path}', [ 'path' => $mp3_path ] ); } } // Also delete legacy WAV. $wav_path = $this->cache_dir . '/' . $cache_key . '.wav'; if ( file_exists( $wav_path ) ) { if ( @unlink( $wav_path ) ) { $deleted = true; } else { $this->logger->warning( 'Failed to unlink: {path}', [ 'path' => $wav_path ] ); } } if ( $deleted ) { $this->logger->info( 'Deleted cached audio: {key}', [ 'key' => $cache_key ] ); } else { $this->logger->debug( 'Nothing to delete for key: {key}', [ 'key' => $cache_key ] ); } return $deleted; } /** * Flush the entire cache. * * @return int Number of files deleted. */ public function flush(): int { if ( ! is_dir( $this->cache_dir ) ) { return 0; } $count = 0; // Delete MP3 files. $mp3_files = glob( $this->cache_dir . '/*.mp3' ); if ( false !== $mp3_files ) { foreach ( $mp3_files as $file ) { if ( @unlink( $file ) ) { $count++; } } } // Also clean up any legacy WAV files. $wav_files = glob( $this->cache_dir . '/*.wav' ); if ( false !== $wav_files ) { foreach ( $wav_files as $file ) { if ( @unlink( $file ) ) { $count++; } } } $this->logger->info( 'Flushed {count} cached audio files.', [ 'count' => $count ] ); return $count; } /** * Clear disabled entries — entries not currently active for their post. * * @return int Number of files deleted. */ /** * Clear orphaned files — cache entries not referenced by any post meta. * * @return int Number of orphaned files deleted. */ public function clear_orphans(): int { if ( ! is_dir( $this->cache_dir ) ) { return 0; } $entries = $this->get_entries( 1, 9999 ); $count = 0; $this->logger->debug( 'Clear orphans: scanning {total} entries', [ 'total' => $entries['total'] ] ); foreach ( $entries['entries'] as $entry ) { if ( ! $entry['enabled'] ) { $this->logger->debug( 'Deleting orphaned entry: {key} ({file})', [ 'key' => $entry['key'], 'file' => $entry['filename'] ?? '?', ] ); if ( $this->delete( $entry['key'] ) ) { $count++; } else { $this->logger->warning( 'Failed to delete orphaned entry: {key}', [ 'key' => $entry['key'] ] ); } } } // Also delete model preview and test preview files that accumulate // when models are changed or removed. Not counted — they're not // "orphaned audio" in the user-facing sense. $preview_patterns = [ 'model_preview_*', 'piperless_test_preview*' ]; foreach ( $preview_patterns as $pattern ) { $preview_files = glob( $this->cache_dir . '/' . $pattern . '.mp3' ); if ( false !== $preview_files ) { foreach ( $preview_files as $file ) { @unlink( $file ); } } $preview_wavs = glob( $this->cache_dir . '/' . $pattern . '.wav' ); if ( false !== $preview_wavs ) { foreach ( $preview_wavs as $file ) { @unlink( $file ); } } } $this->logger->info( 'Cleared {count} orphaned/disabled audio files.', [ 'count' => $count ] ); return $count; } /** * Get cache statistics. * * @return array{cached_files:int,total_bytes:int} */ public function stats(): array { if ( ! is_dir( $this->cache_dir ) ) { return [ 'cached_files' => 0, 'total_bytes' => 0 ]; } $total = 0; $counted = 0; $all_files = array_merge( (array) glob( $this->cache_dir . '/*.mp3' ), (array) glob( $this->cache_dir . '/*.wav' ) ); foreach ( $all_files as $file ) { $key = basename( $file ); $key = str_replace( [ '.mp3', '.wav' ], '', $key ); if ( str_starts_with( $key, 'model_preview_' ) || str_starts_with( $key, 'piperless_test_preview' ) ) { continue; } $size = @filesize( $file ); if ( false !== $size ) { $total += $size; $counted++; } } return [ 'cached_files' => $counted, 'total_bytes' => $total, ]; } /** * Get the proxy URL for serving a cached audio file through the REST API. * * @param string $cache_key Cache key. * @return string */ public function proxy_url( string $cache_key ): string { return rest_url( 'piperless/v1/audio' ) . '?key=' . urlencode( $cache_key ); } /** * Find ffmpeg binary on the system. * * @return string|null */ private function find_ffmpeg(): ?string { static $cached = null; static $resolved_path = null; // Return cached result. if ( null !== $cached ) { return $resolved_path; } $cached = false; $resolved_path = null; $settings = get_option( 'piperless_settings', [] ); $custom = $settings['piper_ffmpeg_binary'] ?? ''; if ( '' !== $custom ) { if ( @file_exists( $custom ) && @is_executable( $custom ) ) { $cached = true; $resolved_path = $custom; return $resolved_path; } $this->logger->warning( 'Configured ffmpeg binary not found or not executable: {path}. Trying auto-detection.', [ 'path' => $custom ] ); } $candidates = apply_filters( 'piperless_ffmpeg_paths', [ '/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/bin/ffmpeg' ] ); foreach ( $candidates as $path ) { if ( @file_exists( $path ) && @is_executable( $path ) ) { $cached = true; $resolved_path = $path; $this->logger->info( 'Found ffmpeg at {path}', [ 'path' => $path ] ); return $resolved_path; } } $this->logger->warning( 'ffmpeg not found on this system — audio will be stored as WAV. Install ffmpeg or set the FFmpeg Binary Path in settings.' ); $cached = true; // Negative cache — don't re-scan. return null; } /** * Get paginated cache entries with associated post info. * * @param int $page Page number (1-based). * @param int $per_page Entries per page. * @return array{entries:array,total:int,pages:int} */ public function get_entries( int $page = 1, int $per_page = 20 ): array { if ( ! is_dir( $this->cache_dir ) ) { return [ 'entries' => [], 'total' => 0, 'pages' => 0 ]; } // Collect all referenced cache keys from post meta. global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s", '_piperless_cache_key' ) ); $key_to_post = []; $key_to_model = []; foreach ( $rows as $row ) { $keys = maybe_unserialize( $row->meta_value ); if ( is_array( $keys ) ) { foreach ( $keys as $k => $v ) { if ( is_string( $k ) && '' !== $k ) { $key_to_post[ $k ] = (int) $row->post_id; if ( is_string( $v ) && '' !== $v ) { $key_to_model[ $k ] = $v; } } } } elseif ( is_string( $keys ) ) { $key_to_post[ $keys ] = (int) $row->post_id; } } // Also collect mp3 keys. $rows_mp3 = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s", '_piperless_cache_key' ) ); // Scan both MP3 and legacy WAV files. $all_files = array_merge( (array) glob( $this->cache_dir . '/*.mp3' ), (array) glob( $this->cache_dir . '/*.wav' ) ); $entries = []; foreach ( $all_files as $path ) { $ext = pathinfo( $path, PATHINFO_EXTENSION ); $key = basename( $path, '.' . $ext ); // Skip preview-only cache entries. if ( str_starts_with( $key, 'model_preview_' ) || str_starts_with( $key, 'piperless_test_preview' ) ) { continue; } $post_id = $key_to_post[ $key ] ?? null; $title = ''; $edit_url = ''; $model = ''; if ( null !== $post_id ) { $post = get_post( $post_id ); if ( $post ) { $title = $post->post_title; $edit_url = get_edit_post_link( $post_id, 'raw' ); } $model = $key_to_model[ $key ] ?? ''; } $size_bytes = filesize( $path ); $is_mp3 = ( 'mp3' === $ext ); // Check for the companion format. $has_mp3 = $is_mp3 || file_exists( $this->cache_dir . '/' . $key . '.mp3' ); $has_wav = ( ! $is_mp3 ) || file_exists( $this->cache_dir . '/' . $key . '.wav' ); // Check if this entry is the active audio for its post. $enabled = false; if ( null !== $post_id ) { $post_audio_url = get_post_meta( $post_id, '_piperless_audio_url', true ); $enabled = ( $this->proxy_url( $key ) === $post_audio_url ); } $settings = get_option( 'piperless_settings', [] ); $mtime = filemtime( $path ); $entries[] = [ 'key' => $key, 'filename' => basename( $path ), 'size_bytes' => $size_bytes, 'has_mp3' => $has_mp3, 'bitrate' => $has_mp3 ? ( $settings['piper_mp3_bitrate'] ?? '32k' ) : '', 'created' => $mtime ? gmdate( 'Y-m-d H:i', $mtime ) : '', 'enabled' => $enabled, 'model' => $model, 'post_id' => $post_id, 'post_title' => $title, 'edit_url' => $edit_url, 'orphaned' => ( null === $post_id ), 'proxy_url' => $this->proxy_url( $key ), ]; } $total = count( $entries ); $pages = (int) ceil( $total / max( 1, $per_page ) ); $page = max( 1, min( $page, max( 1, $pages ) ) ); $offset = ( $page - 1 ) * $per_page; $entries = array_slice( $entries, $offset, $per_page ); return [ 'entries' => array_values( $entries ), 'total' => $total, 'pages' => $pages, ]; } /** * Delete a cache entry by key (both WAV and MP3). * * @param string $cache_key Cache key. * @return bool */ public function delete_entry( string $cache_key ): bool { return $this->delete( $cache_key ); } /** * Return the cache directory path. * * @return string */ public function dir(): string { return $this->cache_dir; } /** * Ensure the cache directory exists. * * @return bool */ public function ensure_dir(): bool { if ( is_dir( $this->cache_dir ) ) { return true; } $created = wp_mkdir_p( $this->cache_dir ); if ( ! $created ) { $this->logger->error( 'Failed to create cache directory: {dir}', [ 'dir' => $this->cache_dir ] ); return false; } // Security: prevent all direct access. Audio served via REST API. $htaccess = $this->cache_dir . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { @file_put_contents( $htaccess, "Deny from all\n" ); } if ( ! file_exists( $this->cache_dir . '/index.php' ) ) { @file_put_contents( $this->cache_dir . '/index.php', "