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 '

' . esc_html__( 'You own this track.', 'wp-fedistream' ) . '

'; return; } if ( $album_id && Integration::user_has_purchased( $user_id, 'album', $album_id ) ) { echo '

' . esc_html__( 'You own this album.', 'wp-fedistream' ) . '

'; 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 '
'; echo ''; echo esc_html__( 'Buy Track', 'wp-fedistream' ) . ' - ' . wp_kses_post( $product->get_price_html() ); echo ''; echo '
'; } } // 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 '
'; echo ''; /* 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 ''; echo '
'; } } } } }