Files
wp-fedistream/includes/WooCommerce/StreamingAccess.php
magdev 4a5d7b9f4d 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>
2026-01-28 23:23:05 +01:00

417 lines
12 KiB
PHP

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