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( '%s', 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( '%s', 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; } }