feat: Initial release v0.1.0

WP FediStream - Stream music over ActivityPub

Features:
- Custom post types: Artist, Album, Track, Playlist
- Custom taxonomies: Genre, Mood, License
- User roles: Artist, Label
- Admin dashboard with statistics
- Frontend templates and shortcodes
- Audio player with queue management
- ActivityPub integration with actor support
- WooCommerce product types for albums/tracks
- User library with favorites and history
- Notification system (in-app and email)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 23:23:05 +01:00
commit 4a5d7b9f4d
91 changed files with 22750 additions and 0 deletions

View File

@@ -0,0 +1,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 );
}
}