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,499 @@
<?php
/**
* Album Product Type for WooCommerce.
*
* @package WP_FediStream
*/
namespace WP_FediStream\WooCommerce;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* FediStream Album product type.
*
* Digital product representing a FediStream album.
*/
class AlbumProduct extends \WC_Product {
/**
* Product type.
*
* @var string
*/
protected $product_type = 'fedistream_album';
/**
* Constructor.
*
* @param int|\WC_Product|object $product Product ID or object.
*/
public function __construct( $product = 0 ) {
parent::__construct( $product );
}
/**
* Get product type.
*
* @return string
*/
public function get_type(): string {
return 'fedistream_album';
}
/**
* Albums are virtual products.
*
* @param string $context View or edit context.
* @return bool
*/
public function get_virtual( $context = 'view' ): bool {
return true;
}
/**
* Albums are downloadable products.
*
* @param string $context View or edit context.
* @return bool
*/
public function get_downloadable( $context = 'view' ): bool {
return true;
}
/**
* Get the linked album ID.
*
* @return int
*/
public function get_linked_album_id(): int {
return (int) $this->get_meta( '_fedistream_linked_album', true );
}
/**
* Get the linked album post.
*
* @return \WP_Post|null
*/
public function get_linked_album(): ?\WP_Post {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return null;
}
$album = get_post( $album_id );
if ( ! $album || 'fedistream_album' !== $album->post_type ) {
return null;
}
return $album;
}
/**
* Get the pricing type.
*
* @return string fixed, pwyw, or nyp
*/
public function get_pricing_type(): string {
return $this->get_meta( '_fedistream_pricing_type', true ) ?: 'fixed';
}
/**
* Get minimum price for PWYW.
*
* @return float
*/
public function get_min_price(): float {
return (float) $this->get_meta( '_fedistream_min_price', true );
}
/**
* Get suggested price for PWYW.
*
* @return float
*/
public function get_suggested_price(): float {
return (float) $this->get_meta( '_fedistream_suggested_price', true );
}
/**
* Check if streaming is included.
*
* @return bool
*/
public function includes_streaming(): bool {
return 'yes' === $this->get_meta( '_fedistream_include_streaming', true );
}
/**
* Get available download formats.
*
* @return array
*/
public function get_available_formats(): array {
$formats = $this->get_meta( '_fedistream_available_formats', true );
return is_array( $formats ) ? $formats : array( 'mp3' );
}
/**
* Get tracks in this album.
*
* @return array Array of WP_Post objects.
*/
public function get_tracks(): array {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return array();
}
$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,
),
),
)
);
return $tracks;
}
/**
* Get track count.
*
* @return int
*/
public function get_track_count(): int {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return 0;
}
$count = get_post_meta( $album_id, '_fedistream_total_tracks', true );
if ( $count ) {
return (int) $count;
}
return count( $this->get_tracks() );
}
/**
* Get total duration in seconds.
*
* @return int
*/
public function get_total_duration(): int {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return 0;
}
$duration = get_post_meta( $album_id, '_fedistream_total_duration', true );
if ( $duration ) {
return (int) $duration;
}
// Calculate from tracks.
$tracks = $this->get_tracks();
$duration = 0;
foreach ( $tracks as $track ) {
$duration += (int) get_post_meta( $track->ID, '_fedistream_duration', true );
}
return $duration;
}
/**
* Get formatted duration.
*
* @return string
*/
public function get_formatted_duration(): string {
$seconds = $this->get_total_duration();
if ( ! $seconds ) {
return '';
}
$hours = floor( $seconds / 3600 );
$mins = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
if ( $hours > 0 ) {
return sprintf( '%d:%02d:%02d', $hours, $mins, $secs );
}
return sprintf( '%d:%02d', $mins, $secs );
}
/**
* Get artist name(s).
*
* @return string
*/
public function get_artist_name(): string {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return '';
}
$artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true );
if ( ! $artist_id ) {
return '';
}
$artist = get_post( $artist_id );
return $artist ? $artist->post_title : '';
}
/**
* Get album artwork URL.
*
* @param string $size Image size.
* @return string
*/
public function get_album_artwork( string $size = 'medium' ): string {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return '';
}
$thumbnail_id = get_post_thumbnail_id( $album_id );
if ( ! $thumbnail_id ) {
return '';
}
$image = wp_get_attachment_image_url( $thumbnail_id, $size );
return $image ?: '';
}
/**
* Get release date.
*
* @return string
*/
public function get_release_date(): string {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return '';
}
return get_post_meta( $album_id, '_fedistream_release_date', true ) ?: '';
}
/**
* Get album type (album, ep, single, compilation).
*
* @return string
*/
public function get_album_type(): string {
$album_id = $this->get_linked_album_id();
if ( ! $album_id ) {
return '';
}
return get_post_meta( $album_id, '_fedistream_album_type', true ) ?: 'album';
}
/**
* Get downloads for this product.
*
* Generates downloadable files based on available formats.
*
* @param string $context View or edit context.
* @return array
*/
public function get_downloads( $context = 'view' ): array {
$downloads = parent::get_downloads( $context );
// If no manual downloads set, generate from linked album.
if ( empty( $downloads ) && $this->get_linked_album_id() ) {
$downloads = $this->generate_album_downloads();
}
return $downloads;
}
/**
* Generate download files from linked album.
*
* @return array
*/
private function generate_album_downloads(): array {
$downloads = array();
$tracks = $this->get_tracks();
$formats = $this->get_available_formats();
$album = $this->get_linked_album();
if ( empty( $tracks ) || ! $album ) {
return $downloads;
}
// For each format, create a download entry.
foreach ( $formats as $format ) {
$format_label = strtoupper( $format );
// Create album ZIP download entry.
$download_id = 'album-' . $album->ID . '-' . $format;
$downloads[ $download_id ] = array(
'id' => $download_id,
'name' => sprintf(
/* translators: 1: Album name, 2: Format name */
__( '%1$s (%2$s)', 'wp-fedistream' ),
$album->post_title,
$format_label
),
'file' => add_query_arg(
array(
'fedistream_download' => 'album',
'album_id' => $album->ID,
'format' => $format,
),
home_url( '/' )
),
);
}
return $downloads;
}
/**
* Check if purchasable.
*
* @return bool
*/
public function is_purchasable(): bool {
// Must have a linked album.
if ( ! $this->get_linked_album_id() ) {
return false;
}
// Check price for fixed pricing.
if ( 'fixed' === $this->get_pricing_type() ) {
return $this->get_price() !== '' && $this->get_price() >= 0;
}
// PWYW and NYP are always purchasable.
return true;
}
/**
* Get price HTML.
*
* @param string $price Price HTML.
* @return string
*/
public function get_price_html( $price = '' ): string {
$pricing_type = $this->get_pricing_type();
if ( 'nyp' === $pricing_type ) {
return '<span class="fedistream-nyp-price">' . esc_html__( 'Name Your Price', 'wp-fedistream' ) . '</span>';
}
if ( 'pwyw' === $pricing_type ) {
$min_price = $this->get_min_price();
$suggested = $this->get_suggested_price();
$html = '<span class="fedistream-pwyw-price">';
if ( $min_price > 0 ) {
$html .= sprintf(
/* translators: %s: Minimum price */
esc_html__( 'From %s', 'wp-fedistream' ),
wc_price( $min_price )
);
} else {
$html .= esc_html__( 'Pay What You Want', 'wp-fedistream' );
}
if ( $suggested > 0 ) {
$html .= ' <span class="fedistream-suggested">';
$html .= sprintf(
/* translators: %s: Suggested price */
esc_html__( '(Suggested: %s)', 'wp-fedistream' ),
wc_price( $suggested )
);
$html .= '</span>';
}
$html .= '</span>';
return $html;
}
return parent::get_price_html( $price );
}
/**
* Add to cart validation for PWYW products.
*
* @param bool $passed Validation passed.
* @param int $product_id Product ID.
* @param int $quantity Quantity.
* @return bool
*/
public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity ): bool {
$product = wc_get_product( $product_id );
if ( ! $product || 'fedistream_album' !== $product->get_type() ) {
return $passed;
}
$pricing_type = $product->get_pricing_type();
if ( 'pwyw' === $pricing_type || 'nyp' === $pricing_type ) {
// Check if custom price is set.
$custom_price = isset( $_POST['fedistream_custom_price'] ) ? // phpcs:ignore WordPress.Security.NonceVerification.Missing
wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['fedistream_custom_price'] ) ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Missing
0;
$min_price = $product->get_min_price();
if ( 'pwyw' === $pricing_type && $custom_price < $min_price ) {
wc_add_notice(
sprintf(
/* translators: %s: Minimum price */
__( 'Please enter at least %s', 'wp-fedistream' ),
wc_price( $min_price )
),
'error'
);
return false;
}
// Store custom price in session for cart.
WC()->session->set( 'fedistream_custom_price_' . $product_id, $custom_price );
}
return $passed;
}
}

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;
}
}

View File

@@ -0,0 +1,738 @@
<?php
/**
* WooCommerce Integration.
*
* @package WP_FediStream
*/
namespace WP_FediStream\WooCommerce;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main WooCommerce integration class.
*/
class Integration {
/**
* Whether WooCommerce is active.
*
* @var bool
*/
private bool $woocommerce_active = false;
/**
* Constructor.
*/
public function __construct() {
add_action( 'plugins_loaded', array( $this, 'check_woocommerce' ), 5 );
add_action( 'plugins_loaded', array( $this, 'init' ), 20 );
}
/**
* Check if WooCommerce is active.
*
* @return void
*/
public function check_woocommerce(): void {
$this->woocommerce_active = class_exists( 'WooCommerce' );
}
/**
* Initialize WooCommerce integration.
*
* @return void
*/
public function init(): void {
if ( ! $this->woocommerce_active ) {
return;
}
// Register custom product types.
add_filter( 'product_type_selector', array( $this, 'add_product_types' ) );
add_filter( 'woocommerce_product_class', array( $this, 'product_class' ), 10, 2 );
// Initialize product type classes.
add_action( 'init', array( $this, 'register_product_types' ), 5 );
// Add product data tabs.
add_filter( 'woocommerce_product_data_tabs', array( $this, 'add_product_data_tabs' ) );
add_action( 'woocommerce_product_data_panels', array( $this, 'add_product_data_panels' ) );
// Save product meta.
add_action( 'woocommerce_process_product_meta', array( $this, 'save_product_meta' ) );
// Frontend hooks.
add_action( 'woocommerce_single_product_summary', array( $this, 'display_track_preview' ), 25 );
// Purchase access hooks.
add_action( 'woocommerce_order_status_completed', array( $this, 'grant_access_on_purchase' ) );
// Download hooks.
add_filter( 'woocommerce_downloadable_file_allowed_mime_types', array( $this, 'allowed_audio_mimes' ) );
// Admin columns.
add_filter( 'manage_edit-product_columns', array( $this, 'add_product_columns' ) );
add_action( 'manage_product_posts_custom_column', array( $this, 'render_product_columns' ), 10, 2 );
}
/**
* Check if WooCommerce is active.
*
* @return bool
*/
public function is_active(): bool {
return $this->woocommerce_active;
}
/**
* Register custom product types.
*
* @return void
*/
public function register_product_types(): void {
// Product types are registered via class loading.
}
/**
* Add custom product types to the selector.
*
* @param array $types Product types.
* @return array Modified product types.
*/
public function add_product_types( array $types ): array {
$types['fedistream_album'] = __( 'FediStream Album', 'wp-fedistream' );
$types['fedistream_track'] = __( 'FediStream Track', 'wp-fedistream' );
return $types;
}
/**
* Get product class for custom types.
*
* @param string $classname Product class name.
* @param string $product_type Product type.
* @return string Modified class name.
*/
public function product_class( string $classname, string $product_type ): string {
if ( 'fedistream_album' === $product_type ) {
return AlbumProduct::class;
}
if ( 'fedistream_track' === $product_type ) {
return TrackProduct::class;
}
return $classname;
}
/**
* Add product data tabs.
*
* @param array $tabs Product data tabs.
* @return array Modified tabs.
*/
public function add_product_data_tabs( array $tabs ): array {
$tabs['fedistream'] = array(
'label' => __( 'FediStream', 'wp-fedistream' ),
'target' => 'fedistream_product_data',
'class' => array( 'show_if_fedistream_album', 'show_if_fedistream_track' ),
'priority' => 21,
);
$tabs['fedistream_formats'] = array(
'label' => __( 'Audio Formats', 'wp-fedistream' ),
'target' => 'fedistream_formats_data',
'class' => array( 'show_if_fedistream_album', 'show_if_fedistream_track' ),
'priority' => 22,
);
return $tabs;
}
/**
* Add product data panels.
*
* @return void
*/
public function add_product_data_panels(): void {
global $post;
$product_id = $post->ID;
// Get linked content.
$linked_album = get_post_meta( $product_id, '_fedistream_linked_album', true );
$linked_track = get_post_meta( $product_id, '_fedistream_linked_track', true );
// Get pricing options.
$pricing_type = get_post_meta( $product_id, '_fedistream_pricing_type', true ) ?: 'fixed';
$min_price = get_post_meta( $product_id, '_fedistream_min_price', true );
$suggested_price = get_post_meta( $product_id, '_fedistream_suggested_price', true );
// Get format options.
$available_formats = get_post_meta( $product_id, '_fedistream_available_formats', true ) ?: array( 'mp3' );
$include_streaming = get_post_meta( $product_id, '_fedistream_include_streaming', true );
// Get albums for dropdown.
$albums = get_posts(
array(
'post_type' => 'fedistream_album',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
// Get tracks for dropdown.
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<div id="fedistream_product_data" class="panel woocommerce_options_panel">
<div class="options_group show_if_fedistream_album">
<p class="form-field">
<label for="_fedistream_linked_album"><?php esc_html_e( 'Linked Album', 'wp-fedistream' ); ?></label>
<select id="_fedistream_linked_album" name="_fedistream_linked_album" class="wc-enhanced-select" style="width: 50%;">
<option value=""><?php esc_html_e( 'Select an album...', 'wp-fedistream' ); ?></option>
<?php foreach ( $albums as $album ) : ?>
<option value="<?php echo esc_attr( $album->ID ); ?>" <?php selected( $linked_album, $album->ID ); ?>>
<?php echo esc_html( $album->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<?php echo wc_help_tip( __( 'Select the FediStream album this product represents.', 'wp-fedistream' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</p>
</div>
<div class="options_group show_if_fedistream_track">
<p class="form-field">
<label for="_fedistream_linked_track"><?php esc_html_e( 'Linked Track', 'wp-fedistream' ); ?></label>
<select id="_fedistream_linked_track" name="_fedistream_linked_track" class="wc-enhanced-select" style="width: 50%;">
<option value=""><?php esc_html_e( 'Select a track...', 'wp-fedistream' ); ?></option>
<?php foreach ( $tracks as $track ) : ?>
<option value="<?php echo esc_attr( $track->ID ); ?>" <?php selected( $linked_track, $track->ID ); ?>>
<?php echo esc_html( $track->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<?php echo wc_help_tip( __( 'Select the FediStream track this product represents.', 'wp-fedistream' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</p>
</div>
<div class="options_group">
<p class="form-field">
<label for="_fedistream_pricing_type"><?php esc_html_e( 'Pricing Type', 'wp-fedistream' ); ?></label>
<select id="_fedistream_pricing_type" name="_fedistream_pricing_type" class="wc-enhanced-select" style="width: 50%;">
<option value="fixed" <?php selected( $pricing_type, 'fixed' ); ?>><?php esc_html_e( 'Fixed Price', 'wp-fedistream' ); ?></option>
<option value="pwyw" <?php selected( $pricing_type, 'pwyw' ); ?>><?php esc_html_e( 'Pay What You Want', 'wp-fedistream' ); ?></option>
<option value="nyp" <?php selected( $pricing_type, 'nyp' ); ?>><?php esc_html_e( 'Name Your Price (Free+)', 'wp-fedistream' ); ?></option>
</select>
</p>
</div>
<div class="options_group fedistream-pwyw-options">
<?php
woocommerce_wp_text_input(
array(
'id' => '_fedistream_min_price',
'label' => __( 'Minimum Price', 'wp-fedistream' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'desc_tip' => true,
'description' => __( 'Minimum price for Pay What You Want. Leave empty for no minimum.', 'wp-fedistream' ),
'type' => 'text',
'data_type' => 'price',
'value' => $min_price,
)
);
woocommerce_wp_text_input(
array(
'id' => '_fedistream_suggested_price',
'label' => __( 'Suggested Price', 'wp-fedistream' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'desc_tip' => true,
'description' => __( 'Suggested price shown to customers.', 'wp-fedistream' ),
'type' => 'text',
'data_type' => 'price',
'value' => $suggested_price,
)
);
?>
</div>
<div class="options_group">
<?php
woocommerce_wp_checkbox(
array(
'id' => '_fedistream_include_streaming',
'label' => __( 'Include Streaming', 'wp-fedistream' ),
'description' => __( 'Purchase unlocks full-quality streaming access.', 'wp-fedistream' ),
'value' => $include_streaming,
)
);
?>
</div>
</div>
<div id="fedistream_formats_data" class="panel woocommerce_options_panel">
<div class="options_group">
<p class="form-field">
<label><?php esc_html_e( 'Available Formats', 'wp-fedistream' ); ?></label>
<span class="fedistream-format-checkboxes">
<?php
$formats = array(
'mp3' => 'MP3 (320kbps)',
'flac' => 'FLAC (Lossless)',
'wav' => 'WAV (Uncompressed)',
'aac' => 'AAC (256kbps)',
'ogg' => 'OGG Vorbis',
);
foreach ( $formats as $format => $label ) :
$checked = is_array( $available_formats ) && in_array( $format, $available_formats, true );
?>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" name="_fedistream_available_formats[]" value="<?php echo esc_attr( $format ); ?>" <?php checked( $checked ); ?>>
<?php echo esc_html( $label ); ?>
</label>
<?php endforeach; ?>
</span>
</p>
<p class="description" style="margin-left: 150px;">
<?php esc_html_e( 'Select which audio formats customers can download after purchase.', 'wp-fedistream' ); ?>
</p>
</div>
</div>
<script type="text/javascript">
jQuery(function($) {
function togglePricingOptions() {
var type = $('#_fedistream_pricing_type').val();
if (type === 'pwyw' || type === 'nyp') {
$('.fedistream-pwyw-options').show();
} else {
$('.fedistream-pwyw-options').hide();
}
}
$('#_fedistream_pricing_type').on('change', togglePricingOptions);
togglePricingOptions();
// Show/hide tabs based on product type.
$('input#_virtual, input#_downloadable').on('change', function() {
var type = $('select#product-type').val();
if (type === 'fedistream_album' || type === 'fedistream_track') {
$('input#_virtual').prop('checked', true);
$('input#_downloadable').prop('checked', true);
}
});
$('select#product-type').on('change', function() {
var type = $(this).val();
if (type === 'fedistream_album' || type === 'fedistream_track') {
$('input#_virtual').prop('checked', true).trigger('change');
$('input#_downloadable').prop('checked', true).trigger('change');
}
}).trigger('change');
});
</script>
<?php
}
/**
* Save product meta.
*
* @param int $product_id Product ID.
* @return void
*/
public function save_product_meta( int $product_id ): void {
// Linked content.
if ( isset( $_POST['_fedistream_linked_album'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_linked_album', absint( $_POST['_fedistream_linked_album'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
if ( isset( $_POST['_fedistream_linked_track'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_linked_track', absint( $_POST['_fedistream_linked_track'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
// Pricing options.
if ( isset( $_POST['_fedistream_pricing_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_pricing_type', sanitize_text_field( wp_unslash( $_POST['_fedistream_pricing_type'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
if ( isset( $_POST['_fedistream_min_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_min_price', wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['_fedistream_min_price'] ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
if ( isset( $_POST['_fedistream_suggested_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_suggested_price', wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['_fedistream_suggested_price'] ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
// Streaming access.
$include_streaming = isset( $_POST['_fedistream_include_streaming'] ) ? 'yes' : 'no'; // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_include_streaming', $include_streaming );
// Available formats.
if ( isset( $_POST['_fedistream_available_formats'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
$formats = array_map( 'sanitize_text_field', wp_unslash( $_POST['_fedistream_available_formats'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_post_meta( $product_id, '_fedistream_available_formats', $formats );
} else {
update_post_meta( $product_id, '_fedistream_available_formats', array() );
}
}
/**
* Display track preview on product page.
*
* @return void
*/
public function display_track_preview(): void {
global $product;
if ( ! $product ) {
return;
}
$product_type = $product->get_type();
if ( 'fedistream_track' === $product_type ) {
$track_id = get_post_meta( $product->get_id(), '_fedistream_linked_track', true );
if ( $track_id ) {
$this->render_track_preview( $track_id );
}
} elseif ( 'fedistream_album' === $product_type ) {
$album_id = get_post_meta( $product->get_id(), '_fedistream_linked_album', true );
if ( $album_id ) {
$this->render_album_preview( $album_id );
}
}
}
/**
* Render track preview player.
*
* @param int $track_id Track ID.
* @return void
*/
private function render_track_preview( int $track_id ): void {
$audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( ! $audio_url ) {
return;
}
$duration = get_post_meta( $track_id, '_fedistream_duration', true );
?>
<div class="fedistream-product-preview">
<h4><?php esc_html_e( 'Preview', 'wp-fedistream' ); ?></h4>
<div class="fedistream-mini-player" data-track-id="<?php echo esc_attr( $track_id ); ?>">
<button class="fedistream-preview-play" type="button" aria-label="<?php esc_attr_e( 'Play preview', 'wp-fedistream' ); ?>">
<span class="dashicons dashicons-controls-play"></span>
</button>
<div class="fedistream-preview-progress">
<div class="fedistream-preview-progress-bar"></div>
</div>
<?php if ( $duration ) : ?>
<span class="fedistream-preview-duration"><?php echo esc_html( gmdate( 'i:s', $duration ) ); ?></span>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Render album preview tracklist.
*
* @param int $album_id Album ID.
* @return void
*/
private function render_album_preview( int $album_id ): void {
$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 ) ) {
return;
}
?>
<div class="fedistream-product-tracklist">
<h4><?php esc_html_e( 'Tracklist', 'wp-fedistream' ); ?></h4>
<ol class="fedistream-album-tracks">
<?php foreach ( $tracks as $track ) : ?>
<?php
$duration = get_post_meta( $track->ID, '_fedistream_duration', true );
?>
<li class="fedistream-album-track" data-track-id="<?php echo esc_attr( $track->ID ); ?>">
<span class="fedistream-track-title"><?php echo esc_html( $track->post_title ); ?></span>
<?php if ( $duration ) : ?>
<span class="fedistream-track-duration"><?php echo esc_html( gmdate( 'i:s', $duration ) ); ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<?php
}
/**
* Grant streaming/download access on purchase completion.
*
* @param int $order_id Order ID.
* @return void
*/
public function grant_access_on_purchase( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$customer_id = $order->get_customer_id();
if ( ! $customer_id ) {
return;
}
foreach ( $order->get_items() as $item ) {
$product_id = $item->get_product_id();
$product_type = \WC_Product_Factory::get_product_type( $product_id );
if ( 'fedistream_album' === $product_type ) {
$album_id = get_post_meta( $product_id, '_fedistream_linked_album', true );
if ( $album_id ) {
$this->grant_album_access( $customer_id, $album_id, $order_id );
}
} elseif ( 'fedistream_track' === $product_type ) {
$track_id = get_post_meta( $product_id, '_fedistream_linked_track', true );
if ( $track_id ) {
$this->grant_track_access( $customer_id, $track_id, $order_id );
}
}
}
}
/**
* Grant album access to a customer.
*
* @param int $customer_id Customer ID.
* @param int $album_id Album ID.
* @param int $order_id Order ID.
* @return void
*/
private function grant_album_access( int $customer_id, int $album_id, int $order_id ): void {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_purchases';
// Check if access already exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND content_type = 'album' AND content_id = %d",
$customer_id,
$album_id
)
);
if ( $exists ) {
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'user_id' => $customer_id,
'content_type' => 'album',
'content_id' => $album_id,
'order_id' => $order_id,
'purchased_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%d', '%d', '%s' )
);
// Also grant access to all tracks in the album.
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_fedistream_album_id',
'value' => $album_id,
),
),
)
);
foreach ( $tracks as $track ) {
$this->grant_track_access( $customer_id, $track->ID, $order_id );
}
}
/**
* Grant track access to a customer.
*
* @param int $customer_id Customer ID.
* @param int $track_id Track ID.
* @param int $order_id Order ID.
* @return void
*/
private function grant_track_access( int $customer_id, int $track_id, int $order_id ): void {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_purchases';
// Check if access already exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND content_type = 'track' AND content_id = %d",
$customer_id,
$track_id
)
);
if ( $exists ) {
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'user_id' => $customer_id,
'content_type' => 'track',
'content_id' => $track_id,
'order_id' => $order_id,
'purchased_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%d', '%d', '%s' )
);
}
/**
* Check if user has purchased content.
*
* @param int $user_id User ID.
* @param string $content_type Content type (album or track).
* @param int $content_id Content ID.
* @return bool
*/
public static function user_has_purchased( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_purchases';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND content_type = %s AND content_id = %d",
$user_id,
$content_type,
$content_id
)
);
return (bool) $exists;
}
/**
* Add allowed audio MIME types.
*
* @param array $types Allowed MIME types.
* @return array Modified MIME types.
*/
public function allowed_audio_mimes( array $types ): array {
$types['flac'] = 'audio/flac';
$types['wav'] = 'audio/wav';
$types['ogg'] = 'audio/ogg';
$types['aac'] = 'audio/aac';
return $types;
}
/**
* Add product columns.
*
* @param array $columns Columns.
* @return array Modified columns.
*/
public function add_product_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
$new_columns[ $key ] = $value;
if ( 'product_type' === $key ) {
$new_columns['fedistream_linked'] = __( 'FediStream', 'wp-fedistream' );
}
}
return $new_columns;
}
/**
* Render product columns.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public function render_product_columns( string $column, int $post_id ): void {
if ( 'fedistream_linked' !== $column ) {
return;
}
$product_type = \WC_Product_Factory::get_product_type( $post_id );
if ( 'fedistream_album' === $product_type ) {
$album_id = get_post_meta( $post_id, '_fedistream_linked_album', true );
if ( $album_id ) {
$album = get_post( $album_id );
if ( $album ) {
echo '<a href="' . esc_url( get_edit_post_link( $album_id ) ) . '">' . esc_html( $album->post_title ) . '</a>';
}
} else {
echo '<span class="na">&ndash;</span>';
}
} elseif ( 'fedistream_track' === $product_type ) {
$track_id = get_post_meta( $post_id, '_fedistream_linked_track', true );
if ( $track_id ) {
$track = get_post( $track_id );
if ( $track ) {
echo '<a href="' . esc_url( get_edit_post_link( $track_id ) ) . '">' . esc_html( $track->post_title ) . '</a>';
}
} else {
echo '<span class="na">&ndash;</span>';
}
} else {
echo '<span class="na">&ndash;</span>';
}
}
}

View File

@@ -0,0 +1,416 @@
<?php
/**
* Streaming Access Control for WooCommerce.
*
* @package WP_FediStream
*/
namespace WP_FediStream\WooCommerce;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Controls streaming access based on purchases.
*/
class StreamingAccess {
/**
* Constructor.
*/
public function __construct() {
// Filter audio URL access.
add_filter( 'fedistream_can_stream_track', array( $this, 'can_stream_track' ), 10, 3 );
// Add streaming access check to AJAX handler.
add_filter( 'fedistream_track_data', array( $this, 'filter_track_data' ), 10, 2 );
// Handle preview access.
add_action( 'init', array( $this, 'handle_preview_request' ) );
// Add purchase buttons to track display.
add_action( 'fedistream_after_track_player', array( $this, 'add_purchase_button' ) );
}
/**
* Check if user can stream a track.
*
* @param bool $can_stream Default access (true).
* @param int $track_id Track ID.
* @param int $user_id User ID (0 for guests).
* @return bool
*/
public function can_stream_track( bool $can_stream, int $track_id, int $user_id ): bool {
// Check if WooCommerce integration is enabled.
if ( ! get_option( 'wp_fedistream_enable_woocommerce', 0 ) ) {
return $can_stream;
}
// Check if track requires purchase.
$requires_purchase = $this->track_requires_purchase( $track_id );
if ( ! $requires_purchase ) {
return true;
}
// Guest users can't stream paid content.
if ( ! $user_id ) {
return false;
}
// Check if user has purchased this track.
if ( Integration::user_has_purchased( $user_id, 'track', $track_id ) ) {
return true;
}
// Check if user has purchased the album containing this track.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( $album_id && Integration::user_has_purchased( $user_id, 'album', $album_id ) ) {
return true;
}
return false;
}
/**
* Check if a track requires purchase to stream.
*
* @param int $track_id Track ID.
* @return bool
*/
private function track_requires_purchase( int $track_id ): bool {
// Find WooCommerce products linked to this track.
$products = $this->get_products_for_track( $track_id );
if ( empty( $products ) ) {
// Also check album products.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( $album_id ) {
$products = $this->get_products_for_album( $album_id );
}
}
if ( empty( $products ) ) {
return false;
}
// Check if any product includes streaming.
foreach ( $products as $product ) {
$include_streaming = get_post_meta( $product->ID, '_fedistream_include_streaming', true );
if ( 'yes' === $include_streaming ) {
return true;
}
}
return false;
}
/**
* Get WooCommerce products linked to a track.
*
* @param int $track_id Track ID.
* @return array
*/
private function get_products_for_track( int $track_id ): array {
return get_posts(
array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_fedistream_linked_track',
'value' => $track_id,
),
),
)
);
}
/**
* Get WooCommerce products linked to an album.
*
* @param int $album_id Album ID.
* @return array
*/
private function get_products_for_album( int $album_id ): array {
return get_posts(
array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_fedistream_linked_album',
'value' => $album_id,
),
),
)
);
}
/**
* Filter track data for AJAX responses.
*
* @param array $data Track data.
* @param int $track_id Track ID.
* @return array Modified track data.
*/
public function filter_track_data( array $data, int $track_id ): array {
$user_id = get_current_user_id();
$can_stream = apply_filters( 'fedistream_can_stream_track', true, $track_id, $user_id );
if ( ! $can_stream ) {
// Return preview URL instead of full audio.
$data['audio_url'] = $this->get_preview_url( $track_id );
$data['preview_only'] = true;
$data['purchase_url'] = $this->get_purchase_url( $track_id );
} else {
$data['preview_only'] = false;
}
// Add purchase status.
$data['user_has_purchased'] = $user_id && (
Integration::user_has_purchased( $user_id, 'track', $track_id ) ||
Integration::user_has_purchased( $user_id, 'album', get_post_meta( $track_id, '_fedistream_album_id', true ) )
);
return $data;
}
/**
* Get preview URL for a track.
*
* @param int $track_id Track ID.
* @return string
*/
private function get_preview_url( int $track_id ): string {
return add_query_arg(
array(
'fedistream_preview' => $track_id,
'_' => time(), // Cache buster.
),
home_url( '/' )
);
}
/**
* Get purchase URL for a track.
*
* @param int $track_id Track ID.
* @return string
*/
private function get_purchase_url( int $track_id ): string {
// Find product for this track.
$products = $this->get_products_for_track( $track_id );
if ( ! empty( $products ) ) {
return get_permalink( $products[0]->ID );
}
// Check for album product.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( $album_id ) {
$album_products = $this->get_products_for_album( $album_id );
if ( ! empty( $album_products ) ) {
return get_permalink( $album_products[0]->ID );
}
}
return '';
}
/**
* Handle preview request.
*
* @return void
*/
public function handle_preview_request(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['fedistream_preview'] ) ) {
return;
}
$track_id = absint( $_GET['fedistream_preview'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! $track_id ) {
wp_die( esc_html__( 'Invalid 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' ) );
}
// Get preview settings.
$preview_start = (int) get_post_meta( $track_id, '_fedistream_preview_start', true );
$preview_duration = (int) get_post_meta( $track_id, '_fedistream_preview_duration', true );
// Default 30 seconds preview.
if ( ! $preview_duration ) {
$preview_duration = 30;
}
// For now, serve the full file with range headers.
// In production, you'd use FFmpeg to extract a preview clip.
$this->serve_audio_preview( $file_path, $preview_start, $preview_duration );
}
/**
* Serve audio preview with limited duration.
*
* @param string $file_path File path.
* @param int $start_seconds Start time in seconds.
* @param int $duration_seconds Duration in seconds.
* @return void
*/
private function serve_audio_preview( string $file_path, int $start_seconds, int $duration_seconds ): void {
$file_size = filesize( $file_path );
$mime_type = wp_check_filetype( $file_path )['type'] ?: 'audio/mpeg';
// Calculate byte range for preview (rough approximation).
// This is a simplified approach; proper implementation would use FFmpeg.
$duration_total = (int) get_post_meta( $this->get_track_id_from_path( $file_path ), '_fedistream_duration', true );
if ( $duration_total > 0 ) {
$bytes_per_second = $file_size / $duration_total;
$start_byte = (int) ( $start_seconds * $bytes_per_second );
$end_byte = (int) min( ( $start_seconds + $duration_seconds ) * $bytes_per_second, $file_size - 1 );
} else {
// Serve first 30% of file as fallback.
$start_byte = 0;
$end_byte = (int) ( $file_size * 0.3 );
}
// Clean output buffer.
while ( ob_get_level() ) {
ob_end_clean();
}
// Set headers for partial content.
header( 'HTTP/1.1 206 Partial Content' );
header( 'Content-Type: ' . $mime_type );
header( 'Accept-Ranges: bytes' );
header( 'Content-Length: ' . ( $end_byte - $start_byte + 1 ) );
header( "Content-Range: bytes {$start_byte}-{$end_byte}/{$file_size}" );
header( 'Content-Disposition: inline' );
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
// Serve partial content.
$fp = fopen( $file_path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
if ( $fp ) {
fseek( $fp, $start_byte );
echo fread( $fp, $end_byte - $start_byte + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.Security.EscapeOutput.OutputNotEscaped
fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
}
exit;
}
/**
* Get track ID from file path (for cached lookups).
*
* @param string $file_path File path.
* @return int Track ID or 0.
*/
private function get_track_id_from_path( string $file_path ): int {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$attachment_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE guid LIKE %s",
'%' . $wpdb->esc_like( basename( $file_path ) )
)
);
if ( ! $attachment_id ) {
return 0;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$track_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_fedistream_audio_file' AND meta_value = %d",
$attachment_id
)
);
return (int) $track_id;
}
/**
* Add purchase button after track player.
*
* @param int $track_id Track ID.
* @return void
*/
public function add_purchase_button( int $track_id ): void {
if ( ! get_option( 'wp_fedistream_enable_woocommerce', 0 ) ) {
return;
}
$user_id = get_current_user_id();
// Check if already purchased.
if ( $user_id ) {
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( Integration::user_has_purchased( $user_id, 'track', $track_id ) ) {
echo '<p class="fedistream-purchase-status">' . esc_html__( 'You own this track.', 'wp-fedistream' ) . '</p>';
return;
}
if ( $album_id && Integration::user_has_purchased( $user_id, 'album', $album_id ) ) {
echo '<p class="fedistream-purchase-status">' . esc_html__( 'You own this album.', 'wp-fedistream' ) . '</p>';
return;
}
}
// Find product for this track.
$products = $this->get_products_for_track( $track_id );
if ( ! empty( $products ) ) {
$product = wc_get_product( $products[0]->ID );
if ( $product ) {
echo '<div class="fedistream-purchase-button">';
echo '<a href="' . esc_url( get_permalink( $products[0]->ID ) ) . '" class="button">';
echo esc_html__( 'Buy Track', 'wp-fedistream' ) . ' - ' . wp_kses_post( $product->get_price_html() );
echo '</a>';
echo '</div>';
}
}
// Also show album option if available.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( $album_id ) {
$album_products = $this->get_products_for_album( $album_id );
if ( ! empty( $album_products ) ) {
$album_product = wc_get_product( $album_products[0]->ID );
$album = get_post( $album_id );
if ( $album_product && $album ) {
echo '<div class="fedistream-purchase-button fedistream-purchase-album">';
echo '<a href="' . esc_url( get_permalink( $album_products[0]->ID ) ) . '" class="button button-secondary">';
/* translators: %s: Album name */
echo esc_html( sprintf( __( 'Buy Full Album: %s', 'wp-fedistream' ), $album->post_title ) );
echo ' - ' . wp_kses_post( $album_product->get_price_html() );
echo '</a>';
echo '</div>';
}
}
}
}
}

View File

@@ -0,0 +1,520 @@
<?php
/**
* Track Product Type for WooCommerce.
*
* @package WP_FediStream
*/
namespace WP_FediStream\WooCommerce;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* FediStream Track product type.
*
* Digital product representing a single FediStream track.
*/
class TrackProduct extends \WC_Product {
/**
* Product type.
*
* @var string
*/
protected $product_type = 'fedistream_track';
/**
* Constructor.
*
* @param int|\WC_Product|object $product Product ID or object.
*/
public function __construct( $product = 0 ) {
parent::__construct( $product );
}
/**
* Get product type.
*
* @return string
*/
public function get_type(): string {
return 'fedistream_track';
}
/**
* Tracks are virtual products.
*
* @param string $context View or edit context.
* @return bool
*/
public function get_virtual( $context = 'view' ): bool {
return true;
}
/**
* Tracks are downloadable products.
*
* @param string $context View or edit context.
* @return bool
*/
public function get_downloadable( $context = 'view' ): bool {
return true;
}
/**
* Get the linked track ID.
*
* @return int
*/
public function get_linked_track_id(): int {
return (int) $this->get_meta( '_fedistream_linked_track', true );
}
/**
* Get the linked track post.
*
* @return \WP_Post|null
*/
public function get_linked_track(): ?\WP_Post {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return null;
}
$track = get_post( $track_id );
if ( ! $track || 'fedistream_track' !== $track->post_type ) {
return null;
}
return $track;
}
/**
* Get the pricing type.
*
* @return string fixed, pwyw, or nyp
*/
public function get_pricing_type(): string {
return $this->get_meta( '_fedistream_pricing_type', true ) ?: 'fixed';
}
/**
* Get minimum price for PWYW.
*
* @return float
*/
public function get_min_price(): float {
return (float) $this->get_meta( '_fedistream_min_price', true );
}
/**
* Get suggested price for PWYW.
*
* @return float
*/
public function get_suggested_price(): float {
return (float) $this->get_meta( '_fedistream_suggested_price', true );
}
/**
* Check if streaming is included.
*
* @return bool
*/
public function includes_streaming(): bool {
return 'yes' === $this->get_meta( '_fedistream_include_streaming', true );
}
/**
* Get available download formats.
*
* @return array
*/
public function get_available_formats(): array {
$formats = $this->get_meta( '_fedistream_available_formats', true );
return is_array( $formats ) ? $formats : array( 'mp3' );
}
/**
* Get track duration in seconds.
*
* @return int
*/
public function get_duration(): int {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return 0;
}
return (int) get_post_meta( $track_id, '_fedistream_duration', true );
}
/**
* Get formatted duration.
*
* @return string
*/
public function get_formatted_duration(): string {
$seconds = $this->get_duration();
if ( ! $seconds ) {
return '';
}
$mins = floor( $seconds / 60 );
$secs = $seconds % 60;
return sprintf( '%d:%02d', $mins, $secs );
}
/**
* Get artist name(s).
*
* @return string
*/
public function get_artist_name(): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
$artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true );
if ( ! is_array( $artist_ids ) || empty( $artist_ids ) ) {
// Fall back to album artist.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
$artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0;
if ( $artist_id ) {
$artist = get_post( $artist_id );
return $artist ? $artist->post_title : '';
}
return '';
}
$names = array();
foreach ( $artist_ids as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
$names[] = $artist->post_title;
}
}
return implode( ', ', $names );
}
/**
* Get album name.
*
* @return string
*/
public function get_album_name(): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( ! $album_id ) {
return '';
}
$album = get_post( $album_id );
return $album ? $album->post_title : '';
}
/**
* Get track artwork URL.
*
* Falls back to album artwork if track has none.
*
* @param string $size Image size.
* @return string
*/
public function get_track_artwork( string $size = 'medium' ): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
// Try track thumbnail first.
$thumbnail_id = get_post_thumbnail_id( $track_id );
// Fall back to album artwork.
if ( ! $thumbnail_id ) {
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
$thumbnail_id = $album_id ? get_post_thumbnail_id( $album_id ) : 0;
}
if ( ! $thumbnail_id ) {
return '';
}
$image = wp_get_attachment_image_url( $thumbnail_id, $size );
return $image ?: '';
}
/**
* Get audio file URL.
*
* @return string
*/
public function get_audio_url(): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
$audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true );
if ( ! $audio_id ) {
return '';
}
return wp_get_attachment_url( $audio_id ) ?: '';
}
/**
* Check if track is explicit.
*
* @return bool
*/
public function is_explicit(): bool {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return false;
}
return (bool) get_post_meta( $track_id, '_fedistream_explicit', true );
}
/**
* Get track BPM.
*
* @return int
*/
public function get_bpm(): int {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return 0;
}
return (int) get_post_meta( $track_id, '_fedistream_bpm', true );
}
/**
* Get track musical key.
*
* @return string
*/
public function get_musical_key(): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
return get_post_meta( $track_id, '_fedistream_key', true ) ?: '';
}
/**
* Get ISRC code.
*
* @return string
*/
public function get_isrc(): string {
$track_id = $this->get_linked_track_id();
if ( ! $track_id ) {
return '';
}
return get_post_meta( $track_id, '_fedistream_isrc', true ) ?: '';
}
/**
* Get downloads for this product.
*
* Generates downloadable files based on available formats.
*
* @param string $context View or edit context.
* @return array
*/
public function get_downloads( $context = 'view' ): array {
$downloads = parent::get_downloads( $context );
// If no manual downloads set, generate from linked track.
if ( empty( $downloads ) && $this->get_linked_track_id() ) {
$downloads = $this->generate_track_downloads();
}
return $downloads;
}
/**
* Generate download files from linked track.
*
* @return array
*/
private function generate_track_downloads(): array {
$downloads = array();
$track = $this->get_linked_track();
$formats = $this->get_available_formats();
if ( ! $track ) {
return $downloads;
}
// For each format, create a download entry.
foreach ( $formats as $format ) {
$format_label = strtoupper( $format );
$download_id = 'track-' . $track->ID . '-' . $format;
$downloads[ $download_id ] = array(
'id' => $download_id,
'name' => sprintf(
/* translators: 1: Track name, 2: Format name */
__( '%1$s (%2$s)', 'wp-fedistream' ),
$track->post_title,
$format_label
),
'file' => add_query_arg(
array(
'fedistream_download' => 'track',
'track_id' => $track->ID,
'format' => $format,
),
home_url( '/' )
),
);
}
return $downloads;
}
/**
* Check if purchasable.
*
* @return bool
*/
public function is_purchasable(): bool {
// Must have a linked track.
if ( ! $this->get_linked_track_id() ) {
return false;
}
// Check price for fixed pricing.
if ( 'fixed' === $this->get_pricing_type() ) {
return $this->get_price() !== '' && $this->get_price() >= 0;
}
// PWYW and NYP are always purchasable.
return true;
}
/**
* Get price HTML.
*
* @param string $price Price HTML.
* @return string
*/
public function get_price_html( $price = '' ): string {
$pricing_type = $this->get_pricing_type();
if ( 'nyp' === $pricing_type ) {
return '<span class="fedistream-nyp-price">' . esc_html__( 'Name Your Price', 'wp-fedistream' ) . '</span>';
}
if ( 'pwyw' === $pricing_type ) {
$min_price = $this->get_min_price();
$suggested = $this->get_suggested_price();
$html = '<span class="fedistream-pwyw-price">';
if ( $min_price > 0 ) {
$html .= sprintf(
/* translators: %s: Minimum price */
esc_html__( 'From %s', 'wp-fedistream' ),
wc_price( $min_price )
);
} else {
$html .= esc_html__( 'Pay What You Want', 'wp-fedistream' );
}
if ( $suggested > 0 ) {
$html .= ' <span class="fedistream-suggested">';
$html .= sprintf(
/* translators: %s: Suggested price */
esc_html__( '(Suggested: %s)', 'wp-fedistream' ),
wc_price( $suggested )
);
$html .= '</span>';
}
$html .= '</span>';
return $html;
}
return parent::get_price_html( $price );
}
/**
* Add to cart validation for PWYW products.
*
* @param bool $passed Validation passed.
* @param int $product_id Product ID.
* @param int $quantity Quantity.
* @return bool
*/
public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity ): bool {
$product = wc_get_product( $product_id );
if ( ! $product || 'fedistream_track' !== $product->get_type() ) {
return $passed;
}
$pricing_type = $product->get_pricing_type();
if ( 'pwyw' === $pricing_type || 'nyp' === $pricing_type ) {
$custom_price = isset( $_POST['fedistream_custom_price'] ) ? // phpcs:ignore WordPress.Security.NonceVerification.Missing
wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['fedistream_custom_price'] ) ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Missing
0;
$min_price = $product->get_min_price();
if ( 'pwyw' === $pricing_type && $custom_price < $min_price ) {
wc_add_notice(
sprintf(
/* translators: %s: Minimum price */
__( 'Please enter at least %s', 'wp-fedistream' ),
wc_price( $min_price )
),
'error'
);
return false;
}
WC()->session->set( 'fedistream_custom_price_' . $product_id, $custom_price );
}
return $passed;
}
}