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,615 @@
<?php
/**
* Track custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Track post type class.
*
* Handles registration and management of individual tracks.
*/
class Track extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_track';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_track_';
/**
* Constructor.
*/
public function __construct() {
parent::__construct();
add_action( 'save_post_fedistream_track', array( $this, 'update_album_on_save' ), 20, 2 );
}
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Tracks', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Track', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Tracks', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Track', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Track', 'wp-fedistream' ),
'new_item' => __( 'New Track', 'wp-fedistream' ),
'edit_item' => __( 'Edit Track', 'wp-fedistream' ),
'view_item' => __( 'View Track', 'wp-fedistream' ),
'all_items' => __( 'All Tracks', 'wp-fedistream' ),
'search_items' => __( 'Search Tracks', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Tracks:', 'wp-fedistream' ),
'not_found' => __( 'No tracks found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No tracks found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Track Artwork', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set track artwork', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove track artwork', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as track artwork', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Track archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into track', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this track', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter tracks list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Tracks list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Tracks list', 'Screen reader text', 'wp-fedistream' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => false, // Will be added to custom menu.
'query_var' => true,
'rewrite' => array( 'slug' => 'tracks' ),
'capability_type' => array( 'fedistream_track', 'fedistream_tracks' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-format-audio',
'supports' => array( 'title', 'editor', 'thumbnail', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'tracks',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_track_audio',
__( 'Audio File', 'wp-fedistream' ),
array( $this, 'render_audio_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_track_info',
__( 'Track Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_track_album',
__( 'Album', 'wp-fedistream' ),
array( $this, 'render_album_meta_box' ),
$this->post_type,
'side',
'high'
);
add_meta_box(
'fedistream_track_artists',
__( 'Artists', 'wp-fedistream' ),
array( $this, 'render_artists_meta_box' ),
$this->post_type,
'side',
'default'
);
add_meta_box(
'fedistream_track_codes',
__( 'Track Codes', 'wp-fedistream' ),
array( $this, 'render_codes_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render audio file meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_audio_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_track_save', 'fedistream_track_nonce' );
$audio_file = get_post_meta( $post->ID, self::META_PREFIX . 'audio_file', true );
$audio_format = get_post_meta( $post->ID, self::META_PREFIX . 'audio_format', true );
$duration = get_post_meta( $post->ID, self::META_PREFIX . 'duration', true );
wp_enqueue_media();
?>
<div class="fedistream-audio-upload">
<p>
<label for="fedistream_track_audio_file"><?php esc_html_e( 'Audio File', 'wp-fedistream' ); ?></label>
</p>
<input type="hidden" name="fedistream_track_audio_file" id="fedistream_track_audio_file" value="<?php echo esc_attr( $audio_file ); ?>">
<div id="fedistream-audio-preview">
<?php if ( $audio_file ) : ?>
<?php
$audio_url = wp_get_attachment_url( $audio_file );
if ( $audio_url ) :
?>
<audio controls style="width: 100%;">
<source src="<?php echo esc_url( $audio_url ); ?>" type="audio/<?php echo esc_attr( $audio_format ?: 'mpeg' ); ?>">
</audio>
<p><strong><?php echo esc_html( basename( get_attached_file( $audio_file ) ) ); ?></strong></p>
<?php endif; ?>
<?php endif; ?>
</div>
<p>
<button type="button" class="button" id="fedistream-upload-audio"><?php esc_html_e( 'Select Audio File', 'wp-fedistream' ); ?></button>
<button type="button" class="button" id="fedistream-remove-audio" style="<?php echo $audio_file ? '' : 'display:none;'; ?>"><?php esc_html_e( 'Remove', 'wp-fedistream' ); ?></button>
</p>
<p class="description"><?php esc_html_e( 'Supported formats: MP3, WAV, FLAC, OGG', 'wp-fedistream' ); ?></p>
</div>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_track_audio_format"><?php esc_html_e( 'Audio Format', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_track_audio_format" id="fedistream_track_audio_format">
<option value=""><?php esc_html_e( '— Auto-detect —', 'wp-fedistream' ); ?></option>
<option value="mp3" <?php selected( $audio_format, 'mp3' ); ?>>MP3</option>
<option value="wav" <?php selected( $audio_format, 'wav' ); ?>>WAV</option>
<option value="flac" <?php selected( $audio_format, 'flac' ); ?>>FLAC</option>
<option value="ogg" <?php selected( $audio_format, 'ogg' ); ?>>OGG</option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_duration"><?php esc_html_e( 'Duration (seconds)', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_duration" id="fedistream_track_duration" value="<?php echo esc_attr( $duration ); ?>" min="0" class="small-text">
<span id="fedistream-duration-display">
<?php
if ( $duration ) {
printf(
/* translators: %s: formatted duration */
esc_html__( '(%s)', 'wp-fedistream' ),
esc_html( gmdate( 'i:s', (int) $duration ) )
);
}
?>
</span>
<p class="description"><?php esc_html_e( 'Auto-detected from audio file if available.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<script>
jQuery(document).ready(function($) {
var mediaUploader;
$('#fedistream-upload-audio').on('click', function(e) {
e.preventDefault();
if (mediaUploader) {
mediaUploader.open();
return;
}
mediaUploader = wp.media({
title: '<?php echo esc_js( __( 'Select Audio File', 'wp-fedistream' ) ); ?>',
button: { text: '<?php echo esc_js( __( 'Use this file', 'wp-fedistream' ) ); ?>' },
library: { type: 'audio' },
multiple: false
});
mediaUploader.on('select', function() {
var attachment = mediaUploader.state().get('selection').first().toJSON();
$('#fedistream_track_audio_file').val(attachment.id);
$('#fedistream-audio-preview').html(
'<audio controls style="width: 100%;"><source src="' + attachment.url + '" type="' + attachment.mime + '"></audio>' +
'<p><strong>' + attachment.filename + '</strong></p>'
);
$('#fedistream-remove-audio').show();
// Auto-detect format.
var ext = attachment.filename.split('.').pop().toLowerCase();
if (['mp3', 'wav', 'flac', 'ogg'].indexOf(ext) !== -1) {
$('#fedistream_track_audio_format').val(ext);
}
});
mediaUploader.open();
});
$('#fedistream-remove-audio').on('click', function(e) {
e.preventDefault();
$('#fedistream_track_audio_file').val('');
$('#fedistream-audio-preview').html('');
$(this).hide();
});
// Duration display.
$('#fedistream_track_duration').on('change', function() {
var seconds = parseInt($(this).val(), 10);
if (seconds > 0) {
var mins = Math.floor(seconds / 60);
var secs = seconds % 60;
$('#fedistream-duration-display').text('(' + mins + ':' + (secs < 10 ? '0' : '') + secs + ')');
} else {
$('#fedistream-duration-display').text('');
}
});
});
</script>
<?php
}
/**
* Render track info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
$track_number = get_post_meta( $post->ID, self::META_PREFIX . 'number', true );
$disc_number = get_post_meta( $post->ID, self::META_PREFIX . 'disc_number', true );
$bpm = get_post_meta( $post->ID, self::META_PREFIX . 'bpm', true );
$key = get_post_meta( $post->ID, self::META_PREFIX . 'key', true );
$explicit = get_post_meta( $post->ID, self::META_PREFIX . 'explicit', true );
$preview_start = get_post_meta( $post->ID, self::META_PREFIX . 'preview_start', true );
$preview_duration = get_post_meta( $post->ID, self::META_PREFIX . 'preview_duration', true );
$musical_keys = array(
'' => __( '— Select Key —', 'wp-fedistream' ),
'C' => 'C Major',
'Cm' => 'C Minor',
'C#' => 'C# Major',
'C#m' => 'C# Minor',
'D' => 'D Major',
'Dm' => 'D Minor',
'D#' => 'D# Major',
'D#m' => 'D# Minor',
'E' => 'E Major',
'Em' => 'E Minor',
'F' => 'F Major',
'Fm' => 'F Minor',
'F#' => 'F# Major',
'F#m' => 'F# Minor',
'G' => 'G Major',
'Gm' => 'G Minor',
'G#' => 'G# Major',
'G#m' => 'G# Minor',
'A' => 'A Major',
'Am' => 'A Minor',
'A#' => 'A# Major',
'A#m' => 'A# Minor',
'B' => 'B Major',
'Bm' => 'B Minor',
);
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_track_number"><?php esc_html_e( 'Track Number', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_number" id="fedistream_track_number" value="<?php echo esc_attr( $track_number ); ?>" min="1" max="999" class="small-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_disc_number"><?php esc_html_e( 'Disc Number', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_disc_number" id="fedistream_track_disc_number" value="<?php echo esc_attr( $disc_number ?: 1 ); ?>" min="1" max="99" class="small-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_bpm"><?php esc_html_e( 'BPM', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_track_bpm" id="fedistream_track_bpm" value="<?php echo esc_attr( $bpm ); ?>" min="20" max="300" class="small-text">
<p class="description"><?php esc_html_e( 'Beats per minute (tempo).', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_key"><?php esc_html_e( 'Musical Key', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_track_key" id="fedistream_track_key">
<?php foreach ( $musical_keys as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $key, $value ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Explicit Content', 'wp-fedistream' ); ?>
</th>
<td>
<label>
<input type="checkbox" name="fedistream_track_explicit" value="1" <?php checked( $explicit, 1 ); ?>>
<?php esc_html_e( 'This track contains explicit content', 'wp-fedistream' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_track_preview_start"><?php esc_html_e( 'Preview Settings', 'wp-fedistream' ); ?></label>
</th>
<td>
<label>
<?php esc_html_e( 'Start at:', 'wp-fedistream' ); ?>
<input type="number" name="fedistream_track_preview_start" id="fedistream_track_preview_start" value="<?php echo esc_attr( $preview_start ?: 0 ); ?>" min="0" class="small-text">
<?php esc_html_e( 'seconds', 'wp-fedistream' ); ?>
</label>
<br>
<label>
<?php esc_html_e( 'Duration:', 'wp-fedistream' ); ?>
<input type="number" name="fedistream_track_preview_duration" id="fedistream_track_preview_duration" value="<?php echo esc_attr( $preview_duration ?: 30 ); ?>" min="10" max="60" class="small-text">
<?php esc_html_e( 'seconds', 'wp-fedistream' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Preview clip for non-authenticated users or before purchase.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render album selection meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_album_meta_box( \WP_Post $post ): void {
$selected_album = get_post_meta( $post->ID, self::META_PREFIX . 'album', true );
$albums = get_posts(
array(
'post_type' => 'fedistream_album',
'posts_per_page' => -1,
'post_status' => array( 'publish', 'draft' ),
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<select name="fedistream_track_album" id="fedistream_track_album" class="widefat">
<option value=""><?php esc_html_e( '— No Album (Single) —', 'wp-fedistream' ); ?></option>
<?php foreach ( $albums as $album ) : ?>
<?php
$artist_id = get_post_meta( $album->ID, '_fedistream_album_artist', true );
$artist_name = $artist_id ? get_the_title( $artist_id ) : '';
?>
<option value="<?php echo esc_attr( $album->ID ); ?>" <?php selected( $selected_album, $album->ID ); ?>>
<?php echo esc_html( $album->post_title ); ?>
<?php if ( $artist_name ) : ?>
(<?php echo esc_html( $artist_name ); ?>)
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</p>
<?php
}
/**
* Render artists meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_artists_meta_box( \WP_Post $post ): void {
$selected_artists = get_post_meta( $post->ID, self::META_PREFIX . 'artists', true );
if ( ! is_array( $selected_artists ) ) {
$selected_artists = array();
}
$artists = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p class="description"><?php esc_html_e( 'Select all artists featured on this track.', 'wp-fedistream' ); ?></p>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #ddd; padding: 5px;">
<?php foreach ( $artists as $artist ) : ?>
<label style="display: block; padding: 2px 0;">
<input type="checkbox" name="fedistream_track_artists[]" value="<?php echo esc_attr( $artist->ID ); ?>" <?php checked( in_array( $artist->ID, $selected_artists, true ) ); ?>>
<?php echo esc_html( $artist->post_title ); ?>
</label>
<?php endforeach; ?>
</div>
<?php if ( empty( $artists ) ) : ?>
<p>
<?php
printf(
/* translators: %s: URL to add new artist */
esc_html__( 'No artists found. %s', 'wp-fedistream' ),
'<a href="' . esc_url( admin_url( 'post-new.php?post_type=fedistream_artist' ) ) . '">' . esc_html__( 'Add an artist first.', 'wp-fedistream' ) . '</a>'
);
?>
</p>
<?php endif; ?>
<?php
}
/**
* Render track codes meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_codes_meta_box( \WP_Post $post ): void {
$isrc = get_post_meta( $post->ID, self::META_PREFIX . 'isrc', true );
?>
<p>
<label for="fedistream_track_isrc"><?php esc_html_e( 'ISRC', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_track_isrc" id="fedistream_track_isrc" value="<?php echo esc_attr( $isrc ); ?>" class="widefat" pattern="[A-Z]{2}[A-Z0-9]{3}[0-9]{7}" title="<?php esc_attr_e( 'ISRC format: CC-XXX-YY-NNNNN', 'wp-fedistream' ); ?>">
<span class="description"><?php esc_html_e( 'International Standard Recording Code', 'wp-fedistream' ); ?></span>
</p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function save_meta( int $post_id, \WP_Post $post ): void {
if ( ! $this->can_save( $post_id, 'fedistream_track_save', 'fedistream_track_nonce' ) ) {
return;
}
// Save audio fields.
$this->save_int_meta( $post_id, self::META_PREFIX . 'audio_file', 'fedistream_track_audio_file' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'duration', 'fedistream_track_duration' );
// Save audio format.
if ( isset( $_POST['fedistream_track_audio_format'] ) ) {
$allowed_formats = array( '', 'mp3', 'wav', 'flac', 'ogg' );
$format = sanitize_text_field( wp_unslash( $_POST['fedistream_track_audio_format'] ) );
if ( in_array( $format, $allowed_formats, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'audio_format', $format );
}
}
// Save track info.
$this->save_int_meta( $post_id, self::META_PREFIX . 'number', 'fedistream_track_number' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'disc_number', 'fedistream_track_disc_number' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'bpm', 'fedistream_track_bpm' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'key', 'fedistream_track_key' );
$this->save_bool_meta( $post_id, self::META_PREFIX . 'explicit', 'fedistream_track_explicit' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'preview_start', 'fedistream_track_preview_start' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'preview_duration', 'fedistream_track_preview_duration' );
// Save album.
$this->save_int_meta( $post_id, self::META_PREFIX . 'album', 'fedistream_track_album' );
// Save artists.
if ( isset( $_POST['fedistream_track_artists'] ) && is_array( $_POST['fedistream_track_artists'] ) ) {
$artists = array_map( 'absint', $_POST['fedistream_track_artists'] );
update_post_meta( $post_id, self::META_PREFIX . 'artists', $artists );
} else {
delete_post_meta( $post_id, self::META_PREFIX . 'artists' );
}
// Save ISRC.
$this->save_text_meta( $post_id, self::META_PREFIX . 'isrc', 'fedistream_track_isrc' );
}
/**
* Update album stats when track is saved.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function update_album_on_save( int $post_id, \WP_Post $post ): void {
$album_id = get_post_meta( $post_id, self::META_PREFIX . 'album', true );
if ( $album_id ) {
Album::update_album_stats( (int) $album_id );
}
}
/**
* Get track by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Track data or null.
*/
public static function get_track( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_track' !== $post->post_type ) {
return null;
}
$album_id = get_post_meta( $post_id, self::META_PREFIX . 'album', true );
$audio_file = get_post_meta( $post_id, self::META_PREFIX . 'audio_file', true );
$artists = get_post_meta( $post_id, self::META_PREFIX . 'artists', true ) ?: array();
// Get artist names.
$artist_names = array();
foreach ( $artists as $artist_id ) {
$artist_names[] = get_the_title( $artist_id );
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'lyrics' => $post->post_content,
'album_id' => $album_id ? (int) $album_id : null,
'album_title' => $album_id ? get_the_title( $album_id ) : null,
'artists' => $artists,
'artist_names' => $artist_names,
'track_number' => (int) get_post_meta( $post_id, self::META_PREFIX . 'number', true ),
'disc_number' => (int) get_post_meta( $post_id, self::META_PREFIX . 'disc_number', true ) ?: 1,
'duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'duration', true ),
'audio_file' => $audio_file ? (int) $audio_file : null,
'audio_url' => $audio_file ? wp_get_attachment_url( $audio_file ) : null,
'audio_format' => get_post_meta( $post_id, self::META_PREFIX . 'audio_format', true ),
'bpm' => (int) get_post_meta( $post_id, self::META_PREFIX . 'bpm', true ),
'key' => get_post_meta( $post_id, self::META_PREFIX . 'key', true ),
'explicit' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'explicit', true ),
'isrc' => get_post_meta( $post_id, self::META_PREFIX . 'isrc', true ),
'preview_start' => (int) get_post_meta( $post_id, self::META_PREFIX . 'preview_start', true ),
'preview_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'preview_duration', true ) ?: 30,
'artwork' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
}