You've already forked wp-fedistream
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:
433
includes/ActivityPub/AlbumTransformer.php
Normal file
433
includes/ActivityPub/AlbumTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
614
includes/ActivityPub/ArtistActor.php
Normal file
614
includes/ActivityPub/ArtistActor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
477
includes/ActivityPub/FollowerHandler.php
Normal file
477
includes/ActivityPub/FollowerHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
480
includes/ActivityPub/Integration.php
Normal file
480
includes/ActivityPub/Integration.php
Normal 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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
415
includes/ActivityPub/Outbox.php
Normal file
415
includes/ActivityPub/Outbox.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
433
includes/ActivityPub/PlaylistTransformer.php
Normal file
433
includes/ActivityPub/PlaylistTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
476
includes/ActivityPub/RestApi.php
Normal file
476
includes/ActivityPub/RestApi.php
Normal 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;
|
||||
}
|
||||
}
|
||||
412
includes/ActivityPub/TrackTransformer.php
Normal file
412
includes/ActivityPub/TrackTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user