feat: Initial release v0.1.0

WP FediStream - Stream music over ActivityPub

Features:
- Custom post types: Artist, Album, Track, Playlist
- Custom taxonomies: Genre, Mood, License
- User roles: Artist, Label
- Admin dashboard with statistics
- Frontend templates and shortcodes
- Audio player with queue management
- ActivityPub integration with actor support
- WooCommerce product types for albums/tracks
- User library with favorites and history
- Notification system (in-app and email)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 23:23:05 +01:00
commit 4a5d7b9f4d
91 changed files with 22750 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
<?php
/**
* Digital Delivery Handler for WooCommerce.
*
* @package WP_FediStream
*/
namespace WP_FediStream\WooCommerce;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles digital delivery of purchased music.
*/
class DigitalDelivery {
/**
* Constructor.
*/
public function __construct() {
// Handle download requests.
add_action( 'init', array( $this, 'handle_download_request' ) );
// Add download links to order emails.
add_action( 'woocommerce_email_after_order_table', array( $this, 'add_download_links_to_email' ), 10, 4 );
// Add download section to My Account.
add_action( 'woocommerce_account_downloads_endpoint', array( $this, 'customize_downloads_display' ) );
// Generate secure download tokens.
add_filter( 'woocommerce_download_file_force', array( $this, 'force_download_for_audio' ), 10, 2 );
}
/**
* Handle download requests.
*
* @return void
*/
public function handle_download_request(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['fedistream_download'] ) ) {
return;
}
$type = sanitize_text_field( wp_unslash( $_GET['fedistream_download'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Verify user is logged in.
if ( ! is_user_logged_in() ) {
wp_die( esc_html__( 'You must be logged in to download files.', 'wp-fedistream' ) );
}
$user_id = get_current_user_id();
if ( 'track' === $type ) {
$this->handle_track_download( $user_id );
} elseif ( 'album' === $type ) {
$this->handle_album_download( $user_id );
}
}
/**
* Handle track download.
*
* @param int $user_id User ID.
* @return void
*/
private function handle_track_download( int $user_id ): void {
$track_id = isset( $_GET['track_id'] ) ? absint( $_GET['track_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : 'mp3'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! $track_id ) {
wp_die( esc_html__( 'Invalid track.', 'wp-fedistream' ) );
}
// Verify purchase.
if ( ! Integration::user_has_purchased( $user_id, 'track', $track_id ) ) {
wp_die( esc_html__( 'You have not purchased this track.', 'wp-fedistream' ) );
}
$track = get_post( $track_id );
if ( ! $track || 'fedistream_track' !== $track->post_type ) {
wp_die( esc_html__( 'Track not found.', 'wp-fedistream' ) );
}
// Get audio file.
$audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true );
if ( ! $audio_id ) {
wp_die( esc_html__( 'No audio file available.', 'wp-fedistream' ) );
}
$file_path = get_attached_file( $audio_id );
if ( ! $file_path || ! file_exists( $file_path ) ) {
wp_die( esc_html__( 'File not found.', 'wp-fedistream' ) );
}
// Convert format if needed.
$converted_file = $this->get_converted_file( $file_path, $format, $track_id );
// Generate filename.
$filename = sanitize_file_name( $track->post_title ) . '.' . $format;
// Serve the file.
$this->serve_file( $converted_file, $filename, $format );
}
/**
* Handle album download (ZIP of all tracks).
*
* @param int $user_id User ID.
* @return void
*/
private function handle_album_download( int $user_id ): void {
$album_id = isset( $_GET['album_id'] ) ? absint( $_GET['album_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : 'mp3'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! $album_id ) {
wp_die( esc_html__( 'Invalid album.', 'wp-fedistream' ) );
}
// Verify purchase.
if ( ! Integration::user_has_purchased( $user_id, 'album', $album_id ) ) {
wp_die( esc_html__( 'You have not purchased this album.', 'wp-fedistream' ) );
}
$album = get_post( $album_id );
if ( ! $album || 'fedistream_album' !== $album->post_type ) {
wp_die( esc_html__( 'Album not found.', 'wp-fedistream' ) );
}
// Get tracks.
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_key' => '_fedistream_track_number', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'orderby' => 'meta_value_num',
'order' => 'ASC',
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_fedistream_album_id',
'value' => $album_id,
),
),
)
);
if ( empty( $tracks ) ) {
wp_die( esc_html__( 'No tracks found in this album.', 'wp-fedistream' ) );
}
// Create ZIP file.
$zip_file = $this->create_album_zip( $album, $tracks, $format );
if ( ! $zip_file ) {
wp_die( esc_html__( 'Failed to create download package.', 'wp-fedistream' ) );
}
// Generate filename.
$artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true );
$artist = $artist_id ? get_post( $artist_id ) : null;
$artist_name = $artist ? $artist->post_title : 'Unknown Artist';
$filename = sanitize_file_name( $artist_name . ' - ' . $album->post_title ) . '.' . strtoupper( $format ) . '.zip';
// Serve the file.
$this->serve_file( $zip_file, $filename, 'zip' );
// Clean up temp file.
wp_delete_file( $zip_file );
}
/**
* Get converted file path.
*
* @param string $source_path Source file path.
* @param string $format Target format.
* @param int $track_id Track ID for caching.
* @return string Converted file path.
*/
private function get_converted_file( string $source_path, string $format, int $track_id ): string {
$source_ext = strtolower( pathinfo( $source_path, PATHINFO_EXTENSION ) );
// If same format, return source.
if ( $source_ext === $format ) {
return $source_path;
}
// Check for cached conversion.
$cache_dir = wp_upload_dir()['basedir'] . '/fedistream-cache/';
$cache_file = $cache_dir . 'track-' . $track_id . '.' . $format;
if ( file_exists( $cache_file ) ) {
return $cache_file;
}
// Create cache directory.
if ( ! file_exists( $cache_dir ) ) {
wp_mkdir_p( $cache_dir );
}
// For now, return source file (format conversion would require FFmpeg).
// In production, you'd use FFmpeg or similar for conversion.
// This is a placeholder for the conversion logic.
return $source_path;
}
/**
* Create ZIP file for album download.
*
* @param \WP_Post $album Album post.
* @param array $tracks Track posts.
* @param string $format Audio format.
* @return string|null ZIP file path or null on failure.
*/
private function create_album_zip( \WP_Post $album, array $tracks, string $format ): ?string {
if ( ! class_exists( 'ZipArchive' ) ) {
return null;
}
$temp_dir = get_temp_dir();
$zip_path = $temp_dir . 'fedistream-album-' . $album->ID . '-' . time() . '.zip';
$zip = new \ZipArchive();
if ( $zip->open( $zip_path, \ZipArchive::CREATE ) !== true ) {
return null;
}
$track_number = 0;
foreach ( $tracks as $track ) {
++$track_number;
$audio_id = get_post_meta( $track->ID, '_fedistream_audio_file', true );
if ( ! $audio_id ) {
continue;
}
$file_path = get_attached_file( $audio_id );
if ( ! $file_path || ! file_exists( $file_path ) ) {
continue;
}
// Get converted file.
$converted_file = $this->get_converted_file( $file_path, $format, $track->ID );
// Create filename with track number.
$filename = sprintf(
'%02d - %s.%s',
$track_number,
sanitize_file_name( $track->post_title ),
$format
);
$zip->addFile( $converted_file, $filename );
}
// Add cover art if available.
$thumbnail_id = get_post_thumbnail_id( $album->ID );
if ( $thumbnail_id ) {
$cover_path = get_attached_file( $thumbnail_id );
if ( $cover_path && file_exists( $cover_path ) ) {
$cover_ext = pathinfo( $cover_path, PATHINFO_EXTENSION );
$zip->addFile( $cover_path, 'cover.' . $cover_ext );
}
}
$zip->close();
return file_exists( $zip_path ) ? $zip_path : null;
}
/**
* Serve a file for download.
*
* @param string $file_path File path.
* @param string $filename Download filename.
* @param string $format File format.
* @return void
*/
private function serve_file( string $file_path, string $filename, string $format ): void {
if ( ! file_exists( $file_path ) ) {
wp_die( esc_html__( 'File not found.', 'wp-fedistream' ) );
}
// Get MIME type.
$mime_types = array(
'mp3' => 'audio/mpeg',
'flac' => 'audio/flac',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'aac' => 'audio/aac',
'zip' => 'application/zip',
);
$mime_type = $mime_types[ $format ] ?? 'application/octet-stream';
// Clean output buffer.
while ( ob_get_level() ) {
ob_end_clean();
}
// Set headers.
nocache_headers();
header( 'Content-Type: ' . $mime_type );
header( 'Content-Description: File Transfer' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Transfer-Encoding: binary' );
header( 'Content-Length: ' . filesize( $file_path ) );
// Read file.
readfile( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
exit;
}
/**
* Add download links to order confirmation email.
*
* @param \WC_Order $order Order object.
* @param bool $sent_to_admin Whether sent to admin.
* @param bool $plain_text Whether plain text.
* @param object $email Email object.
* @return void
*/
public function add_download_links_to_email( \WC_Order $order, bool $sent_to_admin, bool $plain_text, $email ): void {
if ( $sent_to_admin || 'completed' !== $order->get_status() ) {
return;
}
$has_fedistream = false;
foreach ( $order->get_items() as $item ) {
$product_type = \WC_Product_Factory::get_product_type( $item->get_product_id() );
if ( in_array( $product_type, array( 'fedistream_album', 'fedistream_track' ), true ) ) {
$has_fedistream = true;
break;
}
}
if ( ! $has_fedistream ) {
return;
}
$downloads_url = wc_get_account_endpoint_url( 'downloads' );
if ( $plain_text ) {
echo "\n\n";
echo esc_html__( 'Your FediStream Downloads', 'wp-fedistream' ) . "\n";
echo esc_html__( 'Access your purchased music at:', 'wp-fedistream' ) . ' ' . esc_url( $downloads_url ) . "\n";
} else {
?>
<h2><?php esc_html_e( 'Your FediStream Downloads', 'wp-fedistream' ); ?></h2>
<p>
<?php
printf(
/* translators: %s: Downloads URL */
esc_html__( 'Access your purchased music in your %s.', 'wp-fedistream' ),
'<a href="' . esc_url( $downloads_url ) . '">' . esc_html__( 'account downloads', 'wp-fedistream' ) . '</a>'
);
?>
</p>
<?php
}
}
/**
* Customize downloads display in My Account.
*
* @return void
*/
public function customize_downloads_display(): void {
if ( ! is_user_logged_in() ) {
return;
}
$user_id = get_current_user_id();
$purchases = $this->get_user_purchases( $user_id );
if ( empty( $purchases ) ) {
return;
}
?>
<h3><?php esc_html_e( 'FediStream Library', 'wp-fedistream' ); ?></h3>
<table class="woocommerce-table shop_table shop_table_responsive">
<thead>
<tr>
<th><?php esc_html_e( 'Title', 'wp-fedistream' ); ?></th>
<th><?php esc_html_e( 'Type', 'wp-fedistream' ); ?></th>
<th><?php esc_html_e( 'Purchased', 'wp-fedistream' ); ?></th>
<th><?php esc_html_e( 'Download', 'wp-fedistream' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $purchases as $purchase ) : ?>
<?php
$content = get_post( $purchase->content_id );
if ( ! $content ) {
continue;
}
$formats = array( 'mp3', 'flac' ); // Default formats.
?>
<tr>
<td><?php echo esc_html( $content->post_title ); ?></td>
<td><?php echo esc_html( ucfirst( $purchase->content_type ) ); ?></td>
<td><?php echo esc_html( date_i18n( get_option( 'date_format' ), strtotime( $purchase->purchased_at ) ) ); ?></td>
<td>
<?php foreach ( $formats as $format ) : ?>
<?php
$download_url = add_query_arg(
array(
'fedistream_download' => $purchase->content_type,
$purchase->content_type . '_id' => $purchase->content_id,
'format' => $format,
),
home_url( '/' )
);
?>
<a href="<?php echo esc_url( $download_url ); ?>" class="button button-small">
<?php echo esc_html( strtoupper( $format ) ); ?>
</a>
<?php endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Get user's purchases.
*
* @param int $user_id User ID.
* @return array
*/
private function get_user_purchases( int $user_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_purchases';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$purchases = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE user_id = %d ORDER BY purchased_at DESC",
$user_id
)
);
return $purchases ?: array();
}
/**
* Force download for audio files.
*
* @param bool $force Force download.
* @param string $file_path File path.
* @return bool
*/
public function force_download_for_audio( bool $force, string $file_path ): bool {
$ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
$audio_extensions = array( 'mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a' );
if ( in_array( $ext, $audio_extensions, true ) ) {
return true;
}
return $force;
}
}