feat: Initial release v0.1.0

WP FediStream - Stream music over ActivityPub

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

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

View File

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