You've already forked wp-fedistream
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>
615 lines
15 KiB
PHP
615 lines
15 KiB
PHP
<?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;
|
|
}
|
|
}
|