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,433 @@
<?php
/**
* Album Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Album posts to ActivityPub Collection objects.
*/
class AlbumTransformer {
/**
* The album post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The album post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'Collection';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
$album_type = get_post_meta( $this->post->ID, '_fedistream_album_type', true );
$type_label = '';
switch ( $album_type ) {
case 'ep':
$type_label = ' (EP)';
break;
case 'single':
$type_label = ' (Single)';
break;
case 'compilation':
$type_label = ' (Compilation)';
break;
}
return $this->post->post_title . $type_label;
}
/**
* Get the content (liner notes/description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Build summary from album info.
$artist_id = get_post_meta( $this->post->ID, '_fedistream_album_artist', true );
$artist = $artist_id ? get_post( $artist_id ) : null;
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
$track_count = (int) get_post_meta( $this->post->ID, '_fedistream_total_tracks', true );
$summary = '';
if ( $artist ) {
$summary .= sprintf( __( 'By %s', 'wp-fedistream' ), $artist->post_title );
}
if ( $release_date ) {
/* translators: %s: release date */
$summary .= ' ' . sprintf( __( '- Released %s', 'wp-fedistream' ), $release_date );
}
if ( $track_count ) {
/* translators: %d: number of tracks */
$summary .= ' ' . sprintf( _n( '- %d track', '- %d tracks', $track_count, 'wp-fedistream' ), $track_count );
}
return trim( $summary );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor.
*
* @return string Artist URI.
*/
public function get_attributed_to(): string {
$artist_id = get_post_meta( $this->post->ID, '_fedistream_album_artist', true );
if ( ! $artist_id ) {
return '';
}
return get_permalink( $artist_id );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
// Use release date if available, otherwise post date.
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
if ( $release_date ) {
$date = \DateTime::createFromFormat( 'Y-m-d', $release_date );
if ( $date ) {
return $date->format( 'c' );
}
}
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the total duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_total_duration', true );
if ( ! $seconds ) {
// Calculate from tracks.
$tracks = $this->get_tracks();
foreach ( $tracks as $track ) {
$seconds += (int) get_post_meta( $track->ID, '_fedistream_duration', true );
}
}
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the tracks in this album.
*
* @return array Array of WP_Post objects.
*/
public function get_tracks(): array {
$args = 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' => $this->post->ID,
),
),
);
$query = new \WP_Query( $args );
return $query->posts;
}
/**
* Get the total item count.
*
* @return int
*/
public function get_total_items(): int {
$count = (int) get_post_meta( $this->post->ID, '_fedistream_total_tracks', true );
if ( ! $count ) {
$count = count( $this->get_tracks() );
}
return $count;
}
/**
* Get the collection items (track URIs).
*
* @return array
*/
public function get_items(): array {
$tracks = $this->get_tracks();
$items = array();
foreach ( $tracks as $track ) {
$transformer = new TrackTransformer( $track );
$items[] = $transformer->to_object();
}
return $items;
}
/**
* Get the image/artwork attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (genres).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get genres.
$genres = get_the_terms( $this->post->ID, 'fedistream_genre' );
if ( $genres && ! is_wp_error( $genres ) ) {
foreach ( $genres as $genre ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $genre->name ),
'href' => get_term_link( $genre ),
);
}
}
return $tags;
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
'totalItems' => $this->get_total_items(),
'items' => $this->get_items(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add image.
$image = $this->get_image_attachment();
if ( $image ) {
$object['image'] = $image;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add album-specific metadata.
$album_type = get_post_meta( $this->post->ID, '_fedistream_album_type', true );
if ( $album_type ) {
$object['albumType'] = $album_type;
}
$upc = get_post_meta( $this->post->ID, '_fedistream_upc', true );
if ( $upc ) {
$object['upc'] = $upc;
}
$catalog = get_post_meta( $this->post->ID, '_fedistream_catalog_number', true );
if ( $catalog ) {
$object['catalogNumber'] = $catalog;
}
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
if ( $release_date ) {
$object['releaseDate'] = $release_date;
}
return $object;
}
/**
* Create a Create activity for this album.
*
* @return array
*/
public function to_create_activity(): array {
$actor = $this->get_attributed_to();
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Create an Announce activity for this album.
*
* @param string $actor_uri The actor announcing the album.
* @return array
*/
public function to_announce_activity( string $actor_uri ): array {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $this->get_id() . '#activity-announce-' . time(),
'actor' => $actor_uri,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor_uri . '/followers' ),
'object' => $this->get_id(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}

View File

@@ -0,0 +1,614 @@
<?php
/**
* Artist ActivityPub Actor.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Represents an artist as an ActivityPub actor.
*/
class ArtistActor {
/**
* The artist post.
*
* @var \WP_Post
*/
private \WP_Post $artist;
/**
* Constructor.
*
* @param \WP_Post $artist The artist post.
*/
public function __construct( \WP_Post $artist ) {
$this->artist = $artist;
}
/**
* Get the actor ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->artist->ID );
}
/**
* Get the actor type.
*
* @return string Person for solo, Group for bands.
*/
public function get_type(): string {
$artist_type = get_post_meta( $this->artist->ID, '_fedistream_artist_type', true );
// Bands, duos, collectives are Groups.
if ( in_array( $artist_type, array( 'band', 'duo', 'collective', 'orchestra' ), true ) ) {
return 'Group';
}
return 'Person';
}
/**
* Get the preferred username.
*
* @return string
*/
public function get_preferred_username(): string {
return 'artist-' . $this->artist->ID;
}
/**
* Get the display name.
*
* @return string
*/
public function get_name(): string {
return $this->artist->post_title;
}
/**
* Get the actor summary/bio.
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->artist->post_excerpt ) ) {
return wp_kses_post( $this->artist->post_excerpt );
}
// Use first paragraph of content as summary.
$content = wp_strip_all_tags( $this->artist->post_content );
$parts = explode( "\n\n", $content, 2 );
return ! empty( $parts[0] ) ? wp_trim_words( $parts[0], 50 ) : '';
}
/**
* Get the actor URL (profile page).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->artist->ID );
}
/**
* Get the inbox URL.
*
* @return string
*/
public function get_inbox(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'inbox';
}
/**
* Get the outbox URL.
*
* @return string
*/
public function get_outbox(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'outbox';
}
/**
* Get the followers URL.
*
* @return string
*/
public function get_followers(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'followers';
}
/**
* Get the following URL.
*
* @return string
*/
public function get_following(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'following';
}
/**
* Get the avatar/icon.
*
* @return array|null
*/
public function get_icon(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->artist->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get the header/banner image.
*
* @return array|null
*/
public function get_image(): ?array {
$banner_id = get_post_meta( $this->artist->ID, '_fedistream_artist_banner', true );
if ( ! $banner_id ) {
// Fall back to featured image at larger size.
$banner_id = get_post_thumbnail_id( $this->artist->ID );
}
if ( ! $banner_id ) {
return null;
}
$image = wp_get_attachment_image_src( $banner_id, 'large' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $banner_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get the public key.
*
* @return array
*/
public function get_public_key(): array {
$key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true );
if ( ! $key ) {
// Generate key pair if not exists.
$this->generate_keys();
$key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true );
}
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => $key,
);
}
/**
* Generate RSA key pair for the artist.
*
* @return bool True on success.
*/
private function generate_keys(): bool {
$config = array(
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
);
$resource = openssl_pkey_new( $config );
if ( ! $resource ) {
return false;
}
// Export private key.
openssl_pkey_export( $resource, $private_key );
// Get public key.
$details = openssl_pkey_get_details( $resource );
$public_key = $details['key'] ?? '';
if ( ! $private_key || ! $public_key ) {
return false;
}
update_post_meta( $this->artist->ID, '_fedistream_activitypub_private_key', $private_key );
update_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', $public_key );
return true;
}
/**
* Get attachment properties (social links, etc.).
*
* @return array
*/
public function get_attachment(): array {
$attachments = array();
// Add website.
$website = get_post_meta( $this->artist->ID, '_fedistream_artist_website', true );
if ( $website ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Website', 'wp-fedistream' ),
'value' => sprintf( '<a href="%s" rel="me nofollow noopener" target="_blank">%s</a>', esc_url( $website ), esc_html( $website ) ),
);
}
// Add location.
$location = get_post_meta( $this->artist->ID, '_fedistream_artist_location', true );
if ( $location ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Location', 'wp-fedistream' ),
'value' => esc_html( $location ),
);
}
// Add social links.
$social_links = get_post_meta( $this->artist->ID, '_fedistream_artist_social_links', true );
if ( is_array( $social_links ) ) {
foreach ( $social_links as $link ) {
$platform = $link['platform'] ?? '';
$url = $link['url'] ?? '';
if ( $platform && $url ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => esc_html( ucfirst( $platform ) ),
'value' => sprintf( '<a href="%s" rel="me nofollow noopener" target="_blank">%s</a>', esc_url( $url ), esc_html( $url ) ),
);
}
}
}
// Add formed date.
$formed = get_post_meta( $this->artist->ID, '_fedistream_artist_formed_date', true );
if ( $formed ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Active Since', 'wp-fedistream' ),
'value' => esc_html( $formed ),
);
}
return $attachments;
}
/**
* Get the full actor object as an array.
*
* @return array
*/
public function to_array(): array {
$actor = array(
'@context' => array(
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
array(
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
),
),
'id' => $this->get_id(),
'type' => $this->get_type(),
'preferredUsername' => $this->get_preferred_username(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'url' => $this->get_url(),
'inbox' => $this->get_inbox(),
'outbox' => $this->get_outbox(),
'followers' => $this->get_followers(),
'following' => $this->get_following(),
'publicKey' => $this->get_public_key(),
'manuallyApprovesFollowers' => false,
'published' => get_the_date( 'c', $this->artist ),
'updated' => get_the_modified_date( 'c', $this->artist ),
);
// Add icon if available.
$icon = $this->get_icon();
if ( $icon ) {
$actor['icon'] = $icon;
}
// Add image if available.
$image = $this->get_image();
if ( $image ) {
$actor['image'] = $image;
}
// Add attachments.
$attachments = $this->get_attachment();
if ( ! empty( $attachments ) ) {
$actor['attachment'] = $attachments;
}
return $actor;
}
/**
* Get webfinger data for the artist.
*
* @return array
*/
public function get_webfinger(): array {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$resource = 'acct:' . $this->get_preferred_username() . '@' . $host;
return array(
'subject' => $resource,
'aliases' => array(
$this->get_id(),
$this->get_url(),
),
'links' => array(
array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => $this->get_id(),
),
array(
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => $this->get_url(),
),
),
);
}
/**
* Get the outbox collection.
*
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_outbox_collection( int $page = 0, int $per_page = 20 ): array {
// Get all tracks and albums by this artist.
$args = array(
'post_type' => array( 'fedistream_track', 'fedistream_album' ),
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'OR',
array(
'key' => '_fedistream_album_artist',
'value' => $this->artist->ID,
),
array(
'key' => '_fedistream_artist_ids',
'value' => $this->artist->ID,
'compare' => 'LIKE',
),
),
);
$query = new \WP_Query( $args );
$total = $query->found_posts;
// Return summary if page 0.
if ( $page === 0 ) {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_outbox(),
'type' => 'OrderedCollection',
'totalItems' => $total,
'first' => $this->get_outbox() . '?page=1',
'last' => $this->get_outbox() . '?page=' . max( 1, ceil( $total / $per_page ) ),
);
}
// Get paginated items.
$args['posts_per_page'] = $per_page;
$args['paged'] = $page;
$args['orderby'] = 'date';
$args['order'] = 'DESC';
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->post_to_create_activity( $post );
}
$collection = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_outbox() . '?page=' . $page,
'type' => 'OrderedCollectionPage',
'partOf' => $this->get_outbox(),
'orderedItems' => $items,
);
// Add prev/next links.
if ( $page > 1 ) {
$collection['prev'] = $this->get_outbox() . '?page=' . ( $page - 1 );
}
$total_pages = ceil( $total / $per_page );
if ( $page < $total_pages ) {
$collection['next'] = $this->get_outbox() . '?page=' . ( $page + 1 );
}
return $collection;
}
/**
* Convert a post to a Create activity.
*
* @param \WP_Post $post The post.
* @return array
*/
private function post_to_create_activity( \WP_Post $post ): array {
$object = array(
'type' => 'fedistream_track' === $post->post_type ? 'Audio' : 'Collection',
'id' => get_permalink( $post->ID ),
'name' => $post->post_title,
'content' => wp_strip_all_tags( $post->post_content ),
'attributedTo' => $this->get_id(),
'published' => get_the_date( 'c', $post ),
'url' => get_permalink( $post->ID ),
);
// Add track-specific properties.
if ( 'fedistream_track' === $post->post_type ) {
$duration = get_post_meta( $post->ID, '_fedistream_duration', true );
if ( $duration ) {
$object['duration'] = $this->format_duration_iso8601( (int) $duration );
}
$audio_id = get_post_meta( $post->ID, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( $audio_url ) {
$object['attachment'] = array(
'type' => 'Audio',
'mediaType' => 'audio/mpeg',
'url' => $audio_url,
);
}
}
// Add thumbnail.
$thumbnail_id = get_post_thumbnail_id( $post->ID );
if ( $thumbnail_id ) {
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( $image ) {
$object['image'] = array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
);
}
}
return array(
'type' => 'Create',
'id' => get_permalink( $post->ID ) . '#activity-create',
'actor' => $this->get_id(),
'published' => get_the_date( 'c', $post ),
'object' => $object,
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
/**
* Get the followers collection.
*
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_followers_collection( int $page = 0, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_followers';
// Get total count.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE artist_id = %d",
$this->artist->ID
)
);
// Return summary if page 0.
if ( $page === 0 ) {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_followers(),
'type' => 'OrderedCollection',
'totalItems' => $total,
'first' => $this->get_followers() . '?page=1',
);
}
// Get paginated items.
$offset = ( $page - 1 ) * $per_page;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$followers = $wpdb->get_col(
$wpdb->prepare(
"SELECT follower_uri FROM {$table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d",
$this->artist->ID,
$per_page,
$offset
)
);
$collection = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_followers() . '?page=' . $page,
'type' => 'OrderedCollectionPage',
'partOf' => $this->get_followers(),
'orderedItems' => $followers ?: array(),
);
// Add prev/next links.
if ( $page > 1 ) {
$collection['prev'] = $this->get_followers() . '?page=' . ( $page - 1 );
}
$total_pages = ceil( $total / $per_page );
if ( $page < $total_pages ) {
$collection['next'] = $this->get_followers() . '?page=' . ( $page + 1 );
}
return $collection;
}
}

View File

@@ -0,0 +1,477 @@
<?php
/**
* Follower Handler for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles ActivityPub follower management.
*/
class FollowerHandler {
/**
* The table name.
*
* @var string
*/
private string $table;
/**
* Constructor.
*/
public function __construct() {
global $wpdb;
$this->table = $wpdb->prefix . 'fedistream_followers';
// Register inbox handlers for follow activities.
add_action( 'activitypub_inbox_follow', array( $this, 'handle_follow' ), 10, 2 );
add_action( 'activitypub_inbox_undo', array( $this, 'handle_undo' ), 10, 2 );
}
/**
* Handle incoming Follow activity.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID (if applicable).
* @return void
*/
public function handle_follow( array $activity, int $user_id ): void {
$actor = $activity['actor'] ?? '';
$object = $activity['object'] ?? '';
if ( ! $actor || ! $object ) {
return;
}
// Find the artist being followed.
$artist_id = $this->get_artist_from_object( $object );
if ( ! $artist_id ) {
return;
}
// Add follower.
$result = $this->add_follower( $artist_id, $actor, $activity );
if ( $result ) {
// Send Accept activity.
$this->send_accept( $artist_id, $activity );
}
}
/**
* Handle incoming Undo activity (for Unfollow).
*
* @param array $activity The activity data.
* @param int $user_id The local user ID (if applicable).
* @return void
*/
public function handle_undo( array $activity, int $user_id ): void {
$actor = $activity['actor'] ?? '';
$object = $activity['object'] ?? array();
if ( ! $actor || ! is_array( $object ) ) {
return;
}
// Check if this is an Undo Follow.
$type = $object['type'] ?? '';
if ( 'Follow' !== $type ) {
return;
}
$follow_object = $object['object'] ?? '';
if ( ! $follow_object ) {
return;
}
// Find the artist being unfollowed.
$artist_id = $this->get_artist_from_object( $follow_object );
if ( ! $artist_id ) {
return;
}
// Remove follower.
$this->remove_follower( $artist_id, $actor );
}
/**
* Get artist ID from an object URI.
*
* @param string $object The object URI.
* @return int|null Artist ID or null.
*/
private function get_artist_from_object( string $object ): ?int {
// Try to find by permalink.
$post_id = url_to_postid( $object );
if ( $post_id ) {
$post = get_post( $post_id );
if ( $post && 'fedistream_artist' === $post->post_type ) {
return $post_id;
}
}
// Try to find by artist handle pattern.
if ( preg_match( '/artist-(\d+)/', $object, $matches ) ) {
$artist_id = absint( $matches[1] );
$post = get_post( $artist_id );
if ( $post && 'fedistream_artist' === $post->post_type ) {
return $artist_id;
}
}
return null;
}
/**
* Add a follower.
*
* @param int $artist_id The artist ID.
* @param string $follower_uri The follower's actor URI.
* @param array $activity_data The original activity data.
* @return bool True on success.
*/
public function add_follower( int $artist_id, string $follower_uri, array $activity_data = array() ): bool {
global $wpdb;
// Check if already following.
if ( $this->is_following( $artist_id, $follower_uri ) ) {
return true;
}
// Fetch follower info.
$follower_info = $this->fetch_actor( $follower_uri );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert(
$this->table,
array(
'artist_id' => $artist_id,
'follower_uri' => $follower_uri,
'follower_name' => $follower_info['name'] ?? '',
'follower_icon' => $follower_info['icon']['url'] ?? '',
'inbox' => $follower_info['inbox'] ?? '',
'shared_inbox' => $follower_info['endpoints']['sharedInbox'] ?? $follower_info['sharedInbox'] ?? '',
'activity_data' => wp_json_encode( $activity_data ),
'followed_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
);
if ( $result ) {
// Update follower count.
$count = $this->get_follower_count( $artist_id );
update_post_meta( $artist_id, '_fedistream_follower_count', $count );
do_action( 'fedistream_artist_followed', $artist_id, $follower_uri, $follower_info );
}
return (bool) $result;
}
/**
* Remove a follower.
*
* @param int $artist_id The artist ID.
* @param string $follower_uri The follower's actor URI.
* @return bool True on success.
*/
public function remove_follower( int $artist_id, string $follower_uri ): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->delete(
$this->table,
array(
'artist_id' => $artist_id,
'follower_uri' => $follower_uri,
),
array( '%d', '%s' )
);
if ( $result ) {
// Update follower count.
$count = $this->get_follower_count( $artist_id );
update_post_meta( $artist_id, '_fedistream_follower_count', $count );
do_action( 'fedistream_artist_unfollowed', $artist_id, $follower_uri );
}
return (bool) $result;
}
/**
* Check if an actor is following an artist.
*
* @param int $artist_id The artist ID.
* @param string $follower_uri The follower's actor URI.
* @return bool
*/
public function is_following( int $artist_id, string $follower_uri ): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d AND follower_uri = %s",
$artist_id,
$follower_uri
)
);
return $exists > 0;
}
/**
* Get follower count for an artist.
*
* @param int $artist_id The artist ID.
* @return int
*/
public function get_follower_count( int $artist_id ): int {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d",
$artist_id
)
);
return (int) $count;
}
/**
* Get followers for an artist.
*
* @param int $artist_id The artist ID.
* @param int $limit Maximum number to return.
* @param int $offset Offset for pagination.
* @return array
*/
public function get_followers( int $artist_id, int $limit = 20, int $offset = 0 ): array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$followers = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$this->table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d",
$artist_id,
$limit,
$offset
)
);
return $followers ?: array();
}
/**
* Get all follower inboxes for an artist.
*
* @param int $artist_id The artist ID.
* @return array Array of inbox URLs, with shared inboxes deduplicated.
*/
public function get_follower_inboxes( int $artist_id ): array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT inbox, shared_inbox FROM {$this->table} WHERE artist_id = %d",
$artist_id
)
);
$inboxes = array();
$shared_inboxes = array();
foreach ( $results as $row ) {
// Prefer shared inbox for efficiency.
if ( ! empty( $row->shared_inbox ) ) {
$shared_inboxes[ $row->shared_inbox ] = true;
} elseif ( ! empty( $row->inbox ) ) {
$inboxes[ $row->inbox ] = true;
}
}
// Return unique inboxes (shared inboxes + individual inboxes).
return array_merge( array_keys( $shared_inboxes ), array_keys( $inboxes ) );
}
/**
* Send Accept activity in response to Follow.
*
* @param int $artist_id The artist ID.
* @param array $activity The original Follow activity.
* @return bool True on success.
*/
private function send_accept( int $artist_id, array $activity ): bool {
$artist = get_post( $artist_id );
if ( ! $artist ) {
return false;
}
$actor = new ArtistActor( $artist );
$follower_uri = $activity['actor'] ?? '';
if ( ! $follower_uri ) {
return false;
}
// Create Accept activity.
$accept = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Accept',
'id' => $actor->get_id() . '#accept-' . time(),
'actor' => $actor->get_id(),
'object' => $activity,
);
// Get follower's inbox.
$follower_info = $this->fetch_actor( $follower_uri );
$inbox = $follower_info['inbox'] ?? '';
if ( ! $inbox ) {
return false;
}
// Send the Accept activity.
return $this->send_activity( $accept, $inbox, $artist_id );
}
/**
* Fetch an actor from a remote server.
*
* @param string $actor_uri The actor URI.
* @return array The actor data.
*/
private function fetch_actor( string $actor_uri ): array {
$response = wp_remote_get(
$actor_uri,
array(
'headers' => array(
'Accept' => 'application/activity+json, application/ld+json',
),
'timeout' => 10,
)
);
if ( is_wp_error( $response ) ) {
return array();
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
return is_array( $data ) ? $data : array();
}
/**
* Send an activity to an inbox.
*
* @param array $activity The activity to send.
* @param string $inbox The inbox URL.
* @param int $artist_id The artist ID (for signing).
* @return bool True on success.
*/
private function send_activity( array $activity, string $inbox, int $artist_id ): bool {
$artist = get_post( $artist_id );
if ( ! $artist ) {
return false;
}
// Get the artist's private key for signing.
$private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true );
if ( ! $private_key ) {
// Generate keys if not exists.
$actor = new ArtistActor( $artist );
$actor->get_public_key(); // This triggers key generation.
$private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true );
}
if ( ! $private_key ) {
return false;
}
$body = wp_json_encode( $activity );
$date = gmdate( 'D, d M Y H:i:s T' );
// Parse inbox URL.
$parsed = wp_parse_url( $inbox );
$host = $parsed['host'] ?? '';
$path = $parsed['path'] ?? '/';
// Create signature string.
$string_to_sign = "(request-target): post {$path}\n";
$string_to_sign .= "host: {$host}\n";
$string_to_sign .= "date: {$date}\n";
$string_to_sign .= 'digest: SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
// Sign the string.
$signature = '';
openssl_sign( $string_to_sign, $signature, $private_key, OPENSSL_ALGO_SHA256 );
$signature_b64 = base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
// Build signature header.
$actor = new ArtistActor( $artist );
$key_id = $actor->get_id() . '#main-key';
$signature_header = sprintf(
'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"',
$key_id,
$signature_b64
);
// Send the request.
$response = wp_remote_post(
$inbox,
array(
'headers' => array(
'Content-Type' => 'application/activity+json',
'Accept' => 'application/activity+json',
'Host' => $host,
'Date' => $date,
'Digest' => 'SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
'Signature' => $signature_header,
),
'body' => $body,
'timeout' => 30,
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$code = wp_remote_retrieve_response_code( $response );
// Accept responses: 200, 201, 202 are all valid.
return $code >= 200 && $code < 300;
}
/**
* Broadcast an activity to all followers.
*
* @param int $artist_id The artist ID.
* @param array $activity The activity to broadcast.
* @return array Results with inbox => success/failure.
*/
public function broadcast_activity( int $artist_id, array $activity ): array {
$inboxes = $this->get_follower_inboxes( $artist_id );
$results = array();
foreach ( $inboxes as $inbox ) {
$results[ $inbox ] = $this->send_activity( $activity, $inbox, $artist_id );
}
return $results;
}
}

View File

@@ -0,0 +1,480 @@
<?php
/**
* ActivityPub integration main class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main ActivityPub integration class.
*
* Integrates WP FediStream with the WordPress ActivityPub plugin.
*/
class Integration {
/**
* Whether the ActivityPub plugin is active.
*
* @var bool
*/
private bool $activitypub_active = false;
/**
* Constructor.
*/
public function __construct() {
// Check if ActivityPub plugin is available.
add_action( 'plugins_loaded', array( $this, 'check_activitypub' ), 5 );
// Initialize integration after ActivityPub is loaded.
add_action( 'plugins_loaded', array( $this, 'init' ), 20 );
}
/**
* Check if the ActivityPub plugin is active.
*
* @return void
*/
public function check_activitypub(): void {
$this->activitypub_active = defined( 'ACTIVITYPUB_PLUGIN_VERSION' )
|| class_exists( '\Activitypub\Activitypub' );
}
/**
* Initialize the ActivityPub integration.
*
* @return void
*/
public function init(): void {
if ( ! $this->activitypub_active ) {
return;
}
// Register custom post types for ActivityPub.
add_filter( 'activitypub_post_types', array( $this, 'register_post_types' ) );
// Register custom transformers.
add_filter( 'activitypub_transformers', array( $this, 'register_transformers' ) );
// Register artist actors.
add_action( 'init', array( $this, 'register_artist_actors' ), 20 );
// Add custom properties to ActivityPub objects.
add_filter( 'activitypub_activity_object_array', array( $this, 'add_audio_properties' ), 10, 3 );
// Handle incoming activities.
add_action( 'activitypub_inbox_like', array( $this, 'handle_like' ), 10, 2 );
add_action( 'activitypub_inbox_announce', array( $this, 'handle_announce' ), 10, 2 );
add_action( 'activitypub_inbox_create', array( $this, 'handle_create' ), 10, 2 );
// Add hooks for publishing.
add_action( 'publish_fedistream_track', array( $this, 'on_publish_track' ), 10, 2 );
add_action( 'publish_fedistream_album', array( $this, 'on_publish_album' ), 10, 2 );
}
/**
* Check if ActivityPub is active.
*
* @return bool
*/
public function is_active(): bool {
return $this->activitypub_active;
}
/**
* Register FediStream post types with ActivityPub.
*
* @param array $post_types The registered post types.
* @return array Modified post types.
*/
public function register_post_types( array $post_types ): array {
$post_types[] = 'fedistream_track';
$post_types[] = 'fedistream_album';
$post_types[] = 'fedistream_playlist';
return array_unique( $post_types );
}
/**
* Register custom transformers for FediStream post types.
*
* @param array $transformers The registered transformers.
* @return array Modified transformers.
*/
public function register_transformers( array $transformers ): array {
$transformers['fedistream_track'] = TrackTransformer::class;
$transformers['fedistream_album'] = AlbumTransformer::class;
$transformers['fedistream_playlist'] = PlaylistTransformer::class;
return $transformers;
}
/**
* Register artist actors for ActivityPub.
*
* @return void
*/
public function register_artist_actors(): void {
// Hook into ActivityPub actor discovery.
add_filter( 'activitypub_actor', array( $this, 'get_artist_actor' ), 10, 2 );
// Add artist webfinger handler.
add_filter( 'webfinger_data', array( $this, 'add_artist_webfinger' ), 10, 2 );
}
/**
* Get artist actor for ActivityPub.
*
* @param mixed $actor The current actor.
* @param string $id The actor ID or handle.
* @return mixed The actor object or original.
*/
public function get_artist_actor( $actor, string $id ) {
// Check if this is an artist handle.
if ( strpos( $id, 'artist-' ) === 0 ) {
$artist_id = absint( str_replace( 'artist-', '', $id ) );
$artist = get_post( $artist_id );
if ( $artist && 'fedistream_artist' === $artist->post_type ) {
return new ArtistActor( $artist );
}
}
return $actor;
}
/**
* Add artist to webfinger data.
*
* @param array $data The webfinger data.
* @param string $resource The requested resource.
* @return array Modified webfinger data.
*/
public function add_artist_webfinger( array $data, string $resource ): array {
// Parse acct: URI.
if ( preg_match( '/^acct:artist-(\d+)@/', $resource, $matches ) ) {
$artist_id = absint( $matches[1] );
$artist = get_post( $artist_id );
if ( $artist && 'fedistream_artist' === $artist->post_type ) {
$actor = new ArtistActor( $artist );
$data = $actor->get_webfinger();
}
}
return $data;
}
/**
* Add audio-specific properties to ActivityPub objects.
*
* @param array $array The object array.
* @param object $object The ActivityPub object.
* @param int $post_id The post ID.
* @return array Modified array.
*/
public function add_audio_properties( array $array, $object, int $post_id ): array {
$post = get_post( $post_id );
if ( ! $post ) {
return $array;
}
// Add audio-specific properties for tracks.
if ( 'fedistream_track' === $post->post_type ) {
$duration = get_post_meta( $post_id, '_fedistream_duration', true );
if ( $duration ) {
$array['duration'] = $this->format_duration_iso8601( (int) $duration );
}
$audio_id = get_post_meta( $post_id, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( $audio_url ) {
$array['attachment'][] = array(
'type' => 'Audio',
'mediaType' => 'audio/mpeg',
'url' => $audio_url,
'name' => $post->post_title,
'duration' => $this->format_duration_iso8601( (int) $duration ),
);
}
}
return $array;
}
/**
* Handle incoming Like activity.
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_like( array $activity, int $user_id ): void {
$object_id = $activity['object'] ?? '';
if ( ! $object_id ) {
return;
}
// Find local post from object ID.
$post_id = url_to_postid( $object_id );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the like.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'like', $activity );
}
/**
* Handle incoming Announce (boost) activity.
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_announce( array $activity, int $user_id ): void {
$object_id = $activity['object'] ?? '';
if ( ! $object_id ) {
return;
}
// Find local post from object ID.
$post_id = url_to_postid( $object_id );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the boost.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'boost', $activity );
}
/**
* Handle incoming Create activity (comments/replies).
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_create( array $activity, int $user_id ): void {
$object = $activity['object'] ?? array();
if ( empty( $object ) ) {
return;
}
// Check if this is a reply to our content.
$in_reply_to = $object['inReplyTo'] ?? '';
if ( ! $in_reply_to ) {
return;
}
// Find local post from reply target.
$post_id = url_to_postid( $in_reply_to );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the reply.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'reply', $activity );
}
/**
* Record a reaction from the Fediverse.
*
* @param int $post_id The post ID.
* @param string $actor The actor URI.
* @param string $type The reaction type (like, boost, reply).
* @param array $activity The full activity data.
* @return bool True on success.
*/
private function record_reaction( int $post_id, string $actor, string $type, array $activity ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
// Check if table exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$table_exists = $wpdb->get_var(
$wpdb->prepare(
'SHOW TABLES LIKE %s',
$table
)
);
if ( ! $table_exists ) {
$this->create_reactions_table();
}
// Insert reaction.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert(
$table,
array(
'post_id' => $post_id,
'actor_uri' => $actor,
'reaction_type' => $type,
'activity_data' => wp_json_encode( $activity ),
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s', '%s' )
);
// Update reaction count meta.
if ( $result ) {
$meta_key = '_fedistream_' . $type . '_count';
$count = (int) get_post_meta( $post_id, $meta_key, true );
update_post_meta( $post_id, $meta_key, $count + 1 );
}
return (bool) $result;
}
/**
* Create the reactions table.
*
* @return void
*/
private function create_reactions_table(): void {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
actor_uri varchar(2083) NOT NULL,
reaction_type varchar(50) NOT NULL,
activity_data longtext,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY actor_uri (actor_uri(191)),
KEY reaction_type (reaction_type)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
/**
* Handle track publishing.
*
* @param int $post_id The post ID.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_publish_track( int $post_id, \WP_Post $post ): void {
// The ActivityPub plugin will handle the publishing automatically.
// This hook is for any additional custom logic.
do_action( 'fedistream_track_published_activitypub', $post_id, $post );
}
/**
* Handle album publishing.
*
* @param int $post_id The post ID.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_publish_album( int $post_id, \WP_Post $post ): void {
// The ActivityPub plugin will handle the publishing automatically.
// This hook is for any additional custom logic.
do_action( 'fedistream_album_published_activitypub', $post_id, $post );
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
/**
* Get reactions for a post.
*
* @param int $post_id The post ID.
* @param string $type Optional reaction type filter.
* @return array The reactions.
*/
public function get_reactions( int $post_id, string $type = '' ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
if ( $type ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d AND reaction_type = %s ORDER BY created_at DESC",
$post_id,
$type
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at DESC",
$post_id
)
);
}
return $results ?: array();
}
/**
* Get reaction counts for a post.
*
* @param int $post_id The post ID.
* @return array The reaction counts.
*/
public function get_reaction_counts( int $post_id ): array {
return array(
'likes' => (int) get_post_meta( $post_id, '_fedistream_like_count', true ),
'boosts' => (int) get_post_meta( $post_id, '_fedistream_boost_count', true ),
'replies' => (int) get_post_meta( $post_id, '_fedistream_reply_count', true ),
);
}
}

View File

@@ -0,0 +1,415 @@
<?php
/**
* Outbox handler for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles publishing activities to followers.
*/
class Outbox {
/**
* The follower handler.
*
* @var FollowerHandler
*/
private FollowerHandler $follower_handler;
/**
* Constructor.
*/
public function __construct() {
$this->follower_handler = new FollowerHandler();
// Hook into post publishing.
add_action( 'transition_post_status', array( $this, 'on_post_status_change' ), 10, 3 );
// Hook into post update.
add_action( 'post_updated', array( $this, 'on_post_updated' ), 10, 3 );
}
/**
* Handle post status changes.
*
* @param string $new_status The new post status.
* @param string $old_status The old post status.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_post_status_change( string $new_status, string $old_status, \WP_Post $post ): void {
// Only handle FediStream post types.
if ( ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) {
return;
}
// Check if publishing is enabled for this post.
$enabled = get_post_meta( $post->ID, '_fedistream_activitypub_publish', true );
if ( ! $enabled && $enabled !== '' ) {
// Default to enabled for new posts.
if ( $old_status === 'new' || $old_status === 'auto-draft' ) {
update_post_meta( $post->ID, '_fedistream_activitypub_publish', '1' );
$enabled = '1';
} else {
return;
}
}
// Publish on status change to 'publish'.
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
$this->publish_create( $post );
}
}
/**
* Handle post updates.
*
* @param int $post_id The post ID.
* @param \WP_Post $post_after The post object after update.
* @param \WP_Post $post_before The post object before update.
* @return void
*/
public function on_post_updated( int $post_id, \WP_Post $post_after, \WP_Post $post_before ): void {
// Only handle published FediStream post types.
if ( $post_after->post_status !== 'publish' ) {
return;
}
if ( ! in_array( $post_after->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) {
return;
}
// Check if already published to ActivityPub.
$published = get_post_meta( $post_id, '_fedistream_activitypub_published', true );
if ( ! $published ) {
return;
}
// Check if update publishing is enabled.
$publish_updates = get_post_meta( $post_id, '_fedistream_activitypub_publish_updates', true );
if ( ! $publish_updates ) {
return;
}
// Only publish updates for significant changes.
if ( $this->has_significant_changes( $post_before, $post_after ) ) {
$this->publish_update( $post_after );
}
}
/**
* Check if a post has significant changes.
*
* @param \WP_Post $before The post before update.
* @param \WP_Post $after The post after update.
* @return bool
*/
private function has_significant_changes( \WP_Post $before, \WP_Post $after ): bool {
// Check title change.
if ( $before->post_title !== $after->post_title ) {
return true;
}
// Check content change.
if ( $before->post_content !== $after->post_content ) {
return true;
}
// Check for audio file change (tracks).
if ( 'fedistream_track' === $after->post_type ) {
$audio_before = get_post_meta( $after->ID, '_fedistream_audio_file_previous', true );
$audio_after = get_post_meta( $after->ID, '_fedistream_audio_file', true );
if ( $audio_before !== $audio_after ) {
return true;
}
}
return false;
}
/**
* Publish a Create activity for a post.
*
* @param \WP_Post $post The post to publish.
* @return bool True on success.
*/
public function publish_create( \WP_Post $post ): bool {
$transformer = $this->get_transformer( $post );
if ( ! $transformer ) {
return false;
}
$activity = $transformer->to_create_activity();
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
// Mark as published.
update_post_meta( $post->ID, '_fedistream_activitypub_published', current_time( 'mysql' ) );
// Log results.
$success_count = count( array_filter( $results ) );
$total_count = count( $results );
do_action( 'fedistream_activitypub_published', $post->ID, $success_count, $total_count );
return $success_count > 0 || $total_count === 0;
}
/**
* Publish an Update activity for a post.
*
* @param \WP_Post $post The post to update.
* @return bool True on success.
*/
public function publish_update( \WP_Post $post ): bool {
$transformer = $this->get_transformer( $post );
if ( ! $transformer ) {
return false;
}
// Build Update activity.
$object = $transformer->to_object();
$actor = $transformer->get_attributed_to();
$actor = is_array( $actor ) ? $actor[0] : $actor;
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Update',
'id' => get_permalink( $post->ID ) . '#activity-update-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $object,
);
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
// Update timestamp.
update_post_meta( $post->ID, '_fedistream_activitypub_updated', current_time( 'mysql' ) );
$success_count = count( array_filter( $results ) );
return $success_count > 0 || count( $results ) === 0;
}
/**
* Publish an Announce (boost) activity.
*
* @param int $artist_id The artist ID doing the announcing.
* @param string $object_uri The object URI to announce.
* @return bool True on success.
*/
public function publish_announce( int $artist_id, string $object_uri ): bool {
$artist = get_post( $artist_id );
if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) {
return false;
}
$actor = new ArtistActor( $artist );
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $actor->get_id() . '#announce-' . time(),
'actor' => $actor->get_id(),
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor->get_followers() ),
'object' => $object_uri,
);
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
return count( array_filter( $results ) ) > 0 || count( $results ) === 0;
}
/**
* Publish a Delete activity for a post.
*
* @param \WP_Post $post The post being deleted.
* @return bool True on success.
*/
public function publish_delete( \WP_Post $post ): bool {
// Check if was published to ActivityPub.
$published = get_post_meta( $post->ID, '_fedistream_activitypub_published', true );
if ( ! $published ) {
return false;
}
$actor = $this->get_attributed_to( $post );
if ( ! $actor ) {
return false;
}
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Delete',
'id' => get_permalink( $post->ID ) . '#activity-delete-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'object' => array(
'type' => 'Tombstone',
'id' => get_permalink( $post->ID ),
),
);
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
return count( array_filter( $results ) ) > 0 || count( $results ) === 0;
}
/**
* Get the appropriate transformer for a post.
*
* @param \WP_Post $post The post.
* @return TrackTransformer|AlbumTransformer|PlaylistTransformer|null
*/
private function get_transformer( \WP_Post $post ) {
switch ( $post->post_type ) {
case 'fedistream_track':
return new TrackTransformer( $post );
case 'fedistream_album':
return new AlbumTransformer( $post );
case 'fedistream_playlist':
return new PlaylistTransformer( $post );
default:
return null;
}
}
/**
* Get the artist ID associated with a post.
*
* @param \WP_Post $post The post.
* @return int|null
*/
private function get_artist_for_post( \WP_Post $post ): ?int {
switch ( $post->post_type ) {
case 'fedistream_track':
$artist_ids = get_post_meta( $post->ID, '_fedistream_artist_ids', true );
if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) {
return absint( $artist_ids[0] );
}
// Fall back to album artist.
$album_id = get_post_meta( $post->ID, '_fedistream_album_id', true );
if ( $album_id ) {
return absint( get_post_meta( $album_id, '_fedistream_album_artist', true ) );
}
break;
case 'fedistream_album':
$artist_id = get_post_meta( $post->ID, '_fedistream_album_artist', true );
return $artist_id ? absint( $artist_id ) : null;
case 'fedistream_playlist':
// For playlists, try to find an artist owned by the author.
$artist_args = array(
'post_type' => 'fedistream_artist',
'posts_per_page' => 1,
'author' => $post->post_author,
);
$artists = get_posts( $artist_args );
if ( ! empty( $artists ) ) {
return $artists[0]->ID;
}
break;
}
return null;
}
/**
* Get the attributed actor for a post.
*
* @param \WP_Post $post The post.
* @return string|null Actor URI.
*/
private function get_attributed_to( \WP_Post $post ): ?string {
$artist_id = $this->get_artist_for_post( $post );
if ( $artist_id ) {
return get_permalink( $artist_id );
}
return null;
}
/**
* Manually trigger publication for a post.
*
* @param int $post_id The post ID.
* @return bool True on success.
*/
public function manual_publish( int $post_id ): bool {
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
return false;
}
// Check if already published.
$published = get_post_meta( $post_id, '_fedistream_activitypub_published', true );
if ( $published ) {
return $this->publish_update( $post );
}
return $this->publish_create( $post );
}
/**
* Get the outbox collection for an artist.
*
* @param int $artist_id The artist ID.
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_collection( int $artist_id, int $page = 0, int $per_page = 20 ): array {
$artist = get_post( $artist_id );
if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) {
return array();
}
$actor = new ArtistActor( $artist );
return $actor->get_outbox_collection( $page, $per_page );
}
}

View File

@@ -0,0 +1,433 @@
<?php
/**
* Playlist Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Playlist posts to ActivityPub OrderedCollection objects.
*/
class PlaylistTransformer {
/**
* The playlist post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The playlist post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'OrderedCollection';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
return $this->post->post_title;
}
/**
* Get the content (description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Generate excerpt from content.
return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor (playlist creator).
*
* @return string User's ActivityPub actor URI.
*/
public function get_attributed_to(): string {
$user_id = $this->post->post_author;
// Check if there's an artist associated with this user.
$artist_args = array(
'post_type' => 'fedistream_artist',
'posts_per_page' => 1,
'author' => $user_id,
);
$artists = get_posts( $artist_args );
if ( ! empty( $artists ) ) {
return get_permalink( $artists[0]->ID );
}
// Fall back to user profile URL.
return get_author_posts_url( $user_id );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the total duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_playlist_duration', true );
if ( ! $seconds ) {
// Calculate from tracks.
$tracks = $this->get_tracks();
foreach ( $tracks as $track ) {
$seconds += (int) get_post_meta( $track->ID, '_fedistream_duration', true );
}
}
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the tracks in this playlist.
*
* @return array Array of WP_Post objects.
*/
public function get_tracks(): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// Get track IDs in order.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$track_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT track_id FROM {$table} WHERE playlist_id = %d ORDER BY position ASC",
$this->post->ID
)
);
if ( empty( $track_ids ) ) {
return array();
}
// Get track posts in order.
$args = array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'post__in' => $track_ids,
'orderby' => 'post__in',
'posts_per_page' => -1,
);
$query = new \WP_Query( $args );
return $query->posts;
}
/**
* Get the total item count.
*
* @return int
*/
public function get_total_items(): int {
$count = (int) get_post_meta( $this->post->ID, '_fedistream_track_count', true );
if ( ! $count ) {
$count = count( $this->get_tracks() );
}
return $count;
}
/**
* Get the ordered items (track objects).
*
* @return array
*/
public function get_ordered_items(): array {
$tracks = $this->get_tracks();
$items = array();
foreach ( $tracks as $track ) {
$transformer = new TrackTransformer( $track );
$items[] = $transformer->to_object();
}
return $items;
}
/**
* Get the image/cover attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (moods).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get moods.
$moods = get_the_terms( $this->post->ID, 'fedistream_mood' );
if ( $moods && ! is_wp_error( $moods ) ) {
foreach ( $moods as $mood ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $mood->name ),
'href' => get_term_link( $mood ),
);
}
}
return $tags;
}
/**
* Check if the playlist is public.
*
* @return bool
*/
public function is_public(): bool {
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
return 'public' === $visibility || empty( $visibility );
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
'totalItems' => $this->get_total_items(),
'orderedItems' => $this->get_ordered_items(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add image.
$image = $this->get_image_attachment();
if ( $image ) {
$object['image'] = $image;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add visibility indicator.
if ( ! $this->is_public() ) {
$object['sensitive'] = false;
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
if ( 'unlisted' === $visibility ) {
$object['visibility'] = 'unlisted';
}
}
// Add collaborative flag.
$collaborative = get_post_meta( $this->post->ID, '_fedistream_playlist_collaborative', true );
if ( $collaborative ) {
$object['collaborative'] = true;
}
return $object;
}
/**
* Create a Create activity for this playlist.
*
* @return array
*/
public function to_create_activity(): array {
$actor = $this->get_attributed_to();
// Determine audience based on visibility.
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
$to = array();
$cc = array();
if ( 'public' === $visibility || empty( $visibility ) ) {
$to[] = 'https://www.w3.org/ns/activitystreams#Public';
$cc[] = $actor . '/followers';
} elseif ( 'unlisted' === $visibility ) {
$to[] = $actor . '/followers';
$cc[] = 'https://www.w3.org/ns/activitystreams#Public';
} else {
// Private - only followers.
$to[] = $actor . '/followers';
}
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => $to,
'cc' => $cc,
'object' => $this->to_object(),
);
}
/**
* Create an Update activity for this playlist.
*
* @return array
*/
public function to_update_activity(): array {
$actor = $this->get_attributed_to();
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Update',
'id' => $this->get_id() . '#activity-update-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}

View File

@@ -0,0 +1,476 @@
<?php
/**
* REST API endpoints for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* REST API handler for ActivityPub endpoints.
*/
class RestApi {
/**
* The namespace for REST routes.
*
* @var string
*/
private const NAMESPACE = 'fedistream/v1';
/**
* The follower handler.
*
* @var FollowerHandler
*/
private FollowerHandler $follower_handler;
/**
* The outbox handler.
*
* @var Outbox
*/
private Outbox $outbox;
/**
* Constructor.
*/
public function __construct() {
$this->follower_handler = new FollowerHandler();
$this->outbox = new Outbox();
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
// Add ActivityPub content type to allowed responses.
add_filter( 'rest_pre_serve_request', array( $this, 'serve_activitypub' ), 10, 4 );
}
/**
* Register REST API routes.
*
* @return void
*/
public function register_routes(): void {
// Artist actor endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_artist_actor' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
),
)
);
// Artist inbox endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/inbox',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_inbox' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
),
)
);
// Artist outbox endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/outbox',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_outbox' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
'page' => array(
'default' => 0,
'sanitize_callback' => 'absint',
),
),
)
);
// Artist followers endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/followers',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_followers' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
'page' => array(
'default' => 0,
'sanitize_callback' => 'absint',
),
),
)
);
// Track/Album/Playlist object endpoint.
register_rest_route(
self::NAMESPACE,
'/object/(?P<type>track|album|playlist)/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_object' ),
'permission_callback' => '__return_true',
'args' => array(
'type' => array(
'required' => true,
),
'id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
),
)
);
// Reactions endpoint.
register_rest_route(
self::NAMESPACE,
'/reactions/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_reactions' ),
'permission_callback' => '__return_true',
'args' => array(
'post_id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
'type' => array(
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
// Manual publish endpoint (requires auth).
register_rest_route(
self::NAMESPACE,
'/publish/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'manual_publish' ),
'permission_callback' => array( $this, 'can_edit_post' ),
'args' => array(
'post_id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
),
)
);
}
/**
* Validate artist ID.
*
* @param mixed $id The ID to validate.
* @return bool
*/
public function validate_artist_id( $id ): bool {
$artist = get_post( absint( $id ) );
return $artist && 'fedistream_artist' === $artist->post_type && 'publish' === $artist->post_status;
}
/**
* Check if user can edit the post.
*
* @param \WP_REST_Request $request The request.
* @return bool
*/
public function can_edit_post( \WP_REST_Request $request ): bool {
$post_id = absint( $request->get_param( 'post_id' ) );
return current_user_can( 'edit_post', $post_id );
}
/**
* Get artist actor.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_artist_actor( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$response = new \WP_REST_Response( $actor->to_array() );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Handle inbox requests.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_inbox( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
// Get the activity from request body.
$activity = $request->get_json_params();
if ( empty( $activity ) ) {
return new \WP_REST_Response( array( 'error' => 'Invalid activity' ), 400 );
}
// Verify HTTP signature (basic verification).
$signature = $request->get_header( 'Signature' );
if ( ! $signature ) {
// Allow unsigned requests for now, but log it.
do_action( 'fedistream_unsigned_activity', $activity, $artist_id );
}
// Process the activity based on type.
$type = $activity['type'] ?? '';
switch ( $type ) {
case 'Follow':
$this->follower_handler->handle_follow( $activity, 0 );
break;
case 'Undo':
$this->follower_handler->handle_undo( $activity, 0 );
break;
case 'Like':
do_action( 'activitypub_inbox_like', $activity, 0 );
break;
case 'Announce':
do_action( 'activitypub_inbox_announce', $activity, 0 );
break;
case 'Create':
do_action( 'activitypub_inbox_create', $activity, 0 );
break;
case 'Delete':
do_action( 'activitypub_inbox_delete', $activity, 0 );
break;
default:
do_action( 'fedistream_inbox_activity', $activity, $artist_id, $type );
break;
}
return new \WP_REST_Response( null, 202 );
}
/**
* Get outbox collection.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_outbox( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$page = absint( $request->get_param( 'page' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$collection = $actor->get_outbox_collection( $page );
$response = new \WP_REST_Response( $collection );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get followers collection.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_followers( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$page = absint( $request->get_param( 'page' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$collection = $actor->get_followers_collection( $page );
$response = new \WP_REST_Response( $collection );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get ActivityPub object.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_object( \WP_REST_Request $request ): \WP_REST_Response {
$type = $request->get_param( 'type' );
$post_id = absint( $request->get_param( 'id' ) );
$post_type = 'fedistream_' . $type;
$post = get_post( $post_id );
if ( ! $post || $post->post_type !== $post_type || 'publish' !== $post->post_status ) {
return new \WP_REST_Response( array( 'error' => 'Object not found' ), 404 );
}
$transformer = null;
switch ( $type ) {
case 'track':
$transformer = new TrackTransformer( $post );
break;
case 'album':
$transformer = new AlbumTransformer( $post );
break;
case 'playlist':
$transformer = new PlaylistTransformer( $post );
break;
}
if ( ! $transformer ) {
return new \WP_REST_Response( array( 'error' => 'Invalid object type' ), 400 );
}
$object = $transformer->to_object();
$response = new \WP_REST_Response( $object );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get reactions for a post.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_reactions( \WP_REST_Request $request ): \WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$type = sanitize_text_field( $request->get_param( 'type' ) );
$post = get_post( $post_id );
if ( ! $post ) {
return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 );
}
$integration = new Integration();
$reactions = $integration->get_reactions( $post_id, $type );
$counts = $integration->get_reaction_counts( $post_id );
return new \WP_REST_Response(
array(
'reactions' => $reactions,
'counts' => $counts,
)
);
}
/**
* Manually publish a post to ActivityPub.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function manual_publish( \WP_REST_Request $request ): \WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$result = $this->outbox->manual_publish( $post_id );
if ( $result ) {
return new \WP_REST_Response(
array(
'success' => true,
'message' => __( 'Published to ActivityPub', 'wp-fedistream' ),
)
);
}
return new \WP_REST_Response(
array(
'success' => false,
'message' => __( 'Failed to publish', 'wp-fedistream' ),
),
500
);
}
/**
* Serve ActivityPub content type when requested.
*
* @param bool $served Whether the request has been served.
* @param \WP_REST_Response $result The response.
* @param \WP_REST_Request $request The request.
* @param \WP_REST_Server $server The server.
* @return bool
*/
public function serve_activitypub( bool $served, $result, \WP_REST_Request $request, \WP_REST_Server $server ): bool {
// Check if this is a FediStream route.
$route = $request->get_route();
if ( strpos( $route, '/fedistream/' ) === false ) {
return $served;
}
// Check Accept header for ActivityPub.
$accept = $request->get_header( 'Accept' );
if ( $accept && ( strpos( $accept, 'application/activity+json' ) !== false || strpos( $accept, 'application/ld+json' ) !== false ) ) {
// Will be handled by our response headers.
}
return $served;
}
}

View File

@@ -0,0 +1,412 @@
<?php
/**
* Track Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Track posts to ActivityPub Audio objects.
*/
class TrackTransformer {
/**
* The track post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The track post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'Audio';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
return $this->post->post_title;
}
/**
* Get the content (lyrics or description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Generate excerpt from content.
return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor(s).
*
* @return array|string Artist(s) URIs.
*/
public function get_attributed_to() {
$artist_ids = get_post_meta( $this->post->ID, '_fedistream_artist_ids', true );
if ( ! is_array( $artist_ids ) || empty( $artist_ids ) ) {
// Fall back to album artist.
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
$artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0;
if ( $artist_id ) {
return get_permalink( $artist_id );
}
return array();
}
// Return single artist or array of artists.
if ( count( $artist_ids ) === 1 ) {
return get_permalink( $artist_ids[0] );
}
return array_map( 'get_permalink', $artist_ids );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_duration', true );
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the audio attachment.
*
* @return array|null
*/
public function get_audio_attachment(): ?array {
$audio_id = get_post_meta( $this->post->ID, '_fedistream_audio_file', true );
if ( ! $audio_id ) {
return null;
}
$audio_url = wp_get_attachment_url( $audio_id );
$mime_type = get_post_mime_type( $audio_id );
$audio_meta = wp_get_attachment_metadata( $audio_id );
if ( ! $audio_url ) {
return null;
}
$attachment = array(
'type' => 'Audio',
'mediaType' => $mime_type ?: 'audio/mpeg',
'url' => $audio_url,
'name' => $this->post->post_title,
);
// Add duration if available.
$duration = $this->get_duration();
if ( $duration ) {
$attachment['duration'] = $duration;
}
return $attachment;
}
/**
* Get the image/thumbnail attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
// Fall back to album artwork.
if ( ! $thumbnail_id ) {
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
$thumbnail_id = $album_id ? get_post_thumbnail_id( $album_id ) : 0;
}
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (genres, moods).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get genres.
$genres = get_the_terms( $this->post->ID, 'fedistream_genre' );
if ( $genres && ! is_wp_error( $genres ) ) {
foreach ( $genres as $genre ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $genre->name ),
'href' => get_term_link( $genre ),
);
}
}
// Get moods.
$moods = get_the_terms( $this->post->ID, 'fedistream_mood' );
if ( $moods && ! is_wp_error( $moods ) ) {
foreach ( $moods as $mood ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $mood->name ),
'href' => get_term_link( $mood ),
);
}
}
return $tags;
}
/**
* Get the context (album).
*
* @return string|null Album URI if available.
*/
public function get_context(): ?string {
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
if ( ! $album_id ) {
return null;
}
return get_permalink( $album_id );
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add attachments.
$attachments = array();
$audio = $this->get_audio_attachment();
if ( $audio ) {
$attachments[] = $audio;
}
$image = $this->get_image_attachment();
if ( $image ) {
$attachments[] = $image;
}
if ( ! empty( $attachments ) ) {
$object['attachment'] = $attachments;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add context (album).
$context = $this->get_context();
if ( $context ) {
$object['context'] = $context;
}
// Add additional metadata.
$explicit = get_post_meta( $this->post->ID, '_fedistream_explicit', true );
if ( $explicit ) {
$object['sensitive'] = true;
}
// Add ISRC if available.
$isrc = get_post_meta( $this->post->ID, '_fedistream_isrc', true );
if ( $isrc ) {
$object['isrc'] = $isrc;
}
return $object;
}
/**
* Create a Create activity for this track.
*
* @return array
*/
public function to_create_activity(): array {
$attributed_to = $this->get_attributed_to();
$actor = is_array( $attributed_to ) ? $attributed_to[0] : $attributed_to;
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Create an Announce activity for this track.
*
* @param string $actor_uri The actor announcing the track.
* @return array
*/
public function to_announce_activity( string $actor_uri ): array {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $this->get_id() . '#activity-announce-' . time(),
'actor' => $actor_uri,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor_uri . '/followers' ),
'object' => $this->get_id(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}

View File

@@ -0,0 +1,604 @@
<?php
/**
* Custom list table columns for admin.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Admin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* ListColumns class.
*
* Handles custom columns in admin list tables for all post types.
*/
class ListColumns {
/**
* Constructor.
*/
public function __construct() {
// Artist columns.
add_filter( 'manage_fedistream_artist_posts_columns', array( $this, 'artist_columns' ) );
add_action( 'manage_fedistream_artist_posts_custom_column', array( $this, 'artist_column_content' ), 10, 2 );
add_filter( 'manage_edit-fedistream_artist_sortable_columns', array( $this, 'artist_sortable_columns' ) );
// Album columns.
add_filter( 'manage_fedistream_album_posts_columns', array( $this, 'album_columns' ) );
add_action( 'manage_fedistream_album_posts_custom_column', array( $this, 'album_column_content' ), 10, 2 );
add_filter( 'manage_edit-fedistream_album_sortable_columns', array( $this, 'album_sortable_columns' ) );
// Track columns.
add_filter( 'manage_fedistream_track_posts_columns', array( $this, 'track_columns' ) );
add_action( 'manage_fedistream_track_posts_custom_column', array( $this, 'track_column_content' ), 10, 2 );
add_filter( 'manage_edit-fedistream_track_sortable_columns', array( $this, 'track_sortable_columns' ) );
// Playlist columns.
add_filter( 'manage_fedistream_playlist_posts_columns', array( $this, 'playlist_columns' ) );
add_action( 'manage_fedistream_playlist_posts_custom_column', array( $this, 'playlist_column_content' ), 10, 2 );
add_filter( 'manage_edit-fedistream_playlist_sortable_columns', array( $this, 'playlist_sortable_columns' ) );
// Handle sorting.
add_action( 'pre_get_posts', array( $this, 'handle_sorting' ) );
}
/**
* Define artist list columns.
*
* @param array $columns Default columns.
* @return array Modified columns.
*/
public function artist_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
if ( 'title' === $key ) {
$new_columns['fedistream_photo'] = '';
}
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['fedistream_type'] = __( 'Type', 'wp-fedistream' );
$new_columns['fedistream_albums'] = __( 'Albums', 'wp-fedistream' );
$new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' );
}
}
// Remove date, we'll add it back at the end.
unset( $new_columns['date'] );
$new_columns['date'] = __( 'Date', 'wp-fedistream' );
return $new_columns;
}
/**
* Render artist column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public function artist_column_content( string $column, int $post_id ): void {
switch ( $column ) {
case 'fedistream_photo':
$thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) );
if ( $thumbnail ) {
echo wp_kses_post( $thumbnail );
} else {
echo '<span class="dashicons dashicons-admin-users" style="font-size: 40px; width: 40px; height: 40px; color: #ccc;"></span>';
}
break;
case 'fedistream_type':
$type = get_post_meta( $post_id, '_fedistream_artist_type', true );
$types = array(
'solo' => __( 'Solo', 'wp-fedistream' ),
'band' => __( 'Band', 'wp-fedistream' ),
'duo' => __( 'Duo', 'wp-fedistream' ),
'collective' => __( 'Collective', 'wp-fedistream' ),
);
echo esc_html( $types[ $type ] ?? __( 'Solo', 'wp-fedistream' ) );
break;
case 'fedistream_albums':
$count = $this->count_posts_by_meta( 'fedistream_album', '_fedistream_album_artist', $post_id );
echo '<a href="' . esc_url( admin_url( 'edit.php?post_type=fedistream_album&artist=' . $post_id ) ) . '">' . esc_html( $count ) . '</a>';
break;
case 'fedistream_tracks':
$count = $this->count_tracks_by_artist( $post_id );
echo esc_html( $count );
break;
}
}
/**
* Define sortable artist columns.
*
* @param array $columns Sortable columns.
* @return array Modified columns.
*/
public function artist_sortable_columns( array $columns ): array {
$columns['fedistream_type'] = 'fedistream_type';
return $columns;
}
/**
* Define album list columns.
*
* @param array $columns Default columns.
* @return array Modified columns.
*/
public function album_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
if ( 'title' === $key ) {
$new_columns['fedistream_artwork'] = '';
}
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['fedistream_artist'] = __( 'Artist', 'wp-fedistream' );
$new_columns['fedistream_type'] = __( 'Type', 'wp-fedistream' );
$new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' );
$new_columns['fedistream_release_date'] = __( 'Release Date', 'wp-fedistream' );
}
}
unset( $new_columns['date'] );
$new_columns['date'] = __( 'Date', 'wp-fedistream' );
return $new_columns;
}
/**
* Render album column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public function album_column_content( string $column, int $post_id ): void {
switch ( $column ) {
case 'fedistream_artwork':
$thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) );
if ( $thumbnail ) {
echo wp_kses_post( $thumbnail );
} else {
echo '<span class="dashicons dashicons-album" style="font-size: 40px; width: 40px; height: 40px; color: #ccc;"></span>';
}
break;
case 'fedistream_artist':
$artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true );
if ( $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
echo '<a href="' . esc_url( get_edit_post_link( $artist_id ) ) . '">' . esc_html( $artist->post_title ) . '</a>';
}
} else {
echo '<span class="description">' . esc_html__( 'No artist', 'wp-fedistream' ) . '</span>';
}
break;
case 'fedistream_type':
$type = get_post_meta( $post_id, '_fedistream_album_type', true );
$types = array(
'album' => __( 'Album', 'wp-fedistream' ),
'ep' => __( 'EP', 'wp-fedistream' ),
'single' => __( 'Single', 'wp-fedistream' ),
'compilation' => __( 'Compilation', 'wp-fedistream' ),
'live' => __( 'Live', 'wp-fedistream' ),
'remix' => __( 'Remix', 'wp-fedistream' ),
);
echo esc_html( $types[ $type ] ?? __( 'Album', 'wp-fedistream' ) );
break;
case 'fedistream_tracks':
$count = get_post_meta( $post_id, '_fedistream_album_total_tracks', true );
echo '<a href="' . esc_url( admin_url( 'edit.php?post_type=fedistream_track&album=' . $post_id ) ) . '">' . esc_html( $count ?: 0 ) . '</a>';
break;
case 'fedistream_release_date':
$date = get_post_meta( $post_id, '_fedistream_album_release_date', true );
if ( $date ) {
echo esc_html( date_i18n( get_option( 'date_format' ), strtotime( $date ) ) );
} else {
echo '<span class="description">—</span>';
}
break;
}
}
/**
* Define sortable album columns.
*
* @param array $columns Sortable columns.
* @return array Modified columns.
*/
public function album_sortable_columns( array $columns ): array {
$columns['fedistream_type'] = 'fedistream_type';
$columns['fedistream_release_date'] = 'fedistream_release_date';
$columns['fedistream_artist'] = 'fedistream_artist';
return $columns;
}
/**
* Define track list columns.
*
* @param array $columns Default columns.
* @return array Modified columns.
*/
public function track_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
if ( 'title' === $key ) {
$new_columns['fedistream_artwork'] = '';
}
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['fedistream_artists'] = __( 'Artists', 'wp-fedistream' );
$new_columns['fedistream_album'] = __( 'Album', 'wp-fedistream' );
$new_columns['fedistream_duration'] = __( 'Duration', 'wp-fedistream' );
$new_columns['fedistream_plays'] = __( 'Plays', 'wp-fedistream' );
}
}
unset( $new_columns['date'] );
$new_columns['date'] = __( 'Date', 'wp-fedistream' );
return $new_columns;
}
/**
* Render track column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public function track_column_content( string $column, int $post_id ): void {
switch ( $column ) {
case 'fedistream_artwork':
$thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) );
if ( ! $thumbnail ) {
// Try album artwork.
$album_id = get_post_meta( $post_id, '_fedistream_track_album', true );
if ( $album_id ) {
$thumbnail = get_the_post_thumbnail( $album_id, array( 40, 40 ) );
}
}
if ( $thumbnail ) {
echo wp_kses_post( $thumbnail );
} else {
echo '<span class="dashicons dashicons-format-audio" style="font-size: 40px; width: 40px; height: 40px; color: #ccc;"></span>';
}
break;
case 'fedistream_artists':
$artists = get_post_meta( $post_id, '_fedistream_track_artists', true );
if ( is_array( $artists ) && ! empty( $artists ) ) {
$artist_links = array();
foreach ( $artists as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
$artist_links[] = '<a href="' . esc_url( get_edit_post_link( $artist_id ) ) . '">' . esc_html( $artist->post_title ) . '</a>';
}
}
echo wp_kses_post( implode( ', ', $artist_links ) );
} else {
echo '<span class="description">' . esc_html__( 'No artist', 'wp-fedistream' ) . '</span>';
}
break;
case 'fedistream_album':
$album_id = get_post_meta( $post_id, '_fedistream_track_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="description">' . esc_html__( 'Single', 'wp-fedistream' ) . '</span>';
}
break;
case 'fedistream_duration':
$duration = get_post_meta( $post_id, '_fedistream_track_duration', true );
if ( $duration ) {
$minutes = floor( $duration / 60 );
$seconds = $duration % 60;
echo esc_html( sprintf( '%d:%02d', $minutes, $seconds ) );
} else {
echo '<span class="description">—</span>';
}
break;
case 'fedistream_plays':
$plays = $this->get_track_plays( $post_id );
echo esc_html( number_format_i18n( $plays ) );
break;
}
}
/**
* Define sortable track columns.
*
* @param array $columns Sortable columns.
* @return array Modified columns.
*/
public function track_sortable_columns( array $columns ): array {
$columns['fedistream_duration'] = 'fedistream_duration';
$columns['fedistream_plays'] = 'fedistream_plays';
$columns['fedistream_album'] = 'fedistream_album';
return $columns;
}
/**
* Define playlist list columns.
*
* @param array $columns Default columns.
* @return array Modified columns.
*/
public function playlist_columns( array $columns ): array {
$new_columns = array();
foreach ( $columns as $key => $value ) {
if ( 'title' === $key ) {
$new_columns['fedistream_cover'] = '';
}
$new_columns[ $key ] = $value;
if ( 'title' === $key ) {
$new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' );
$new_columns['fedistream_duration'] = __( 'Duration', 'wp-fedistream' );
$new_columns['fedistream_visibility'] = __( 'Visibility', 'wp-fedistream' );
}
}
unset( $new_columns['date'] );
$new_columns['date'] = __( 'Date', 'wp-fedistream' );
return $new_columns;
}
/**
* Render playlist column content.
*
* @param string $column Column name.
* @param int $post_id Post ID.
* @return void
*/
public function playlist_column_content( string $column, int $post_id ): void {
switch ( $column ) {
case 'fedistream_cover':
$thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) );
if ( $thumbnail ) {
echo wp_kses_post( $thumbnail );
} else {
echo '<span class="dashicons dashicons-playlist-audio" style="font-size: 40px; width: 40px; height: 40px; color: #ccc;"></span>';
}
break;
case 'fedistream_tracks':
$count = get_post_meta( $post_id, '_fedistream_playlist_track_count', true );
echo esc_html( $count ?: 0 );
break;
case 'fedistream_duration':
$duration = get_post_meta( $post_id, '_fedistream_playlist_total_duration', true );
if ( $duration ) {
if ( $duration >= 3600 ) {
$hours = floor( $duration / 3600 );
$minutes = floor( ( $duration % 3600 ) / 60 );
echo esc_html( sprintf( '%d:%02d:%02d', $hours, $minutes, $duration % 60 ) );
} else {
$minutes = floor( $duration / 60 );
$seconds = $duration % 60;
echo esc_html( sprintf( '%d:%02d', $minutes, $seconds ) );
}
} else {
echo '<span class="description">—</span>';
}
break;
case 'fedistream_visibility':
$visibility = get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public';
$labels = array(
'public' => __( 'Public', 'wp-fedistream' ),
'unlisted' => __( 'Unlisted', 'wp-fedistream' ),
'private' => __( 'Private', 'wp-fedistream' ),
);
$icons = array(
'public' => 'dashicons-visibility',
'unlisted' => 'dashicons-hidden',
'private' => 'dashicons-lock',
);
echo '<span class="dashicons ' . esc_attr( $icons[ $visibility ] ?? 'dashicons-visibility' ) . '" title="' . esc_attr( $labels[ $visibility ] ?? '' ) . '"></span> ';
echo esc_html( $labels[ $visibility ] ?? __( 'Public', 'wp-fedistream' ) );
break;
}
}
/**
* Define sortable playlist columns.
*
* @param array $columns Sortable columns.
* @return array Modified columns.
*/
public function playlist_sortable_columns( array $columns ): array {
$columns['fedistream_tracks'] = 'fedistream_track_count';
$columns['fedistream_duration'] = 'fedistream_duration';
$columns['fedistream_visibility'] = 'fedistream_visibility';
return $columns;
}
/**
* Handle custom column sorting.
*
* @param \WP_Query $query The query object.
* @return void
*/
public function handle_sorting( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
$orderby = $query->get( 'orderby' );
switch ( $orderby ) {
case 'fedistream_type':
$post_type = $query->get( 'post_type' );
if ( 'fedistream_artist' === $post_type ) {
$query->set( 'meta_key', '_fedistream_artist_type' );
} elseif ( 'fedistream_album' === $post_type ) {
$query->set( 'meta_key', '_fedistream_album_type' );
}
$query->set( 'orderby', 'meta_value' );
break;
case 'fedistream_release_date':
$query->set( 'meta_key', '_fedistream_album_release_date' );
$query->set( 'orderby', 'meta_value' );
break;
case 'fedistream_artist':
$query->set( 'meta_key', '_fedistream_album_artist' );
$query->set( 'orderby', 'meta_value_num' );
break;
case 'fedistream_duration':
$post_type = $query->get( 'post_type' );
if ( 'fedistream_track' === $post_type ) {
$query->set( 'meta_key', '_fedistream_track_duration' );
} elseif ( 'fedistream_playlist' === $post_type ) {
$query->set( 'meta_key', '_fedistream_playlist_total_duration' );
}
$query->set( 'orderby', 'meta_value_num' );
break;
case 'fedistream_track_count':
$query->set( 'meta_key', '_fedistream_playlist_track_count' );
$query->set( 'orderby', 'meta_value_num' );
break;
case 'fedistream_visibility':
$query->set( 'meta_key', '_fedistream_playlist_visibility' );
$query->set( 'orderby', 'meta_value' );
break;
case 'fedistream_album':
$query->set( 'meta_key', '_fedistream_track_album' );
$query->set( 'orderby', 'meta_value_num' );
break;
}
}
/**
* Count posts by meta value.
*
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @return int Count.
*/
private function count_posts_by_meta( string $post_type, string $meta_key, $meta_value ): int {
$query = new \WP_Query(
array(
'post_type' => $post_type,
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => $meta_key,
'meta_value' => $meta_value,
'fields' => 'ids',
)
);
return $query->found_posts;
}
/**
* Count tracks by artist.
*
* @param int $artist_id Artist post ID.
* @return int Track count.
*/
private function count_tracks_by_artist( int $artist_id ): int {
global $wpdb;
// Count tracks where artist is in the artists array.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts p
INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'fedistream_track'
AND p.post_status = 'publish'
AND pm.meta_key = '_fedistream_track_artists'
AND pm.meta_value LIKE %s",
'%"' . $artist_id . '"%'
)
);
// Also check serialized format.
if ( ! $count ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts p
INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'fedistream_track'
AND p.post_status = 'publish'
AND pm.meta_key = '_fedistream_track_artists'
AND pm.meta_value LIKE %s",
'%i:' . $artist_id . ';%'
)
);
}
return (int) $count;
}
/**
* Get track play count.
*
* @param int $track_id Track post ID.
* @return int Play count.
*/
private function get_track_plays( int $track_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_plays';
// Check if table exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$table_exists = $wpdb->get_var(
$wpdb->prepare(
'SHOW TABLES LIKE %s',
$table
)
);
if ( ! $table_exists ) {
return 0;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE track_id = %d",
$track_id
)
);
return (int) $count;
}
}

1
includes/Admin/index.php Normal file
View File

@@ -0,0 +1 @@
<?php // Silence is golden.

172
includes/Frontend/Ajax.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
/**
* AJAX handlers for frontend functionality.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles AJAX requests for the frontend.
*/
class Ajax {
/**
* Constructor.
*/
public function __construct() {
// Track data endpoint (public).
add_action( 'wp_ajax_fedistream_get_track', array( $this, 'get_track' ) );
add_action( 'wp_ajax_nopriv_fedistream_get_track', array( $this, 'get_track' ) );
// Record play endpoint (public).
add_action( 'wp_ajax_fedistream_record_play', array( $this, 'record_play' ) );
add_action( 'wp_ajax_nopriv_fedistream_record_play', array( $this, 'record_play' ) );
}
/**
* Get track data via AJAX.
*
* @return void
*/
public function get_track(): void {
// Verify nonce.
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) );
}
$track_id = isset( $_POST['track_id'] ) ? absint( $_POST['track_id'] ) : 0;
if ( ! $track_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid track ID.', 'wp-fedistream' ) ) );
}
$track = get_post( $track_id );
if ( ! $track || 'fedistream_track' !== $track->post_type ) {
wp_send_json_error( array( 'message' => __( 'Track not found.', 'wp-fedistream' ) ) );
}
// Check if track is published.
if ( 'publish' !== $track->post_status ) {
wp_send_json_error( array( 'message' => __( 'Track not available.', 'wp-fedistream' ) ) );
}
// Get audio file.
$audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( ! $audio_url ) {
wp_send_json_error( array( 'message' => __( 'No audio file available.', 'wp-fedistream' ) ) );
}
// Get track metadata.
$thumbnail_id = get_post_thumbnail_id( $track_id );
$thumbnail = $thumbnail_id ? wp_get_attachment_image_url( $thumbnail_id, 'medium' ) : '';
// Get album info.
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
$album = $album_id ? get_post( $album_id ) : null;
// Get artists.
$artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true );
$artists = array();
if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) {
foreach ( $artist_ids as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist && 'fedistream_artist' === $artist->post_type ) {
$artists[] = array(
'id' => $artist->ID,
'name' => $artist->post_title,
'link' => get_permalink( $artist->ID ),
);
}
}
}
// Get duration.
$duration = get_post_meta( $track_id, '_fedistream_duration', true );
$duration_formatted = '';
if ( $duration ) {
$mins = floor( $duration / 60 );
$secs = $duration % 60;
$duration_formatted = $mins . ':' . str_pad( $secs, 2, '0', STR_PAD_LEFT );
}
$data = array(
'id' => $track->ID,
'title' => $track->post_title,
'permalink' => get_permalink( $track->ID ),
'audio_url' => $audio_url,
'thumbnail' => $thumbnail,
'artists' => $artists,
'artist' => ! empty( $artists ) ? $artists[0]['name'] : '',
'album' => $album ? $album->post_title : '',
'album_id' => $album ? $album->ID : 0,
'album_link' => $album ? get_permalink( $album->ID ) : '',
'duration' => $duration,
'duration_formatted' => $duration_formatted,
'explicit' => (bool) get_post_meta( $track_id, '_fedistream_explicit', true ),
);
wp_send_json_success( $data );
}
/**
* Record a track play via AJAX.
*
* @return void
*/
public function record_play(): void {
// Verify nonce.
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) );
}
$track_id = isset( $_POST['track_id'] ) ? absint( $_POST['track_id'] ) : 0;
if ( ! $track_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid track ID.', 'wp-fedistream' ) ) );
}
$track = get_post( $track_id );
if ( ! $track || 'fedistream_track' !== $track->post_type ) {
wp_send_json_error( array( 'message' => __( 'Track not found.', 'wp-fedistream' ) ) );
}
global $wpdb;
// Insert play record.
$table = $wpdb->prefix . 'fedistream_plays';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'track_id' => $track_id,
'user_id' => get_current_user_id() ?: null,
'played_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
// Update play count in post meta.
$play_count = (int) get_post_meta( $track_id, '_fedistream_play_count', true );
update_post_meta( $track_id, '_fedistream_play_count', $play_count + 1 );
wp_send_json_success(
array(
'message' => __( 'Play recorded.', 'wp-fedistream' ),
'play_count' => $play_count + 1,
)
);
}
}

View File

@@ -0,0 +1,513 @@
<?php
/**
* Shortcodes handler.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers and handles all plugin shortcodes.
*/
class Shortcodes {
/**
* Plugin instance.
*
* @var Plugin
*/
private Plugin $plugin;
/**
* Constructor.
*/
public function __construct() {
$this->plugin = Plugin::get_instance();
$this->register_shortcodes();
}
/**
* Register all shortcodes.
*
* @return void
*/
private function register_shortcodes(): void {
add_shortcode( 'fedistream_artist', array( $this, 'render_artist' ) );
add_shortcode( 'fedistream_album', array( $this, 'render_album' ) );
add_shortcode( 'fedistream_track', array( $this, 'render_track' ) );
add_shortcode( 'fedistream_playlist', array( $this, 'render_playlist' ) );
add_shortcode( 'fedistream_latest_releases', array( $this, 'render_latest_releases' ) );
add_shortcode( 'fedistream_popular_tracks', array( $this, 'render_popular_tracks' ) );
add_shortcode( 'fedistream_artists', array( $this, 'render_artists_grid' ) );
add_shortcode( 'fedistream_player', array( $this, 'render_player' ) );
}
/**
* Render single artist shortcode.
*
* [fedistream_artist id="123" show_albums="true" show_tracks="true"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_artist( array $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'slug' => '',
'show_albums' => 'true',
'show_tracks' => 'true',
'layout' => 'full', // full, card, compact
),
$atts,
'fedistream_artist'
);
$post = $this->get_post( $atts, 'fedistream_artist' );
if ( ! $post ) {
return '';
}
$context = array(
'post' => TemplateLoader::get_artist_data( $post ),
'show_albums' => filter_var( $atts['show_albums'], FILTER_VALIDATE_BOOLEAN ),
'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ),
'layout' => sanitize_key( $atts['layout'] ),
);
$template = 'card' === $atts['layout'] ? 'partials/card-artist' : 'shortcodes/artist';
return $this->render_template( $template, $context );
}
/**
* Render single album shortcode.
*
* [fedistream_album id="123" show_tracks="true"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_album( array $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'slug' => '',
'show_tracks' => 'true',
'layout' => 'full', // full, card, compact
),
$atts,
'fedistream_album'
);
$post = $this->get_post( $atts, 'fedistream_album' );
if ( ! $post ) {
return '';
}
$context = array(
'post' => TemplateLoader::get_album_data( $post ),
'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ),
'layout' => sanitize_key( $atts['layout'] ),
);
$template = 'card' === $atts['layout'] ? 'partials/card-album' : 'shortcodes/album';
return $this->render_template( $template, $context );
}
/**
* Render single track shortcode.
*
* [fedistream_track id="123" show_player="true"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_track( array $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'slug' => '',
'show_player' => 'true',
'layout' => 'full', // full, card, compact
),
$atts,
'fedistream_track'
);
$post = $this->get_post( $atts, 'fedistream_track' );
if ( ! $post ) {
return '';
}
$context = array(
'post' => TemplateLoader::get_track_data( $post ),
'show_player' => filter_var( $atts['show_player'], FILTER_VALIDATE_BOOLEAN ),
'layout' => sanitize_key( $atts['layout'] ),
);
$template = 'card' === $atts['layout'] ? 'partials/card-track' : 'shortcodes/track';
return $this->render_template( $template, $context );
}
/**
* Render playlist shortcode.
*
* [fedistream_playlist id="123" show_tracks="true"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_playlist( array $atts ): string {
$atts = shortcode_atts(
array(
'id' => 0,
'slug' => '',
'show_tracks' => 'true',
'layout' => 'full', // full, card, compact
),
$atts,
'fedistream_playlist'
);
$post = $this->get_post( $atts, 'fedistream_playlist' );
if ( ! $post ) {
return '';
}
$context = array(
'post' => TemplateLoader::get_playlist_data( $post ),
'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ),
'layout' => sanitize_key( $atts['layout'] ),
);
$template = 'card' === $atts['layout'] ? 'partials/card-playlist' : 'shortcodes/playlist';
return $this->render_template( $template, $context );
}
/**
* Render latest releases shortcode.
*
* [fedistream_latest_releases count="6" type="album" columns="3"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_latest_releases( array $atts ): string {
$atts = shortcode_atts(
array(
'count' => 6,
'type' => '', // album, ep, single, compilation or empty for all
'columns' => 3,
'artist' => 0, // Filter by artist ID
),
$atts,
'fedistream_latest_releases'
);
$query_args = array(
'post_type' => 'fedistream_album',
'posts_per_page' => absint( $atts['count'] ),
'orderby' => 'meta_value',
'meta_key' => '_fedistream_release_date',
'order' => 'DESC',
'post_status' => 'publish',
);
// Filter by album type.
if ( ! empty( $atts['type'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_fedistream_album_type',
'value' => sanitize_key( $atts['type'] ),
);
}
// Filter by artist.
if ( ! empty( $atts['artist'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_fedistream_artist_id',
'value' => absint( $atts['artist'] ),
);
}
$query = new \WP_Query( $query_args );
$posts = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts[] = TemplateLoader::get_album_data( get_post() );
}
wp_reset_postdata();
}
$context = array(
'posts' => $posts,
'columns' => absint( $atts['columns'] ),
'title' => __( 'Latest Releases', 'wp-fedistream' ),
);
return $this->render_template( 'shortcodes/releases-grid', $context );
}
/**
* Render popular tracks shortcode.
*
* [fedistream_popular_tracks count="10" columns="1"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_popular_tracks( array $atts ): string {
$atts = shortcode_atts(
array(
'count' => 10,
'columns' => 1,
'artist' => 0, // Filter by artist ID
'genre' => '', // Filter by genre slug
),
$atts,
'fedistream_popular_tracks'
);
$query_args = array(
'post_type' => 'fedistream_track',
'posts_per_page' => absint( $atts['count'] ),
'orderby' => 'meta_value_num',
'meta_key' => '_fedistream_play_count',
'order' => 'DESC',
'post_status' => 'publish',
);
// Filter by artist.
if ( ! empty( $atts['artist'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_fedistream_artist_ids',
'value' => absint( $atts['artist'] ),
'compare' => 'LIKE',
);
}
// Filter by genre.
if ( ! empty( $atts['genre'] ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => 'fedistream_genre',
'field' => 'slug',
'terms' => sanitize_title( $atts['genre'] ),
);
}
$query = new \WP_Query( $query_args );
$posts = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts[] = TemplateLoader::get_track_data( get_post() );
}
wp_reset_postdata();
}
$context = array(
'posts' => $posts,
'columns' => absint( $atts['columns'] ),
'title' => __( 'Popular Tracks', 'wp-fedistream' ),
);
return $this->render_template( 'shortcodes/tracks-list', $context );
}
/**
* Render artists grid shortcode.
*
* [fedistream_artists count="12" columns="4" type="band"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_artists_grid( array $atts ): string {
$atts = shortcode_atts(
array(
'count' => 12,
'columns' => 4,
'type' => '', // solo, band, duo, collective, or empty for all
'genre' => '', // Filter by genre slug
'orderby' => 'title',
'order' => 'ASC',
),
$atts,
'fedistream_artists'
);
$query_args = array(
'post_type' => 'fedistream_artist',
'posts_per_page' => absint( $atts['count'] ),
'orderby' => sanitize_key( $atts['orderby'] ),
'order' => 'DESC' === strtoupper( $atts['order'] ) ? 'DESC' : 'ASC',
'post_status' => 'publish',
);
// Filter by artist type.
if ( ! empty( $atts['type'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_fedistream_artist_type',
'value' => sanitize_key( $atts['type'] ),
);
}
// Filter by genre.
if ( ! empty( $atts['genre'] ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => 'fedistream_genre',
'field' => 'slug',
'terms' => sanitize_title( $atts['genre'] ),
);
}
$query = new \WP_Query( $query_args );
$posts = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts[] = TemplateLoader::get_artist_data( get_post() );
}
wp_reset_postdata();
}
$context = array(
'posts' => $posts,
'columns' => absint( $atts['columns'] ),
'title' => __( 'Artists', 'wp-fedistream' ),
);
return $this->render_template( 'shortcodes/artists-grid', $context );
}
/**
* Render audio player shortcode.
*
* [fedistream_player track="123" autoplay="false"]
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_player( array $atts ): string {
$atts = shortcode_atts(
array(
'track' => 0,
'playlist' => 0,
'album' => 0,
'autoplay' => 'false',
'style' => 'default', // default, compact, mini
),
$atts,
'fedistream_player'
);
$tracks = array();
// Get tracks based on source.
if ( ! empty( $atts['track'] ) ) {
$post = get_post( absint( $atts['track'] ) );
if ( $post && 'fedistream_track' === $post->post_type ) {
$tracks[] = TemplateLoader::get_track_data( $post );
}
} elseif ( ! empty( $atts['album'] ) ) {
$album_id = absint( $atts['album'] );
$track_ids = get_post_meta( $album_id, '_fedistream_track_ids', true );
if ( is_array( $track_ids ) ) {
foreach ( $track_ids as $track_id ) {
$post = get_post( $track_id );
if ( $post && 'fedistream_track' === $post->post_type ) {
$tracks[] = TemplateLoader::get_track_data( $post );
}
}
}
} elseif ( ! empty( $atts['playlist'] ) ) {
$playlist_id = absint( $atts['playlist'] );
$track_ids = get_post_meta( $playlist_id, '_fedistream_track_ids', true );
if ( is_array( $track_ids ) ) {
foreach ( $track_ids as $track_id ) {
$post = get_post( $track_id );
if ( $post && 'fedistream_track' === $post->post_type ) {
$tracks[] = TemplateLoader::get_track_data( $post );
}
}
}
}
if ( empty( $tracks ) ) {
return '';
}
$context = array(
'tracks' => $tracks,
'autoplay' => filter_var( $atts['autoplay'], FILTER_VALIDATE_BOOLEAN ),
'style' => sanitize_key( $atts['style'] ),
);
return $this->render_template( 'shortcodes/player', $context );
}
/**
* Get post by ID or slug.
*
* @param array $atts Shortcode attributes.
* @param string $post_type Post type.
* @return \WP_Post|null
*/
private function get_post( array $atts, string $post_type ): ?\WP_Post {
if ( ! empty( $atts['id'] ) ) {
$post = get_post( absint( $atts['id'] ) );
if ( $post && $post->post_type === $post_type ) {
return $post;
}
}
if ( ! empty( $atts['slug'] ) ) {
$posts = get_posts(
array(
'name' => sanitize_title( $atts['slug'] ),
'post_type' => $post_type,
'posts_per_page' => 1,
'post_status' => 'publish',
)
);
if ( ! empty( $posts ) ) {
return $posts[0];
}
}
return null;
}
/**
* Render a Twig template.
*
* @param string $template Template name.
* @param array $context Template context.
* @return string
*/
private function render_template( string $template, array $context ): string {
try {
return $this->plugin->render( $template, $context );
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
return '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
}
return '';
}
}
}

View File

@@ -0,0 +1,528 @@
<?php
/**
* Template loader for frontend display.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* TemplateLoader class.
*
* Handles loading custom templates for FediStream post types.
*/
class TemplateLoader {
/**
* Constructor.
*/
public function __construct() {
add_filter( 'template_include', array( $this, 'template_include' ) );
add_filter( 'body_class', array( $this, 'body_class' ) );
}
/**
* Include custom templates for FediStream post types.
*
* @param string $template Template path.
* @return string Modified template path.
*/
public function template_include( string $template ): string {
// Check if we're on a FediStream page.
if ( is_singular( 'fedistream_artist' ) ) {
return $this->get_template( 'single-artist' );
}
if ( is_singular( 'fedistream_album' ) ) {
return $this->get_template( 'single-album' );
}
if ( is_singular( 'fedistream_track' ) ) {
return $this->get_template( 'single-track' );
}
if ( is_singular( 'fedistream_playlist' ) ) {
return $this->get_template( 'single-playlist' );
}
if ( is_post_type_archive( 'fedistream_artist' ) ) {
return $this->get_template( 'archive-artist' );
}
if ( is_post_type_archive( 'fedistream_album' ) ) {
return $this->get_template( 'archive-album' );
}
if ( is_post_type_archive( 'fedistream_track' ) ) {
return $this->get_template( 'archive-track' );
}
if ( is_post_type_archive( 'fedistream_playlist' ) ) {
return $this->get_template( 'archive-playlist' );
}
if ( is_tax( 'fedistream_genre' ) ) {
return $this->get_template( 'taxonomy-genre' );
}
if ( is_tax( 'fedistream_mood' ) ) {
return $this->get_template( 'taxonomy-mood' );
}
return $template;
}
/**
* Get template file path.
*
* First checks theme for override, then uses plugin template.
*
* @param string $template_name Template name without extension.
* @return string Template path.
*/
private function get_template( string $template_name ): string {
// Check theme for override.
$theme_template = locate_template(
array(
"fedistream/{$template_name}.php",
"fedistream/{$template_name}.twig",
)
);
if ( $theme_template ) {
return $theme_template;
}
// Use plugin template wrapper.
return WP_FEDISTREAM_PATH . 'includes/Frontend/template-wrapper.php';
}
/**
* Add FediStream classes to body.
*
* @param array $classes Body classes.
* @return array Modified classes.
*/
public function body_class( array $classes ): array {
if ( $this->is_fedistream_page() ) {
$classes[] = 'fedistream';
if ( is_singular( 'fedistream_artist' ) ) {
$classes[] = 'fedistream-artist';
$classes[] = 'fedistream-single';
} elseif ( is_singular( 'fedistream_album' ) ) {
$classes[] = 'fedistream-album';
$classes[] = 'fedistream-single';
} elseif ( is_singular( 'fedistream_track' ) ) {
$classes[] = 'fedistream-track';
$classes[] = 'fedistream-single';
} elseif ( is_singular( 'fedistream_playlist' ) ) {
$classes[] = 'fedistream-playlist';
$classes[] = 'fedistream-single';
} elseif ( is_post_type_archive( 'fedistream_artist' ) ) {
$classes[] = 'fedistream-archive';
$classes[] = 'fedistream-artists';
} elseif ( is_post_type_archive( 'fedistream_album' ) ) {
$classes[] = 'fedistream-archive';
$classes[] = 'fedistream-albums';
} elseif ( is_post_type_archive( 'fedistream_track' ) ) {
$classes[] = 'fedistream-archive';
$classes[] = 'fedistream-tracks';
} elseif ( is_post_type_archive( 'fedistream_playlist' ) ) {
$classes[] = 'fedistream-archive';
$classes[] = 'fedistream-playlists';
} elseif ( is_tax( 'fedistream_genre' ) || is_tax( 'fedistream_mood' ) ) {
$classes[] = 'fedistream-archive';
$classes[] = 'fedistream-taxonomy';
}
}
return $classes;
}
/**
* Check if current page is a FediStream page.
*
* @return bool
*/
public function is_fedistream_page(): bool {
return is_singular( array( 'fedistream_artist', 'fedistream_album', 'fedistream_track', 'fedistream_playlist' ) )
|| is_post_type_archive( array( 'fedistream_artist', 'fedistream_album', 'fedistream_track', 'fedistream_playlist' ) )
|| is_tax( array( 'fedistream_genre', 'fedistream_mood', 'fedistream_license' ) );
}
/**
* Get template context for current page.
*
* @return array Template context.
*/
public static function get_context(): array {
$context = array(
'site_name' => get_bloginfo( 'name' ),
'site_url' => home_url(),
'is_singular' => is_singular(),
'is_archive' => is_archive(),
'current_url' => get_permalink(),
);
if ( is_singular() ) {
global $post;
$context['post'] = self::get_post_data( $post );
}
if ( is_post_type_archive() || is_tax() ) {
$context['posts'] = self::get_archive_posts();
$context['pagination'] = self::get_pagination();
$context['archive_title'] = self::get_archive_title();
$context['archive_description'] = self::get_archive_description();
}
return $context;
}
/**
* Get post data for template.
*
* @param \WP_Post $post Post object.
* @return array Post data.
*/
public static function get_post_data( \WP_Post $post ): array {
$data = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
'excerpt' => get_the_excerpt( $post ),
'permalink' => get_permalink( $post ),
'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ),
'date' => get_the_date( '', $post ),
'author' => get_the_author_meta( 'display_name', $post->post_author ),
);
// Add post type specific data.
switch ( $post->post_type ) {
case 'fedistream_artist':
$data = array_merge( $data, self::get_artist_data( $post->ID ) );
break;
case 'fedistream_album':
$data = array_merge( $data, self::get_album_data( $post->ID ) );
break;
case 'fedistream_track':
$data = array_merge( $data, self::get_track_data( $post->ID ) );
break;
case 'fedistream_playlist':
$data = array_merge( $data, self::get_playlist_data( $post->ID ) );
break;
}
// Add taxonomies.
$data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' );
$data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' );
return $data;
}
/**
* Get artist-specific data.
*
* @param int $post_id Post ID.
* @return array Artist data.
*/
private static function get_artist_data( int $post_id ): array {
$type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo';
$types = array(
'solo' => __( 'Solo Artist', 'wp-fedistream' ),
'band' => __( 'Band', 'wp-fedistream' ),
'duo' => __( 'Duo', 'wp-fedistream' ),
'collective' => __( 'Collective', 'wp-fedistream' ),
);
$albums = get_posts(
array(
'post_type' => 'fedistream_album',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_album_artist',
'meta_value' => $post_id,
'orderby' => 'meta_value',
'meta_query' => array(
array(
'key' => '_fedistream_album_release_date',
'compare' => 'EXISTS',
),
),
'order' => 'DESC',
)
);
return array(
'artist_type' => $type,
'artist_type_label' => $types[ $type ] ?? $types['solo'],
'formed_date' => get_post_meta( $post_id, '_fedistream_artist_formed_date', true ),
'location' => get_post_meta( $post_id, '_fedistream_artist_location', true ),
'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ),
'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(),
'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(),
'albums' => array_map( array( __CLASS__, 'get_post_data' ), $albums ),
'album_count' => count( $albums ),
);
}
/**
* Get album-specific data.
*
* @param int $post_id Post ID.
* @return array Album data.
*/
private static function get_album_data( int $post_id ): array {
$type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album';
$types = array(
'album' => __( 'Album', 'wp-fedistream' ),
'ep' => __( 'EP', 'wp-fedistream' ),
'single' => __( 'Single', 'wp-fedistream' ),
'compilation' => __( 'Compilation', 'wp-fedistream' ),
'live' => __( 'Live Album', 'wp-fedistream' ),
'remix' => __( 'Remix Album', 'wp-fedistream' ),
);
$artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true );
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_track_album',
'meta_value' => $post_id,
'orderby' => 'meta_value_num',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_fedistream_track_number',
'compare' => 'EXISTS',
),
),
'order' => 'ASC',
)
);
return array(
'album_type' => $type,
'album_type_label' => $types[ $type ] ?? $types['album'],
'release_date' => get_post_meta( $post_id, '_fedistream_album_release_date', true ),
'release_year' => date( 'Y', strtotime( get_post_meta( $post_id, '_fedistream_album_release_date', true ) ?: 'now' ) ),
'artist_id' => $artist_id,
'artist_name' => $artist_id ? get_the_title( $artist_id ) : '',
'artist_url' => $artist_id ? get_permalink( $artist_id ) : '',
'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ),
'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ),
'total_tracks' => count( $tracks ),
'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ),
'tracks' => array_map( array( __CLASS__, 'get_post_data' ), $tracks ),
);
}
/**
* Get track-specific data.
*
* @param int $post_id Post ID.
* @return array Track data.
*/
private static function get_track_data( int $post_id ): array {
$album_id = get_post_meta( $post_id, '_fedistream_track_album', true );
$audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true );
$artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array();
$duration = (int) get_post_meta( $post_id, '_fedistream_track_duration', true );
$artist_data = array();
foreach ( $artists as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
$artist_data[] = array(
'id' => $artist_id,
'name' => $artist->post_title,
'url' => get_permalink( $artist_id ),
);
}
}
return array(
'track_number' => (int) get_post_meta( $post_id, '_fedistream_track_number', true ),
'disc_number' => (int) get_post_meta( $post_id, '_fedistream_track_disc_number', true ) ?: 1,
'duration' => $duration,
'duration_formatted' => $duration ? sprintf( '%d:%02d', floor( $duration / 60 ), $duration % 60 ) : '',
'audio_url' => $audio_file ? wp_get_attachment_url( $audio_file ) : '',
'audio_format' => get_post_meta( $post_id, '_fedistream_track_audio_format', true ),
'bpm' => (int) get_post_meta( $post_id, '_fedistream_track_bpm', true ),
'key' => get_post_meta( $post_id, '_fedistream_track_key', true ),
'explicit' => (bool) get_post_meta( $post_id, '_fedistream_track_explicit', true ),
'isrc' => get_post_meta( $post_id, '_fedistream_track_isrc', true ),
'album_id' => $album_id,
'album_title' => $album_id ? get_the_title( $album_id ) : '',
'album_url' => $album_id ? get_permalink( $album_id ) : '',
'album_artwork' => $album_id ? get_the_post_thumbnail_url( $album_id, 'medium' ) : '',
'artists' => $artist_data,
);
}
/**
* Get playlist-specific data.
*
* @param int $post_id Post ID.
* @return array Playlist data.
*/
private static function get_playlist_data( int $post_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
$duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true );
// Get tracks.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$track_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT track_id FROM $table WHERE playlist_id = %d ORDER BY position ASC",
$post_id
)
);
$tracks = array();
foreach ( $track_ids as $track_id ) {
$track = get_post( $track_id );
if ( $track && 'publish' === $track->post_status ) {
$tracks[] = self::get_post_data( $track );
}
}
return array(
'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public',
'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ),
'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ),
'track_count' => count( $tracks ),
'total_duration' => $duration,
'duration_formatted' => $duration >= 3600
? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 )
: sprintf( '%d:%02d', floor( $duration / 60 ), $duration % 60 ),
'tracks' => $tracks,
);
}
/**
* Get taxonomy terms for post.
*
* @param int $post_id Post ID.
* @param string $taxonomy Taxonomy name.
* @return array Terms with name and URL.
*/
private static function get_terms( int $post_id, string $taxonomy ): array {
$terms = get_the_terms( $post_id, $taxonomy );
if ( ! $terms || is_wp_error( $terms ) ) {
return array();
}
return array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'url' => get_term_link( $term ),
);
},
$terms
);
}
/**
* Get archive posts.
*
* @return array Posts for archive.
*/
private static function get_archive_posts(): array {
global $wp_query;
$posts = array();
if ( $wp_query->have_posts() ) {
while ( $wp_query->have_posts() ) {
$wp_query->the_post();
$posts[] = self::get_post_data( get_post() );
}
wp_reset_postdata();
}
return $posts;
}
/**
* Get pagination data.
*
* @return array Pagination data.
*/
private static function get_pagination(): array {
global $wp_query;
$total_pages = $wp_query->max_num_pages;
$current = max( 1, get_query_var( 'paged' ) );
return array(
'total_pages' => $total_pages,
'current_page' => $current,
'has_prev' => $current > 1,
'has_next' => $current < $total_pages,
'prev_url' => $current > 1 ? get_pagenum_link( $current - 1 ) : '',
'next_url' => $current < $total_pages ? get_pagenum_link( $current + 1 ) : '',
'links' => paginate_links(
array(
'total' => $total_pages,
'current' => $current,
'type' => 'array',
'prev_text' => __( '&laquo; Previous', 'wp-fedistream' ),
'next_text' => __( 'Next &raquo;', 'wp-fedistream' ),
)
) ?: array(),
);
}
/**
* Get archive title.
*
* @return string Archive title.
*/
private static function get_archive_title(): string {
if ( is_post_type_archive( 'fedistream_artist' ) ) {
return __( 'Artists', 'wp-fedistream' );
}
if ( is_post_type_archive( 'fedistream_album' ) ) {
return __( 'Albums', 'wp-fedistream' );
}
if ( is_post_type_archive( 'fedistream_track' ) ) {
return __( 'Tracks', 'wp-fedistream' );
}
if ( is_post_type_archive( 'fedistream_playlist' ) ) {
return __( 'Playlists', 'wp-fedistream' );
}
if ( is_tax() ) {
return single_term_title( '', false );
}
return get_the_archive_title();
}
/**
* Get archive description.
*
* @return string Archive description.
*/
private static function get_archive_description(): string {
if ( is_tax() ) {
return term_description();
}
return get_the_archive_description();
}
}

View File

@@ -0,0 +1,38 @@
<?php
/**
* Widgets handler.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers and manages all plugin widgets.
*/
class Widgets {
/**
* Constructor.
*/
public function __construct() {
add_action( 'widgets_init', array( $this, 'register_widgets' ) );
}
/**
* Register all widgets.
*
* @return void
*/
public function register_widgets(): void {
register_widget( Widgets\RecentReleasesWidget::class );
register_widget( Widgets\PopularTracksWidget::class );
register_widget( Widgets\FeaturedArtistWidget::class );
register_widget( Widgets\NowPlayingWidget::class );
}
}

View File

@@ -0,0 +1,162 @@
<?php
/**
* Featured Artist Widget.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend\Widgets;
use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Displays a featured artist.
*/
class FeaturedArtistWidget extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'fedistream_featured_artist',
__( 'FediStream: Featured Artist', 'wp-fedistream' ),
array(
'description' => __( 'Display a featured artist.', 'wp-fedistream' ),
'classname' => 'fedistream-widget fedistream-widget--featured-artist',
)
);
}
/**
* Front-end display.
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Featured Artist', 'wp-fedistream' );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$artist_id = ! empty( $instance['artist_id'] ) ? absint( $instance['artist_id'] ) : 0;
$random = ! empty( $instance['random'] ) && $instance['random'];
$post = null;
if ( $random ) {
// Get a random artist.
$posts = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => 1,
'orderby' => 'rand',
'post_status' => 'publish',
)
);
if ( ! empty( $posts ) ) {
$post = $posts[0];
}
} elseif ( $artist_id ) {
$post = get_post( $artist_id );
if ( $post && 'fedistream_artist' !== $post->post_type ) {
$post = null;
}
}
if ( ! $post ) {
return;
}
$artist_data = TemplateLoader::get_artist_data( $post );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
try {
$plugin = Plugin::get_instance();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render(
'widgets/featured-artist',
array(
'post' => $artist_data,
)
);
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
echo '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
}
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Back-end widget form.
*
* @param array $instance Previously saved values from database.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Featured Artist', 'wp-fedistream' );
$artist_id = ! empty( $instance['artist_id'] ) ? absint( $instance['artist_id'] ) : 0;
$random = ! empty( $instance['random'] ) && $instance['random'];
// Get all artists for dropdown.
$artists = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'post_status' => 'publish',
)
);
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'wp-fedistream' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label>
<input type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'random' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'random' ) ); ?>" value="1" <?php checked( $random ); ?>>
<?php esc_html_e( 'Show random artist', 'wp-fedistream' ); ?>
</label>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'artist_id' ) ); ?>"><?php esc_html_e( 'Or select specific artist:', 'wp-fedistream' ); ?></label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'artist_id' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'artist_id' ) ); ?>">
<option value=""><?php esc_html_e( '-- Select Artist --', 'wp-fedistream' ); ?></option>
<?php foreach ( $artists as $artist ) : ?>
<option value="<?php echo esc_attr( $artist->ID ); ?>" <?php selected( $artist_id, $artist->ID ); ?>>
<?php echo esc_html( $artist->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<?php
}
/**
* Sanitize widget form values as they are saved.
*
* @param array $new_instance Values just sent to be saved.
* @param array $old_instance Previously saved values from database.
* @return array Updated safe values to be saved.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['artist_id'] = ! empty( $new_instance['artist_id'] ) ? absint( $new_instance['artist_id'] ) : 0;
$instance['random'] = ! empty( $new_instance['random'] );
return $instance;
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Now Playing Widget.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend\Widgets;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Displays the currently playing track (updates via JavaScript).
*/
class NowPlayingWidget extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'fedistream_now_playing',
__( 'FediStream: Now Playing', 'wp-fedistream' ),
array(
'description' => __( 'Display the currently playing track.', 'wp-fedistream' ),
'classname' => 'fedistream-widget fedistream-widget--now-playing',
)
);
}
/**
* Front-end display.
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Now Playing', 'wp-fedistream' );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$show_player = ! empty( $instance['show_player'] );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
try {
$plugin = Plugin::get_instance();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render(
'widgets/now-playing',
array(
'show_player' => $show_player,
)
);
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
echo '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
}
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Back-end widget form.
*
* @param array $instance Previously saved values from database.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Now Playing', 'wp-fedistream' );
$show_player = ! empty( $instance['show_player'] );
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'wp-fedistream' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label>
<input type="checkbox" id="<?php echo esc_attr( $this->get_field_id( 'show_player' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'show_player' ) ); ?>" value="1" <?php checked( $show_player ); ?>>
<?php esc_html_e( 'Show player controls', 'wp-fedistream' ); ?>
</label>
</p>
<p class="description">
<?php esc_html_e( 'This widget shows information about the currently playing track and updates automatically via JavaScript.', 'wp-fedistream' ); ?>
</p>
<?php
}
/**
* Sanitize widget form values as they are saved.
*
* @param array $new_instance Values just sent to be saved.
* @param array $old_instance Previously saved values from database.
* @return array Updated safe values to be saved.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['show_player'] = ! empty( $new_instance['show_player'] );
return $instance;
}
}

View File

@@ -0,0 +1,127 @@
<?php
/**
* Popular Tracks Widget.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend\Widgets;
use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Displays popular tracks based on play count.
*/
class PopularTracksWidget extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'fedistream_popular_tracks',
__( 'FediStream: Popular Tracks', 'wp-fedistream' ),
array(
'description' => __( 'Display popular tracks by play count.', 'wp-fedistream' ),
'classname' => 'fedistream-widget fedistream-widget--popular-tracks',
)
);
}
/**
* Front-end display.
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Popular Tracks', 'wp-fedistream' );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5;
$query_args = array(
'post_type' => 'fedistream_track',
'posts_per_page' => $count,
'orderby' => 'meta_value_num',
'meta_key' => '_fedistream_play_count',
'order' => 'DESC',
'post_status' => 'publish',
);
$query = new \WP_Query( $query_args );
$posts = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts[] = TemplateLoader::get_track_data( get_post() );
}
wp_reset_postdata();
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
try {
$plugin = Plugin::get_instance();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render(
'widgets/popular-tracks',
array(
'posts' => $posts,
)
);
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
echo '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
}
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Back-end widget form.
*
* @param array $instance Previously saved values from database.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Popular Tracks', 'wp-fedistream' );
$count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'wp-fedistream' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"><?php esc_html_e( 'Number of tracks:', 'wp-fedistream' ); ?></label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>" type="number" min="1" max="20" value="<?php echo esc_attr( $count ); ?>">
</p>
<?php
}
/**
* Sanitize widget form values as they are saved.
*
* @param array $new_instance Values just sent to be saved.
* @param array $old_instance Previously saved values from database.
* @return array Updated safe values to be saved.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['count'] = ! empty( $new_instance['count'] ) ? absint( $new_instance['count'] ) : 5;
return $instance;
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* Recent Releases Widget.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Frontend\Widgets;
use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Plugin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Displays recent album releases.
*/
class RecentReleasesWidget extends \WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'fedistream_recent_releases',
__( 'FediStream: Recent Releases', 'wp-fedistream' ),
array(
'description' => __( 'Display recent album releases.', 'wp-fedistream' ),
'classname' => 'fedistream-widget fedistream-widget--recent-releases',
)
);
}
/**
* Front-end display.
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
* @return void
*/
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Releases', 'wp-fedistream' );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5;
$type = ! empty( $instance['type'] ) ? $instance['type'] : '';
$query_args = array(
'post_type' => 'fedistream_album',
'posts_per_page' => $count,
'orderby' => 'meta_value',
'meta_key' => '_fedistream_release_date',
'order' => 'DESC',
'post_status' => 'publish',
);
if ( ! empty( $type ) ) {
$query_args['meta_query'][] = array(
'key' => '_fedistream_album_type',
'value' => $type,
);
}
$query = new \WP_Query( $query_args );
$posts = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts[] = TemplateLoader::get_album_data( get_post() );
}
wp_reset_postdata();
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
try {
$plugin = Plugin::get_instance();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render(
'widgets/recent-releases',
array(
'posts' => $posts,
)
);
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
echo '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
}
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Back-end widget form.
*
* @param array $instance Previously saved values from database.
* @return void
*/
public function form( $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Releases', 'wp-fedistream' );
$count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5;
$type = ! empty( $instance['type'] ) ? $instance['type'] : '';
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'wp-fedistream' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"><?php esc_html_e( 'Number of releases:', 'wp-fedistream' ); ?></label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>" type="number" min="1" max="20" value="<?php echo esc_attr( $count ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'type' ) ); ?>"><?php esc_html_e( 'Release type:', 'wp-fedistream' ); ?></label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'type' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'type' ) ); ?>">
<option value="" <?php selected( $type, '' ); ?>><?php esc_html_e( 'All types', 'wp-fedistream' ); ?></option>
<option value="album" <?php selected( $type, 'album' ); ?>><?php esc_html_e( 'Album', 'wp-fedistream' ); ?></option>
<option value="ep" <?php selected( $type, 'ep' ); ?>><?php esc_html_e( 'EP', 'wp-fedistream' ); ?></option>
<option value="single" <?php selected( $type, 'single' ); ?>><?php esc_html_e( 'Single', 'wp-fedistream' ); ?></option>
<option value="compilation" <?php selected( $type, 'compilation' ); ?>><?php esc_html_e( 'Compilation', 'wp-fedistream' ); ?></option>
</select>
</p>
<?php
}
/**
* Sanitize widget form values as they are saved.
*
* @param array $new_instance Values just sent to be saved.
* @param array $old_instance Previously saved values from database.
* @return array Updated safe values to be saved.
*/
public function update( $new_instance, $old_instance ): array {
$instance = array();
$instance['title'] = ! empty( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '';
$instance['count'] = ! empty( $new_instance['count'] ) ? absint( $new_instance['count'] ) : 5;
$instance['type'] = ! empty( $new_instance['type'] ) ? sanitize_key( $new_instance['type'] ) : '';
return $instance;
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* Template wrapper for FediStream Twig templates.
*
* @package WP_FediStream
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use WP_FediStream\Plugin;
use WP_FediStream\Frontend\TemplateLoader;
// Get template context.
$context = TemplateLoader::get_context();
// Determine template name.
$template_name = '';
if ( is_singular( 'fedistream_artist' ) ) {
$template_name = 'single/artist';
} elseif ( is_singular( 'fedistream_album' ) ) {
$template_name = 'single/album';
} elseif ( is_singular( 'fedistream_track' ) ) {
$template_name = 'single/track';
} elseif ( is_singular( 'fedistream_playlist' ) ) {
$template_name = 'single/playlist';
} elseif ( is_post_type_archive( 'fedistream_artist' ) ) {
$template_name = 'archive/artist';
} elseif ( is_post_type_archive( 'fedistream_album' ) ) {
$template_name = 'archive/album';
} elseif ( is_post_type_archive( 'fedistream_track' ) ) {
$template_name = 'archive/track';
} elseif ( is_post_type_archive( 'fedistream_playlist' ) ) {
$template_name = 'archive/playlist';
} elseif ( is_tax( 'fedistream_genre' ) ) {
$template_name = 'archive/taxonomy';
$context['taxonomy_name'] = __( 'Genre', 'wp-fedistream' );
} elseif ( is_tax( 'fedistream_mood' ) ) {
$template_name = 'archive/taxonomy';
$context['taxonomy_name'] = __( 'Mood', 'wp-fedistream' );
}
// Get the plugin instance.
$plugin = Plugin::get_instance();
get_header();
?>
<main id="fedistream-content" class="fedistream-main">
<?php
if ( $template_name ) {
try {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render( $template_name, $context );
} catch ( \Exception $e ) {
if ( WP_DEBUG ) {
echo '<div class="fedistream-error">';
echo '<p>' . esc_html__( 'Template Error:', 'wp-fedistream' ) . ' ' . esc_html( $e->getMessage() ) . '</p>';
echo '</div>';
}
}
} else {
// Fallback to default content.
if ( have_posts() ) {
while ( have_posts() ) {
the_post();
the_content();
}
}
}
?>
</main>
<?php
get_footer();

462
includes/Installer.php Normal file
View File

@@ -0,0 +1,462 @@
<?php
/**
* Plugin installer class.
*
* @package WP_FediStream
*/
namespace WP_FediStream;
use WP_FediStream\Roles\Capabilities;
use WP_FediStream\Taxonomies\Genre;
use WP_FediStream\Taxonomies\Mood;
use WP_FediStream\Taxonomies\License;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles plugin activation, deactivation, and uninstallation.
*/
class Installer {
/**
* Plugin activation.
*
* @return void
*/
public static function activate(): void {
self::create_tables();
self::create_directories();
self::set_default_options();
self::schedule_events();
// Install roles and capabilities.
Capabilities::install();
// Store installed version.
update_option( 'wp_fedistream_version', WP_FEDISTREAM_VERSION );
// Flush rewrite rules for custom post types.
flush_rewrite_rules();
// Install default taxonomy terms (after rewrite rules flush).
// Schedule for next page load since taxonomies need to be registered first.
update_option( 'wp_fedistream_install_defaults', 1 );
}
/**
* Plugin deactivation.
*
* @return void
*/
public static function deactivate(): void {
self::unschedule_events();
// Flush rewrite rules.
flush_rewrite_rules();
}
/**
* Plugin uninstallation.
*
* @return void
*/
public static function uninstall(): void {
// Only run if uninstall is explicitly requested.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
return;
}
self::delete_tables();
self::delete_options();
self::delete_user_meta();
self::delete_transients();
self::delete_posts();
self::delete_terms();
// Remove roles and capabilities.
Capabilities::uninstall();
}
/**
* Delete all plugin posts.
*
* @return void
*/
private static function delete_posts(): void {
$post_types = array(
'fedistream_artist',
'fedistream_album',
'fedistream_track',
'fedistream_playlist',
);
foreach ( $post_types as $post_type ) {
$posts = get_posts(
array(
'post_type' => $post_type,
'posts_per_page' => -1,
'post_status' => 'any',
'fields' => 'ids',
)
);
foreach ( $posts as $post_id ) {
wp_delete_post( $post_id, true );
}
}
}
/**
* Delete all plugin taxonomy terms.
*
* @return void
*/
private static function delete_terms(): void {
$taxonomies = array(
'fedistream_genre',
'fedistream_mood',
'fedistream_license',
);
foreach ( $taxonomies as $taxonomy ) {
$terms = get_terms(
array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'fields' => 'ids',
)
);
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term_id ) {
wp_delete_term( $term_id, $taxonomy );
}
}
}
}
/**
* Install default taxonomy terms.
*
* Called on first load after activation when taxonomies are registered.
*
* @return void
*/
public static function install_defaults(): void {
if ( ! get_option( 'wp_fedistream_install_defaults' ) ) {
return;
}
// Install default genres.
Genre::install_defaults();
// Install default moods.
Mood::install_defaults();
// Install default licenses.
License::install_defaults();
// Clear the flag.
delete_option( 'wp_fedistream_install_defaults' );
}
/**
* Create custom database tables.
*
* @return void
*/
private static function create_tables(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Track plays table.
$table_plays = $wpdb->prefix . 'fedistream_plays';
$sql_plays = "CREATE TABLE $table_plays (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
track_id bigint(20) unsigned NOT NULL,
user_id bigint(20) unsigned DEFAULT NULL,
remote_actor varchar(255) DEFAULT NULL,
played_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
play_duration int(11) unsigned DEFAULT 0,
PRIMARY KEY (id),
KEY track_id (track_id),
KEY user_id (user_id),
KEY played_at (played_at)
) $charset_collate;";
// Playlist tracks table (many-to-many relationship).
$table_playlist_tracks = $wpdb->prefix . 'fedistream_playlist_tracks';
$sql_playlist_tracks = "CREATE TABLE $table_playlist_tracks (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
playlist_id bigint(20) unsigned NOT NULL,
track_id bigint(20) unsigned NOT NULL,
position int(11) unsigned NOT NULL DEFAULT 0,
added_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY playlist_track (playlist_id, track_id),
KEY playlist_id (playlist_id),
KEY track_id (track_id),
KEY position (position)
) $charset_collate;";
// ActivityPub followers table.
$table_followers = $wpdb->prefix . 'fedistream_followers';
$sql_followers = "CREATE TABLE $table_followers (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
artist_id bigint(20) unsigned NOT NULL,
follower_uri varchar(2083) NOT NULL,
follower_name varchar(255) DEFAULT NULL,
follower_icon varchar(2083) DEFAULT NULL,
inbox varchar(2083) DEFAULT NULL,
shared_inbox varchar(2083) DEFAULT NULL,
activity_data longtext DEFAULT NULL,
followed_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY artist_follower (artist_id, follower_uri(191)),
KEY artist_id (artist_id),
KEY followed_at (followed_at)
) $charset_collate;";
// WooCommerce purchases table.
$table_purchases = $wpdb->prefix . 'fedistream_purchases';
$sql_purchases = "CREATE TABLE $table_purchases (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
content_type varchar(50) NOT NULL,
content_id bigint(20) unsigned NOT NULL,
order_id bigint(20) unsigned NOT NULL,
purchased_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY user_content (user_id, content_type, content_id),
KEY user_id (user_id),
KEY content_type (content_type),
KEY content_id (content_id),
KEY order_id (order_id)
) $charset_collate;";
// User favorites table.
$table_favorites = $wpdb->prefix . 'fedistream_favorites';
$sql_favorites = "CREATE TABLE $table_favorites (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
content_type varchar(50) NOT NULL,
content_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY user_content (user_id, content_type, content_id),
KEY user_id (user_id),
KEY content_type (content_type),
KEY content_id (content_id),
KEY created_at (created_at)
) $charset_collate;";
// User follows table (local follows, not ActivityPub).
$table_user_follows = $wpdb->prefix . 'fedistream_user_follows';
$sql_user_follows = "CREATE TABLE $table_user_follows (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
artist_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY user_artist (user_id, artist_id),
KEY user_id (user_id),
KEY artist_id (artist_id),
KEY created_at (created_at)
) $charset_collate;";
// Listening history table.
$table_history = $wpdb->prefix . 'fedistream_listening_history';
$sql_history = "CREATE TABLE $table_history (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
track_id bigint(20) unsigned NOT NULL,
played_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY track_id (track_id),
KEY played_at (played_at),
KEY user_played (user_id, played_at)
) $charset_collate;";
// Notifications table.
$table_notifications = $wpdb->prefix . 'fedistream_notifications';
$sql_notifications = "CREATE TABLE $table_notifications (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
type varchar(50) NOT NULL,
title varchar(255) NOT NULL,
message text NOT NULL,
data longtext DEFAULT NULL,
is_read tinyint(1) unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY type (type),
KEY is_read (is_read),
KEY created_at (created_at),
KEY user_unread (user_id, is_read)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql_plays );
dbDelta( $sql_playlist_tracks );
dbDelta( $sql_followers );
dbDelta( $sql_purchases );
dbDelta( $sql_favorites );
dbDelta( $sql_user_follows );
dbDelta( $sql_history );
dbDelta( $sql_notifications );
}
/**
* Create required directories.
*
* @return void
*/
private static function create_directories(): void {
$directories = array(
WP_FEDISTREAM_PATH . 'cache/twig',
);
foreach ( $directories as $directory ) {
if ( ! file_exists( $directory ) ) {
wp_mkdir_p( $directory );
}
}
// Create .htaccess to protect cache directory.
$htaccess = WP_FEDISTREAM_PATH . 'cache/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, 'Deny from all' );
}
}
/**
* Set default plugin options.
*
* @return void
*/
private static function set_default_options(): void {
$defaults = array(
'wp_fedistream_enable_activitypub' => 1,
'wp_fedistream_enable_woocommerce' => 0,
'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ),
'wp_fedistream_max_upload_size' => 50, // MB
'wp_fedistream_default_license' => 'all-rights-reserved',
);
foreach ( $defaults as $option => $value ) {
if ( false === get_option( $option ) ) {
add_option( $option, $value );
}
}
}
/**
* Schedule cron events.
*
* @return void
*/
private static function schedule_events(): void {
if ( ! wp_next_scheduled( 'wp_fedistream_daily_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'wp_fedistream_daily_cleanup' );
}
}
/**
* Unschedule cron events.
*
* @return void
*/
private static function unschedule_events(): void {
$timestamp = wp_next_scheduled( 'wp_fedistream_daily_cleanup' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'wp_fedistream_daily_cleanup' );
}
}
/**
* Delete custom database tables.
*
* @return void
*/
private static function delete_tables(): void {
global $wpdb;
$tables = array(
$wpdb->prefix . 'fedistream_plays',
$wpdb->prefix . 'fedistream_playlist_tracks',
$wpdb->prefix . 'fedistream_followers',
$wpdb->prefix . 'fedistream_purchases',
$wpdb->prefix . 'fedistream_reactions',
$wpdb->prefix . 'fedistream_favorites',
$wpdb->prefix . 'fedistream_user_follows',
$wpdb->prefix . 'fedistream_listening_history',
$wpdb->prefix . 'fedistream_notifications',
);
foreach ( $tables as $table ) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS $table" );
}
}
/**
* Delete plugin options.
*
* @return void
*/
private static function delete_options(): void {
global $wpdb;
// Delete all options starting with wp_fedistream_.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $wpdb->options WHERE option_name LIKE %s",
'wp_fedistream_%'
)
);
}
/**
* Delete user meta.
*
* @return void
*/
private static function delete_user_meta(): void {
global $wpdb;
// Delete all user meta starting with wp_fedistream_.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $wpdb->usermeta WHERE meta_key LIKE %s",
'wp_fedistream_%'
)
);
}
/**
* Delete transients.
*
* @return void
*/
private static function delete_transients(): void {
global $wpdb;
// Delete all transients starting with wp_fedistream_.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
'_transient_wp_fedistream_%',
'_transient_timeout_wp_fedistream_%'
)
);
}
}

603
includes/Plugin.php Normal file
View File

@@ -0,0 +1,603 @@
<?php
/**
* Main plugin class.
*
* @package WP_FediStream
*/
namespace WP_FediStream;
use WP_FediStream\ActivityPub\Integration as ActivityPubIntegration;
use WP_FediStream\ActivityPub\RestApi as ActivityPubRestApi;
use WP_FediStream\Admin\ListColumns;
use WP_FediStream\Frontend\Ajax;
use WP_FediStream\Frontend\Shortcodes;
use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Frontend\Widgets;
use WP_FediStream\PostTypes\Artist;
use WP_FediStream\WooCommerce\Integration as WooCommerceIntegration;
use WP_FediStream\WooCommerce\DigitalDelivery;
use WP_FediStream\WooCommerce\StreamingAccess;
use WP_FediStream\PostTypes\Album;
use WP_FediStream\PostTypes\Track;
use WP_FediStream\PostTypes\Playlist;
use WP_FediStream\Taxonomies\Genre;
use WP_FediStream\Taxonomies\Mood;
use WP_FediStream\Taxonomies\License;
use WP_FediStream\User\Library as UserLibrary;
use WP_FediStream\User\LibraryPage;
use WP_FediStream\User\Notifications;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Plugin singleton class.
*
* Initializes and manages all plugin components.
*/
final class Plugin {
/**
* Singleton instance.
*
* @var Plugin|null
*/
private static ?Plugin $instance = null;
/**
* Twig environment instance.
*
* @var \Twig\Environment|null
*/
private ?\Twig\Environment $twig = null;
/**
* Post type instances.
*
* @var array
*/
private array $post_types = array();
/**
* Taxonomy instances.
*
* @var array
*/
private array $taxonomies = array();
/**
* Get singleton instance.
*
* @return Plugin
*/
public static function get_instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor to enforce singleton pattern.
*/
private function __construct() {
$this->init_twig();
$this->init_components();
$this->init_hooks();
$this->load_textdomain();
}
/**
* Prevent cloning.
*
* @return void
*/
private function __clone() {}
/**
* Prevent unserialization.
*
* @throws \Exception Always throws to prevent unserialization.
* @return void
*/
public function __wakeup(): void {
throw new \Exception( 'Cannot unserialize singleton' );
}
/**
* Initialize Twig template engine.
*
* @return void
*/
private function init_twig(): void {
$loader = new \Twig\Loader\FilesystemLoader( WP_FEDISTREAM_PATH . 'templates' );
$this->twig = new \Twig\Environment(
$loader,
array(
'cache' => WP_FEDISTREAM_PATH . 'cache/twig',
'auto_reload' => WP_DEBUG,
)
);
// Add WordPress escaping functions.
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_html', 'esc_html' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_attr', 'esc_attr' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_url', 'esc_url' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_js', 'esc_js' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'wp_nonce_field', 'wp_nonce_field', array( 'is_safe' => array( 'html' ) ) ) );
$this->twig->addFunction( new \Twig\TwigFunction( '__', '__' ) );
$this->twig->addFunction( new \Twig\TwigFunction( '_e', '_e' ) );
}
/**
* Initialize plugin components.
*
* @return void
*/
private function init_components(): void {
// Initialize post types.
$this->post_types['artist'] = new Artist();
$this->post_types['album'] = new Album();
$this->post_types['track'] = new Track();
$this->post_types['playlist'] = new Playlist();
// Initialize taxonomies.
$this->taxonomies['genre'] = new Genre();
$this->taxonomies['mood'] = new Mood();
$this->taxonomies['license'] = new License();
// Initialize admin components.
if ( is_admin() ) {
new ListColumns();
}
// Initialize frontend components.
if ( ! is_admin() ) {
new TemplateLoader();
new Shortcodes();
}
// Initialize widgets (always needed for admin widget management).
new Widgets();
// Initialize AJAX handlers.
new Ajax();
// Initialize ActivityPub integration.
if ( get_option( 'wp_fedistream_enable_activitypub', 1 ) ) {
new ActivityPubIntegration();
new ActivityPubRestApi();
}
// Initialize WooCommerce integration.
if ( get_option( 'wp_fedistream_enable_woocommerce', 0 ) && $this->is_woocommerce_active() ) {
new WooCommerceIntegration();
new DigitalDelivery();
new StreamingAccess();
}
// Initialize user library and notifications.
new UserLibrary();
new LibraryPage();
new Notifications();
}
/**
* Initialize WordPress hooks.
*
* @return void
*/
private function init_hooks(): void {
add_action( 'init', array( $this, 'maybe_install_defaults' ), 20 );
add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) );
}
/**
* Maybe install default taxonomy terms.
*
* @return void
*/
public function maybe_install_defaults(): void {
Installer::install_defaults();
}
/**
* Load plugin textdomain.
*
* @return void
*/
private function load_textdomain(): void {
load_plugin_textdomain(
'wp-fedistream',
false,
dirname( WP_FEDISTREAM_BASENAME ) . '/languages'
);
}
/**
* Add admin menu.
*
* @return void
*/
public function add_admin_menu(): void {
// Main menu.
add_menu_page(
__( 'FediStream', 'wp-fedistream' ),
__( 'FediStream', 'wp-fedistream' ),
'edit_fedistream_tracks',
'fedistream',
array( $this, 'render_dashboard_page' ),
'dashicons-format-audio',
30
);
// Dashboard submenu.
add_submenu_page(
'fedistream',
__( 'Dashboard', 'wp-fedistream' ),
__( 'Dashboard', 'wp-fedistream' ),
'edit_fedistream_tracks',
'fedistream',
array( $this, 'render_dashboard_page' )
);
// Artists submenu.
add_submenu_page(
'fedistream',
__( 'Artists', 'wp-fedistream' ),
__( 'Artists', 'wp-fedistream' ),
'edit_fedistream_artists',
'edit.php?post_type=fedistream_artist'
);
// Albums submenu.
add_submenu_page(
'fedistream',
__( 'Albums', 'wp-fedistream' ),
__( 'Albums', 'wp-fedistream' ),
'edit_fedistream_albums',
'edit.php?post_type=fedistream_album'
);
// Tracks submenu.
add_submenu_page(
'fedistream',
__( 'Tracks', 'wp-fedistream' ),
__( 'Tracks', 'wp-fedistream' ),
'edit_fedistream_tracks',
'edit.php?post_type=fedistream_track'
);
// Playlists submenu.
add_submenu_page(
'fedistream',
__( 'Playlists', 'wp-fedistream' ),
__( 'Playlists', 'wp-fedistream' ),
'edit_fedistream_playlists',
'edit.php?post_type=fedistream_playlist'
);
// Genres submenu.
add_submenu_page(
'fedistream',
__( 'Genres', 'wp-fedistream' ),
__( 'Genres', 'wp-fedistream' ),
'manage_fedistream_genres',
'edit-tags.php?taxonomy=fedistream_genre'
);
// Settings submenu.
add_submenu_page(
'fedistream',
__( 'Settings', 'wp-fedistream' ),
__( 'Settings', 'wp-fedistream' ),
'manage_fedistream_settings',
'fedistream-settings',
array( $this, 'render_settings_page' )
);
}
/**
* Render dashboard page.
*
* @return void
*/
public function render_dashboard_page(): void {
// Get stats.
$artist_count = wp_count_posts( 'fedistream_artist' )->publish ?? 0;
$album_count = wp_count_posts( 'fedistream_album' )->publish ?? 0;
$track_count = wp_count_posts( 'fedistream_track' )->publish ?? 0;
$playlist_count = wp_count_posts( 'fedistream_playlist' )->publish ?? 0;
?>
<div class="wrap">
<h1><?php esc_html_e( 'FediStream Dashboard', 'wp-fedistream' ); ?></h1>
<div class="fedistream-dashboard">
<div class="fedistream-stats" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0;">
<div class="fedistream-stat-box" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 10px;"><?php esc_html_e( 'Artists', 'wp-fedistream' ); ?></h3>
<p style="font-size: 2em; margin: 0; color: #2271b1;"><?php echo esc_html( $artist_count ); ?></p>
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=fedistream_artist' ) ); ?>"><?php esc_html_e( 'Manage Artists', 'wp-fedistream' ); ?></a>
</div>
<div class="fedistream-stat-box" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 10px;"><?php esc_html_e( 'Albums', 'wp-fedistream' ); ?></h3>
<p style="font-size: 2em; margin: 0; color: #2271b1;"><?php echo esc_html( $album_count ); ?></p>
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=fedistream_album' ) ); ?>"><?php esc_html_e( 'Manage Albums', 'wp-fedistream' ); ?></a>
</div>
<div class="fedistream-stat-box" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 10px;"><?php esc_html_e( 'Tracks', 'wp-fedistream' ); ?></h3>
<p style="font-size: 2em; margin: 0; color: #2271b1;"><?php echo esc_html( $track_count ); ?></p>
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=fedistream_track' ) ); ?>"><?php esc_html_e( 'Manage Tracks', 'wp-fedistream' ); ?></a>
</div>
<div class="fedistream-stat-box" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 10px;"><?php esc_html_e( 'Playlists', 'wp-fedistream' ); ?></h3>
<p style="font-size: 2em; margin: 0; color: #2271b1;"><?php echo esc_html( $playlist_count ); ?></p>
<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=fedistream_playlist' ) ); ?>"><?php esc_html_e( 'Manage Playlists', 'wp-fedistream' ); ?></a>
</div>
</div>
<div class="fedistream-quick-actions" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px; margin: 20px 0;">
<h2><?php esc_html_e( 'Quick Actions', 'wp-fedistream' ); ?></h2>
<p>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=fedistream_artist' ) ); ?>" class="button button-primary"><?php esc_html_e( 'Add Artist', 'wp-fedistream' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=fedistream_album' ) ); ?>" class="button"><?php esc_html_e( 'Add Album', 'wp-fedistream' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=fedistream_track' ) ); ?>" class="button"><?php esc_html_e( 'Add Track', 'wp-fedistream' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=fedistream_playlist' ) ); ?>" class="button"><?php esc_html_e( 'Add Playlist', 'wp-fedistream' ); ?></a>
</p>
</div>
<div class="fedistream-info" style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h2><?php esc_html_e( 'Getting Started', 'wp-fedistream' ); ?></h2>
<ol>
<li><?php esc_html_e( 'Add your artists or bands.', 'wp-fedistream' ); ?></li>
<li><?php esc_html_e( 'Create albums and assign them to artists.', 'wp-fedistream' ); ?></li>
<li><?php esc_html_e( 'Upload tracks and add them to albums.', 'wp-fedistream' ); ?></li>
<li><?php esc_html_e( 'Create playlists to curate your music.', 'wp-fedistream' ); ?></li>
<li><?php esc_html_e( 'Share your music via ActivityPub to the Fediverse!', 'wp-fedistream' ); ?></li>
</ol>
</div>
</div>
</div>
<?php
}
/**
* Render settings page.
*
* @return void
*/
public function render_settings_page(): void {
// Check user capabilities.
if ( ! current_user_can( 'manage_fedistream_settings' ) ) {
return;
}
// Save settings.
if ( isset( $_POST['fedistream_settings_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['fedistream_settings_nonce'] ), 'fedistream_save_settings' ) ) {
update_option( 'wp_fedistream_enable_activitypub', isset( $_POST['enable_activitypub'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_woocommerce', isset( $_POST['enable_woocommerce'] ) ? 1 : 0 );
update_option( 'wp_fedistream_max_upload_size', absint( $_POST['max_upload_size'] ?? 50 ) );
update_option( 'wp_fedistream_default_license', sanitize_text_field( wp_unslash( $_POST['default_license'] ?? 'all-rights-reserved' ) ) );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'wp-fedistream' ) . '</p></div>';
}
// Get current settings.
$enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 );
$enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 );
$max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 );
$default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' );
?>
<div class="wrap">
<h1><?php esc_html_e( 'FediStream Settings', 'wp-fedistream' ); ?></h1>
<form method="post" action="">
<?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'ActivityPub Integration', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_activitypub" value="1" <?php checked( $enable_activitypub, 1 ); ?>>
<?php esc_html_e( 'Enable ActivityPub features', 'wp-fedistream' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Publish releases to the Fediverse and allow followers.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'WooCommerce Integration', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_woocommerce" value="1" <?php checked( $enable_woocommerce, 1 ); ?> <?php disabled( ! $this->is_woocommerce_active() ); ?>>
<?php esc_html_e( 'Enable WooCommerce features', 'wp-fedistream' ); ?>
</label>
<?php if ( ! $this->is_woocommerce_active() ) : ?>
<p class="description" style="color: #d63638;"><?php esc_html_e( 'WooCommerce is not installed or active.', 'wp-fedistream' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Sell albums and tracks through WooCommerce.', 'wp-fedistream' ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="max_upload_size"><?php esc_html_e( 'Max Upload Size', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="max_upload_size" id="max_upload_size" value="<?php echo esc_attr( $max_upload_size ); ?>" min="1" max="500" class="small-text"> MB
<p class="description"><?php esc_html_e( 'Maximum file size for audio uploads.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="default_license"><?php esc_html_e( 'Default License', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="default_license" id="default_license">
<option value="all-rights-reserved" <?php selected( $default_license, 'all-rights-reserved' ); ?>><?php esc_html_e( 'All Rights Reserved', 'wp-fedistream' ); ?></option>
<option value="cc-by" <?php selected( $default_license, 'cc-by' ); ?>>CC BY</option>
<option value="cc-by-sa" <?php selected( $default_license, 'cc-by-sa' ); ?>>CC BY-SA</option>
<option value="cc-by-nc" <?php selected( $default_license, 'cc-by-nc' ); ?>>CC BY-NC</option>
<option value="cc-by-nc-sa" <?php selected( $default_license, 'cc-by-nc-sa' ); ?>>CC BY-NC-SA</option>
<option value="cc0" <?php selected( $default_license, 'cc0' ); ?>>CC0 (Public Domain)</option>
</select>
<p class="description"><?php esc_html_e( 'Default license for new uploads.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
/**
* Enqueue admin assets.
*
* @param string $hook_suffix The current admin page.
* @return void
*/
public function enqueue_admin_assets( string $hook_suffix ): void {
// Only enqueue on FediStream pages.
$screen = get_current_screen();
if ( ! $screen ) {
return;
}
$fedistream_screens = array(
'toplevel_page_fedistream',
'fedistream_page_fedistream-settings',
'fedistream_artist',
'fedistream_album',
'fedistream_track',
'fedistream_playlist',
'edit-fedistream_artist',
'edit-fedistream_album',
'edit-fedistream_track',
'edit-fedistream_playlist',
'edit-fedistream_genre',
'edit-fedistream_mood',
'edit-fedistream_license',
);
if ( ! in_array( $screen->id, $fedistream_screens, true ) ) {
return;
}
wp_enqueue_style(
'wp-fedistream-admin',
WP_FEDISTREAM_URL . 'assets/css/admin.css',
array(),
WP_FEDISTREAM_VERSION
);
wp_enqueue_script(
'wp-fedistream-admin',
WP_FEDISTREAM_URL . 'assets/js/admin.js',
array( 'jquery', 'jquery-ui-sortable' ),
WP_FEDISTREAM_VERSION,
true
);
}
/**
* Enqueue frontend assets.
*
* @return void
*/
public function enqueue_frontend_assets(): void {
// Always enqueue as shortcodes/widgets can be used anywhere.
// Assets are lightweight and properly cached.
wp_enqueue_style(
'wp-fedistream',
WP_FEDISTREAM_URL . 'assets/css/frontend.css',
array(),
WP_FEDISTREAM_VERSION
);
wp_enqueue_script(
'wp-fedistream',
WP_FEDISTREAM_URL . 'assets/js/frontend.js',
array(),
WP_FEDISTREAM_VERSION,
true
);
wp_localize_script(
'wp-fedistream',
'wpFediStream',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'wp-fedistream-nonce' ),
)
);
}
/**
* Get Twig environment.
*
* @return \Twig\Environment
*/
public function get_twig(): \Twig\Environment {
return $this->twig;
}
/**
* Render a Twig template.
*
* @param string $template Template name (without .twig extension).
* @param array $context Template context variables.
* @return string Rendered template.
*/
public function render( string $template, array $context = array() ): string {
return $this->twig->render( $template . '.twig', $context );
}
/**
* Get a post type instance.
*
* @param string $name Post type name.
* @return object|null Post type instance or null.
*/
public function get_post_type( string $name ): ?object {
return $this->post_types[ $name ] ?? null;
}
/**
* Get a taxonomy instance.
*
* @param string $name Taxonomy name.
* @return object|null Taxonomy instance or null.
*/
public function get_taxonomy( string $name ): ?object {
return $this->taxonomies[ $name ] ?? null;
}
/**
* Check if WooCommerce is active.
*
* @return bool
*/
public function is_woocommerce_active(): bool {
return class_exists( 'WooCommerce' );
}
/**
* Check if ActivityPub plugin is active.
*
* @return bool
*/
public function is_activitypub_active(): bool {
return class_exists( 'Activitypub\Activitypub' );
}
}

View File

@@ -0,0 +1,202 @@
<?php
/**
* Abstract base class for custom post types.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract post type class.
*
* Provides common functionality for all custom post types.
*/
abstract class AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type;
/**
* Constructor.
*/
public function __construct() {
add_action( 'init', array( $this, 'register' ) );
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) );
add_action( 'save_post_' . $this->post_type, array( $this, 'save_meta' ), 10, 2 );
}
/**
* Register the post type.
*
* @return void
*/
abstract public function register(): void;
/**
* Add meta boxes.
*
* @return void
*/
abstract public function add_meta_boxes(): void;
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
abstract public function save_meta( int $post_id, \WP_Post $post ): void;
/**
* Get the post type key.
*
* @return string
*/
public function get_post_type(): string {
return $this->post_type;
}
/**
* Verify nonce and user capabilities before saving.
*
* @param int $post_id Post ID.
* @param string $nonce_action Nonce action name.
* @param string $nonce_name Nonce field name.
* @return bool Whether save should proceed.
*/
protected function can_save( int $post_id, string $nonce_action, string $nonce_name ): bool {
// Verify nonce.
if ( ! isset( $_POST[ $nonce_name ] ) || ! wp_verify_nonce( sanitize_key( $_POST[ $nonce_name ] ), $nonce_action ) ) {
return false;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return false;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return false;
}
return true;
}
/**
* Sanitize and save a text meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_text_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, sanitize_text_field( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save a textarea meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_textarea_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, sanitize_textarea_field( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save an integer meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_int_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, absint( $_POST[ $post_key ] ) );
}
}
/**
* Sanitize and save a URL meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_url_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, esc_url_raw( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save a boolean meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_bool_meta( int $post_id, string $meta_key, string $post_key ): void {
$value = isset( $_POST[ $post_key ] ) ? 1 : 0;
update_post_meta( $post_id, $meta_key, $value );
}
/**
* Sanitize and save an array meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_array_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) && is_array( $_POST[ $post_key ] ) ) {
$values = array_map( 'sanitize_text_field', wp_unslash( $_POST[ $post_key ] ) );
update_post_meta( $post_id, $meta_key, $values );
} else {
delete_post_meta( $post_id, $meta_key );
}
}
/**
* Sanitize and save a date meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_date_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) && ! empty( $_POST[ $post_key ] ) ) {
$date = sanitize_text_field( wp_unslash( $_POST[ $post_key ] ) );
// Validate date format (YYYY-MM-DD).
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
update_post_meta( $post_id, $meta_key, $date );
}
} else {
delete_post_meta( $post_id, $meta_key );
}
}
}

View File

@@ -0,0 +1,340 @@
<?php
/**
* Album custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Album post type class.
*
* Handles registration and management of albums/releases.
*/
class Album extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_album';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_album_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Albums', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Album', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Albums', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Album', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Album', 'wp-fedistream' ),
'new_item' => __( 'New Album', 'wp-fedistream' ),
'edit_item' => __( 'Edit Album', 'wp-fedistream' ),
'view_item' => __( 'View Album', 'wp-fedistream' ),
'all_items' => __( 'All Albums', 'wp-fedistream' ),
'search_items' => __( 'Search Albums', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Albums:', 'wp-fedistream' ),
'not_found' => __( 'No albums found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No albums found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Album Artwork', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set album artwork', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove album artwork', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as album artwork', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Album archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into album', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this album', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter albums list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Albums list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Albums list', 'Screen reader text', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'query_var' => true,
'rewrite' => array( 'slug' => 'albums' ),
'capability_type' => array( 'fedistream_album', 'fedistream_albums' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-album',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'albums',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_album_info',
__( 'Album Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_album_artist',
__( 'Artist', 'wp-fedistream' ),
array( $this, 'render_artist_meta_box' ),
$this->post_type,
'side',
'high'
);
add_meta_box(
'fedistream_album_codes',
__( 'Album Codes', 'wp-fedistream' ),
array( $this, 'render_codes_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render album info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_album_save', 'fedistream_album_nonce' );
$album_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
$release_date = get_post_meta( $post->ID, self::META_PREFIX . 'release_date', true );
$total_tracks = get_post_meta( $post->ID, self::META_PREFIX . 'total_tracks', true );
$total_duration = get_post_meta( $post->ID, self::META_PREFIX . 'total_duration', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_album_type"><?php esc_html_e( 'Release Type', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_album_type" id="fedistream_album_type">
<option value="album" <?php selected( $album_type, 'album' ); ?>><?php esc_html_e( 'Album', 'wp-fedistream' ); ?></option>
<option value="ep" <?php selected( $album_type, 'ep' ); ?>><?php esc_html_e( 'EP', 'wp-fedistream' ); ?></option>
<option value="single" <?php selected( $album_type, 'single' ); ?>><?php esc_html_e( 'Single', 'wp-fedistream' ); ?></option>
<option value="compilation" <?php selected( $album_type, 'compilation' ); ?>><?php esc_html_e( 'Compilation', 'wp-fedistream' ); ?></option>
<option value="live" <?php selected( $album_type, 'live' ); ?>><?php esc_html_e( 'Live Album', 'wp-fedistream' ); ?></option>
<option value="remix" <?php selected( $album_type, 'remix' ); ?>><?php esc_html_e( 'Remix Album', 'wp-fedistream' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_release_date"><?php esc_html_e( 'Release Date', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="date" name="fedistream_album_release_date" id="fedistream_album_release_date" value="<?php echo esc_attr( $release_date ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_total_tracks"><?php esc_html_e( 'Total Tracks', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_album_total_tracks" id="fedistream_album_total_tracks" value="<?php echo esc_attr( $total_tracks ); ?>" min="1" max="999" class="small-text">
<p class="description"><?php esc_html_e( 'Auto-calculated when tracks are added.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_total_duration"><?php esc_html_e( 'Total Duration', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_album_total_duration" id="fedistream_album_total_duration" value="<?php echo esc_attr( $total_duration ); ?>" min="0" class="small-text" readonly>
<span class="description"><?php esc_html_e( 'seconds (auto-calculated)', 'wp-fedistream' ); ?></span>
</td>
</tr>
</table>
<?php
}
/**
* Render artist selection meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_artist_meta_box( \WP_Post $post ): void {
$selected_artist = get_post_meta( $post->ID, self::META_PREFIX . 'artist', true );
$artists = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<label for="fedistream_album_artist"><?php esc_html_e( 'Primary Artist', 'wp-fedistream' ); ?></label>
</p>
<select name="fedistream_album_artist" id="fedistream_album_artist" class="widefat">
<option value=""><?php esc_html_e( '— Select Artist —', 'wp-fedistream' ); ?></option>
<?php foreach ( $artists as $artist ) : ?>
<option value="<?php echo esc_attr( $artist->ID ); ?>" <?php selected( $selected_artist, $artist->ID ); ?>>
<?php echo esc_html( $artist->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php
if ( empty( $artists ) ) {
printf(
/* translators: %s: URL to add new artist */
esc_html__( 'No artists found. %s', 'wp-fedistream' ),
'<a href="' . esc_url( admin_url( 'post-new.php?post_type=fedistream_artist' ) ) . '">' . esc_html__( 'Add an artist first.', 'wp-fedistream' ) . '</a>'
);
}
?>
</p>
<?php
}
/**
* Render album codes meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_codes_meta_box( \WP_Post $post ): void {
$upc = get_post_meta( $post->ID, self::META_PREFIX . 'upc', true );
$catalog_number = get_post_meta( $post->ID, self::META_PREFIX . 'catalog_number', true );
?>
<p>
<label for="fedistream_album_upc"><?php esc_html_e( 'UPC/EAN', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_album_upc" id="fedistream_album_upc" value="<?php echo esc_attr( $upc ); ?>" class="widefat" pattern="[0-9]{12,13}" title="<?php esc_attr_e( '12-13 digit UPC/EAN code', 'wp-fedistream' ); ?>">
</p>
<p>
<label for="fedistream_album_catalog_number"><?php esc_html_e( 'Catalog Number', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_album_catalog_number" id="fedistream_album_catalog_number" value="<?php echo esc_attr( $catalog_number ); ?>" class="widefat">
</p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function save_meta( int $post_id, \WP_Post $post ): void {
if ( ! $this->can_save( $post_id, 'fedistream_album_save', 'fedistream_album_nonce' ) ) {
return;
}
// Save album type.
if ( isset( $_POST['fedistream_album_type'] ) ) {
$allowed_types = array( 'album', 'ep', 'single', 'compilation', 'live', 'remix' );
$type = sanitize_text_field( wp_unslash( $_POST['fedistream_album_type'] ) );
if ( in_array( $type, $allowed_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'type', $type );
}
}
// Save other fields.
$this->save_date_meta( $post_id, self::META_PREFIX . 'release_date', 'fedistream_album_release_date' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'artist', 'fedistream_album_artist' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'total_tracks', 'fedistream_album_total_tracks' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'upc', 'fedistream_album_upc' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'catalog_number', 'fedistream_album_catalog_number' );
}
/**
* Get album by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Album data or null.
*/
public static function get_album( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_album' !== $post->post_type ) {
return null;
}
$artist_id = get_post_meta( $post_id, self::META_PREFIX . 'artist', true );
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'description' => $post->post_content,
'excerpt' => $post->post_excerpt,
'type' => get_post_meta( $post_id, self::META_PREFIX . 'type', true ) ?: 'album',
'release_date' => get_post_meta( $post_id, self::META_PREFIX . 'release_date', true ),
'artist_id' => $artist_id,
'artist_name' => $artist_id ? get_the_title( $artist_id ) : '',
'total_tracks' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_tracks', true ),
'total_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_duration', true ),
'upc' => get_post_meta( $post_id, self::META_PREFIX . 'upc', true ),
'catalog_number' => get_post_meta( $post_id, self::META_PREFIX . 'catalog_number', true ),
'artwork' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
/**
* Update album track count and duration.
*
* @param int $album_id Album post ID.
* @return void
*/
public static function update_album_stats( int $album_id ): void {
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_track_album',
'meta_value' => $album_id,
)
);
$total_tracks = count( $tracks );
$total_duration = 0;
foreach ( $tracks as $track ) {
$duration = (int) get_post_meta( $track->ID, '_fedistream_track_duration', true );
$total_duration += $duration;
}
update_post_meta( $album_id, self::META_PREFIX . 'total_tracks', $total_tracks );
update_post_meta( $album_id, self::META_PREFIX . 'total_duration', $total_duration );
}
}

View File

@@ -0,0 +1,331 @@
<?php
/**
* Artist custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Artist post type class.
*
* Handles registration and management of artist/band profiles.
*/
class Artist extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_artist';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_artist_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Artists', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Artist', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Artists', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Artist', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Artist', 'wp-fedistream' ),
'new_item' => __( 'New Artist', 'wp-fedistream' ),
'edit_item' => __( 'Edit Artist', 'wp-fedistream' ),
'view_item' => __( 'View Artist', 'wp-fedistream' ),
'all_items' => __( 'All Artists', 'wp-fedistream' ),
'search_items' => __( 'Search Artists', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Artists:', 'wp-fedistream' ),
'not_found' => __( 'No artists found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No artists found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Artist Photo', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set artist photo', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove artist photo', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as artist photo', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Artist archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into artist', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this artist', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter artists list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Artists list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Artists list', 'Screen reader text', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'query_var' => true,
'rewrite' => array( 'slug' => 'artists' ),
'capability_type' => array( 'fedistream_artist', 'fedistream_artists' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-groups',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'artists',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_artist_info',
__( 'Artist Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_artist_social',
__( 'Social Links', 'wp-fedistream' ),
array( $this, 'render_social_meta_box' ),
$this->post_type,
'normal',
'default'
);
add_meta_box(
'fedistream_artist_members',
__( 'Band Members', 'wp-fedistream' ),
array( $this, 'render_members_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render artist info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_artist_save', 'fedistream_artist_nonce' );
$artist_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
$formed_date = get_post_meta( $post->ID, self::META_PREFIX . 'formed_date', true );
$location = get_post_meta( $post->ID, self::META_PREFIX . 'location', true );
$website = get_post_meta( $post->ID, self::META_PREFIX . 'website', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_artist_type"><?php esc_html_e( 'Artist Type', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_artist_type" id="fedistream_artist_type">
<option value="solo" <?php selected( $artist_type, 'solo' ); ?>><?php esc_html_e( 'Solo Artist', 'wp-fedistream' ); ?></option>
<option value="band" <?php selected( $artist_type, 'band' ); ?>><?php esc_html_e( 'Band', 'wp-fedistream' ); ?></option>
<option value="duo" <?php selected( $artist_type, 'duo' ); ?>><?php esc_html_e( 'Duo', 'wp-fedistream' ); ?></option>
<option value="collective" <?php selected( $artist_type, 'collective' ); ?>><?php esc_html_e( 'Collective', 'wp-fedistream' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_formed_date"><?php esc_html_e( 'Formed Date', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="date" name="fedistream_artist_formed_date" id="fedistream_artist_formed_date" value="<?php echo esc_attr( $formed_date ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'When the artist/band was formed or started their career.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_location"><?php esc_html_e( 'Location', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="text" name="fedistream_artist_location" id="fedistream_artist_location" value="<?php echo esc_attr( $location ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'City, Country or region where the artist is based.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_website"><?php esc_html_e( 'Website', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="url" name="fedistream_artist_website" id="fedistream_artist_website" value="<?php echo esc_url( $website ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'Official website URL.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render social links meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_social_meta_box( \WP_Post $post ): void {
$social_links = get_post_meta( $post->ID, self::META_PREFIX . 'social_links', true );
if ( ! is_array( $social_links ) ) {
$social_links = array();
}
$platforms = array(
'mastodon' => __( 'Mastodon', 'wp-fedistream' ),
'bandcamp' => __( 'Bandcamp', 'wp-fedistream' ),
'soundcloud' => __( 'SoundCloud', 'wp-fedistream' ),
'youtube' => __( 'YouTube', 'wp-fedistream' ),
'instagram' => __( 'Instagram', 'wp-fedistream' ),
'twitter' => __( 'Twitter/X', 'wp-fedistream' ),
'facebook' => __( 'Facebook', 'wp-fedistream' ),
'tiktok' => __( 'TikTok', 'wp-fedistream' ),
'other' => __( 'Other', 'wp-fedistream' ),
);
?>
<table class="form-table">
<?php foreach ( $platforms as $key => $label ) : ?>
<tr>
<th scope="row">
<label for="fedistream_social_<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
</th>
<td>
<input type="url" name="fedistream_artist_social[<?php echo esc_attr( $key ); ?>]" id="fedistream_social_<?php echo esc_attr( $key ); ?>" value="<?php echo esc_url( $social_links[ $key ] ?? '' ); ?>" class="regular-text">
</td>
</tr>
<?php endforeach; ?>
</table>
<?php
}
/**
* Render band members meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_members_meta_box( \WP_Post $post ): void {
$members = get_post_meta( $post->ID, self::META_PREFIX . 'members', true );
if ( ! is_array( $members ) ) {
$members = array();
}
$artist_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
?>
<div id="fedistream-members-wrapper" style="<?php echo 'solo' === $artist_type ? 'display:none;' : ''; ?>">
<p class="description"><?php esc_html_e( 'Add band/group members (comma-separated names).', 'wp-fedistream' ); ?></p>
<textarea name="fedistream_artist_members" id="fedistream_artist_members" rows="5" class="large-text"><?php echo esc_textarea( implode( "\n", $members ) ); ?></textarea>
<p class="description"><?php esc_html_e( 'One member per line.', 'wp-fedistream' ); ?></p>
</div>
<script>
jQuery(document).ready(function($) {
$('#fedistream_artist_type').on('change', function() {
if ($(this).val() === 'solo') {
$('#fedistream-members-wrapper').hide();
} else {
$('#fedistream-members-wrapper').show();
}
});
});
</script>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function save_meta( int $post_id, \WP_Post $post ): void {
if ( ! $this->can_save( $post_id, 'fedistream_artist_save', 'fedistream_artist_nonce' ) ) {
return;
}
// Save artist type.
if ( isset( $_POST['fedistream_artist_type'] ) ) {
$allowed_types = array( 'solo', 'band', 'duo', 'collective' );
$type = sanitize_text_field( wp_unslash( $_POST['fedistream_artist_type'] ) );
if ( in_array( $type, $allowed_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'type', $type );
}
}
// Save other fields.
$this->save_date_meta( $post_id, self::META_PREFIX . 'formed_date', 'fedistream_artist_formed_date' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'location', 'fedistream_artist_location' );
$this->save_url_meta( $post_id, self::META_PREFIX . 'website', 'fedistream_artist_website' );
// Save social links.
if ( isset( $_POST['fedistream_artist_social'] ) && is_array( $_POST['fedistream_artist_social'] ) ) {
$social_links = array();
foreach ( $_POST['fedistream_artist_social'] as $key => $url ) {
$clean_key = sanitize_key( $key );
$clean_url = esc_url_raw( wp_unslash( $url ) );
if ( ! empty( $clean_url ) ) {
$social_links[ $clean_key ] = $clean_url;
}
}
update_post_meta( $post_id, self::META_PREFIX . 'social_links', $social_links );
}
// Save members.
if ( isset( $_POST['fedistream_artist_members'] ) ) {
$members_text = sanitize_textarea_field( wp_unslash( $_POST['fedistream_artist_members'] ) );
$members = array_filter( array_map( 'trim', explode( "\n", $members_text ) ) );
update_post_meta( $post_id, self::META_PREFIX . 'members', $members );
}
}
/**
* Get artist by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Artist data or null.
*/
public static function get_artist( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_artist' !== $post->post_type ) {
return null;
}
return array(
'id' => $post->ID,
'name' => $post->post_title,
'slug' => $post->post_name,
'bio' => $post->post_content,
'excerpt' => $post->post_excerpt,
'type' => get_post_meta( $post_id, self::META_PREFIX . 'type', true ) ?: 'solo',
'formed_date' => get_post_meta( $post_id, self::META_PREFIX . 'formed_date', true ),
'location' => get_post_meta( $post_id, self::META_PREFIX . 'location', true ),
'website' => get_post_meta( $post_id, self::META_PREFIX . 'website', true ),
'social_links' => get_post_meta( $post_id, self::META_PREFIX . 'social_links', true ) ?: array(),
'members' => get_post_meta( $post_id, self::META_PREFIX . 'members', true ) ?: array(),
'photo' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
}

View File

@@ -0,0 +1,458 @@
<?php
/**
* Playlist custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Playlist post type class.
*
* Handles registration and management of playlists.
*/
class Playlist extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_playlist';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_playlist_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Playlists', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Playlist', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Playlists', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Playlist', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Playlist', 'wp-fedistream' ),
'new_item' => __( 'New Playlist', 'wp-fedistream' ),
'edit_item' => __( 'Edit Playlist', 'wp-fedistream' ),
'view_item' => __( 'View Playlist', 'wp-fedistream' ),
'all_items' => __( 'All Playlists', 'wp-fedistream' ),
'search_items' => __( 'Search Playlists', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Playlists:', 'wp-fedistream' ),
'not_found' => __( 'No playlists found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No playlists found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Playlist Cover', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set playlist cover', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove playlist cover', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as playlist cover', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Playlist archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into playlist', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this playlist', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter playlists list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Playlists list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Playlists list', 'Screen reader text', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'query_var' => true,
'rewrite' => array( 'slug' => 'playlists' ),
'capability_type' => array( 'fedistream_playlist', 'fedistream_playlists' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-playlist-audio',
'supports' => array( 'title', 'editor', 'thumbnail', 'author', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'playlists',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_playlist_tracks',
__( 'Playlist Tracks', 'wp-fedistream' ),
array( $this, 'render_tracks_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_playlist_settings',
__( 'Playlist Settings', 'wp-fedistream' ),
array( $this, 'render_settings_meta_box' ),
$this->post_type,
'side',
'default'
);
add_meta_box(
'fedistream_playlist_stats',
__( 'Playlist Stats', 'wp-fedistream' ),
array( $this, 'render_stats_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render playlist tracks meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_tracks_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_playlist_save', 'fedistream_playlist_nonce' );
global $wpdb;
// Get tracks in this playlist from the pivot table.
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$playlist_tracks = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id, position FROM $table WHERE playlist_id = %d ORDER BY position ASC",
$post->ID
)
);
$track_ids = wp_list_pluck( $playlist_tracks, 'track_id' );
// Get all available tracks.
$available_tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<div class="fedistream-playlist-tracks">
<h4><?php esc_html_e( 'Current Tracks', 'wp-fedistream' ); ?></h4>
<ul id="fedistream-playlist-track-list" class="fedistream-sortable">
<?php
foreach ( $track_ids as $track_id ) :
$track = get_post( $track_id );
if ( ! $track ) {
continue;
}
$artists = get_post_meta( $track_id, '_fedistream_track_artists', true ) ?: array();
$duration = get_post_meta( $track_id, '_fedistream_track_duration', true );
?>
<li data-track-id="<?php echo esc_attr( $track_id ); ?>" style="padding: 8px; margin: 4px 0; background: #f9f9f9; border: 1px solid #ddd; cursor: move;">
<input type="hidden" name="fedistream_playlist_tracks[]" value="<?php echo esc_attr( $track_id ); ?>">
<span class="dashicons dashicons-menu" style="vertical-align: middle;"></span>
<strong><?php echo esc_html( $track->post_title ); ?></strong>
<?php if ( ! empty( $artists ) ) : ?>
<span class="description">
— <?php echo esc_html( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>
</span>
<?php endif; ?>
<?php if ( $duration ) : ?>
<span class="description">(<?php echo esc_html( gmdate( 'i:s', (int) $duration ) ); ?>)</span>
<?php endif; ?>
<button type="button" class="button-link fedistream-remove-track" style="color: #a00; float: right;">
<?php esc_html_e( 'Remove', 'wp-fedistream' ); ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<hr>
<h4><?php esc_html_e( 'Add Tracks', 'wp-fedistream' ); ?></h4>
<p>
<select id="fedistream-add-track-select" class="widefat">
<option value=""><?php esc_html_e( '— Select a track to add —', 'wp-fedistream' ); ?></option>
<?php foreach ( $available_tracks as $track ) : ?>
<?php
$artists = get_post_meta( $track->ID, '_fedistream_track_artists', true ) ?: array();
$duration = get_post_meta( $track->ID, '_fedistream_track_duration', true );
?>
<option value="<?php echo esc_attr( $track->ID ); ?>" data-title="<?php echo esc_attr( $track->post_title ); ?>" data-artists="<?php echo esc_attr( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>" data-duration="<?php echo esc_attr( $duration ? gmdate( 'i:s', (int) $duration ) : '' ); ?>">
<?php echo esc_html( $track->post_title ); ?>
<?php if ( ! empty( $artists ) ) : ?>
— <?php echo esc_html( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<button type="button" class="button" id="fedistream-add-track-btn"><?php esc_html_e( 'Add to Playlist', 'wp-fedistream' ); ?></button>
</p>
</div>
<style>
.fedistream-sortable li { list-style: none; }
.fedistream-sortable .ui-sortable-helper { background: #fff !important; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.fedistream-sortable .ui-sortable-placeholder { visibility: visible !important; background: #e0e0e0; border: 1px dashed #999; }
</style>
<script>
jQuery(document).ready(function($) {
// Make list sortable.
$('#fedistream-playlist-track-list').sortable({
placeholder: 'ui-sortable-placeholder',
axis: 'y'
});
// Add track.
$('#fedistream-add-track-btn').on('click', function() {
var $select = $('#fedistream-add-track-select');
var trackId = $select.val();
if (!trackId) return;
var $option = $select.find('option:selected');
var title = $option.data('title');
var artists = $option.data('artists');
var duration = $option.data('duration');
var html = '<li data-track-id="' + trackId + '" style="padding: 8px; margin: 4px 0; background: #f9f9f9; border: 1px solid #ddd; cursor: move;">' +
'<input type="hidden" name="fedistream_playlist_tracks[]" value="' + trackId + '">' +
'<span class="dashicons dashicons-menu" style="vertical-align: middle;"></span> ' +
'<strong>' + title + '</strong>';
if (artists) {
html += ' <span class="description">— ' + artists + '</span>';
}
if (duration) {
html += ' <span class="description">(' + duration + ')</span>';
}
html += '<button type="button" class="button-link fedistream-remove-track" style="color: #a00; float: right;"><?php echo esc_js( __( 'Remove', 'wp-fedistream' ) ); ?></button>' +
'</li>';
$('#fedistream-playlist-track-list').append(html);
$select.val('');
});
// Remove track.
$(document).on('click', '.fedistream-remove-track', function() {
$(this).closest('li').remove();
});
});
</script>
<?php
}
/**
* Render playlist settings meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_settings_meta_box( \WP_Post $post ): void {
$visibility = get_post_meta( $post->ID, self::META_PREFIX . 'visibility', true ) ?: 'public';
$collaborative = get_post_meta( $post->ID, self::META_PREFIX . 'collaborative', true );
$federated = get_post_meta( $post->ID, self::META_PREFIX . 'federated', true );
?>
<p>
<label for="fedistream_playlist_visibility"><?php esc_html_e( 'Visibility', 'wp-fedistream' ); ?></label>
<select name="fedistream_playlist_visibility" id="fedistream_playlist_visibility" class="widefat">
<option value="public" <?php selected( $visibility, 'public' ); ?>><?php esc_html_e( 'Public', 'wp-fedistream' ); ?></option>
<option value="unlisted" <?php selected( $visibility, 'unlisted' ); ?>><?php esc_html_e( 'Unlisted (link only)', 'wp-fedistream' ); ?></option>
<option value="private" <?php selected( $visibility, 'private' ); ?>><?php esc_html_e( 'Private', 'wp-fedistream' ); ?></option>
</select>
</p>
<p>
<label>
<input type="checkbox" name="fedistream_playlist_collaborative" value="1" <?php checked( $collaborative, 1 ); ?>>
<?php esc_html_e( 'Collaborative', 'wp-fedistream' ); ?>
</label>
<br>
<span class="description"><?php esc_html_e( 'Allow others to add tracks.', 'wp-fedistream' ); ?></span>
</p>
<p>
<label>
<input type="checkbox" name="fedistream_playlist_federated" value="1" <?php checked( $federated, 1 ); ?>>
<?php esc_html_e( 'Federated', 'wp-fedistream' ); ?>
</label>
<br>
<span class="description"><?php esc_html_e( 'Allow tracks from other FediStream instances.', 'wp-fedistream' ); ?></span>
</p>
<?php
}
/**
* Render playlist stats meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_stats_meta_box( \WP_Post $post ): void {
$track_count = get_post_meta( $post->ID, self::META_PREFIX . 'track_count', true ) ?: 0;
$total_duration = get_post_meta( $post->ID, self::META_PREFIX . 'total_duration', true ) ?: 0;
?>
<p>
<strong><?php esc_html_e( 'Tracks:', 'wp-fedistream' ); ?></strong>
<?php echo esc_html( $track_count ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Total Duration:', 'wp-fedistream' ); ?></strong>
<?php
if ( $total_duration > 3600 ) {
echo esc_html( gmdate( 'H:i:s', (int) $total_duration ) );
} else {
echo esc_html( gmdate( 'i:s', (int) $total_duration ) );
}
?>
</p>
<p class="description"><?php esc_html_e( 'Stats are automatically updated when saved.', 'wp-fedistream' ); ?></p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function save_meta( int $post_id, \WP_Post $post ): void {
if ( ! $this->can_save( $post_id, 'fedistream_playlist_save', 'fedistream_playlist_nonce' ) ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// Save visibility.
if ( isset( $_POST['fedistream_playlist_visibility'] ) ) {
$allowed_visibility = array( 'public', 'unlisted', 'private' );
$visibility = sanitize_text_field( wp_unslash( $_POST['fedistream_playlist_visibility'] ) );
if ( in_array( $visibility, $allowed_visibility, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'visibility', $visibility );
}
}
// Save settings.
$this->save_bool_meta( $post_id, self::META_PREFIX . 'collaborative', 'fedistream_playlist_collaborative' );
$this->save_bool_meta( $post_id, self::META_PREFIX . 'federated', 'fedistream_playlist_federated' );
// Save tracks.
// First, delete existing tracks for this playlist.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->delete( $table, array( 'playlist_id' => $post_id ), array( '%d' ) );
// Insert new tracks.
if ( isset( $_POST['fedistream_playlist_tracks'] ) && is_array( $_POST['fedistream_playlist_tracks'] ) ) {
$position = 0;
$total_duration = 0;
foreach ( $_POST['fedistream_playlist_tracks'] as $track_id ) {
$track_id = absint( $track_id );
if ( $track_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'playlist_id' => $post_id,
'track_id' => $track_id,
'position' => $position,
),
array( '%d', '%d', '%d' )
);
++$position;
// Sum duration.
$duration = (int) get_post_meta( $track_id, '_fedistream_track_duration', true );
$total_duration += $duration;
}
}
update_post_meta( $post_id, self::META_PREFIX . 'track_count', $position );
update_post_meta( $post_id, self::META_PREFIX . 'total_duration', $total_duration );
} else {
update_post_meta( $post_id, self::META_PREFIX . 'track_count', 0 );
update_post_meta( $post_id, self::META_PREFIX . 'total_duration', 0 );
}
}
/**
* Get playlist by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Playlist data or null.
*/
public static function get_playlist( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_playlist' !== $post->post_type ) {
return null;
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'description' => $post->post_content,
'author_id' => $post->post_author,
'author_name' => get_the_author_meta( 'display_name', $post->post_author ),
'visibility' => get_post_meta( $post_id, self::META_PREFIX . 'visibility', true ) ?: 'public',
'collaborative' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'collaborative', true ),
'federated' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'federated', true ),
'track_count' => (int) get_post_meta( $post_id, self::META_PREFIX . 'track_count', true ),
'total_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_duration', true ),
'cover' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
/**
* Get tracks in a playlist.
*
* @param int $playlist_id Playlist post ID.
* @return array Array of track data.
*/
public static function get_playlist_tracks( int $playlist_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id FROM $table WHERE playlist_id = %d ORDER BY position ASC",
$playlist_id
)
);
$tracks = array();
foreach ( $results as $row ) {
$track = Track::get_track( (int) $row->track_id );
if ( $track ) {
$tracks[] = $track;
}
}
return $tracks;
}
}

View File

@@ -0,0 +1,615 @@
<?php
/**
* Track custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Track post type class.
*
* Handles registration and management of individual tracks.
*/
class Track extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_track';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_track_';
/**
* Constructor.
*/
public function __construct() {
parent::__construct();
add_action( 'save_post_fedistream_track', array( $this, 'update_album_on_save' ), 20, 2 );
}
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Tracks', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Track', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Tracks', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Track', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Track', 'wp-fedistream' ),
'new_item' => __( 'New Track', 'wp-fedistream' ),
'edit_item' => __( 'Edit Track', 'wp-fedistream' ),
'view_item' => __( 'View Track', 'wp-fedistream' ),
'all_items' => __( 'All Tracks', 'wp-fedistream' ),
'search_items' => __( 'Search Tracks', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Tracks:', 'wp-fedistream' ),
'not_found' => __( 'No tracks found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No tracks found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Track Artwork', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set track artwork', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove track artwork', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as track artwork', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Track archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into track', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this track', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter tracks list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Tracks list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Tracks list', 'Screen reader text', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'query_var' => true,
'rewrite' => array( 'slug' => 'tracks' ),
'capability_type' => array( 'fedistream_track', 'fedistream_tracks' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-format-audio',
'supports' => array( 'title', 'editor', 'thumbnail', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'tracks',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_track_audio',
__( 'Audio File', 'wp-fedistream' ),
array( $this, 'render_audio_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_track_info',
__( 'Track Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_track_album',
__( 'Album', 'wp-fedistream' ),
array( $this, 'render_album_meta_box' ),
$this->post_type,
'side',
'high'
);
add_meta_box(
'fedistream_track_artists',
__( 'Artists', 'wp-fedistream' ),
array( $this, 'render_artists_meta_box' ),
$this->post_type,
'side',
'default'
);
add_meta_box(
'fedistream_track_codes',
__( 'Track Codes', 'wp-fedistream' ),
array( $this, 'render_codes_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render audio file meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_audio_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_track_save', 'fedistream_track_nonce' );
$audio_file = get_post_meta( $post->ID, self::META_PREFIX . 'audio_file', true );
$audio_format = get_post_meta( $post->ID, self::META_PREFIX . 'audio_format', true );
$duration = get_post_meta( $post->ID, self::META_PREFIX . 'duration', true );
wp_enqueue_media();
?>
<div class="fedistream-audio-upload">
<p>
<label for="fedistream_track_audio_file"><?php esc_html_e( 'Audio File', 'wp-fedistream' ); ?></label>
</p>
<input type="hidden" name="fedistream_track_audio_file" id="fedistream_track_audio_file" value="<?php echo esc_attr( $audio_file ); ?>">
<div id="fedistream-audio-preview">
<?php if ( $audio_file ) : ?>
<?php
$audio_url = wp_get_attachment_url( $audio_file );
if ( $audio_url ) :
?>
<audio controls style="width: 100%;">
<source src="<?php echo esc_url( $audio_url ); ?>" type="audio/<?php echo esc_attr( $audio_format ?: 'mpeg' ); ?>">
</audio>
<p><strong><?php echo esc_html( basename( get_attached_file( $audio_file ) ) ); ?></strong></p>
<?php endif; ?>
<?php endif; ?>
</div>
<p>
<button type="button" class="button" id="fedistream-upload-audio"><?php esc_html_e( 'Select Audio File', 'wp-fedistream' ); ?></button>
<button type="button" class="button" id="fedistream-remove-audio" style="<?php echo $audio_file ? '' : 'display:none;'; ?>"><?php esc_html_e( 'Remove', 'wp-fedistream' ); ?></button>
</p>
<p class="description"><?php esc_html_e( 'Supported formats: MP3, WAV, FLAC, OGG', 'wp-fedistream' ); ?></p>
</div>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_track_audio_format"><?php esc_html_e( 'Audio Format', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_track_audio_format" id="fedistream_track_audio_format">
<option value=""><?php esc_html_e( '— Auto-detect —', 'wp-fedistream' ); ?></option>
<option value="mp3" <?php selected( $audio_format, 'mp3' ); ?>>MP3</option>
<option value="wav" <?php selected( $audio_format, 'wav' ); ?>>WAV</option>
<option value="flac" <?php selected( $audio_format, 'flac' ); ?>>FLAC</option>
<option value="ogg" <?php selected( $audio_format, 'ogg' ); ?>>OGG</option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_duration"><?php esc_html_e( 'Duration (seconds)', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_duration" id="fedistream_track_duration" value="<?php echo esc_attr( $duration ); ?>" min="0" class="small-text">
<span id="fedistream-duration-display">
<?php
if ( $duration ) {
printf(
/* translators: %s: formatted duration */
esc_html__( '(%s)', 'wp-fedistream' ),
esc_html( gmdate( 'i:s', (int) $duration ) )
);
}
?>
</span>
<p class="description"><?php esc_html_e( 'Auto-detected from audio file if available.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<script>
jQuery(document).ready(function($) {
var mediaUploader;
$('#fedistream-upload-audio').on('click', function(e) {
e.preventDefault();
if (mediaUploader) {
mediaUploader.open();
return;
}
mediaUploader = wp.media({
title: '<?php echo esc_js( __( 'Select Audio File', 'wp-fedistream' ) ); ?>',
button: { text: '<?php echo esc_js( __( 'Use this file', 'wp-fedistream' ) ); ?>' },
library: { type: 'audio' },
multiple: false
});
mediaUploader.on('select', function() {
var attachment = mediaUploader.state().get('selection').first().toJSON();
$('#fedistream_track_audio_file').val(attachment.id);
$('#fedistream-audio-preview').html(
'<audio controls style="width: 100%;"><source src="' + attachment.url + '" type="' + attachment.mime + '"></audio>' +
'<p><strong>' + attachment.filename + '</strong></p>'
);
$('#fedistream-remove-audio').show();
// Auto-detect format.
var ext = attachment.filename.split('.').pop().toLowerCase();
if (['mp3', 'wav', 'flac', 'ogg'].indexOf(ext) !== -1) {
$('#fedistream_track_audio_format').val(ext);
}
});
mediaUploader.open();
});
$('#fedistream-remove-audio').on('click', function(e) {
e.preventDefault();
$('#fedistream_track_audio_file').val('');
$('#fedistream-audio-preview').html('');
$(this).hide();
});
// Duration display.
$('#fedistream_track_duration').on('change', function() {
var seconds = parseInt($(this).val(), 10);
if (seconds > 0) {
var mins = Math.floor(seconds / 60);
var secs = seconds % 60;
$('#fedistream-duration-display').text('(' + mins + ':' + (secs < 10 ? '0' : '') + secs + ')');
} else {
$('#fedistream-duration-display').text('');
}
});
});
</script>
<?php
}
/**
* Render track info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
$track_number = get_post_meta( $post->ID, self::META_PREFIX . 'number', true );
$disc_number = get_post_meta( $post->ID, self::META_PREFIX . 'disc_number', true );
$bpm = get_post_meta( $post->ID, self::META_PREFIX . 'bpm', true );
$key = get_post_meta( $post->ID, self::META_PREFIX . 'key', true );
$explicit = get_post_meta( $post->ID, self::META_PREFIX . 'explicit', true );
$preview_start = get_post_meta( $post->ID, self::META_PREFIX . 'preview_start', true );
$preview_duration = get_post_meta( $post->ID, self::META_PREFIX . 'preview_duration', true );
$musical_keys = array(
'' => __( '— Select Key —', 'wp-fedistream' ),
'C' => 'C Major',
'Cm' => 'C Minor',
'C#' => 'C# Major',
'C#m' => 'C# Minor',
'D' => 'D Major',
'Dm' => 'D Minor',
'D#' => 'D# Major',
'D#m' => 'D# Minor',
'E' => 'E Major',
'Em' => 'E Minor',
'F' => 'F Major',
'Fm' => 'F Minor',
'F#' => 'F# Major',
'F#m' => 'F# Minor',
'G' => 'G Major',
'Gm' => 'G Minor',
'G#' => 'G# Major',
'G#m' => 'G# Minor',
'A' => 'A Major',
'Am' => 'A Minor',
'A#' => 'A# Major',
'A#m' => 'A# Minor',
'B' => 'B Major',
'Bm' => 'B Minor',
);
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_track_number"><?php esc_html_e( 'Track Number', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_number" id="fedistream_track_number" value="<?php echo esc_attr( $track_number ); ?>" min="1" max="999" class="small-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_disc_number"><?php esc_html_e( 'Disc Number', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_disc_number" id="fedistream_track_disc_number" value="<?php echo esc_attr( $disc_number ?: 1 ); ?>" min="1" max="99" class="small-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_bpm"><?php esc_html_e( 'BPM', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_bpm" id="fedistream_track_bpm" value="<?php echo esc_attr( $bpm ); ?>" min="20" max="300" class="small-text">
<p class="description"><?php esc_html_e( 'Beats per minute (tempo).', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_key"><?php esc_html_e( 'Musical Key', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_track_key" id="fedistream_track_key">
<?php foreach ( $musical_keys as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $key, $value ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Explicit Content', 'wp-fedistream' ); ?>
</th>
<td>
<label>
<input type="checkbox" name="fedistream_track_explicit" value="1" <?php checked( $explicit, 1 ); ?>>
<?php esc_html_e( 'This track contains explicit content', 'wp-fedistream' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_preview_start"><?php esc_html_e( 'Preview Settings', 'wp-fedistream' ); ?></label>
</th>
<td>
<label>
<?php esc_html_e( 'Start at:', 'wp-fedistream' ); ?>
<input type="number" name="fedistream_track_preview_start" id="fedistream_track_preview_start" value="<?php echo esc_attr( $preview_start ?: 0 ); ?>" min="0" class="small-text">
<?php esc_html_e( 'seconds', 'wp-fedistream' ); ?>
</label>
<br>
<label>
<?php esc_html_e( 'Duration:', 'wp-fedistream' ); ?>
<input type="number" name="fedistream_track_preview_duration" id="fedistream_track_preview_duration" value="<?php echo esc_attr( $preview_duration ?: 30 ); ?>" min="10" max="60" class="small-text">
<?php esc_html_e( 'seconds', 'wp-fedistream' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Preview clip for non-authenticated users or before purchase.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render album selection meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_album_meta_box( \WP_Post $post ): void {
$selected_album = get_post_meta( $post->ID, self::META_PREFIX . 'album', true );
$albums = get_posts(
array(
'post_type' => 'fedistream_album',
'posts_per_page' => -1,
'post_status' => array( 'publish', 'draft' ),
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<select name="fedistream_track_album" id="fedistream_track_album" class="widefat">
<option value=""><?php esc_html_e( '— No Album (Single) —', 'wp-fedistream' ); ?></option>
<?php foreach ( $albums as $album ) : ?>
<?php
$artist_id = get_post_meta( $album->ID, '_fedistream_album_artist', true );
$artist_name = $artist_id ? get_the_title( $artist_id ) : '';
?>
<option value="<?php echo esc_attr( $album->ID ); ?>" <?php selected( $selected_album, $album->ID ); ?>>
<?php echo esc_html( $album->post_title ); ?>
<?php if ( $artist_name ) : ?>
(<?php echo esc_html( $artist_name ); ?>)
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</p>
<?php
}
/**
* Render artists meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_artists_meta_box( \WP_Post $post ): void {
$selected_artists = get_post_meta( $post->ID, self::META_PREFIX . 'artists', true );
if ( ! is_array( $selected_artists ) ) {
$selected_artists = array();
}
$artists = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p class="description"><?php esc_html_e( 'Select all artists featured on this track.', 'wp-fedistream' ); ?></p>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #ddd; padding: 5px;">
<?php foreach ( $artists as $artist ) : ?>
<label style="display: block; padding: 2px 0;">
<input type="checkbox" name="fedistream_track_artists[]" value="<?php echo esc_attr( $artist->ID ); ?>" <?php checked( in_array( $artist->ID, $selected_artists, true ) ); ?>>
<?php echo esc_html( $artist->post_title ); ?>
</label>
<?php endforeach; ?>
</div>
<?php if ( empty( $artists ) ) : ?>
<p>
<?php
printf(
/* translators: %s: URL to add new artist */
esc_html__( 'No artists found. %s', 'wp-fedistream' ),
'<a href="' . esc_url( admin_url( 'post-new.php?post_type=fedistream_artist' ) ) . '">' . esc_html__( 'Add an artist first.', 'wp-fedistream' ) . '</a>'
);
?>
</p>
<?php endif; ?>
<?php
}
/**
* Render track codes meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_codes_meta_box( \WP_Post $post ): void {
$isrc = get_post_meta( $post->ID, self::META_PREFIX . 'isrc', true );
?>
<p>
<label for="fedistream_track_isrc"><?php esc_html_e( 'ISRC', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_track_isrc" id="fedistream_track_isrc" value="<?php echo esc_attr( $isrc ); ?>" class="widefat" pattern="[A-Z]{2}[A-Z0-9]{3}[0-9]{7}" title="<?php esc_attr_e( 'ISRC format: CC-XXX-YY-NNNNN', 'wp-fedistream' ); ?>">
<span class="description"><?php esc_html_e( 'International Standard Recording Code', 'wp-fedistream' ); ?></span>
</p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function save_meta( int $post_id, \WP_Post $post ): void {
if ( ! $this->can_save( $post_id, 'fedistream_track_save', 'fedistream_track_nonce' ) ) {
return;
}
// Save audio fields.
$this->save_int_meta( $post_id, self::META_PREFIX . 'audio_file', 'fedistream_track_audio_file' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'duration', 'fedistream_track_duration' );
// Save audio format.
if ( isset( $_POST['fedistream_track_audio_format'] ) ) {
$allowed_formats = array( '', 'mp3', 'wav', 'flac', 'ogg' );
$format = sanitize_text_field( wp_unslash( $_POST['fedistream_track_audio_format'] ) );
if ( in_array( $format, $allowed_formats, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'audio_format', $format );
}
}
// Save track info.
$this->save_int_meta( $post_id, self::META_PREFIX . 'number', 'fedistream_track_number' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'disc_number', 'fedistream_track_disc_number' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'bpm', 'fedistream_track_bpm' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'key', 'fedistream_track_key' );
$this->save_bool_meta( $post_id, self::META_PREFIX . 'explicit', 'fedistream_track_explicit' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'preview_start', 'fedistream_track_preview_start' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'preview_duration', 'fedistream_track_preview_duration' );
// Save album.
$this->save_int_meta( $post_id, self::META_PREFIX . 'album', 'fedistream_track_album' );
// Save artists.
if ( isset( $_POST['fedistream_track_artists'] ) && is_array( $_POST['fedistream_track_artists'] ) ) {
$artists = array_map( 'absint', $_POST['fedistream_track_artists'] );
update_post_meta( $post_id, self::META_PREFIX . 'artists', $artists );
} else {
delete_post_meta( $post_id, self::META_PREFIX . 'artists' );
}
// Save ISRC.
$this->save_text_meta( $post_id, self::META_PREFIX . 'isrc', 'fedistream_track_isrc' );
}
/**
* Update album stats when track is saved.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function update_album_on_save( int $post_id, \WP_Post $post ): void {
$album_id = get_post_meta( $post_id, self::META_PREFIX . 'album', true );
if ( $album_id ) {
Album::update_album_stats( (int) $album_id );
}
}
/**
* Get track by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Track data or null.
*/
public static function get_track( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_track' !== $post->post_type ) {
return null;
}
$album_id = get_post_meta( $post_id, self::META_PREFIX . 'album', true );
$audio_file = get_post_meta( $post_id, self::META_PREFIX . 'audio_file', true );
$artists = get_post_meta( $post_id, self::META_PREFIX . 'artists', true ) ?: array();
// Get artist names.
$artist_names = array();
foreach ( $artists as $artist_id ) {
$artist_names[] = get_the_title( $artist_id );
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'lyrics' => $post->post_content,
'album_id' => $album_id ? (int) $album_id : null,
'album_title' => $album_id ? get_the_title( $album_id ) : null,
'artists' => $artists,
'artist_names' => $artist_names,
'track_number' => (int) get_post_meta( $post_id, self::META_PREFIX . 'number', true ),
'disc_number' => (int) get_post_meta( $post_id, self::META_PREFIX . 'disc_number', true ) ?: 1,
'duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'duration', true ),
'audio_file' => $audio_file ? (int) $audio_file : null,
'audio_url' => $audio_file ? wp_get_attachment_url( $audio_file ) : null,
'audio_format' => get_post_meta( $post_id, self::META_PREFIX . 'audio_format', true ),
'bpm' => (int) get_post_meta( $post_id, self::META_PREFIX . 'bpm', true ),
'key' => get_post_meta( $post_id, self::META_PREFIX . 'key', true ),
'explicit' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'explicit', true ),
'isrc' => get_post_meta( $post_id, self::META_PREFIX . 'isrc', true ),
'preview_start' => (int) get_post_meta( $post_id, self::META_PREFIX . 'preview_start', true ),
'preview_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'preview_duration', true ) ?: 30,
'artwork' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
}

View File

@@ -0,0 +1 @@
<?php // Silence is golden.

View File

@@ -0,0 +1,308 @@
<?php
/**
* User roles and capabilities.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Roles;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Capabilities class.
*
* Handles custom user roles and capabilities.
*/
class Capabilities {
/**
* Post type capabilities.
*
* @var array
*/
private const POST_TYPE_CAPS = array(
'fedistream_artist' => array(
'edit_post' => 'edit_fedistream_artist',
'read_post' => 'read_fedistream_artist',
'delete_post' => 'delete_fedistream_artist',
'edit_posts' => 'edit_fedistream_artists',
'edit_others_posts' => 'edit_others_fedistream_artists',
'publish_posts' => 'publish_fedistream_artists',
'read_private_posts' => 'read_private_fedistream_artists',
),
'fedistream_album' => array(
'edit_post' => 'edit_fedistream_album',
'read_post' => 'read_fedistream_album',
'delete_post' => 'delete_fedistream_album',
'edit_posts' => 'edit_fedistream_albums',
'edit_others_posts' => 'edit_others_fedistream_albums',
'publish_posts' => 'publish_fedistream_albums',
'read_private_posts' => 'read_private_fedistream_albums',
),
'fedistream_track' => array(
'edit_post' => 'edit_fedistream_track',
'read_post' => 'read_fedistream_track',
'delete_post' => 'delete_fedistream_track',
'edit_posts' => 'edit_fedistream_tracks',
'edit_others_posts' => 'edit_others_fedistream_tracks',
'publish_posts' => 'publish_fedistream_tracks',
'read_private_posts' => 'read_private_fedistream_tracks',
),
'fedistream_playlist' => array(
'edit_post' => 'edit_fedistream_playlist',
'read_post' => 'read_fedistream_playlist',
'delete_post' => 'delete_fedistream_playlist',
'edit_posts' => 'edit_fedistream_playlists',
'edit_others_posts' => 'edit_others_fedistream_playlists',
'publish_posts' => 'publish_fedistream_playlists',
'read_private_posts' => 'read_private_fedistream_playlists',
),
);
/**
* Taxonomy capabilities.
*
* @var array
*/
private const TAXONOMY_CAPS = array(
'manage_fedistream_genres',
'manage_fedistream_moods',
'manage_fedistream_licenses',
);
/**
* Custom capabilities.
*
* @var array
*/
private const CUSTOM_CAPS = array(
'view_fedistream_stats',
'manage_fedistream_settings',
);
/**
* Get all custom capabilities.
*
* @return array Array of all custom capabilities.
*/
public static function get_all_capabilities(): array {
$caps = array();
// Post type capabilities.
foreach ( self::POST_TYPE_CAPS as $post_type_caps ) {
$caps = array_merge( $caps, array_values( $post_type_caps ) );
}
// Delete capabilities.
$caps[] = 'delete_fedistream_artists';
$caps[] = 'delete_others_fedistream_artists';
$caps[] = 'delete_published_fedistream_artists';
$caps[] = 'delete_private_fedistream_artists';
$caps[] = 'delete_fedistream_albums';
$caps[] = 'delete_others_fedistream_albums';
$caps[] = 'delete_published_fedistream_albums';
$caps[] = 'delete_private_fedistream_albums';
$caps[] = 'delete_fedistream_tracks';
$caps[] = 'delete_others_fedistream_tracks';
$caps[] = 'delete_published_fedistream_tracks';
$caps[] = 'delete_private_fedistream_tracks';
$caps[] = 'delete_fedistream_playlists';
$caps[] = 'delete_others_fedistream_playlists';
$caps[] = 'delete_published_fedistream_playlists';
$caps[] = 'delete_private_fedistream_playlists';
// Taxonomy capabilities.
$caps = array_merge( $caps, self::TAXONOMY_CAPS );
// Custom capabilities.
$caps = array_merge( $caps, self::CUSTOM_CAPS );
return array_unique( $caps );
}
/**
* Get Artist role capabilities.
*
* @return array Array of capabilities for the Artist role.
*/
public static function get_artist_capabilities(): array {
return array(
// Core WordPress.
'read' => true,
'upload_files' => true,
// Own artists.
'edit_fedistream_artists' => true,
'edit_published_fedistream_artists' => true,
'publish_fedistream_artists' => true,
'delete_fedistream_artists' => true,
'delete_published_fedistream_artists' => true,
// Own albums.
'edit_fedistream_albums' => true,
'edit_published_fedistream_albums' => true,
'publish_fedistream_albums' => true,
'delete_fedistream_albums' => true,
'delete_published_fedistream_albums' => true,
// Own tracks.
'edit_fedistream_tracks' => true,
'edit_published_fedistream_tracks' => true,
'publish_fedistream_tracks' => true,
'delete_fedistream_tracks' => true,
'delete_published_fedistream_tracks' => true,
// Own playlists.
'edit_fedistream_playlists' => true,
'edit_published_fedistream_playlists' => true,
'publish_fedistream_playlists' => true,
'delete_fedistream_playlists' => true,
'delete_published_fedistream_playlists' => true,
// View stats.
'view_fedistream_stats' => true,
);
}
/**
* Get Label role capabilities.
*
* @return array Array of capabilities for the Label role.
*/
public static function get_label_capabilities(): array {
$caps = self::get_artist_capabilities();
// Add label-specific capabilities.
$label_caps = array(
// Manage others' content.
'edit_others_fedistream_artists' => true,
'edit_others_fedistream_albums' => true,
'edit_others_fedistream_tracks' => true,
'edit_others_fedistream_playlists' => true,
'delete_others_fedistream_artists' => true,
'delete_others_fedistream_albums' => true,
'delete_others_fedistream_tracks' => true,
'delete_others_fedistream_playlists' => true,
'read_private_fedistream_artists' => true,
'read_private_fedistream_albums' => true,
'read_private_fedistream_tracks' => true,
'read_private_fedistream_playlists' => true,
'delete_private_fedistream_artists' => true,
'delete_private_fedistream_albums' => true,
'delete_private_fedistream_tracks' => true,
'delete_private_fedistream_playlists' => true,
// Manage taxonomies.
'manage_fedistream_genres' => true,
'manage_fedistream_moods' => true,
'manage_fedistream_licenses' => true,
// View stats.
'view_fedistream_stats' => true,
);
return array_merge( $caps, $label_caps );
}
/**
* Add custom roles.
*
* @return void
*/
public static function add_roles(): void {
// Remove existing roles first to ensure clean slate.
remove_role( 'fedistream_artist' );
remove_role( 'fedistream_label' );
// Add Artist role.
add_role(
'fedistream_artist',
__( 'FediStream Artist', 'wp-fedistream' ),
self::get_artist_capabilities()
);
// Add Label role.
add_role(
'fedistream_label',
__( 'FediStream Label', 'wp-fedistream' ),
self::get_label_capabilities()
);
}
/**
* Remove custom roles.
*
* @return void
*/
public static function remove_roles(): void {
remove_role( 'fedistream_artist' );
remove_role( 'fedistream_label' );
}
/**
* Add capabilities to administrator role.
*
* @return void
*/
public static function add_admin_capabilities(): void {
$admin = get_role( 'administrator' );
if ( ! $admin ) {
return;
}
// Add all custom capabilities.
$all_caps = self::get_all_capabilities();
foreach ( $all_caps as $cap ) {
$admin->add_cap( $cap );
}
// Add management capability.
$admin->add_cap( 'manage_fedistream_settings' );
}
/**
* Remove capabilities from administrator role.
*
* @return void
*/
public static function remove_admin_capabilities(): void {
$admin = get_role( 'administrator' );
if ( ! $admin ) {
return;
}
// Remove all custom capabilities.
$all_caps = self::get_all_capabilities();
foreach ( $all_caps as $cap ) {
$admin->remove_cap( $cap );
}
$admin->remove_cap( 'manage_fedistream_settings' );
}
/**
* Install roles and capabilities.
*
* @return void
*/
public static function install(): void {
self::add_roles();
self::add_admin_capabilities();
}
/**
* Uninstall roles and capabilities.
*
* @return void
*/
public static function uninstall(): void {
self::remove_roles();
self::remove_admin_capabilities();
}
}

1
includes/Roles/index.php Normal file
View File

@@ -0,0 +1 @@
<?php // Silence is golden.

View File

@@ -0,0 +1,67 @@
<?php
/**
* Abstract base class for custom taxonomies.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Taxonomies;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract taxonomy class.
*
* Provides common functionality for all custom taxonomies.
*/
abstract class AbstractTaxonomy {
/**
* Taxonomy key.
*
* @var string
*/
protected string $taxonomy;
/**
* Post types this taxonomy applies to.
*
* @var array
*/
protected array $post_types = array();
/**
* Constructor.
*/
public function __construct() {
add_action( 'init', array( $this, 'register' ) );
}
/**
* Register the taxonomy.
*
* @return void
*/
abstract public function register(): void;
/**
* Get the taxonomy key.
*
* @return string
*/
public function get_taxonomy(): string {
return $this->taxonomy;
}
/**
* Get the post types this taxonomy is registered for.
*
* @return array
*/
public function get_post_types(): array {
return $this->post_types;
}
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Genre custom taxonomy.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Taxonomies;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Genre taxonomy class.
*
* Hierarchical taxonomy for music genres.
*/
class Genre extends AbstractTaxonomy {
/**
* Taxonomy key.
*
* @var string
*/
protected string $taxonomy = 'fedistream_genre';
/**
* Post types this taxonomy applies to.
*
* @var array
*/
protected array $post_types = array(
'fedistream_artist',
'fedistream_album',
'fedistream_track',
);
/**
* Register the taxonomy.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Genres', 'taxonomy general name', 'wp-fedistream' ),
'singular_name' => _x( 'Genre', 'taxonomy singular name', 'wp-fedistream' ),
'search_items' => __( 'Search Genres', 'wp-fedistream' ),
'popular_items' => __( 'Popular Genres', 'wp-fedistream' ),
'all_items' => __( 'All Genres', 'wp-fedistream' ),
'parent_item' => __( 'Parent Genre', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Genre:', 'wp-fedistream' ),
'edit_item' => __( 'Edit Genre', 'wp-fedistream' ),
'view_item' => __( 'View Genre', 'wp-fedistream' ),
'update_item' => __( 'Update Genre', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Genre', 'wp-fedistream' ),
'new_item_name' => __( 'New Genre Name', 'wp-fedistream' ),
'separate_items_with_commas' => __( 'Separate genres with commas', 'wp-fedistream' ),
'add_or_remove_items' => __( 'Add or remove genres', 'wp-fedistream' ),
'choose_from_most_used' => __( 'Choose from the most used genres', 'wp-fedistream' ),
'not_found' => __( 'No genres found.', 'wp-fedistream' ),
'no_terms' => __( 'No genres', 'wp-fedistream' ),
'menu_name' => __( 'Genres', 'wp-fedistream' ),
'items_list_navigation' => __( 'Genres list navigation', 'wp-fedistream' ),
'items_list' => __( 'Genres list', 'wp-fedistream' ),
'back_to_items' => __( '&larr; Back to Genres', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => true, // Like categories.
'public' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'show_admin_column' => true,
'show_in_nav_menus' => true,
'show_tagcloud' => true,
'show_in_rest' => true,
'rest_base' => 'genres',
'query_var' => true,
'rewrite' => array( 'slug' => 'genre' ),
'capabilities' => array(
'manage_terms' => 'manage_fedistream_genres',
'edit_terms' => 'manage_fedistream_genres',
'delete_terms' => 'manage_fedistream_genres',
'assign_terms' => 'edit_fedistream_tracks',
),
);
register_taxonomy( $this->taxonomy, $this->post_types, $args );
}
/**
* Get default genres.
*
* @return array Array of genres with children.
*/
public static function get_default_genres(): array {
return array(
'Rock' => array( 'Alternative Rock', 'Hard Rock', 'Indie Rock', 'Progressive Rock', 'Punk Rock' ),
'Pop' => array( 'Dance Pop', 'Electropop', 'Indie Pop', 'Synth Pop' ),
'Electronic' => array( 'Ambient', 'Drum and Bass', 'Dubstep', 'House', 'Techno', 'Trance' ),
'Hip Hop' => array( 'Rap', 'Trap', 'Lo-Fi Hip Hop' ),
'Jazz' => array( 'Bebop', 'Cool Jazz', 'Free Jazz', 'Fusion' ),
'Classical' => array( 'Baroque', 'Chamber Music', 'Opera', 'Orchestral', 'Romantic' ),
'R&B' => array( 'Contemporary R&B', 'Neo Soul', 'Soul' ),
'Country' => array( 'Americana', 'Bluegrass', 'Country Rock' ),
'Metal' => array( 'Black Metal', 'Death Metal', 'Heavy Metal', 'Thrash Metal' ),
'Folk' => array( 'Acoustic', 'Folk Rock', 'Traditional Folk' ),
'Blues' => array( 'Chicago Blues', 'Delta Blues', 'Electric Blues' ),
'Reggae' => array( 'Dancehall', 'Dub', 'Roots Reggae', 'Ska' ),
'Latin' => array( 'Bossa Nova', 'Salsa', 'Reggaeton', 'Tango' ),
'World' => array( 'African', 'Asian', 'Celtic', 'Middle Eastern' ),
'Soundtrack' => array( 'Film Score', 'Video Game', 'Musical Theatre' ),
'Other' => array(),
);
}
/**
* Install default genres.
*
* @return void
*/
public static function install_defaults(): void {
$genres = self::get_default_genres();
foreach ( $genres as $parent => $children ) {
// Check if parent exists.
$parent_term = term_exists( $parent, 'fedistream_genre' );
if ( ! $parent_term ) {
$parent_term = wp_insert_term( $parent, 'fedistream_genre' );
}
if ( is_wp_error( $parent_term ) ) {
continue;
}
$parent_id = is_array( $parent_term ) ? $parent_term['term_id'] : $parent_term;
// Insert children.
foreach ( $children as $child ) {
if ( ! term_exists( $child, 'fedistream_genre' ) ) {
wp_insert_term(
$child,
'fedistream_genre',
array( 'parent' => (int) $parent_id )
);
}
}
}
}
}

View File

@@ -0,0 +1,164 @@
<?php
/**
* License custom taxonomy.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Taxonomies;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* License taxonomy class.
*
* Hierarchical taxonomy for content licenses.
*/
class License extends AbstractTaxonomy {
/**
* Taxonomy key.
*
* @var string
*/
protected string $taxonomy = 'fedistream_license';
/**
* Post types this taxonomy applies to.
*
* @var array
*/
protected array $post_types = array(
'fedistream_album',
'fedistream_track',
);
/**
* Register the taxonomy.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Licenses', 'taxonomy general name', 'wp-fedistream' ),
'singular_name' => _x( 'License', 'taxonomy singular name', 'wp-fedistream' ),
'search_items' => __( 'Search Licenses', 'wp-fedistream' ),
'popular_items' => __( 'Popular Licenses', 'wp-fedistream' ),
'all_items' => __( 'All Licenses', 'wp-fedistream' ),
'parent_item' => __( 'Parent License', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent License:', 'wp-fedistream' ),
'edit_item' => __( 'Edit License', 'wp-fedistream' ),
'view_item' => __( 'View License', 'wp-fedistream' ),
'update_item' => __( 'Update License', 'wp-fedistream' ),
'add_new_item' => __( 'Add New License', 'wp-fedistream' ),
'new_item_name' => __( 'New License Name', 'wp-fedistream' ),
'separate_items_with_commas' => __( 'Separate licenses with commas', 'wp-fedistream' ),
'add_or_remove_items' => __( 'Add or remove licenses', 'wp-fedistream' ),
'choose_from_most_used' => __( 'Choose from the most used licenses', 'wp-fedistream' ),
'not_found' => __( 'No licenses found.', 'wp-fedistream' ),
'no_terms' => __( 'No licenses', 'wp-fedistream' ),
'menu_name' => __( 'Licenses', 'wp-fedistream' ),
'items_list_navigation' => __( 'Licenses list navigation', 'wp-fedistream' ),
'items_list' => __( 'Licenses list', 'wp-fedistream' ),
'back_to_items' => __( '&larr; Back to Licenses', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => true, // Like categories.
'public' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'show_admin_column' => true,
'show_in_nav_menus' => false,
'show_tagcloud' => false,
'show_in_rest' => true,
'rest_base' => 'licenses',
'query_var' => true,
'rewrite' => array( 'slug' => 'license' ),
'capabilities' => array(
'manage_terms' => 'manage_fedistream_licenses',
'edit_terms' => 'manage_fedistream_licenses',
'delete_terms' => 'manage_fedistream_licenses',
'assign_terms' => 'edit_fedistream_tracks',
),
);
register_taxonomy( $this->taxonomy, $this->post_types, $args );
}
/**
* Get default licenses.
*
* @return array Array of licenses with descriptions.
*/
public static function get_default_licenses(): array {
return array(
'All Rights Reserved' => array(
'description' => __( 'Standard copyright. All rights reserved by the creator.', 'wp-fedistream' ),
'children' => array(),
),
'Creative Commons' => array(
'description' => __( 'Creative Commons licenses for sharing and reuse.', 'wp-fedistream' ),
'children' => array(
'CC0' => __( 'Public Domain Dedication - No rights reserved', 'wp-fedistream' ),
'CC BY' => __( 'Attribution - Credit must be given', 'wp-fedistream' ),
'CC BY-SA' => __( 'Attribution-ShareAlike - Credit and share under same terms', 'wp-fedistream' ),
'CC BY-ND' => __( 'Attribution-NoDerivs - Credit, no modifications', 'wp-fedistream' ),
'CC BY-NC' => __( 'Attribution-NonCommercial - Credit, non-commercial only', 'wp-fedistream' ),
'CC BY-NC-SA' => __( 'Attribution-NonCommercial-ShareAlike', 'wp-fedistream' ),
'CC BY-NC-ND' => __( 'Attribution-NonCommercial-NoDerivs', 'wp-fedistream' ),
),
),
'Public Domain' => array(
'description' => __( 'Works in the public domain with no copyright restrictions.', 'wp-fedistream' ),
'children' => array(),
),
);
}
/**
* Install default licenses.
*
* @return void
*/
public static function install_defaults(): void {
$licenses = self::get_default_licenses();
foreach ( $licenses as $name => $data ) {
// Check if parent exists.
$parent_term = term_exists( $name, 'fedistream_license' );
if ( ! $parent_term ) {
$parent_term = wp_insert_term(
$name,
'fedistream_license',
array( 'description' => $data['description'] )
);
}
if ( is_wp_error( $parent_term ) ) {
continue;
}
$parent_id = is_array( $parent_term ) ? $parent_term['term_id'] : $parent_term;
// Insert children.
foreach ( $data['children'] as $child_name => $child_desc ) {
if ( ! term_exists( $child_name, 'fedistream_license' ) ) {
wp_insert_term(
$child_name,
'fedistream_license',
array(
'parent' => (int) $parent_id,
'description' => $child_desc,
)
);
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
<?php
/**
* Mood custom taxonomy.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Taxonomies;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Mood taxonomy class.
*
* Non-hierarchical taxonomy for track/playlist moods.
*/
class Mood extends AbstractTaxonomy {
/**
* Taxonomy key.
*
* @var string
*/
protected string $taxonomy = 'fedistream_mood';
/**
* Post types this taxonomy applies to.
*
* @var array
*/
protected array $post_types = array(
'fedistream_track',
'fedistream_playlist',
);
/**
* Register the taxonomy.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Moods', 'taxonomy general name', 'wp-fedistream' ),
'singular_name' => _x( 'Mood', 'taxonomy singular name', 'wp-fedistream' ),
'search_items' => __( 'Search Moods', 'wp-fedistream' ),
'popular_items' => __( 'Popular Moods', 'wp-fedistream' ),
'all_items' => __( 'All Moods', 'wp-fedistream' ),
'edit_item' => __( 'Edit Mood', 'wp-fedistream' ),
'view_item' => __( 'View Mood', 'wp-fedistream' ),
'update_item' => __( 'Update Mood', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Mood', 'wp-fedistream' ),
'new_item_name' => __( 'New Mood Name', 'wp-fedistream' ),
'separate_items_with_commas' => __( 'Separate moods with commas', 'wp-fedistream' ),
'add_or_remove_items' => __( 'Add or remove moods', 'wp-fedistream' ),
'choose_from_most_used' => __( 'Choose from the most used moods', 'wp-fedistream' ),
'not_found' => __( 'No moods found.', 'wp-fedistream' ),
'no_terms' => __( 'No moods', 'wp-fedistream' ),
'menu_name' => __( 'Moods', 'wp-fedistream' ),
'items_list_navigation' => __( 'Moods list navigation', 'wp-fedistream' ),
'items_list' => __( 'Moods list', 'wp-fedistream' ),
'back_to_items' => __( '&larr; Back to Moods', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => false, // Like tags.
'public' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'show_admin_column' => true,
'show_in_nav_menus' => true,
'show_tagcloud' => true,
'show_in_rest' => true,
'rest_base' => 'moods',
'query_var' => true,
'rewrite' => array( 'slug' => 'mood' ),
'capabilities' => array(
'manage_terms' => 'manage_fedistream_moods',
'edit_terms' => 'manage_fedistream_moods',
'delete_terms' => 'manage_fedistream_moods',
'assign_terms' => 'edit_fedistream_tracks',
),
);
register_taxonomy( $this->taxonomy, $this->post_types, $args );
}
/**
* Get default moods.
*
* @return array Array of moods.
*/
public static function get_default_moods(): array {
return array(
__( 'Energetic', 'wp-fedistream' ),
__( 'Calm', 'wp-fedistream' ),
__( 'Uplifting', 'wp-fedistream' ),
__( 'Melancholic', 'wp-fedistream' ),
__( 'Aggressive', 'wp-fedistream' ),
__( 'Romantic', 'wp-fedistream' ),
__( 'Happy', 'wp-fedistream' ),
__( 'Sad', 'wp-fedistream' ),
__( 'Relaxing', 'wp-fedistream' ),
__( 'Intense', 'wp-fedistream' ),
__( 'Dreamy', 'wp-fedistream' ),
__( 'Dark', 'wp-fedistream' ),
__( 'Groovy', 'wp-fedistream' ),
__( 'Epic', 'wp-fedistream' ),
__( 'Peaceful', 'wp-fedistream' ),
__( 'Motivational', 'wp-fedistream' ),
__( 'Nostalgic', 'wp-fedistream' ),
__( 'Playful', 'wp-fedistream' ),
__( 'Sensual', 'wp-fedistream' ),
__( 'Suspenseful', 'wp-fedistream' ),
);
}
/**
* Install default moods.
*
* @return void
*/
public static function install_defaults(): void {
$moods = self::get_default_moods();
foreach ( $moods as $mood ) {
if ( ! term_exists( $mood, 'fedistream_mood' ) ) {
wp_insert_term( $mood, 'fedistream_mood' );
}
}
}
}

View File

@@ -0,0 +1 @@
<?php // Silence is golden.

794
includes/User/Library.php Normal file
View File

@@ -0,0 +1,794 @@
<?php
/**
* User Library class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles user library features (favorites, follows, history).
*/
class Library {
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_ajax_fedistream_toggle_favorite', array( $this, 'ajax_toggle_favorite' ) );
add_action( 'wp_ajax_fedistream_toggle_follow', array( $this, 'ajax_toggle_follow' ) );
add_action( 'wp_ajax_fedistream_get_library', array( $this, 'ajax_get_library' ) );
add_action( 'wp_ajax_fedistream_get_followed_artists', array( $this, 'ajax_get_followed_artists' ) );
add_action( 'wp_ajax_fedistream_get_history', array( $this, 'ajax_get_history' ) );
add_action( 'wp_ajax_fedistream_clear_history', array( $this, 'ajax_clear_history' ) );
// Add library buttons to content.
add_filter( 'fedistream_track_actions', array( $this, 'add_favorite_button' ), 10, 2 );
add_filter( 'fedistream_album_actions', array( $this, 'add_favorite_button' ), 10, 2 );
add_filter( 'fedistream_artist_actions', array( $this, 'add_follow_button' ), 10, 2 );
// Record play history.
add_action( 'fedistream_track_played', array( $this, 'record_play_history' ), 10, 2 );
}
/**
* Toggle favorite via AJAX.
*
* @return void
*/
public function ajax_toggle_favorite(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$content_type = isset( $_POST['content_type'] ) ? sanitize_text_field( wp_unslash( $_POST['content_type'] ) ) : '';
$content_id = isset( $_POST['content_id'] ) ? absint( $_POST['content_id'] ) : 0;
if ( ! in_array( $content_type, array( 'track', 'album', 'playlist' ), true ) || ! $content_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid request.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$is_favorited = self::is_favorited( $user_id, $content_type, $content_id );
if ( $is_favorited ) {
$result = self::remove_favorite( $user_id, $content_type, $content_id );
$action = 'removed';
} else {
$result = self::add_favorite( $user_id, $content_type, $content_id );
$action = 'added';
}
if ( $result ) {
wp_send_json_success(
array(
'action' => $action,
'is_favorited' => ! $is_favorited,
'message' => 'added' === $action
? __( 'Added to your library.', 'wp-fedistream' )
: __( 'Removed from your library.', 'wp-fedistream' ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update library.', 'wp-fedistream' ) ) );
}
}
/**
* Toggle follow via AJAX.
*
* @return void
*/
public function ajax_toggle_follow(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$artist_id = isset( $_POST['artist_id'] ) ? absint( $_POST['artist_id'] ) : 0;
if ( ! $artist_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid artist.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$is_following = self::is_following( $user_id, $artist_id );
if ( $is_following ) {
$result = self::unfollow_artist( $user_id, $artist_id );
$action = 'unfollowed';
} else {
$result = self::follow_artist( $user_id, $artist_id );
$action = 'followed';
}
if ( $result ) {
wp_send_json_success(
array(
'action' => $action,
'is_following' => ! $is_following,
'message' => 'followed' === $action
? __( 'You are now following this artist.', 'wp-fedistream' )
: __( 'You unfollowed this artist.', 'wp-fedistream' ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update follow status.', 'wp-fedistream' ) ) );
}
}
/**
* Get user library via AJAX.
*
* @return void
*/
public function ajax_get_library(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$type = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'all';
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 20;
$library = self::get_user_library( $user_id, $type, $page, $per_page );
wp_send_json_success( $library );
}
/**
* Get followed artists via AJAX.
*
* @return void
*/
public function ajax_get_followed_artists(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 20;
$artists = self::get_followed_artists( $user_id, $page, $per_page );
wp_send_json_success( $artists );
}
/**
* Get listening history via AJAX.
*
* @return void
*/
public function ajax_get_history(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$per_page = 50;
$history = self::get_listening_history( $user_id, $page, $per_page );
wp_send_json_success( $history );
}
/**
* Clear listening history via AJAX.
*
* @return void
*/
public function ajax_clear_history(): void {
check_ajax_referer( 'fedistream_library', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$result = self::clear_listening_history( $user_id );
if ( $result ) {
wp_send_json_success( array( 'message' => __( 'History cleared.', 'wp-fedistream' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to clear history.', 'wp-fedistream' ) ) );
}
}
/**
* Add a favorite.
*
* @param int $user_id User ID.
* @param string $content_type Content type (track, album, playlist).
* @param int $content_id Content ID.
* @return bool
*/
public static function add_favorite( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'content_type' => $content_type,
'content_id' => $content_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%d', '%s' )
);
if ( $result ) {
do_action( 'fedistream_favorite_added', $user_id, $content_type, $content_id );
}
return (bool) $result;
}
/**
* Remove a favorite.
*
* @param int $user_id User ID.
* @param string $content_type Content type (track, album, playlist).
* @param int $content_id Content ID.
* @return bool
*/
public static function remove_favorite( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$result = $wpdb->delete(
$table,
array(
'user_id' => $user_id,
'content_type' => $content_type,
'content_id' => $content_id,
),
array( '%d', '%s', '%d' )
);
if ( $result ) {
do_action( 'fedistream_favorite_removed', $user_id, $content_type, $content_id );
}
return (bool) $result;
}
/**
* Check if content is favorited.
*
* @param int $user_id User ID.
* @param string $content_type Content type.
* @param int $content_id Content ID.
* @return bool
*/
public static function is_favorited( int $user_id, string $content_type, int $content_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$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;
}
/**
* Follow an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function follow_artist( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'artist_id' => $artist_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
if ( $result ) {
do_action( 'fedistream_artist_followed', $user_id, $artist_id );
}
return (bool) $result;
}
/**
* Unfollow an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function unfollow_artist( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$result = $wpdb->delete(
$table,
array(
'user_id' => $user_id,
'artist_id' => $artist_id,
),
array( '%d', '%d' )
);
if ( $result ) {
do_action( 'fedistream_artist_unfollowed', $user_id, $artist_id );
}
return (bool) $result;
}
/**
* Check if user is following an artist.
*
* @param int $user_id User ID.
* @param int $artist_id Artist post ID.
* @return bool
*/
public static function is_following( int $user_id, int $artist_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND artist_id = %d",
$user_id,
$artist_id
)
);
return (bool) $exists;
}
/**
* Record play history.
*
* @param int $track_id Track post ID.
* @param int $user_id User ID (0 for anonymous).
* @return void
*/
public function record_play_history( int $track_id, int $user_id ): void {
if ( ! $user_id ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
// Check if recently played (within last 30 seconds) to avoid duplicates.
$recent = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND track_id = %d AND played_at > DATE_SUB(NOW(), INTERVAL 30 SECOND)",
$user_id,
$track_id
)
);
if ( $recent ) {
return;
}
$wpdb->insert(
$table,
array(
'user_id' => $user_id,
'track_id' => $track_id,
'played_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
}
/**
* Get user library.
*
* @param int $user_id User ID.
* @param string $type Content type filter (all, tracks, albums, playlists).
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_user_library( int $user_id, string $type = 'all', int $page = 1, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
$offset = ( $page - 1 ) * $per_page;
$where = 'WHERE user_id = %d';
$params = array( $user_id );
if ( 'all' !== $type && in_array( $type, array( 'track', 'album', 'playlist' ), true ) ) {
$where .= ' AND content_type = %s';
$params[] = $type;
}
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} {$where}",
...$params
)
);
$params[] = $per_page;
$params[] = $offset;
$favorites = $wpdb->get_results(
$wpdb->prepare(
"SELECT content_type, content_id, created_at FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d",
...$params
)
);
$items = array();
foreach ( $favorites as $favorite ) {
$post = get_post( $favorite->content_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$item = array(
'id' => $post->ID,
'type' => $favorite->content_type,
'title' => $post->post_title,
'permalink' => get_permalink( $post ),
'added_at' => $favorite->created_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'medium' ),
);
if ( 'track' === $favorite->content_type ) {
$item['duration'] = get_post_meta( $post->ID, '_fedistream_duration', true );
$item['artist'] = self::get_track_artist_name( $post->ID );
} elseif ( 'album' === $favorite->content_type ) {
$item['artist'] = self::get_album_artist_name( $post->ID );
$item['track_count'] = get_post_meta( $post->ID, '_fedistream_total_tracks', true );
}
$items[] = $item;
}
return array(
'items' => $items,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Get followed artists.
*
* @param int $user_id User ID.
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_followed_artists( int $user_id, int $page = 1, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$offset = ( $page - 1 ) * $per_page;
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
$follows = $wpdb->get_results(
$wpdb->prepare(
"SELECT artist_id, created_at FROM {$table} WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
)
);
$artists = array();
foreach ( $follows as $follow ) {
$post = get_post( $follow->artist_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$artists[] = array(
'id' => $post->ID,
'name' => $post->post_title,
'permalink' => get_permalink( $post ),
'followed_at' => $follow->created_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ),
'type' => get_post_meta( $post->ID, '_fedistream_artist_type', true ) ?: 'solo',
);
}
return array(
'artists' => $artists,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Get listening history.
*
* @param int $user_id User ID.
* @param int $page Page number.
* @param int $per_page Items per page.
* @return array
*/
public static function get_listening_history( int $user_id, int $page = 1, int $per_page = 50 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
$offset = ( $page - 1 ) * $per_page;
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
$history = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id, played_at FROM {$table} WHERE user_id = %d ORDER BY played_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
)
);
$tracks = array();
foreach ( $history as $item ) {
$post = get_post( $item->track_id );
if ( ! $post || 'publish' !== $post->post_status ) {
continue;
}
$tracks[] = array(
'id' => $post->ID,
'title' => $post->post_title,
'permalink' => get_permalink( $post ),
'played_at' => $item->played_at,
'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ),
'duration' => get_post_meta( $post->ID, '_fedistream_duration', true ),
'artist' => self::get_track_artist_name( $post->ID ),
);
}
return array(
'tracks' => $tracks,
'total' => (int) $total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int) ceil( $total / $per_page ),
);
}
/**
* Clear listening history.
*
* @param int $user_id User ID.
* @return bool
*/
public static function clear_listening_history( int $user_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_listening_history';
$result = $wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) );
return false !== $result;
}
/**
* Get track artist name.
*
* @param int $track_id Track post ID.
* @return string
*/
private static function get_track_artist_name( int $track_id ): string {
$artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true );
if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) {
$names = array();
foreach ( $artist_ids as $artist_id ) {
$artist = get_post( $artist_id );
if ( $artist ) {
$names[] = $artist->post_title;
}
}
return implode( ', ', $names );
}
$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 '';
}
/**
* Get album artist name.
*
* @param int $album_id Album post ID.
* @return string
*/
private static function get_album_artist_name( int $album_id ): string {
$artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true );
if ( $artist_id ) {
$artist = get_post( $artist_id );
return $artist ? $artist->post_title : '';
}
return '';
}
/**
* Add favorite button to track/album actions.
*
* @param string $actions HTML actions.
* @param int $post_id Post ID.
* @return string
*/
public function add_favorite_button( string $actions, int $post_id ): string {
if ( ! is_user_logged_in() ) {
return $actions;
}
$post = get_post( $post_id );
if ( ! $post ) {
return $actions;
}
$content_type = str_replace( 'fedistream_', '', $post->post_type );
$user_id = get_current_user_id();
$is_favorited = self::is_favorited( $user_id, $content_type, $post_id );
$button = sprintf(
'<button class="fedistream-favorite-btn%s" data-content-type="%s" data-content-id="%d" title="%s">
<span class="dashicons dashicons-heart"></span>
</button>',
$is_favorited ? ' is-favorited' : '',
esc_attr( $content_type ),
$post_id,
$is_favorited ? esc_attr__( 'Remove from library', 'wp-fedistream' ) : esc_attr__( 'Add to library', 'wp-fedistream' )
);
return $actions . $button;
}
/**
* Add follow button to artist actions.
*
* @param string $actions HTML actions.
* @param int $artist_id Artist post ID.
* @return string
*/
public function add_follow_button( string $actions, int $artist_id ): string {
if ( ! is_user_logged_in() ) {
return $actions;
}
$user_id = get_current_user_id();
$is_following = self::is_following( $user_id, $artist_id );
$button = sprintf(
'<button class="fedistream-follow-btn%s" data-artist-id="%d">
<span class="dashicons dashicons-%s"></span>
<span class="button-text">%s</span>
</button>',
$is_following ? ' is-following' : '',
$artist_id,
$is_following ? 'yes' : 'plus',
$is_following ? esc_html__( 'Following', 'wp-fedistream' ) : esc_html__( 'Follow', 'wp-fedistream' )
);
return $actions . $button;
}
/**
* Get user's favorite count.
*
* @param int $user_id User ID.
* @param string $content_type Optional content type filter.
* @return int
*/
public static function get_favorite_count( int $user_id, string $content_type = '' ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_favorites';
if ( $content_type ) {
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND content_type = %s",
$user_id,
$content_type
)
);
} else {
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
}
return (int) $count;
}
/**
* Get user's followed artist count.
*
* @param int $user_id User ID.
* @return int
*/
public static function get_following_count( int $user_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
)
);
return (int) $count;
}
}

View File

@@ -0,0 +1,324 @@
<?php
/**
* User Library Page class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles the My Library page display.
*/
class LibraryPage {
/**
* Constructor.
*/
public function __construct() {
add_shortcode( 'fedistream_library', array( $this, 'render_library_shortcode' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
}
/**
* Enqueue library scripts and styles.
*
* @return void
*/
public function enqueue_scripts(): void {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'fedistream-library',
WP_FEDISTREAM_URL . 'assets/js/library.js',
array( 'jquery' ),
WP_FEDISTREAM_VERSION,
true
);
wp_localize_script(
'fedistream-library',
'fedistreamLibrary',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'fedistream_library' ),
'i18n' => array(
'loading' => __( 'Loading...', 'wp-fedistream' ),
'noFavorites' => __( 'No favorites yet.', 'wp-fedistream' ),
'noArtists' => __( 'Not following any artists yet.', 'wp-fedistream' ),
'noHistory' => __( 'No listening history.', 'wp-fedistream' ),
'confirmClear' => __( 'Are you sure you want to clear your listening history?', 'wp-fedistream' ),
'historyCleared' => __( 'History cleared.', 'wp-fedistream' ),
'error' => __( 'An error occurred. Please try again.', 'wp-fedistream' ),
),
)
);
}
/**
* Render the library shortcode.
*
* @param array $atts Shortcode attributes.
* @return string
*/
public function render_library_shortcode( array $atts = array() ): string {
$atts = shortcode_atts(
array(
'tab' => 'favorites',
),
$atts,
'fedistream_library'
);
if ( ! is_user_logged_in() ) {
return $this->render_login_required();
}
$user_id = get_current_user_id();
// Get counts for tabs.
$favorite_count = Library::get_favorite_count( $user_id );
$following_count = Library::get_following_count( $user_id );
ob_start();
?>
<div class="fedistream-library" data-initial-tab="<?php echo esc_attr( $atts['tab'] ); ?>">
<nav class="fedistream-library-nav">
<button class="tab-btn active" data-tab="favorites">
<?php esc_html_e( 'Favorites', 'wp-fedistream' ); ?>
<span class="count"><?php echo esc_html( $favorite_count ); ?></span>
</button>
<button class="tab-btn" data-tab="artists">
<?php esc_html_e( 'Artists', 'wp-fedistream' ); ?>
<span class="count"><?php echo esc_html( $following_count ); ?></span>
</button>
<button class="tab-btn" data-tab="history">
<?php esc_html_e( 'History', 'wp-fedistream' ); ?>
</button>
</nav>
<div class="fedistream-library-filters" data-tab="favorites">
<select class="filter-type">
<option value="all"><?php esc_html_e( 'All', 'wp-fedistream' ); ?></option>
<option value="track"><?php esc_html_e( 'Tracks', 'wp-fedistream' ); ?></option>
<option value="album"><?php esc_html_e( 'Albums', 'wp-fedistream' ); ?></option>
<option value="playlist"><?php esc_html_e( 'Playlists', 'wp-fedistream' ); ?></option>
</select>
</div>
<div class="fedistream-library-content">
<div class="tab-content active" id="tab-favorites">
<div class="library-grid favorites-grid">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="favorites"></div>
</div>
<div class="tab-content" id="tab-artists">
<div class="library-grid artists-grid">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="artists"></div>
</div>
<div class="tab-content" id="tab-history">
<div class="history-actions">
<button class="btn-clear-history">
<?php esc_html_e( 'Clear History', 'wp-fedistream' ); ?>
</button>
</div>
<div class="library-list history-list">
<!-- Loaded via AJAX -->
</div>
<div class="library-pagination" data-tab="history"></div>
</div>
</div>
<div class="fedistream-library-loading">
<span class="spinner"></span>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render login required message.
*
* @return string
*/
private function render_login_required(): string {
ob_start();
?>
<div class="fedistream-login-required">
<p><?php esc_html_e( 'Please log in to view your library.', 'wp-fedistream' ); ?></p>
<a href="<?php echo esc_url( wp_login_url( get_permalink() ) ); ?>" class="btn btn-primary">
<?php esc_html_e( 'Log In', 'wp-fedistream' ); ?>
</a>
</div>
<?php
return ob_get_clean();
}
/**
* Render a favorite item.
*
* @param array $item Item data.
* @return string
*/
public static function render_favorite_item( array $item ): string {
ob_start();
?>
<div class="library-item favorite-item" data-type="<?php echo esc_attr( $item['type'] ); ?>" data-id="<?php echo esc_attr( $item['id'] ); ?>">
<div class="item-thumbnail">
<?php if ( ! empty( $item['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $item['thumbnail'] ); ?>" alt="<?php echo esc_attr( $item['title'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-<?php echo 'track' === $item['type'] ? 'format-audio' : ( 'album' === $item['type'] ? 'album' : 'playlist-audio' ); ?>"></span>
</div>
<?php endif; ?>
<?php if ( 'track' === $item['type'] ) : ?>
<button class="play-btn" data-track-id="<?php echo esc_attr( $item['id'] ); ?>">
<span class="dashicons dashicons-controls-play"></span>
</button>
<?php endif; ?>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $item['permalink'] ); ?>"><?php echo esc_html( $item['title'] ); ?></a>
</h4>
<?php if ( ! empty( $item['artist'] ) ) : ?>
<p class="item-artist"><?php echo esc_html( $item['artist'] ); ?></p>
<?php endif; ?>
<?php if ( 'track' === $item['type'] && ! empty( $item['duration'] ) ) : ?>
<p class="item-duration"><?php echo esc_html( self::format_duration( $item['duration'] ) ); ?></p>
<?php elseif ( 'album' === $item['type'] && ! empty( $item['track_count'] ) ) : ?>
<p class="item-tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', (int) $item['track_count'], 'wp-fedistream' ) ),
(int) $item['track_count']
);
?>
</p>
<?php endif; ?>
</div>
<div class="item-actions">
<button class="unfavorite-btn" data-content-type="<?php echo esc_attr( $item['type'] ); ?>" data-content-id="<?php echo esc_attr( $item['id'] ); ?>" title="<?php esc_attr_e( 'Remove from library', 'wp-fedistream' ); ?>">
<span class="dashicons dashicons-heart"></span>
</button>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render an artist item.
*
* @param array $artist Artist data.
* @return string
*/
public static function render_artist_item( array $artist ): string {
ob_start();
?>
<div class="library-item artist-item" data-id="<?php echo esc_attr( $artist['id'] ); ?>">
<div class="item-thumbnail artist-avatar">
<?php if ( ! empty( $artist['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $artist['thumbnail'] ); ?>" alt="<?php echo esc_attr( $artist['name'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-<?php echo 'band' === $artist['type'] ? 'groups' : 'admin-users'; ?>"></span>
</div>
<?php endif; ?>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $artist['permalink'] ); ?>"><?php echo esc_html( $artist['name'] ); ?></a>
</h4>
<p class="item-type">
<?php echo 'band' === $artist['type'] ? esc_html__( 'Band', 'wp-fedistream' ) : esc_html__( 'Artist', 'wp-fedistream' ); ?>
</p>
</div>
<div class="item-actions">
<button class="unfollow-btn" data-artist-id="<?php echo esc_attr( $artist['id'] ); ?>" title="<?php esc_attr_e( 'Unfollow', 'wp-fedistream' ); ?>">
<span class="dashicons dashicons-minus"></span>
<span class="button-text"><?php esc_html_e( 'Unfollow', 'wp-fedistream' ); ?></span>
</button>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Render a history item.
*
* @param array $track Track data.
* @return string
*/
public static function render_history_item( array $track ): string {
ob_start();
?>
<div class="library-item history-item" data-id="<?php echo esc_attr( $track['id'] ); ?>">
<div class="item-thumbnail">
<?php if ( ! empty( $track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $track['thumbnail'] ); ?>" alt="<?php echo esc_attr( $track['title'] ); ?>">
<?php else : ?>
<div class="placeholder-thumbnail">
<span class="dashicons dashicons-format-audio"></span>
</div>
<?php endif; ?>
<button class="play-btn" data-track-id="<?php echo esc_attr( $track['id'] ); ?>">
<span class="dashicons dashicons-controls-play"></span>
</button>
</div>
<div class="item-info">
<h4 class="item-title">
<a href="<?php echo esc_url( $track['permalink'] ); ?>"><?php echo esc_html( $track['title'] ); ?></a>
</h4>
<?php if ( ! empty( $track['artist'] ) ) : ?>
<p class="item-artist"><?php echo esc_html( $track['artist'] ); ?></p>
<?php endif; ?>
<p class="item-played">
<?php
printf(
/* translators: %s: relative time */
esc_html__( 'Played %s', 'wp-fedistream' ),
esc_html( human_time_diff( strtotime( $track['played_at'] ), current_time( 'timestamp' ) ) . ' ' . __( 'ago', 'wp-fedistream' ) )
);
?>
</p>
</div>
<div class="item-meta">
<?php if ( ! empty( $track['duration'] ) ) : ?>
<span class="item-duration"><?php echo esc_html( self::format_duration( $track['duration'] ) ); ?></span>
<?php endif; ?>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Format duration in seconds to MM:SS.
*
* @param int $seconds Duration in seconds.
* @return string
*/
private static function format_duration( int $seconds ): string {
$minutes = floor( $seconds / 60 );
$secs = $seconds % 60;
return sprintf( '%d:%02d', $minutes, $secs );
}
}

View File

@@ -0,0 +1,828 @@
<?php
/**
* User Notifications class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\User;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles user notifications (in-app and email).
*/
class Notifications {
/**
* Notification types.
*/
const TYPE_NEW_RELEASE = 'new_release';
const TYPE_NEW_FOLLOWER = 'new_follower';
const TYPE_FEDIVERSE_LIKE = 'fediverse_like';
const TYPE_FEDIVERSE_BOOST = 'fediverse_boost';
const TYPE_PLAYLIST_ADDED = 'playlist_added';
const TYPE_PURCHASE = 'purchase';
const TYPE_SYSTEM = 'system';
/**
* Constructor.
*/
public function __construct() {
// AJAX handlers.
add_action( 'wp_ajax_fedistream_get_notifications', array( $this, 'ajax_get_notifications' ) );
add_action( 'wp_ajax_fedistream_mark_notification_read', array( $this, 'ajax_mark_read' ) );
add_action( 'wp_ajax_fedistream_mark_all_notifications_read', array( $this, 'ajax_mark_all_read' ) );
add_action( 'wp_ajax_fedistream_delete_notification', array( $this, 'ajax_delete' ) );
// Notification triggers.
add_action( 'fedistream_album_published', array( $this, 'notify_new_release' ), 10, 1 );
add_action( 'fedistream_track_published', array( $this, 'notify_new_track' ), 10, 1 );
add_action( 'fedistream_artist_followed', array( $this, 'notify_artist_followed' ), 10, 2 );
add_action( 'fedistream_activitypub_like_received', array( $this, 'notify_fediverse_like' ), 10, 2 );
add_action( 'fedistream_activitypub_announce_received', array( $this, 'notify_fediverse_boost' ), 10, 2 );
// Email notifications.
add_action( 'fedistream_notification_created', array( $this, 'maybe_send_email' ), 10, 2 );
// Admin bar notification count.
add_action( 'admin_bar_menu', array( $this, 'add_notification_indicator' ), 100 );
// Enqueue scripts.
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
}
/**
* Enqueue notification scripts.
*
* @return void
*/
public function enqueue_scripts(): void {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'fedistream-notifications',
WP_FEDISTREAM_URL . 'assets/js/notifications.js',
array( 'jquery' ),
WP_FEDISTREAM_VERSION,
true
);
wp_localize_script(
'fedistream-notifications',
'fedistreamNotifications',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'fedistream_notifications' ),
'pollInterval' => 60000, // 1 minute.
'i18n' => array(
'noNotifications' => __( 'No notifications', 'wp-fedistream' ),
'markAllRead' => __( 'Mark all as read', 'wp-fedistream' ),
'viewAll' => __( 'View all notifications', 'wp-fedistream' ),
'justNow' => __( 'Just now', 'wp-fedistream' ),
'error' => __( 'An error occurred.', 'wp-fedistream' ),
),
)
);
}
/**
* Create a notification.
*
* @param int $user_id User ID.
* @param string $type Notification type.
* @param string $title Notification title.
* @param string $message Notification message.
* @param array $data Additional data.
* @return int|false Notification ID or false on failure.
*/
public static function create( int $user_id, string $type, string $title, string $message, array $data = array() ) {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'type' => $type,
'title' => $title,
'message' => $message,
'data' => wp_json_encode( $data ),
'is_read' => 0,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s', '%s', '%d', '%s' )
);
if ( $result ) {
$notification_id = $wpdb->insert_id;
/**
* Fires when a notification is created.
*
* @param int $notification_id Notification ID.
* @param array $notification Notification data.
*/
do_action(
'fedistream_notification_created',
$notification_id,
array(
'user_id' => $user_id,
'type' => $type,
'title' => $title,
'message' => $message,
'data' => $data,
)
);
return $notification_id;
}
return false;
}
/**
* Get notifications for a user.
*
* @param int $user_id User ID.
* @param bool $unread_only Only get unread notifications.
* @param int $limit Number of notifications to retrieve.
* @param int $offset Offset for pagination.
* @return array
*/
public static function get_for_user( int $user_id, bool $unread_only = false, int $limit = 20, int $offset = 0 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$where = 'WHERE user_id = %d';
$params = array( $user_id );
if ( $unread_only ) {
$where .= ' AND is_read = 0';
}
$params[] = $limit;
$params[] = $offset;
$notifications = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d",
...$params
)
);
$result = array();
foreach ( $notifications as $notification ) {
$result[] = array(
'id' => (int) $notification->id,
'type' => $notification->type,
'title' => $notification->title,
'message' => $notification->message,
'data' => json_decode( $notification->data, true ) ?: array(),
'is_read' => (bool) $notification->is_read,
'created_at' => $notification->created_at,
'read_at' => $notification->read_at,
);
}
return $result;
}
/**
* Get unread count for a user.
*
* @param int $user_id User ID.
* @return int
*/
public static function get_unread_count( int $user_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND is_read = 0",
$user_id
)
);
return (int) $count;
}
/**
* Mark a notification as read.
*
* @param int $notification_id Notification ID.
* @param int $user_id User ID (for verification).
* @return bool
*/
public static function mark_read( int $notification_id, int $user_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$result = $wpdb->update(
$table,
array(
'is_read' => 1,
'read_at' => current_time( 'mysql' ),
),
array(
'id' => $notification_id,
'user_id' => $user_id,
),
array( '%d', '%s' ),
array( '%d', '%d' )
);
return false !== $result;
}
/**
* Mark all notifications as read for a user.
*
* @param int $user_id User ID.
* @return bool
*/
public static function mark_all_read( int $user_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$result = $wpdb->update(
$table,
array(
'is_read' => 1,
'read_at' => current_time( 'mysql' ),
),
array(
'user_id' => $user_id,
'is_read' => 0,
),
array( '%d', '%s' ),
array( '%d', '%d' )
);
return false !== $result;
}
/**
* Delete a notification.
*
* @param int $notification_id Notification ID.
* @param int $user_id User ID (for verification).
* @return bool
*/
public static function delete( int $notification_id, int $user_id ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_notifications';
$result = $wpdb->delete(
$table,
array(
'id' => $notification_id,
'user_id' => $user_id,
),
array( '%d', '%d' )
);
return (bool) $result;
}
/**
* AJAX: Get notifications.
*
* @return void
*/
public function ajax_get_notifications(): void {
check_ajax_referer( 'fedistream_notifications', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$unread_only = isset( $_POST['unread_only'] ) && $_POST['unread_only'];
$limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 20;
$offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0;
$notifications = self::get_for_user( $user_id, $unread_only, $limit, $offset );
$unread_count = self::get_unread_count( $user_id );
wp_send_json_success(
array(
'notifications' => $notifications,
'unread_count' => $unread_count,
)
);
}
/**
* AJAX: Mark notification as read.
*
* @return void
*/
public function ajax_mark_read(): void {
check_ajax_referer( 'fedistream_notifications', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) );
}
$notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0;
if ( ! $notification_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$result = self::mark_read( $notification_id, $user_id );
if ( $result ) {
wp_send_json_success(
array(
'unread_count' => self::get_unread_count( $user_id ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update notification.', 'wp-fedistream' ) ) );
}
}
/**
* AJAX: Mark all notifications as read.
*
* @return void
*/
public function ajax_mark_all_read(): void {
check_ajax_referer( 'fedistream_notifications', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$result = self::mark_all_read( $user_id );
if ( $result ) {
wp_send_json_success( array( 'unread_count' => 0 ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to update notifications.', 'wp-fedistream' ) ) );
}
}
/**
* AJAX: Delete notification.
*
* @return void
*/
public function ajax_delete(): void {
check_ajax_referer( 'fedistream_notifications', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) );
}
$notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0;
if ( ! $notification_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) );
}
$user_id = get_current_user_id();
$result = self::delete( $notification_id, $user_id );
if ( $result ) {
wp_send_json_success(
array(
'unread_count' => self::get_unread_count( $user_id ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Failed to delete notification.', 'wp-fedistream' ) ) );
}
}
/**
* Notify followers of a new album release.
*
* @param int $album_id Album post ID.
* @return void
*/
public function notify_new_release( int $album_id ): void {
$album = get_post( $album_id );
if ( ! $album ) {
return;
}
$artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true );
if ( ! $artist_id ) {
return;
}
$artist = get_post( $artist_id );
if ( ! $artist ) {
return;
}
// Get all local followers of this artist.
$followers = $this->get_artist_local_followers( $artist_id );
foreach ( $followers as $user_id ) {
self::create(
$user_id,
self::TYPE_NEW_RELEASE,
sprintf(
/* translators: %s: artist name */
__( 'New release from %s', 'wp-fedistream' ),
$artist->post_title
),
sprintf(
/* translators: 1: artist name, 2: album title */
__( '%1$s released a new album: %2$s', 'wp-fedistream' ),
$artist->post_title,
$album->post_title
),
array(
'album_id' => $album_id,
'artist_id' => $artist_id,
'album_url' => get_permalink( $album ),
'artist_url' => get_permalink( $artist ),
'thumbnail' => get_the_post_thumbnail_url( $album, 'thumbnail' ),
)
);
}
}
/**
* Notify followers of a new track.
*
* @param int $track_id Track post ID.
* @return void
*/
public function notify_new_track( int $track_id ): void {
$track = get_post( $track_id );
if ( ! $track ) {
return;
}
// Get artist from track or album.
$artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true );
if ( empty( $artist_ids ) ) {
$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;
$artist_ids = $artist_id ? array( $artist_id ) : array();
}
if ( empty( $artist_ids ) ) {
return;
}
$artist_id = $artist_ids[0];
$artist = get_post( $artist_id );
if ( ! $artist ) {
return;
}
// Only notify for single releases (tracks without album).
$album_id = get_post_meta( $track_id, '_fedistream_album_id', true );
if ( $album_id ) {
return; // Album release handles notification.
}
$followers = $this->get_artist_local_followers( $artist_id );
foreach ( $followers as $user_id ) {
self::create(
$user_id,
self::TYPE_NEW_RELEASE,
sprintf(
/* translators: %s: artist name */
__( 'New track from %s', 'wp-fedistream' ),
$artist->post_title
),
sprintf(
/* translators: 1: artist name, 2: track title */
__( '%1$s released a new track: %2$s', 'wp-fedistream' ),
$artist->post_title,
$track->post_title
),
array(
'track_id' => $track_id,
'artist_id' => $artist_id,
'track_url' => get_permalink( $track ),
'artist_url' => get_permalink( $artist ),
)
);
}
}
/**
* Notify artist when they get a new local follower.
*
* @param int $user_id User ID who followed.
* @param int $artist_id Artist post ID.
* @return void
*/
public function notify_artist_followed( int $user_id, int $artist_id ): void {
$artist = get_post( $artist_id );
if ( ! $artist ) {
return;
}
// Get the artist's WordPress user ID.
$artist_user_id = get_post_meta( $artist_id, '_fedistream_user_id', true );
if ( ! $artist_user_id ) {
return;
}
$follower = get_user_by( 'id', $user_id );
if ( ! $follower ) {
return;
}
self::create(
$artist_user_id,
self::TYPE_NEW_FOLLOWER,
__( 'New follower', 'wp-fedistream' ),
sprintf(
/* translators: %s: follower display name */
__( '%s started following you', 'wp-fedistream' ),
$follower->display_name
),
array(
'follower_id' => $user_id,
'follower_name' => $follower->display_name,
)
);
}
/**
* Notify of a Fediverse like.
*
* @param int $content_id Content post ID.
* @param array $actor Actor data.
* @return void
*/
public function notify_fediverse_like( int $content_id, array $actor ): void {
$post = get_post( $content_id );
if ( ! $post ) {
return;
}
$artist_user_id = $this->get_content_owner_user_id( $content_id );
if ( ! $artist_user_id ) {
return;
}
$actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' );
self::create(
$artist_user_id,
self::TYPE_FEDIVERSE_LIKE,
__( 'New like from Fediverse', 'wp-fedistream' ),
sprintf(
/* translators: 1: actor name, 2: content title */
__( '%1$s liked your %2$s', 'wp-fedistream' ),
$actor_name,
$post->post_title
),
array(
'content_id' => $content_id,
'content_type' => $post->post_type,
'actor_uri' => $actor['id'] ?? '',
'actor_name' => $actor_name,
'actor_icon' => $actor['icon']['url'] ?? '',
)
);
}
/**
* Notify of a Fediverse boost/announce.
*
* @param int $content_id Content post ID.
* @param array $actor Actor data.
* @return void
*/
public function notify_fediverse_boost( int $content_id, array $actor ): void {
$post = get_post( $content_id );
if ( ! $post ) {
return;
}
$artist_user_id = $this->get_content_owner_user_id( $content_id );
if ( ! $artist_user_id ) {
return;
}
$actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' );
self::create(
$artist_user_id,
self::TYPE_FEDIVERSE_BOOST,
__( 'New boost from Fediverse', 'wp-fedistream' ),
sprintf(
/* translators: 1: actor name, 2: content title */
__( '%1$s boosted your %2$s', 'wp-fedistream' ),
$actor_name,
$post->post_title
),
array(
'content_id' => $content_id,
'content_type' => $post->post_type,
'actor_uri' => $actor['id'] ?? '',
'actor_name' => $actor_name,
'actor_icon' => $actor['icon']['url'] ?? '',
)
);
}
/**
* Maybe send email notification.
*
* @param int $notification_id Notification ID.
* @param array $notification Notification data.
* @return void
*/
public function maybe_send_email( int $notification_id, array $notification ): void {
$user_id = $notification['user_id'];
$type = $notification['type'];
// Check user preference for email notifications.
$email_enabled = get_user_meta( $user_id, 'fedistream_email_notifications', true );
if ( '0' === $email_enabled ) {
return;
}
// Check specific notification type preference.
$type_enabled = get_user_meta( $user_id, 'fedistream_email_' . $type, true );
if ( '0' === $type_enabled ) {
return;
}
$user = get_user_by( 'id', $user_id );
if ( ! $user || ! $user->user_email ) {
return;
}
$subject = sprintf(
/* translators: 1: site name, 2: notification title */
__( '[%1$s] %2$s', 'wp-fedistream' ),
get_bloginfo( 'name' ),
$notification['title']
);
$message = $this->build_email_message( $notification );
$headers = array(
'Content-Type: text/html; charset=UTF-8',
);
wp_mail( $user->user_email, $subject, $message, $headers );
}
/**
* Build email message HTML.
*
* @param array $notification Notification data.
* @return string
*/
private function build_email_message( array $notification ): string {
$site_name = get_bloginfo( 'name' );
$site_url = home_url();
$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>';
$html .= '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
$html .= '<h2 style="color: #333;">' . esc_html( $notification['title'] ) . '</h2>';
$html .= '<p style="color: #666; font-size: 16px;">' . esc_html( $notification['message'] ) . '</p>';
// Add action link if available.
$data = $notification['data'];
$link = '';
if ( ! empty( $data['album_url'] ) ) {
$link = $data['album_url'];
} elseif ( ! empty( $data['track_url'] ) ) {
$link = $data['track_url'];
} elseif ( ! empty( $data['artist_url'] ) ) {
$link = $data['artist_url'];
}
if ( $link ) {
$html .= '<p><a href="' . esc_url( $link ) . '" style="display: inline-block; padding: 10px 20px; background: #0073aa; color: #fff; text-decoration: none; border-radius: 4px;">' . esc_html__( 'View Details', 'wp-fedistream' ) . '</a></p>';
}
$html .= '<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">';
$html .= '<p style="color: #999; font-size: 12px;">' . sprintf(
/* translators: %s: site name */
esc_html__( 'This email was sent by %s.', 'wp-fedistream' ),
'<a href="' . esc_url( $site_url ) . '">' . esc_html( $site_name ) . '</a>'
) . '</p>';
$html .= '</div></body></html>';
return $html;
}
/**
* Add notification indicator to admin bar.
*
* @param \WP_Admin_Bar $admin_bar Admin bar instance.
* @return void
*/
public function add_notification_indicator( \WP_Admin_Bar $admin_bar ): void {
if ( ! is_user_logged_in() ) {
return;
}
$user_id = get_current_user_id();
$unread_count = self::get_unread_count( $user_id );
$title = '<span class="ab-icon dashicons dashicons-bell"></span>';
if ( $unread_count > 0 ) {
$title .= '<span class="fedistream-notification-count">' . esc_html( $unread_count ) . '</span>';
}
$admin_bar->add_node(
array(
'id' => 'fedistream-notifications',
'title' => $title,
'href' => '#',
'meta' => array(
'class' => 'fedistream-notifications-menu',
),
)
);
}
/**
* Get local followers for an artist.
*
* @param int $artist_id Artist post ID.
* @return array User IDs.
*/
private function get_artist_local_followers( int $artist_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_user_follows';
$user_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT user_id FROM {$table} WHERE artist_id = %d",
$artist_id
)
);
return array_map( 'intval', $user_ids );
}
/**
* Get the WordPress user ID who owns a piece of content.
*
* @param int $content_id Content post ID.
* @return int|null User ID or null.
*/
private function get_content_owner_user_id( int $content_id ): ?int {
$post = get_post( $content_id );
if ( ! $post ) {
return null;
}
// For tracks, get artist.
if ( 'fedistream_track' === $post->post_type ) {
$artist_ids = get_post_meta( $content_id, '_fedistream_artist_ids', true );
if ( ! empty( $artist_ids ) ) {
$artist_id = $artist_ids[0];
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
$album_id = get_post_meta( $content_id, '_fedistream_album_id', true );
$artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0;
if ( $artist_id ) {
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
}
// For albums, get artist.
if ( 'fedistream_album' === $post->post_type ) {
$artist_id = get_post_meta( $content_id, '_fedistream_album_artist', true );
if ( $artist_id ) {
return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null;
}
}
// For artists.
if ( 'fedistream_artist' === $post->post_type ) {
return (int) get_post_meta( $content_id, '_fedistream_user_id', true ) ?: null;
}
return null;
}
}

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

1
includes/index.php Normal file
View File

@@ -0,0 +1 @@
<?php // Silence is golden.