mirror of
https://github.com/forkless/Piperless.git
synced 2026-08-21 02:25:32 +02:00
Initial commit — Piperless v1.0.0
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
/**
|
||||
* Audio file cache manager.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio file cache manager.
|
||||
*
|
||||
* Stores generated WAV/MP3 files in wp-content/uploads/piperless/.
|
||||
* Files are content-addressed: the cache key is a SHA-256 hash of
|
||||
* text + model + language + quality + bitrate, so identical content
|
||||
* always produces the same key and reuses the file.
|
||||
*
|
||||
* ## MP3 conversion
|
||||
*
|
||||
* When ffmpeg is available (auto-detected from common paths or
|
||||
* configured via piper_ffmpeg_binary), WAV data is written to a temp
|
||||
* file, converted to MP3 via exec(), and stored as .mp3. If ffmpeg
|
||||
* is unavailable or conversion fails, the raw WAV is stored as .wav.
|
||||
* Both formats coexist in the same directory; the REST proxy prefers
|
||||
* MP3 and falls back to WAV.
|
||||
*
|
||||
* ## Orphan detection
|
||||
*
|
||||
* clear_orphans() cross-references every file in the cache directory
|
||||
* against the _piperless_cache_key post meta across all posts. Files
|
||||
* with no matching post are deleted. Model preview and test preview
|
||||
* files (model_preview_*, piperless_test_preview*) are silently
|
||||
* cleaned up but not counted in the returned total.
|
||||
*
|
||||
* ## Security
|
||||
*
|
||||
* The cache directory is protected by .htaccess (Deny from all) on
|
||||
* Apache. Audio is served exclusively through the REST API proxy
|
||||
* endpoint /piperless/v1/audio, which validates cache keys and
|
||||
* supports HTTP Range requests for seeking.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Cache_Manager {
|
||||
|
||||
/** @var Logger */
|
||||
private Logger $logger;
|
||||
|
||||
/** @var string Absolute path to the cache directory. */
|
||||
private string $cache_dir;
|
||||
|
||||
/** @var string URL to the cache directory. */
|
||||
private string $cache_url;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param Logger $logger Logger instance.
|
||||
*/
|
||||
public function __construct( Logger $logger ) {
|
||||
$this->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', "<?php // Silence is golden.\n" );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user