mirror of
https://github.com/forkless/Piperless.git
synced 2026-08-24 11:32:39 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
/**
|
||||
* Gutenberg (block editor) integration.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutenberg (block editor) integration.
|
||||
*
|
||||
* Registers a PluginSidebar panel in the post editor with three
|
||||
* collapsible sections (Generation, Voice Settings, Display Settings)
|
||||
* and an audio preview panel. Per-post overrides for voice, language,
|
||||
* quality, player style, placement, title, sentence silence, and
|
||||
* length scale are stored as post meta (show_in_rest: true).
|
||||
*
|
||||
* ## REST API endpoints
|
||||
*
|
||||
* All endpoints are registered under /piperless/v1/:
|
||||
*
|
||||
* - GET /audio?key=… — serve cached audio (public, rate-limited)
|
||||
* - POST /generate — trigger audio generation
|
||||
* - GET /status/<id> — check audio status
|
||||
* - GET /models — list available voice models
|
||||
* - DELETE /audio/<id> — remove audio and cached files
|
||||
*
|
||||
* Generate, status, and remove check per-post ownership
|
||||
* (current_user_can('edit_post', $post_id)). The audio proxy endpoint
|
||||
* is intentionally public (required by frontend players) but
|
||||
* rate-limited per IP and validates cache keys with a regex.
|
||||
*
|
||||
* ## Audio proxy
|
||||
*
|
||||
* stream_file() handles HTTP Range requests (206 Partial Content)
|
||||
* so browsers can determine audio duration and seek. Without this,
|
||||
* audio.duration stays NaN and the progress bar, time display, and
|
||||
* scrub bar all break.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Gutenberg {
|
||||
|
||||
/** @var Logger */
|
||||
private Logger $logger;
|
||||
|
||||
/** @var Transcriber */
|
||||
private Transcriber $transcriber;
|
||||
|
||||
/** @var Cache_Manager */
|
||||
private Cache_Manager $cache;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param Logger $logger Logger.
|
||||
* @param Transcriber $transcriber Transcriber.
|
||||
* @param Cache_Manager $cache Cache manager.
|
||||
*/
|
||||
public function __construct( Logger $logger, Transcriber $transcriber, Cache_Manager $cache ) {
|
||||
$this->logger = $logger;
|
||||
$this->transcriber = $transcriber;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_editor_assets' ] );
|
||||
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
|
||||
|
||||
// Register post meta for per-post overrides.
|
||||
$post_types = apply_filters( 'piperless_post_types', [ 'post', 'page' ] );
|
||||
|
||||
foreach ( $post_types as $post_type ) {
|
||||
register_post_meta( $post_type, '_piperless_voice', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_language', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_quality', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_audio_url', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_duration', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'number',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_title', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_sentence_silence', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_length_scale', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_placement', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
register_post_meta( $post_type, '_piperless_style', [
|
||||
'show_in_rest' => true,
|
||||
'single' => true,
|
||||
'type' => 'string',
|
||||
'auth_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the Gutenberg sidebar script.
|
||||
*/
|
||||
public function enqueue_editor_assets(): void {
|
||||
$screen = get_current_screen();
|
||||
|
||||
if ( ! $screen || ! $screen->is_block_editor() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_types = apply_filters( 'piperless_post_types', [ 'post', 'page' ] );
|
||||
|
||||
if ( ! in_array( $screen->post_type, $post_types, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'piperless-gutenberg',
|
||||
PIPERLESS_PLUGIN_URL . 'assets/js/gutenberg.js',
|
||||
[
|
||||
'wp-plugins',
|
||||
'wp-edit-post',
|
||||
'wp-components',
|
||||
'wp-data',
|
||||
'wp-element',
|
||||
'wp-i18n',
|
||||
'wp-api-fetch',
|
||||
'wp-notices',
|
||||
],
|
||||
PIPERLESS_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// Pass data to the JS.
|
||||
$post_id = get_the_ID();
|
||||
$status = $post_id ? $this->transcriber->status( $post_id ) : [
|
||||
'has_audio' => false,
|
||||
'url' => null,
|
||||
'duration' => null,
|
||||
];
|
||||
|
||||
wp_localize_script( 'piperless-gutenberg', 'piperlessEditor', [
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
'postId' => $post_id,
|
||||
'audioStatus' => $status,
|
||||
'defaultVoice' => '',
|
||||
'defaultLang' => 'en_US',
|
||||
'defaultQuality' => 'medium',
|
||||
'defaultTitle' => get_option( 'piperless_settings', [] )['player_title'] ?? __( 'Audio transcript', 'piperless' ),
|
||||
'i18n' => [
|
||||
'title' => __( 'Audio Transcript', 'piperless' ),
|
||||
'generate' => __( 'Generate Audio', 'piperless' ),
|
||||
'regenerate' => __( 'Regenerate Audio', 'piperless' ),
|
||||
'generating' => __( 'Generating…', 'piperless' ),
|
||||
'preview' => __( 'Preview', 'piperless' ),
|
||||
'remove' => __( 'Remove Audio', 'piperless' ),
|
||||
'noAudio' => __( 'No audio generated yet.', 'piperless' ),
|
||||
'duration' => __( 'Duration:', 'piperless' ),
|
||||
'success' => __( 'Audio generated successfully!', 'piperless' ),
|
||||
'error' => __( 'Generation failed. Check the plugin log.', 'piperless' ),
|
||||
'voiceLabel' => __( 'Voice', 'piperless' ),
|
||||
'languageLabel' => __( 'Language', 'piperless' ),
|
||||
'qualityLabel' => __( 'Quality', 'piperless' ),
|
||||
'confirmRegen' => __( 'Regenerate audio? This will overwrite the existing transcript.', 'piperless' ),
|
||||
'titleLabel' => __( 'Player Title', 'piperless' ),
|
||||
'titlePlaceholder' => __( 'Audio transcript', 'piperless' ),
|
||||
],
|
||||
] );
|
||||
|
||||
wp_set_script_translations( 'piperless-gutenberg', 'piperless', PIPERLESS_PLUGIN_DIR . 'languages' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API endpoints.
|
||||
*/
|
||||
public function register_rest_routes(): void {
|
||||
// GET /piperless/v1/audio — serve cached audio through PHP proxy.
|
||||
register_rest_route( 'piperless/v1', '/audio', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [ $this, 'rest_serve_audio' ],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'description' => __( 'Cache key', 'piperless' ),
|
||||
],
|
||||
],
|
||||
] );
|
||||
|
||||
// POST /piperless/v1/generate — trigger audio generation.
|
||||
register_rest_route( 'piperless/v1', '/generate', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [ $this, 'rest_generate' ],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
'args' => [
|
||||
'post_id' => [
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
'description' => __( 'Post ID', 'piperless' ),
|
||||
],
|
||||
'voice' => [
|
||||
'type' => 'string',
|
||||
'description' => __( 'Voice name override', 'piperless' ),
|
||||
],
|
||||
'language' => [
|
||||
'type' => 'string',
|
||||
'description' => __( 'Language code override', 'piperless' ),
|
||||
],
|
||||
'quality' => [
|
||||
'type' => 'string',
|
||||
'description' => __( 'Quality tier override', 'piperless' ),
|
||||
],
|
||||
],
|
||||
] );
|
||||
|
||||
// GET /piperless/v1/status/<post_id> — check audio status.
|
||||
register_rest_route( 'piperless/v1', '/status/(?P<post_id>\d+)', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [ $this, 'rest_status' ],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
'args' => [
|
||||
'post_id' => [
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
'description' => __( 'Post ID', 'piperless' ),
|
||||
],
|
||||
],
|
||||
] );
|
||||
|
||||
// GET /piperless/v1/models — list available models.
|
||||
register_rest_route( 'piperless/v1', '/models', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [ $this, 'rest_models' ],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
] );
|
||||
|
||||
// DELETE /piperless/v1/audio/<post_id> — remove audio for a post.
|
||||
register_rest_route( 'piperless/v1', '/audio/(?P<post_id>\d+)', [
|
||||
'methods' => \WP_REST_Server::DELETABLE,
|
||||
'callback' => [ $this, 'rest_remove_audio' ],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
'args' => [
|
||||
'post_id' => [
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
],
|
||||
],
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* REST: trigger audio generation.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function rest_generate( \WP_REST_Request $request ) {
|
||||
$post_id = (int) $request->get_param( 'post_id' );
|
||||
$voice = sanitize_text_field( $request->get_param( 'voice' ) ?? '' );
|
||||
$language = sanitize_text_field( $request->get_param( 'language' ) ?? '' );
|
||||
$quality = sanitize_text_field( $request->get_param( 'quality' ) ?? '' );
|
||||
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return new \WP_Error(
|
||||
'piperless_forbidden',
|
||||
__( 'You do not have permission to edit this post.', 'piperless' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->transcriber->generate( $post_id, $voice, $language, $quality );
|
||||
|
||||
if ( ! $result['success'] ) {
|
||||
return new \WP_Error(
|
||||
'piperless_generation_failed',
|
||||
$result['error'],
|
||||
[ 'status' => 500 ]
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response( [
|
||||
'url' => $result['url'],
|
||||
'duration' => get_post_meta( $post_id, '_piperless_duration', true ),
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* REST: get audio status for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function rest_status( \WP_REST_Request $request ) {
|
||||
$post_id = (int) $request->get_param( 'post_id' );
|
||||
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return new \WP_Error(
|
||||
'piperless_forbidden',
|
||||
__( 'You do not have permission to edit this post.', 'piperless' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response( $this->transcriber->status( $post_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* REST: list available models.
|
||||
*
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function rest_models() {
|
||||
$piper = new Piper( $this->logger );
|
||||
$models = $piper->scan_models();
|
||||
|
||||
// Simplify for the frontend.
|
||||
$voices = [];
|
||||
$languages = [];
|
||||
$qualities = [];
|
||||
|
||||
foreach ( $models as $model ) {
|
||||
$voices[ $model['voice'] ] = true;
|
||||
$languages[ $model['language'] ] = true;
|
||||
$qualities[ $model['quality'] ] = true;
|
||||
}
|
||||
|
||||
// Strip absolute filesystem paths — only expose the model basename.
|
||||
$safe_models = [];
|
||||
foreach ( $models as $m ) {
|
||||
$safe_models[] = [
|
||||
'name' => $m['name'],
|
||||
'voice' => $m['voice'],
|
||||
'language' => $m['language'],
|
||||
'quality' => $m['quality'],
|
||||
'path' => basename( $m['path'] ),
|
||||
];
|
||||
}
|
||||
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$aliases = $settings['voice_aliases'] ?? [];
|
||||
|
||||
return rest_ensure_response( [
|
||||
'models' => $safe_models,
|
||||
'voices' => array_keys( $voices ),
|
||||
'languages' => array_keys( $languages ),
|
||||
'qualities' => array_keys( $qualities ),
|
||||
'voice_aliases' => $aliases,
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* REST: remove audio for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function rest_remove_audio( \WP_REST_Request $request ) {
|
||||
$post_id = (int) $request->get_param( 'post_id' );
|
||||
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return new \WP_Error(
|
||||
'piperless_forbidden',
|
||||
__( 'You do not have permission to edit this post.', 'piperless' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
// Delete cached audio files from disk.
|
||||
$cache_keys = get_post_meta( $post_id, '_piperless_cache_key', true );
|
||||
if ( is_array( $cache_keys ) ) {
|
||||
foreach ( $cache_keys as $key => $model ) {
|
||||
$this->cache->delete( is_string( $key ) ? $key : $model );
|
||||
}
|
||||
} elseif ( is_string( $cache_keys ) && '' !== $cache_keys ) {
|
||||
$this->cache->delete( $cache_keys );
|
||||
}
|
||||
|
||||
delete_post_meta( $post_id, '_piperless_audio_url' );
|
||||
delete_post_meta( $post_id, '_piperless_duration' );
|
||||
delete_post_meta( $post_id, '_piperless_generated_at' );
|
||||
delete_post_meta( $post_id, '_piperless_cache_key' );
|
||||
|
||||
$this->logger->info( 'Audio removed for post {id}', [ 'id' => $post_id ] );
|
||||
|
||||
return rest_ensure_response( [ 'success' => true ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* REST: serve cached audio file through PHP proxy.
|
||||
*
|
||||
* Reads the file from the protected cache directory and streams it
|
||||
* with proper Content-Type and Content-Length headers.
|
||||
* Prefers MP3 if cached, falls back to WAV.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function rest_serve_audio( \WP_REST_Request $request ) {
|
||||
$cache_key = sanitize_text_field( $request->get_param( 'key' ) );
|
||||
|
||||
// Validate: only alphanumeric + underscores + hyphens in cache keys.
|
||||
if ( ! preg_match( '/^[a-zA-Z0-9_-]+$/', $cache_key ) ) {
|
||||
return new \WP_Error( 'invalid_key', __( 'Invalid cache key.', 'piperless' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
// Rate limit: configurable requests per minute per IP.
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$rate_key = 'piperless_rate_audio_' . md5( $ip );
|
||||
$rate_count = (int) get_transient( $rate_key );
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$rate_limit = max( 1, min( 600, (int) ( $settings['audio_rate_limit'] ?? 60 ) ) );
|
||||
|
||||
if ( $rate_count >= $rate_limit ) {
|
||||
return new \WP_Error(
|
||||
'rate_limited',
|
||||
__( 'Too many requests. Please try again later.', 'piperless' ),
|
||||
[ 'status' => 429 ]
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( $rate_key, $rate_count + 1, 60 );
|
||||
|
||||
// Try MP3 first (canonical format).
|
||||
$mp3_path = $this->cache->file_path( $cache_key );
|
||||
|
||||
if ( file_exists( $mp3_path ) ) {
|
||||
return $this->stream_file( $mp3_path, 'audio/mpeg' );
|
||||
}
|
||||
|
||||
// Fall back to legacy WAV.
|
||||
$wav_path = $this->cache->dir() . '/' . $cache_key . '.wav';
|
||||
|
||||
if ( ! file_exists( $wav_path ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Audio file not found.', 'piperless' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
return $this->stream_file( $wav_path, 'audio/wav' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with proper HTTP headers.
|
||||
*
|
||||
* @param string $path Absolute file path.
|
||||
* @param string $content_type MIME type.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
private function stream_file( string $path, string $content_type ): \WP_REST_Response {
|
||||
$size = filesize( $path );
|
||||
$fp = fopen( $path, 'rb' );
|
||||
|
||||
if ( false === $fp ) {
|
||||
return new \WP_Error( 'read_error', __( 'Could not read audio file.', 'piperless' ), [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
// ── Range request support ───────────────────────────────────
|
||||
// Browsers use Range requests to read audio headers (duration
|
||||
// detection) and to seek within the file. Without 206 Partial
|
||||
// Content responses, the progress bar, time display, and scrub
|
||||
// bar all fail because audio.duration stays NaN.
|
||||
$range_header = $_SERVER['HTTP_RANGE'] ?? '';
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
$is_range = false;
|
||||
|
||||
if ( preg_match( '/bytes=(\d*)-(\d*)/', $range_header, $m ) ) {
|
||||
$is_range = true;
|
||||
$start = ( '' !== $m[1] ) ? (int) $m[1] : 0;
|
||||
$end = ( '' !== $m[2] ) ? (int) $m[2] : ( $size - 1 );
|
||||
$start = max( 0, min( $start, $size - 1 ) );
|
||||
$end = max( $start, min( $end, $size - 1 ) );
|
||||
}
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
if ( $is_range ) {
|
||||
header( 'HTTP/1.1 206 Partial Content' );
|
||||
header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $size );
|
||||
header( 'Content-Length: ' . (string) $length );
|
||||
fseek( $fp, $start );
|
||||
} else {
|
||||
header( 'Content-Length: ' . (string) $size );
|
||||
}
|
||||
|
||||
header( 'Content-Type: ' . $content_type );
|
||||
header( 'Accept-Ranges: bytes' );
|
||||
header( 'Cache-Control: public, max-age=86400' );
|
||||
|
||||
// Stream the requested range in chunks.
|
||||
$sent = 0;
|
||||
while ( $sent < $length && ! feof( $fp ) ) {
|
||||
$chunk = fread( $fp, min( 8192, $length - $sent ) );
|
||||
if ( false === $chunk ) {
|
||||
break;
|
||||
}
|
||||
echo $chunk;
|
||||
$sent += strlen( $chunk );
|
||||
if ( ob_get_level() > 0 ) {
|
||||
ob_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose( $fp );
|
||||
|
||||
do_action( 'shutdown' );
|
||||
die();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* PSR-3-inspired file logger for Piperless.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* PSR-3-inspired file logger.
|
||||
*
|
||||
* Writes to two channels:
|
||||
* - WordPress debug.log (when WP_DEBUG + WP_DEBUG_LOG are enabled)
|
||||
* - A dedicated piperless.log in wp-content/uploads/piperless/
|
||||
*
|
||||
* The dedicated log file is chmod'd 0600 after every write to prevent
|
||||
* world-readable access on servers without .htaccess protection
|
||||
* (Nginx, IIS, LiteSpeed). If the dedicated file can't be written,
|
||||
* entries fall through to PHP's error_log() so diagnostics are never
|
||||
* completely lost.
|
||||
*
|
||||
* Severity levels (PSR-3 compatible): emergency, alert, critical,
|
||||
* error, warning, notice, info, debug. The minimum level is
|
||||
* controlled by the logging_level setting.
|
||||
*
|
||||
* log_last_error() captures error_get_last() after @-suppressed
|
||||
* filesystem operations — call error_clear_last() before the @ call
|
||||
* for deterministic attribution.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Logger {
|
||||
|
||||
public const EMERGENCY = 'emergency';
|
||||
public const ALERT = 'alert';
|
||||
public const CRITICAL = 'critical';
|
||||
public const ERROR = 'error';
|
||||
public const WARNING = 'warning';
|
||||
public const NOTICE = 'notice';
|
||||
public const INFO = 'info';
|
||||
public const DEBUG = 'debug';
|
||||
|
||||
/**
|
||||
* Ordered severity (lowest number = most severe).
|
||||
*
|
||||
* @var array<string,int>
|
||||
*/
|
||||
private const LEVELS = [
|
||||
self::EMERGENCY => 0,
|
||||
self::ALERT => 1,
|
||||
self::CRITICAL => 2,
|
||||
self::ERROR => 3,
|
||||
self::WARNING => 4,
|
||||
self::NOTICE => 5,
|
||||
self::INFO => 6,
|
||||
self::DEBUG => 7,
|
||||
];
|
||||
|
||||
/** @var string Minimum level to record. */
|
||||
private string $threshold;
|
||||
|
||||
/** @var string Path to log file. */
|
||||
private string $log_file;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$this->threshold = $settings['logging_level'] ?? self::WARNING;
|
||||
|
||||
$upload_dir = wp_upload_dir();
|
||||
$this->log_file = trailingslashit( $upload_dir['basedir'] ) . 'piperless/piperless.log';
|
||||
}
|
||||
|
||||
/**
|
||||
* System is unusable.
|
||||
*/
|
||||
public function emergency( string $message, array $context = [] ): void {
|
||||
$this->log( self::EMERGENCY, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Action must be taken immediately.
|
||||
*/
|
||||
public function alert( string $message, array $context = [] ): void {
|
||||
$this->log( self::ALERT, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Critical conditions.
|
||||
*/
|
||||
public function critical( string $message, array $context = [] ): void {
|
||||
$this->log( self::CRITICAL, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime errors that do not require immediate action.
|
||||
*/
|
||||
public function error( string $message, array $context = [] ): void {
|
||||
$this->log( self::ERROR, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Exceptional occurrences that are not errors.
|
||||
*/
|
||||
public function warning( string $message, array $context = [] ): void {
|
||||
$this->log( self::WARNING, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal but significant events.
|
||||
*/
|
||||
public function notice( string $message, array $context = [] ): void {
|
||||
$this->log( self::NOTICE, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Interesting events.
|
||||
*/
|
||||
public function info( string $message, array $context = [] ): void {
|
||||
$this->log( self::INFO, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed debug information.
|
||||
*/
|
||||
public function debug( string $message, array $context = [] ): void {
|
||||
$this->log( self::DEBUG, $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry.
|
||||
*
|
||||
* @param string $level Severity level.
|
||||
* @param string $message Log message.
|
||||
* @param array<string,mixed> $context Additional data.
|
||||
*/
|
||||
private function log( string $level, string $message, array $context = [] ): void {
|
||||
if ( ! isset( self::LEVELS[ $level ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( self::LEVELS[ $level ] > self::LEVELS[ $this->threshold ] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$line = $this->format( $level, $message, $context );
|
||||
|
||||
// WordPress debug log.
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( '[piperless] ' . $line );
|
||||
}
|
||||
|
||||
// Dedicated log file.
|
||||
$dir = dirname( $this->log_file );
|
||||
$written = false;
|
||||
|
||||
if ( wp_mkdir_p( $dir ) ) {
|
||||
$written = ( false !== @file_put_contents( $this->log_file, $line . "\n", FILE_APPEND | LOCK_EX ) );
|
||||
// Restrict to owner-only: 0600 prevents web-server read on
|
||||
// hosts without .htaccess protection (Nginx, IIS, LiteSpeed).
|
||||
if ( $written ) {
|
||||
@chmod( $this->log_file, 0600 );
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the dedicated log file couldn't be written,
|
||||
// send the entry to PHP's error_log so it reaches the server log
|
||||
// (or syslog). This ensures diagnostics are not lost on hosts
|
||||
// where the uploads directory is not writable.
|
||||
if ( ! $written ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( '[piperless] [FALLBACK] ' . $line );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log line.
|
||||
*
|
||||
* @param string $level Severity.
|
||||
* @param string $message Message text.
|
||||
* @param array<string,mixed> $context Context data.
|
||||
* @return string
|
||||
*/
|
||||
private function format( string $level, string $message, array $context ): string {
|
||||
$timestamp = gmdate( 'Y-m-d H:i:s' );
|
||||
$upper = strtoupper( $level );
|
||||
|
||||
// Interpolate context placeholders: {key}
|
||||
if ( [] !== $context ) {
|
||||
$replacements = [];
|
||||
foreach ( $context as $key => $val ) {
|
||||
if ( ! is_array( $val ) && ( ! is_object( $val ) || method_exists( $val, '__toString' ) ) ) {
|
||||
$replacements[ '{' . $key . '}' ] = (string) $val;
|
||||
}
|
||||
}
|
||||
$message = strtr( $message, $replacements );
|
||||
}
|
||||
|
||||
return sprintf( '[%s] %s: %s', $timestamp, $upper, $message );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the log file path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function log_file_path(): string {
|
||||
return $this->log_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read recent log entries.
|
||||
*
|
||||
* @param int $lines Number of lines to return.
|
||||
* @return string[]
|
||||
*/
|
||||
public function tail( int $lines = 50 ): array {
|
||||
if ( ! file_exists( $this->log_file ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = @file( $this->log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
|
||||
if ( false === $content ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice( $content, -$lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture and log the last PHP error (typically after a @-suppressed
|
||||
* filesystem operation). Call immediately after a failed @ call to
|
||||
* preserve the diagnostic information.
|
||||
*
|
||||
* @param string $context Description of what was being attempted.
|
||||
*/
|
||||
public function log_last_error( string $context ): void {
|
||||
$err = error_get_last();
|
||||
if ( null === $err ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warning(
|
||||
'{context}: {type}: {message} in {file}:{line}',
|
||||
[
|
||||
'context' => $context,
|
||||
'type' => $err['type'] ?? '?',
|
||||
'message' => $err['message'] ?? '?',
|
||||
'file' => $err['file'] ?? '?',
|
||||
'line' => $err['line'] ?? '?',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the log file.
|
||||
*/
|
||||
public function clear(): void {
|
||||
if ( file_exists( $this->log_file ) ) {
|
||||
@unlink( $this->log_file );
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* Frontend audio player rendering.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend audio player rendering.
|
||||
*
|
||||
* Renders a custom HTML5 audio player with play/pause, click-to-seek
|
||||
* progress bar, volume control, and time display. The player markup
|
||||
* is pure HTML/CSS; behaviour is driven by assets/js/player.js.
|
||||
*
|
||||
* ## Placement
|
||||
*
|
||||
* maybe_prepend_player() hooks into the_content filter (priority 20)
|
||||
* and inserts the player based on the placement setting (per-post
|
||||
* _piperless_placement meta wins over the global player_placement
|
||||
* setting). Supported values: before, after, both, manual.
|
||||
*
|
||||
* ## Player style
|
||||
*
|
||||
* Six themes are available (Classic, Minimal, Modern Dark, NewsViews,
|
||||
* NewsViews Classic, Custom CSS). The theme class is applied as
|
||||
* piperless-player--<style> on the container div. Per-post override
|
||||
* via _piperless_style meta.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Player {
|
||||
|
||||
/** @var Logger */
|
||||
private Logger $logger;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param Logger $logger Logger.
|
||||
*/
|
||||
public function __construct( Logger $logger ) {
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public function init(): void {
|
||||
add_filter( 'the_content', [ $this, 'maybe_prepend_player' ], 20 );
|
||||
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
|
||||
add_shortcode( 'piperless_player', [ $this, 'shortcode' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the player before or after content based on settings.
|
||||
*
|
||||
* @param string $content Post content.
|
||||
* @return string
|
||||
*/
|
||||
public function maybe_prepend_player( string $content ): string {
|
||||
// Only on singular views.
|
||||
if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$post_meta = get_post_meta( get_the_ID(), '_piperless_placement', true );
|
||||
$placement = ( '' !== $post_meta ) ? $post_meta : ( $settings['player_placement'] ?? 'after' );
|
||||
|
||||
if ( 'manual' === $placement ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$player_html = $this->render( get_the_ID() );
|
||||
|
||||
if ( '' === $player_html ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
if ( 'before' === $placement ) {
|
||||
return $player_html . $content;
|
||||
}
|
||||
|
||||
if ( 'both' === $placement ) {
|
||||
return $player_html . $content . $player_html;
|
||||
}
|
||||
|
||||
return $content . $player_html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcode: [piperless_player post_id="123"]
|
||||
*
|
||||
* @param array<string,mixed> $atts Attributes.
|
||||
* @return string
|
||||
*/
|
||||
public function shortcode( array $atts ): string {
|
||||
$atts = shortcode_atts( [ 'post_id' => 0 ], $atts, 'piperless_player' );
|
||||
|
||||
$post_id = (int) $atts['post_id'];
|
||||
if ( 0 === $post_id ) {
|
||||
$post_id = get_the_ID();
|
||||
}
|
||||
|
||||
return $this->render( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the audio player for a post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return string HTML or empty string.
|
||||
*/
|
||||
public function render( int $post_id ): string {
|
||||
$audio_url = get_post_meta( $post_id, '_piperless_audio_url', true );
|
||||
|
||||
if ( empty( $audio_url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$post_style = get_post_meta( $post_id, '_piperless_style', true );
|
||||
$style = ( '' !== $post_style ) ? $post_style : ( $settings['player_style'] ?? 'classic' );
|
||||
$show_meta = ( $settings['player_show_meta'] ?? '1' ) === '1';
|
||||
$duration = (float) get_post_meta( $post_id, '_piperless_duration', true );
|
||||
|
||||
// Title: per-post override wins, then global default, then empty.
|
||||
$title = get_post_meta( $post_id, '_piperless_title', true );
|
||||
if ( '' === $title ) {
|
||||
$title = $settings['player_title'] ?? '';
|
||||
}
|
||||
|
||||
$style_class = 'piperless-player--' . esc_attr( $style );
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div
|
||||
class="piperless-player <?php echo esc_attr( $style_class ); ?>"
|
||||
data-style="<?php echo esc_attr( $style ); ?>"
|
||||
data-audio-url="<?php echo esc_url( $audio_url ); ?>"
|
||||
role="region"
|
||||
aria-label="<?php echo esc_attr( sprintf(
|
||||
/* translators: %s: post title or player title */
|
||||
__( 'Audio transcript: %s', 'piperless' ),
|
||||
( '' !== $title ) ? $title : get_the_title( $post_id )
|
||||
) ); ?>"
|
||||
>
|
||||
<?php if ( '' !== $title ) : ?>
|
||||
<div class="piperless-player__title"><?php echo esc_html( $title ); ?></div>
|
||||
<?php endif; ?>
|
||||
<audio class="piperless-player__audio" preload="auto">
|
||||
<source src="<?php echo esc_url( $audio_url ); ?>">
|
||||
<?php esc_html_e( 'Your browser does not support the audio element.', 'piperless' ); ?>
|
||||
</audio>
|
||||
|
||||
<button
|
||||
class="piperless-player__play"
|
||||
type="button"
|
||||
aria-label="<?php esc_attr_e( 'Play', 'piperless' ); ?>"
|
||||
>
|
||||
<svg class="piperless-player__play-icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<polygon points="6,3 20,12 6,21"/>
|
||||
</svg>
|
||||
<svg class="piperless-player__pause-icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<rect x="5" y="3" width="5" height="18" rx="1"/>
|
||||
<rect x="14" y="3" width="5" height="18" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="piperless-player__progress-wrapper">
|
||||
<div class="piperless-player__progress">
|
||||
<div class="piperless-player__progress-buffered"></div>
|
||||
<div class="piperless-player__progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="piperless-player__time">
|
||||
<span class="piperless-player__current">00:00</span>
|
||||
<?php if ( $show_meta && $duration > 0 ) : ?>
|
||||
<span class="piperless-player__separator">/</span>
|
||||
<span class="piperless-player__duration"><?php echo esc_html( $this->format_duration( $duration ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="piperless-player__volume">
|
||||
<button class="piperless-player__volume-btn" type="button" aria-label="<?php esc_attr_e( 'Volume', 'piperless' ); ?>">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="piperless-player__volume-slider">
|
||||
<input type="range" min="0" max="100" value="100" step="1" orient="vertical" aria-label="<?php esc_attr_e( 'Volume', 'piperless' ); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend CSS and JS.
|
||||
*/
|
||||
public function enqueue_assets(): void {
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
$style = $settings['player_style'] ?? 'classic';
|
||||
|
||||
// Base player styles.
|
||||
wp_enqueue_style(
|
||||
'piperless-player-base',
|
||||
PIPERLESS_PLUGIN_URL . 'assets/css/player-base.css',
|
||||
[],
|
||||
PIPERLESS_VERSION
|
||||
);
|
||||
|
||||
// Selected theme.
|
||||
if ( 'custom' !== $style ) {
|
||||
$theme_file = 'assets/css/player-' . $style . '.css';
|
||||
if ( file_exists( PIPERLESS_PLUGIN_DIR . $theme_file ) ) {
|
||||
wp_enqueue_style(
|
||||
'piperless-player-' . $style,
|
||||
PIPERLESS_PLUGIN_URL . $theme_file,
|
||||
[ 'piperless-player-base' ],
|
||||
PIPERLESS_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Player max-width (inline).
|
||||
$max_width = (int) ( $settings['player_max_width'] ?? 680 );
|
||||
if ( $max_width > 0 ) {
|
||||
wp_add_inline_style( 'piperless-player-base', '.piperless-player{max-width:' . $max_width . 'px}' );
|
||||
} else {
|
||||
wp_add_inline_style( 'piperless-player-base', '.piperless-player{max-width:none}' );
|
||||
}
|
||||
|
||||
// Custom CSS (inline).
|
||||
if ( 'custom' === $style && ! empty( $settings['player_custom_css'] ) ) {
|
||||
wp_add_inline_style( 'piperless-player-base', $settings['player_custom_css'] );
|
||||
}
|
||||
|
||||
// Player JavaScript.
|
||||
wp_enqueue_script(
|
||||
'piperless-player',
|
||||
PIPERLESS_PLUGIN_URL . 'assets/js/player.js',
|
||||
[],
|
||||
PIPERLESS_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds as mm:ss.
|
||||
*
|
||||
* @param float $seconds Duration in seconds.
|
||||
* @return string
|
||||
*/
|
||||
public function format_duration( float $seconds ): string {
|
||||
$minutes = (int) floor( $seconds / 60 );
|
||||
$secs = (int) round( $seconds % 60 );
|
||||
|
||||
return sprintf( '%02d:%02d', $minutes, $secs );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Main plugin class — singleton orchestrator.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main plugin class — singleton orchestrator.
|
||||
*
|
||||
* Instantiates all subsystems (Logger, Piper, Settings, Transcriber,
|
||||
* Cache_Manager, Player, Gutenberg) and wires their init() hooks.
|
||||
* Held as a singleton via Plugin::instance().
|
||||
*
|
||||
* ## Auto-generation
|
||||
*
|
||||
* maybe_auto_generate() fires on transition_post_status. When a post
|
||||
* transitions to 'publish' from a non-publish state AND auto-generate
|
||||
* is enabled AND no audio URL exists yet, a one-shot cron event
|
||||
* (piperless_auto_generate) is scheduled 5 seconds in the future.
|
||||
* This defers the expensive Piper call off the HTTP response path.
|
||||
* do_auto_generate() re-checks for existing audio as a safety net.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Plugin {
|
||||
|
||||
/** @var self|null Singleton instance. */
|
||||
private static ?self $instance = null;
|
||||
|
||||
/** @var Logger */
|
||||
private Logger $logger;
|
||||
|
||||
/** @var Settings */
|
||||
private Settings $settings;
|
||||
|
||||
/** @var Piper */
|
||||
private Piper $piper;
|
||||
|
||||
/** @var Cache_Manager */
|
||||
private Cache_Manager $cache_manager;
|
||||
|
||||
/** @var Transcriber */
|
||||
private Transcriber $transcriber;
|
||||
|
||||
/** @var Player */
|
||||
private Player $player;
|
||||
|
||||
/** @var Gutenberg */
|
||||
private Gutenberg $gutenberg;
|
||||
|
||||
/**
|
||||
* Private constructor — use ::instance().
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->logger = new Logger();
|
||||
$this->cache_manager = new Cache_Manager( $this->logger );
|
||||
$this->piper = new Piper( $this->logger );
|
||||
$this->settings = new Settings( $this->logger, $this->piper, $this->cache_manager );
|
||||
$this->transcriber = new Transcriber( $this->logger, $this->piper, $this->cache_manager );
|
||||
$this->player = new Player( $this->logger );
|
||||
$this->gutenberg = new Gutenberg( $this->logger, $this->transcriber, $this->cache_manager );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the singleton instance.
|
||||
*/
|
||||
public static function instance(): self {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire all hooks.
|
||||
*/
|
||||
public function init(): void {
|
||||
$this->logger->info( 'Piperless initialising.' );
|
||||
|
||||
$this->settings->init();
|
||||
$this->gutenberg->init();
|
||||
$this->player->init();
|
||||
|
||||
// Auto-generate on publish.
|
||||
add_action( 'transition_post_status', [ $this, 'maybe_auto_generate' ], 10, 3 );
|
||||
add_action( 'piperless_auto_generate', [ $this, 'do_auto_generate' ] );
|
||||
|
||||
$this->logger->info( 'Piperless initialised.' );
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
public function logger(): Logger {
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
public function settings(): Settings {
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
public function piper(): Piper {
|
||||
return $this->piper;
|
||||
}
|
||||
|
||||
public function cache_manager(): Cache_Manager {
|
||||
return $this->cache_manager;
|
||||
}
|
||||
|
||||
public function transcriber(): Transcriber {
|
||||
return $this->transcriber;
|
||||
}
|
||||
|
||||
public function player(): Player {
|
||||
return $this->player;
|
||||
}
|
||||
|
||||
public function gutenberg(): Gutenberg {
|
||||
return $this->gutenberg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate audio when a post is first published.
|
||||
*
|
||||
* @param string $new_status New post status.
|
||||
* @param string $old_status Old post status.
|
||||
* @param \WP_Post $post Post object.
|
||||
*/
|
||||
public function maybe_auto_generate( string $new_status, string $old_status, \WP_Post $post ): void {
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
if ( ( $settings['auto_generate_on_publish'] ?? '0' ) !== '1' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'publish' !== $new_status || 'publish' === $old_status ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! in_array( $post->post_type, apply_filters( 'piperless_post_types', [ 'post', 'page' ] ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if the post already has audio — prevents re-generation on
|
||||
// posts where audio was manually generated before publishing.
|
||||
$existing_url = get_post_meta( $post->ID, '_piperless_audio_url', true );
|
||||
if ( ! empty( $existing_url ) ) {
|
||||
$this->logger->info( 'Skipping auto-generation for post {id} — audio already exists.', [ 'id' => $post->ID ] );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info( 'Scheduling auto-generation for post {id}', [ 'id' => $post->ID ] );
|
||||
|
||||
// Defer to cron so publishing doesn't block the HTTP response.
|
||||
// The scheduled event fires on the next cron run (or WP-cron loop).
|
||||
if ( ! wp_next_scheduled( 'piperless_auto_generate', [ $post->ID ] ) ) {
|
||||
wp_schedule_single_event( time() + 5, 'piperless_auto_generate', [ $post->ID ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron callback: execute deferred auto-generation.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
*/
|
||||
public function do_auto_generate( int $post_id ): void {
|
||||
// Safety net: skip if audio was generated between scheduling and now.
|
||||
$existing_url = get_post_meta( $post_id, '_piperless_audio_url', true );
|
||||
if ( ! empty( $existing_url ) ) {
|
||||
$this->logger->info( 'Skipping auto-generation for post {id} — audio already exists.', [ 'id' => $post_id ] );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info( 'Auto-generating audio for post {id}', [ 'id' => $post_id ] );
|
||||
$this->transcriber->generate( $post_id );
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
/**
|
||||
* Audio generation orchestrator.
|
||||
*
|
||||
* @package Piperless
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Piperless;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio generation orchestrator.
|
||||
*
|
||||
* Coordinates the full text-to-speech pipeline for a WordPress post:
|
||||
*
|
||||
* 1. Resolve voice / language / quality from per-post meta, REST
|
||||
* parameters, or plugin settings.
|
||||
* 2. Extract text — manual excerpt first, then content with optional
|
||||
* embedded-block filtering (skip_embedded_content setting).
|
||||
* 3. Generate a SHA-256 cache key from text + model + parameters.
|
||||
* 4. Return cached audio if it exists.
|
||||
* 5. Acquire a mutex transient to prevent parallel Piper processes
|
||||
* for the same cache key (5-minute TTL safety net).
|
||||
* 6. Shell out to Piper, wrap raw PCM in WAV, convert to MP3 if
|
||||
* ffmpeg is available.
|
||||
* 7. Store in the cache directory, update post meta, release mutex.
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* A transient-based mutex (piperless_synthesising_<cache_key>) prevents
|
||||
* two requests from running Piper simultaneously for identical content.
|
||||
* The lock is released on all exit paths (success, synthesis failure,
|
||||
* cache-write failure). A 5-minute TTL prevents permanent deadlocks.
|
||||
*
|
||||
* ## Text extraction
|
||||
*
|
||||
* When skip_embedded_content is enabled and no manual excerpt exists,
|
||||
* the post body is parsed as Gutenberg blocks. Embed-type blocks
|
||||
* (core/embed, core-embed/*, extensible via piperless_skip_blocks) are
|
||||
* stripped before text is collected from innerHTML recursively.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class Transcriber {
|
||||
|
||||
/** @var Logger */
|
||||
private Logger $logger;
|
||||
|
||||
/** @var Piper */
|
||||
private Piper $piper;
|
||||
|
||||
/** @var Cache_Manager */
|
||||
private Cache_Manager $cache;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param Logger $logger Logger instance.
|
||||
* @param Piper $piper Piper TTS wrapper.
|
||||
* @param Cache_Manager $cache Cache manager.
|
||||
*/
|
||||
public function __construct( Logger $logger, Piper $piper, Cache_Manager $cache ) {
|
||||
$this->logger = $logger;
|
||||
$this->piper = $piper;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate (or retrieve) an audio transcript for a post.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $voice Voice name override, or '' for default.
|
||||
* @param string $language Language code override, or '' for default.
|
||||
* @param string $quality Quality override, or '' for default.
|
||||
* @return array{success:bool,url:string|null,error:string|null}
|
||||
*/
|
||||
public function generate( int $post_id, string $voice = '', string $language = '', string $quality = '' ): array {
|
||||
$post = get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return $this->error( __( 'Post not found.', 'piperless' ) );
|
||||
}
|
||||
|
||||
if ( ! in_array( $post->post_type, $this->supported_post_types(), true ) ) {
|
||||
return $this->error( __( 'Unsupported post type.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Resolve parameters — per-post meta > REST params > settings defaults.
|
||||
$settings = get_option( 'piperless_settings', [] );
|
||||
|
||||
$post_voice = get_post_meta( $post_id, '_piperless_voice', true );
|
||||
$post_language = get_post_meta( $post_id, '_piperless_language', true );
|
||||
$post_quality = get_post_meta( $post_id, '_piperless_quality', true );
|
||||
|
||||
$voice = ( '' !== $voice ) ? $voice
|
||||
: ( ( ! empty( $post_voice ) ) ? $post_voice
|
||||
: ( $settings['default_voice'] ?? '' ) );
|
||||
$language = ( '' !== $language ) ? $language
|
||||
: ( ( ! empty( $post_language ) ) ? $post_language
|
||||
: ( $settings['default_language'] ?? 'en_US' ) );
|
||||
$quality = ( '' !== $quality ) ? $quality
|
||||
: ( ( ! empty( $post_quality ) ) ? $post_quality
|
||||
: ( $settings['default_quality'] ?? 'medium' ) );
|
||||
|
||||
$this->logger->debug( 'Generate: voice={voice} lang={lang} quality={quality} for post {id}', [
|
||||
'voice' => $voice,
|
||||
'lang' => $language,
|
||||
'quality' => $quality,
|
||||
'id' => $post_id,
|
||||
] );
|
||||
|
||||
// Per-post Piper overrides.
|
||||
$post_silence = get_post_meta( $post_id, '_piperless_sentence_silence', true );
|
||||
$post_length_scale = get_post_meta( $post_id, '_piperless_length_scale', true );
|
||||
|
||||
// Find the model.
|
||||
$model = $this->piper->find_model( $voice, $language, $quality );
|
||||
|
||||
if ( null === $model ) {
|
||||
return $this->error( __( 'No suitable Piper voice model found. Check your models directory.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Extract text — prefer excerpt, fallback to content.
|
||||
$text = $this->extract_text( $post, $settings );
|
||||
|
||||
if ( '' === trim( $text ) ) {
|
||||
return $this->error( __( 'No text content available for this post.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Cache key (includes bitrate so changing it regenerates files).
|
||||
$bitrate = get_option( 'piperless_settings', [] )['piper_mp3_bitrate'] ?? '32k';
|
||||
$cache_key = $this->cache->cache_key( $text, $model, $language, $quality, $bitrate );
|
||||
|
||||
// Store cache key in post meta before generation so it is tracked.
|
||||
$model_basename = basename( $model, '.onnx' );
|
||||
$this->store_cache_key( $post_id, $cache_key, $model_basename );
|
||||
|
||||
// Return cached audio if available.
|
||||
if ( $this->cache->exists( $cache_key ) ) {
|
||||
$this->update_post_meta( $post_id, $cache_key, basename( $model, '.onnx' ) );
|
||||
|
||||
$this->logger->info( 'Using cached audio for post {id}', [ 'id' => $post_id ] );
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'url' => $this->cache->proxy_url( $cache_key ),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Ensure cache directory.
|
||||
if ( ! $this->cache->ensure_dir() ) {
|
||||
return $this->error( __( 'Failed to create cache directory.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Concurrency mutex: only one synthesis per cache key at a time.
|
||||
// Prevents parallel Piper processes from exhausting server resources.
|
||||
$mutex_key = 'piperless_synthesising_' . $cache_key;
|
||||
if ( false !== get_transient( $mutex_key ) ) {
|
||||
return $this->error( __( 'Audio generation already in progress for this content. Please wait.', 'piperless' ) );
|
||||
}
|
||||
set_transient( $mutex_key, 1, 300 ); // 5-minute TTL as safety net.
|
||||
|
||||
// Generate audio.
|
||||
$this->logger->info( 'Generating audio for post {id} with model {model}', [
|
||||
'id' => $post_id,
|
||||
'model' => basename( $model ),
|
||||
] );
|
||||
|
||||
$wav = $this->piper->synthesise( $text, $model, $quality, $post_silence, $post_length_scale );
|
||||
|
||||
if ( null === $wav ) {
|
||||
delete_transient( $mutex_key );
|
||||
return $this->error( __( 'Piper failed to generate audio. Check the logs for details.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Store in cache — release mutex on write failure.
|
||||
if ( ! $this->cache->put( $cache_key, $wav ) ) {
|
||||
delete_transient( $mutex_key );
|
||||
return $this->error( __( 'Failed to write audio file to cache.', 'piperless' ) );
|
||||
}
|
||||
|
||||
// Update post meta (MP3 conversion happens inside cache->put()).
|
||||
$this->update_post_meta( $post_id, $cache_key, basename( $model, '.onnx' ) );
|
||||
|
||||
// Release mutex on success.
|
||||
delete_transient( $mutex_key );
|
||||
|
||||
$this->logger->info( 'Audio generated for post {id}', [ 'id' => $post_id ] );
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'url' => $this->cache->proxy_url( $cache_key ),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audio status for a post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array{has_audio:bool,url:string|null,duration:float|null,cache_key:string|null}
|
||||
*/
|
||||
public function status( int $post_id ): array {
|
||||
$url = get_post_meta( $post_id, '_piperless_audio_url', true );
|
||||
$duration = get_post_meta( $post_id, '_piperless_duration', true );
|
||||
$cache_key = get_post_meta( $post_id, '_piperless_cache_key', true );
|
||||
|
||||
return [
|
||||
'has_audio' => ! empty( $url ),
|
||||
'url' => $url ?: null,
|
||||
'duration' => $duration ? (float) $duration : null,
|
||||
'cache_key' => $cache_key ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a post.
|
||||
*
|
||||
* Priority: custom excerpt → filtered content → full content.
|
||||
*
|
||||
* When "Skip Embedded Content" is enabled and no manual excerpt exists,
|
||||
* the post body is parsed as Gutenberg blocks and any embed-type blocks
|
||||
* (core/embed, core-embed/*, plus blocks added via the piperless_skip_blocks
|
||||
* filter) are excluded before text extraction.
|
||||
*
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param array<string,mixed> $settings Plugin settings.
|
||||
* @return string
|
||||
*/
|
||||
private function extract_text( \WP_Post $post, array $settings ): string {
|
||||
$title = trim( $post->post_title );
|
||||
|
||||
// Check for manual excerpt first.
|
||||
$excerpt = $post->post_excerpt;
|
||||
|
||||
if ( '' !== trim( $excerpt ) ) {
|
||||
$body = wp_strip_all_tags( $excerpt, true );
|
||||
return ( '' !== $title ? $title . '. ' : '' ) . $body;
|
||||
}
|
||||
|
||||
// When skip_embedded_content is enabled and the post has blocks,
|
||||
// strip embed-type blocks before rendering.
|
||||
$skip_embeds = ! empty( $settings['skip_embedded_content'] );
|
||||
|
||||
if ( $skip_embeds && has_blocks( $post->post_content ) ) {
|
||||
$blocks = parse_blocks( $post->post_content );
|
||||
$blocks = $this->filter_embed_blocks( $blocks );
|
||||
|
||||
// Extract text directly from the block tree — avoids
|
||||
// render_block() + the_content double-processing which
|
||||
// can strip paragraph/heading content.
|
||||
$content = $this->extract_block_text( $blocks );
|
||||
$content = wp_strip_all_tags( $content, true );
|
||||
|
||||
return ( '' !== $title ? $title . '. ' : '' ) . trim( $content );
|
||||
}
|
||||
|
||||
// Standard fallback: render everything.
|
||||
$content = get_the_content( null, false, $post );
|
||||
$content = apply_filters( 'the_content', $content );
|
||||
$content = wp_strip_all_tags( $content, true );
|
||||
|
||||
return ( '' !== $title ? $title . '. ' : '' ) . trim( $content );
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract text from a parsed block tree.
|
||||
*
|
||||
* Walks the block tree and concatenates innerHTML from every block,
|
||||
* recursing into innerBlocks. This avoids the render_block() +
|
||||
* the_content double-processing path which can lose content.
|
||||
*
|
||||
* @param array<int, array> $blocks Parsed blocks.
|
||||
* @return string
|
||||
*/
|
||||
private function extract_block_text( array $blocks ): string {
|
||||
$text = '';
|
||||
|
||||
foreach ( $blocks as $block ) {
|
||||
// Collect the block's own inner HTML (e.g. the <p> content).
|
||||
$text .= $block['innerHTML'] ?? '';
|
||||
|
||||
// Recurse into inner blocks (columns, groups, etc.).
|
||||
if ( ! empty( $block['innerBlocks'] ) ) {
|
||||
$text .= $this->extract_block_text( $block['innerBlocks'] );
|
||||
}
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove embedded content blocks from a parsed block list.
|
||||
*
|
||||
* Skipped patterns: core/embed, core-embed/* by default.
|
||||
* Extensible via the `piperless_skip_blocks` filter.
|
||||
*
|
||||
* @param array<int, array> $blocks Parsed blocks from parse_blocks().
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function filter_embed_blocks( array $blocks ): array {
|
||||
$skip_patterns = apply_filters( 'piperless_skip_blocks', [
|
||||
'core/embed',
|
||||
'core-embed/',
|
||||
] );
|
||||
|
||||
$filtered = [];
|
||||
|
||||
foreach ( $blocks as $block ) {
|
||||
$block_name = $block['blockName'] ?? '';
|
||||
|
||||
// Check skip patterns.
|
||||
$skip = false;
|
||||
foreach ( $skip_patterns as $pattern ) {
|
||||
if ( str_starts_with( $block_name, $pattern ) ) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $skip ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into inner blocks (e.g. columns, groups).
|
||||
if ( ! empty( $block['innerBlocks'] ) ) {
|
||||
$block['innerBlocks'] = $this->filter_embed_blocks( $block['innerBlocks'] );
|
||||
}
|
||||
|
||||
$filtered[] = $block;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store per-post cache key (appends if multiple keys exist).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $cache_key Cache key.
|
||||
*/
|
||||
private function store_cache_key( int $post_id, string $cache_key, string $model_basename = '' ): void {
|
||||
$existing = get_post_meta( $post_id, '_piperless_cache_key', true );
|
||||
|
||||
if ( empty( $existing ) ) {
|
||||
update_post_meta( $post_id, '_piperless_cache_key', [ $cache_key => $model_basename ] );
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = is_array( $existing ) ? $existing : [ $existing ];
|
||||
|
||||
if ( ! isset( $keys[ $cache_key ] ) ) {
|
||||
$keys[ $cache_key ] = $model_basename;
|
||||
update_post_meta( $post_id, '_piperless_cache_key', $keys );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post meta after successful generation.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $cache_key Cache key.
|
||||
* @param string $file_path Absolute WAV path.
|
||||
*/
|
||||
private function update_post_meta( int $post_id, string $cache_key, string $model_basename = '' ): void {
|
||||
$url = $this->cache->proxy_url( $cache_key );
|
||||
$duration = $this->wav_duration( $cache_key );
|
||||
|
||||
update_post_meta( $post_id, '_piperless_audio_url', $url );
|
||||
update_post_meta( $post_id, '_piperless_duration', $duration );
|
||||
update_post_meta( $post_id, '_piperless_generated_at', current_time( 'mysql', true ) );
|
||||
|
||||
if ( '' !== $model_basename ) {
|
||||
update_post_meta( $post_id, '_piperless_model_name', $model_basename );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WAV file header to determine duration in seconds.
|
||||
*
|
||||
* @param string $file_path Absolute path to WAV file.
|
||||
* @return float Duration in seconds.
|
||||
*/
|
||||
public function wav_duration( string $cache_key ): float {
|
||||
$file_path = $this->cache->file_path( $cache_key );
|
||||
if ( ! file_exists( $file_path ) ) {
|
||||
// Try legacy WAV path.
|
||||
$file_path = $this->cache->dir() . '/' . $cache_key . '.wav';
|
||||
}
|
||||
if ( ! file_exists( $file_path ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$fp = @fopen( $file_path, 'rb' );
|
||||
if ( false === $fp ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Read RIFF header.
|
||||
$header = fread( $fp, 44 );
|
||||
fclose( $fp );
|
||||
|
||||
if ( strlen( $header ) < 44 ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$unpacked = unpack( 'Vsample_rate/Vbyte_rate/vblock_align', substr( $header, 24, 12 ) );
|
||||
|
||||
if ( false === $unpacked ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$sample_rate = $unpacked['sample_rate'];
|
||||
$byte_rate = $unpacked['byte_rate'];
|
||||
|
||||
if ( 0 === $byte_rate ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$data_size = filesize( $file_path ) - 44;
|
||||
if ( $data_size <= 0 ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $data_size / $byte_rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post types that Piperless supports.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function supported_post_types(): array {
|
||||
$types = apply_filters( 'piperless_post_types', [ 'post', 'page' ] );
|
||||
return is_array( $types ) ? $types : [ 'post' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an error response.
|
||||
*
|
||||
* @param string $message Error message.
|
||||
* @return array{success:bool,url:null,error:string}
|
||||
*/
|
||||
private function error( string $message ): array {
|
||||
$this->logger->error( $message );
|
||||
return [
|
||||
'success' => false,
|
||||
'url' => null,
|
||||
'error' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user