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,202 @@
<?php
/**
* Abstract base class for custom post types.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract post type class.
*
* Provides common functionality for all custom post types.
*/
abstract class AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type;
/**
* Constructor.
*/
public function __construct() {
add_action( 'init', array( $this, 'register' ) );
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) );
add_action( 'save_post_' . $this->post_type, array( $this, 'save_meta' ), 10, 2 );
}
/**
* Register the post type.
*
* @return void
*/
abstract public function register(): void;
/**
* Add meta boxes.
*
* @return void
*/
abstract public function add_meta_boxes(): void;
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
abstract public function save_meta( int $post_id, \WP_Post $post ): void;
/**
* Get the post type key.
*
* @return string
*/
public function get_post_type(): string {
return $this->post_type;
}
/**
* Verify nonce and user capabilities before saving.
*
* @param int $post_id Post ID.
* @param string $nonce_action Nonce action name.
* @param string $nonce_name Nonce field name.
* @return bool Whether save should proceed.
*/
protected function can_save( int $post_id, string $nonce_action, string $nonce_name ): bool {
// Verify nonce.
if ( ! isset( $_POST[ $nonce_name ] ) || ! wp_verify_nonce( sanitize_key( $_POST[ $nonce_name ] ), $nonce_action ) ) {
return false;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return false;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return false;
}
return true;
}
/**
* Sanitize and save a text meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_text_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, sanitize_text_field( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save a textarea meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_textarea_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, sanitize_textarea_field( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save an integer meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_int_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, absint( $_POST[ $post_key ] ) );
}
}
/**
* Sanitize and save a URL meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_url_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) ) {
update_post_meta( $post_id, $meta_key, esc_url_raw( wp_unslash( $_POST[ $post_key ] ) ) );
}
}
/**
* Sanitize and save a boolean meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_bool_meta( int $post_id, string $meta_key, string $post_key ): void {
$value = isset( $_POST[ $post_key ] ) ? 1 : 0;
update_post_meta( $post_id, $meta_key, $value );
}
/**
* Sanitize and save an array meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_array_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) && is_array( $_POST[ $post_key ] ) ) {
$values = array_map( 'sanitize_text_field', wp_unslash( $_POST[ $post_key ] ) );
update_post_meta( $post_id, $meta_key, $values );
} else {
delete_post_meta( $post_id, $meta_key );
}
}
/**
* Sanitize and save a date meta field.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $post_key POST array key.
* @return void
*/
protected function save_date_meta( int $post_id, string $meta_key, string $post_key ): void {
if ( isset( $_POST[ $post_key ] ) && ! empty( $_POST[ $post_key ] ) ) {
$date = sanitize_text_field( wp_unslash( $_POST[ $post_key ] ) );
// Validate date format (YYYY-MM-DD).
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
update_post_meta( $post_id, $meta_key, $date );
}
} else {
delete_post_meta( $post_id, $meta_key );
}
}
}

View File

@@ -0,0 +1,340 @@
<?php
/**
* Album custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Album post type class.
*
* Handles registration and management of albums/releases.
*/
class Album extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_album';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_album_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Albums', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Album', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Albums', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Album', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Album', 'wp-fedistream' ),
'new_item' => __( 'New Album', 'wp-fedistream' ),
'edit_item' => __( 'Edit Album', 'wp-fedistream' ),
'view_item' => __( 'View Album', 'wp-fedistream' ),
'all_items' => __( 'All Albums', 'wp-fedistream' ),
'search_items' => __( 'Search Albums', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Albums:', 'wp-fedistream' ),
'not_found' => __( 'No albums found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No albums found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Album Artwork', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set album artwork', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove album artwork', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as album artwork', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Album archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into album', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this album', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter albums list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Albums list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Albums 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' => 'albums' ),
'capability_type' => array( 'fedistream_album', 'fedistream_albums' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-album',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'albums',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_album_info',
__( 'Album Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_album_artist',
__( 'Artist', 'wp-fedistream' ),
array( $this, 'render_artist_meta_box' ),
$this->post_type,
'side',
'high'
);
add_meta_box(
'fedistream_album_codes',
__( 'Album Codes', 'wp-fedistream' ),
array( $this, 'render_codes_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render album info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_album_save', 'fedistream_album_nonce' );
$album_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
$release_date = get_post_meta( $post->ID, self::META_PREFIX . 'release_date', true );
$total_tracks = get_post_meta( $post->ID, self::META_PREFIX . 'total_tracks', true );
$total_duration = get_post_meta( $post->ID, self::META_PREFIX . 'total_duration', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_album_type"><?php esc_html_e( 'Release Type', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_album_type" id="fedistream_album_type">
<option value="album" <?php selected( $album_type, 'album' ); ?>><?php esc_html_e( 'Album', 'wp-fedistream' ); ?></option>
<option value="ep" <?php selected( $album_type, 'ep' ); ?>><?php esc_html_e( 'EP', 'wp-fedistream' ); ?></option>
<option value="single" <?php selected( $album_type, 'single' ); ?>><?php esc_html_e( 'Single', 'wp-fedistream' ); ?></option>
<option value="compilation" <?php selected( $album_type, 'compilation' ); ?>><?php esc_html_e( 'Compilation', 'wp-fedistream' ); ?></option>
<option value="live" <?php selected( $album_type, 'live' ); ?>><?php esc_html_e( 'Live Album', 'wp-fedistream' ); ?></option>
<option value="remix" <?php selected( $album_type, 'remix' ); ?>><?php esc_html_e( 'Remix Album', 'wp-fedistream' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_release_date"><?php esc_html_e( 'Release Date', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="date" name="fedistream_album_release_date" id="fedistream_album_release_date" value="<?php echo esc_attr( $release_date ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_total_tracks"><?php esc_html_e( 'Total Tracks', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_album_total_tracks" id="fedistream_album_total_tracks" value="<?php echo esc_attr( $total_tracks ); ?>" min="1" max="999" class="small-text">
<p class="description"><?php esc_html_e( 'Auto-calculated when tracks are added.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_album_total_duration"><?php esc_html_e( 'Total Duration', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="number" name="fedistream_album_total_duration" id="fedistream_album_total_duration" value="<?php echo esc_attr( $total_duration ); ?>" min="0" class="small-text" readonly>
<span class="description"><?php esc_html_e( 'seconds (auto-calculated)', 'wp-fedistream' ); ?></span>
</td>
</tr>
</table>
<?php
}
/**
* Render artist selection meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_artist_meta_box( \WP_Post $post ): void {
$selected_artist = get_post_meta( $post->ID, self::META_PREFIX . 'artist', true );
$artists = get_posts(
array(
'post_type' => 'fedistream_artist',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<p>
<label for="fedistream_album_artist"><?php esc_html_e( 'Primary Artist', 'wp-fedistream' ); ?></label>
</p>
<select name="fedistream_album_artist" id="fedistream_album_artist" class="widefat">
<option value=""><?php esc_html_e( '— Select Artist —', 'wp-fedistream' ); ?></option>
<?php foreach ( $artists as $artist ) : ?>
<option value="<?php echo esc_attr( $artist->ID ); ?>" <?php selected( $selected_artist, $artist->ID ); ?>>
<?php echo esc_html( $artist->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php
if ( empty( $artists ) ) {
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
}
/**
* Render album codes meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_codes_meta_box( \WP_Post $post ): void {
$upc = get_post_meta( $post->ID, self::META_PREFIX . 'upc', true );
$catalog_number = get_post_meta( $post->ID, self::META_PREFIX . 'catalog_number', true );
?>
<p>
<label for="fedistream_album_upc"><?php esc_html_e( 'UPC/EAN', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_album_upc" id="fedistream_album_upc" value="<?php echo esc_attr( $upc ); ?>" class="widefat" pattern="[0-9]{12,13}" title="<?php esc_attr_e( '12-13 digit UPC/EAN code', 'wp-fedistream' ); ?>">
</p>
<p>
<label for="fedistream_album_catalog_number"><?php esc_html_e( 'Catalog Number', 'wp-fedistream' ); ?></label>
<input type="text" name="fedistream_album_catalog_number" id="fedistream_album_catalog_number" value="<?php echo esc_attr( $catalog_number ); ?>" class="widefat">
</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_album_save', 'fedistream_album_nonce' ) ) {
return;
}
// Save album type.
if ( isset( $_POST['fedistream_album_type'] ) ) {
$allowed_types = array( 'album', 'ep', 'single', 'compilation', 'live', 'remix' );
$type = sanitize_text_field( wp_unslash( $_POST['fedistream_album_type'] ) );
if ( in_array( $type, $allowed_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'type', $type );
}
}
// Save other fields.
$this->save_date_meta( $post_id, self::META_PREFIX . 'release_date', 'fedistream_album_release_date' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'artist', 'fedistream_album_artist' );
$this->save_int_meta( $post_id, self::META_PREFIX . 'total_tracks', 'fedistream_album_total_tracks' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'upc', 'fedistream_album_upc' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'catalog_number', 'fedistream_album_catalog_number' );
}
/**
* Get album by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Album data or null.
*/
public static function get_album( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_album' !== $post->post_type ) {
return null;
}
$artist_id = get_post_meta( $post_id, self::META_PREFIX . 'artist', true );
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'description' => $post->post_content,
'excerpt' => $post->post_excerpt,
'type' => get_post_meta( $post_id, self::META_PREFIX . 'type', true ) ?: 'album',
'release_date' => get_post_meta( $post_id, self::META_PREFIX . 'release_date', true ),
'artist_id' => $artist_id,
'artist_name' => $artist_id ? get_the_title( $artist_id ) : '',
'total_tracks' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_tracks', true ),
'total_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_duration', true ),
'upc' => get_post_meta( $post_id, self::META_PREFIX . 'upc', true ),
'catalog_number' => get_post_meta( $post_id, self::META_PREFIX . 'catalog_number', true ),
'artwork' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
/**
* Update album track count and duration.
*
* @param int $album_id Album post ID.
* @return void
*/
public static function update_album_stats( int $album_id ): void {
$tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_track_album',
'meta_value' => $album_id,
)
);
$total_tracks = count( $tracks );
$total_duration = 0;
foreach ( $tracks as $track ) {
$duration = (int) get_post_meta( $track->ID, '_fedistream_track_duration', true );
$total_duration += $duration;
}
update_post_meta( $album_id, self::META_PREFIX . 'total_tracks', $total_tracks );
update_post_meta( $album_id, self::META_PREFIX . 'total_duration', $total_duration );
}
}

View File

@@ -0,0 +1,331 @@
<?php
/**
* Artist custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Artist post type class.
*
* Handles registration and management of artist/band profiles.
*/
class Artist extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_artist';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_artist_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Artists', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Artist', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Artists', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Artist', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Artist', 'wp-fedistream' ),
'new_item' => __( 'New Artist', 'wp-fedistream' ),
'edit_item' => __( 'Edit Artist', 'wp-fedistream' ),
'view_item' => __( 'View Artist', 'wp-fedistream' ),
'all_items' => __( 'All Artists', 'wp-fedistream' ),
'search_items' => __( 'Search Artists', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Artists:', 'wp-fedistream' ),
'not_found' => __( 'No artists found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No artists found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Artist Photo', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set artist photo', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove artist photo', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as artist photo', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Artist archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into artist', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this artist', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter artists list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Artists list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Artists 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' => 'artists' ),
'capability_type' => array( 'fedistream_artist', 'fedistream_artists' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-groups',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'artists',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_artist_info',
__( 'Artist Information', 'wp-fedistream' ),
array( $this, 'render_info_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_artist_social',
__( 'Social Links', 'wp-fedistream' ),
array( $this, 'render_social_meta_box' ),
$this->post_type,
'normal',
'default'
);
add_meta_box(
'fedistream_artist_members',
__( 'Band Members', 'wp-fedistream' ),
array( $this, 'render_members_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render artist info meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_info_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_artist_save', 'fedistream_artist_nonce' );
$artist_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
$formed_date = get_post_meta( $post->ID, self::META_PREFIX . 'formed_date', true );
$location = get_post_meta( $post->ID, self::META_PREFIX . 'location', true );
$website = get_post_meta( $post->ID, self::META_PREFIX . 'website', true );
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="fedistream_artist_type"><?php esc_html_e( 'Artist Type', 'wp-fedistream' ); ?></label>
</th>
<td>
<select name="fedistream_artist_type" id="fedistream_artist_type">
<option value="solo" <?php selected( $artist_type, 'solo' ); ?>><?php esc_html_e( 'Solo Artist', 'wp-fedistream' ); ?></option>
<option value="band" <?php selected( $artist_type, 'band' ); ?>><?php esc_html_e( 'Band', 'wp-fedistream' ); ?></option>
<option value="duo" <?php selected( $artist_type, 'duo' ); ?>><?php esc_html_e( 'Duo', 'wp-fedistream' ); ?></option>
<option value="collective" <?php selected( $artist_type, 'collective' ); ?>><?php esc_html_e( 'Collective', 'wp-fedistream' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_formed_date"><?php esc_html_e( 'Formed Date', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="date" name="fedistream_artist_formed_date" id="fedistream_artist_formed_date" value="<?php echo esc_attr( $formed_date ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'When the artist/band was formed or started their career.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_location"><?php esc_html_e( 'Location', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="text" name="fedistream_artist_location" id="fedistream_artist_location" value="<?php echo esc_attr( $location ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'City, Country or region where the artist is based.', 'wp-fedistream' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="fedistream_artist_website"><?php esc_html_e( 'Website', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="url" name="fedistream_artist_website" id="fedistream_artist_website" value="<?php echo esc_url( $website ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'Official website URL.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render social links meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_social_meta_box( \WP_Post $post ): void {
$social_links = get_post_meta( $post->ID, self::META_PREFIX . 'social_links', true );
if ( ! is_array( $social_links ) ) {
$social_links = array();
}
$platforms = array(
'mastodon' => __( 'Mastodon', 'wp-fedistream' ),
'bandcamp' => __( 'Bandcamp', 'wp-fedistream' ),
'soundcloud' => __( 'SoundCloud', 'wp-fedistream' ),
'youtube' => __( 'YouTube', 'wp-fedistream' ),
'instagram' => __( 'Instagram', 'wp-fedistream' ),
'twitter' => __( 'Twitter/X', 'wp-fedistream' ),
'facebook' => __( 'Facebook', 'wp-fedistream' ),
'tiktok' => __( 'TikTok', 'wp-fedistream' ),
'other' => __( 'Other', 'wp-fedistream' ),
);
?>
<table class="form-table">
<?php foreach ( $platforms as $key => $label ) : ?>
<tr>
<th scope="row">
<label for="fedistream_social_<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
</th>
<td>
<input type="url" name="fedistream_artist_social[<?php echo esc_attr( $key ); ?>]" id="fedistream_social_<?php echo esc_attr( $key ); ?>" value="<?php echo esc_url( $social_links[ $key ] ?? '' ); ?>" class="regular-text">
</td>
</tr>
<?php endforeach; ?>
</table>
<?php
}
/**
* Render band members meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_members_meta_box( \WP_Post $post ): void {
$members = get_post_meta( $post->ID, self::META_PREFIX . 'members', true );
if ( ! is_array( $members ) ) {
$members = array();
}
$artist_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true );
?>
<div id="fedistream-members-wrapper" style="<?php echo 'solo' === $artist_type ? 'display:none;' : ''; ?>">
<p class="description"><?php esc_html_e( 'Add band/group members (comma-separated names).', 'wp-fedistream' ); ?></p>
<textarea name="fedistream_artist_members" id="fedistream_artist_members" rows="5" class="large-text"><?php echo esc_textarea( implode( "\n", $members ) ); ?></textarea>
<p class="description"><?php esc_html_e( 'One member per line.', 'wp-fedistream' ); ?></p>
</div>
<script>
jQuery(document).ready(function($) {
$('#fedistream_artist_type').on('change', function() {
if ($(this).val() === 'solo') {
$('#fedistream-members-wrapper').hide();
} else {
$('#fedistream-members-wrapper').show();
}
});
});
</script>
<?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_artist_save', 'fedistream_artist_nonce' ) ) {
return;
}
// Save artist type.
if ( isset( $_POST['fedistream_artist_type'] ) ) {
$allowed_types = array( 'solo', 'band', 'duo', 'collective' );
$type = sanitize_text_field( wp_unslash( $_POST['fedistream_artist_type'] ) );
if ( in_array( $type, $allowed_types, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'type', $type );
}
}
// Save other fields.
$this->save_date_meta( $post_id, self::META_PREFIX . 'formed_date', 'fedistream_artist_formed_date' );
$this->save_text_meta( $post_id, self::META_PREFIX . 'location', 'fedistream_artist_location' );
$this->save_url_meta( $post_id, self::META_PREFIX . 'website', 'fedistream_artist_website' );
// Save social links.
if ( isset( $_POST['fedistream_artist_social'] ) && is_array( $_POST['fedistream_artist_social'] ) ) {
$social_links = array();
foreach ( $_POST['fedistream_artist_social'] as $key => $url ) {
$clean_key = sanitize_key( $key );
$clean_url = esc_url_raw( wp_unslash( $url ) );
if ( ! empty( $clean_url ) ) {
$social_links[ $clean_key ] = $clean_url;
}
}
update_post_meta( $post_id, self::META_PREFIX . 'social_links', $social_links );
}
// Save members.
if ( isset( $_POST['fedistream_artist_members'] ) ) {
$members_text = sanitize_textarea_field( wp_unslash( $_POST['fedistream_artist_members'] ) );
$members = array_filter( array_map( 'trim', explode( "\n", $members_text ) ) );
update_post_meta( $post_id, self::META_PREFIX . 'members', $members );
}
}
/**
* Get artist by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Artist data or null.
*/
public static function get_artist( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_artist' !== $post->post_type ) {
return null;
}
return array(
'id' => $post->ID,
'name' => $post->post_title,
'slug' => $post->post_name,
'bio' => $post->post_content,
'excerpt' => $post->post_excerpt,
'type' => get_post_meta( $post_id, self::META_PREFIX . 'type', true ) ?: 'solo',
'formed_date' => get_post_meta( $post_id, self::META_PREFIX . 'formed_date', true ),
'location' => get_post_meta( $post_id, self::META_PREFIX . 'location', true ),
'website' => get_post_meta( $post_id, self::META_PREFIX . 'website', true ),
'social_links' => get_post_meta( $post_id, self::META_PREFIX . 'social_links', true ) ?: array(),
'members' => get_post_meta( $post_id, self::META_PREFIX . 'members', true ) ?: array(),
'photo' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
}

View File

@@ -0,0 +1,458 @@
<?php
/**
* Playlist custom post type.
*
* @package WP_FediStream
*/
namespace WP_FediStream\PostTypes;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Playlist post type class.
*
* Handles registration and management of playlists.
*/
class Playlist extends AbstractPostType {
/**
* Post type key.
*
* @var string
*/
protected string $post_type = 'fedistream_playlist';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_fedistream_playlist_';
/**
* Register the post type.
*
* @return void
*/
public function register(): void {
$labels = array(
'name' => _x( 'Playlists', 'Post type general name', 'wp-fedistream' ),
'singular_name' => _x( 'Playlist', 'Post type singular name', 'wp-fedistream' ),
'menu_name' => _x( 'Playlists', 'Admin Menu text', 'wp-fedistream' ),
'name_admin_bar' => _x( 'Playlist', 'Add New on Toolbar', 'wp-fedistream' ),
'add_new' => __( 'Add New', 'wp-fedistream' ),
'add_new_item' => __( 'Add New Playlist', 'wp-fedistream' ),
'new_item' => __( 'New Playlist', 'wp-fedistream' ),
'edit_item' => __( 'Edit Playlist', 'wp-fedistream' ),
'view_item' => __( 'View Playlist', 'wp-fedistream' ),
'all_items' => __( 'All Playlists', 'wp-fedistream' ),
'search_items' => __( 'Search Playlists', 'wp-fedistream' ),
'parent_item_colon' => __( 'Parent Playlists:', 'wp-fedistream' ),
'not_found' => __( 'No playlists found.', 'wp-fedistream' ),
'not_found_in_trash' => __( 'No playlists found in Trash.', 'wp-fedistream' ),
'featured_image' => _x( 'Playlist Cover', 'Overrides the "Featured Image" phrase', 'wp-fedistream' ),
'set_featured_image' => _x( 'Set playlist cover', 'Overrides the "Set featured image" phrase', 'wp-fedistream' ),
'remove_featured_image' => _x( 'Remove playlist cover', 'Overrides the "Remove featured image" phrase', 'wp-fedistream' ),
'use_featured_image' => _x( 'Use as playlist cover', 'Overrides the "Use as featured image" phrase', 'wp-fedistream' ),
'archives' => _x( 'Playlist archives', 'The post type archive label', 'wp-fedistream' ),
'insert_into_item' => _x( 'Insert into playlist', 'Overrides the "Insert into post" phrase', 'wp-fedistream' ),
'uploaded_to_this_item' => _x( 'Uploaded to this playlist', 'Overrides the "Uploaded to this post" phrase', 'wp-fedistream' ),
'filter_items_list' => _x( 'Filter playlists list', 'Screen reader text', 'wp-fedistream' ),
'items_list_navigation' => _x( 'Playlists list navigation', 'Screen reader text', 'wp-fedistream' ),
'items_list' => _x( 'Playlists 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' => 'playlists' ),
'capability_type' => array( 'fedistream_playlist', 'fedistream_playlists' ),
'map_meta_cap' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => null,
'menu_icon' => 'dashicons-playlist-audio',
'supports' => array( 'title', 'editor', 'thumbnail', 'author', 'revisions' ),
'show_in_rest' => true,
'rest_base' => 'playlists',
);
register_post_type( $this->post_type, $args );
}
/**
* Add meta boxes.
*
* @return void
*/
public function add_meta_boxes(): void {
add_meta_box(
'fedistream_playlist_tracks',
__( 'Playlist Tracks', 'wp-fedistream' ),
array( $this, 'render_tracks_meta_box' ),
$this->post_type,
'normal',
'high'
);
add_meta_box(
'fedistream_playlist_settings',
__( 'Playlist Settings', 'wp-fedistream' ),
array( $this, 'render_settings_meta_box' ),
$this->post_type,
'side',
'default'
);
add_meta_box(
'fedistream_playlist_stats',
__( 'Playlist Stats', 'wp-fedistream' ),
array( $this, 'render_stats_meta_box' ),
$this->post_type,
'side',
'default'
);
}
/**
* Render playlist tracks meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_tracks_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'fedistream_playlist_save', 'fedistream_playlist_nonce' );
global $wpdb;
// Get tracks in this playlist from the pivot table.
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$playlist_tracks = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id, position FROM $table WHERE playlist_id = %d ORDER BY position ASC",
$post->ID
)
);
$track_ids = wp_list_pluck( $playlist_tracks, 'track_id' );
// Get all available tracks.
$available_tracks = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
)
);
?>
<div class="fedistream-playlist-tracks">
<h4><?php esc_html_e( 'Current Tracks', 'wp-fedistream' ); ?></h4>
<ul id="fedistream-playlist-track-list" class="fedistream-sortable">
<?php
foreach ( $track_ids as $track_id ) :
$track = get_post( $track_id );
if ( ! $track ) {
continue;
}
$artists = get_post_meta( $track_id, '_fedistream_track_artists', true ) ?: array();
$duration = get_post_meta( $track_id, '_fedistream_track_duration', true );
?>
<li data-track-id="<?php echo esc_attr( $track_id ); ?>" style="padding: 8px; margin: 4px 0; background: #f9f9f9; border: 1px solid #ddd; cursor: move;">
<input type="hidden" name="fedistream_playlist_tracks[]" value="<?php echo esc_attr( $track_id ); ?>">
<span class="dashicons dashicons-menu" style="vertical-align: middle;"></span>
<strong><?php echo esc_html( $track->post_title ); ?></strong>
<?php if ( ! empty( $artists ) ) : ?>
<span class="description">
— <?php echo esc_html( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>
</span>
<?php endif; ?>
<?php if ( $duration ) : ?>
<span class="description">(<?php echo esc_html( gmdate( 'i:s', (int) $duration ) ); ?>)</span>
<?php endif; ?>
<button type="button" class="button-link fedistream-remove-track" style="color: #a00; float: right;">
<?php esc_html_e( 'Remove', 'wp-fedistream' ); ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<hr>
<h4><?php esc_html_e( 'Add Tracks', 'wp-fedistream' ); ?></h4>
<p>
<select id="fedistream-add-track-select" class="widefat">
<option value=""><?php esc_html_e( '— Select a track to add —', 'wp-fedistream' ); ?></option>
<?php foreach ( $available_tracks as $track ) : ?>
<?php
$artists = get_post_meta( $track->ID, '_fedistream_track_artists', true ) ?: array();
$duration = get_post_meta( $track->ID, '_fedistream_track_duration', true );
?>
<option value="<?php echo esc_attr( $track->ID ); ?>" data-title="<?php echo esc_attr( $track->post_title ); ?>" data-artists="<?php echo esc_attr( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>" data-duration="<?php echo esc_attr( $duration ? gmdate( 'i:s', (int) $duration ) : '' ); ?>">
<?php echo esc_html( $track->post_title ); ?>
<?php if ( ! empty( $artists ) ) : ?>
— <?php echo esc_html( implode( ', ', array_map( 'get_the_title', $artists ) ) ); ?>
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<button type="button" class="button" id="fedistream-add-track-btn"><?php esc_html_e( 'Add to Playlist', 'wp-fedistream' ); ?></button>
</p>
</div>
<style>
.fedistream-sortable li { list-style: none; }
.fedistream-sortable .ui-sortable-helper { background: #fff !important; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.fedistream-sortable .ui-sortable-placeholder { visibility: visible !important; background: #e0e0e0; border: 1px dashed #999; }
</style>
<script>
jQuery(document).ready(function($) {
// Make list sortable.
$('#fedistream-playlist-track-list').sortable({
placeholder: 'ui-sortable-placeholder',
axis: 'y'
});
// Add track.
$('#fedistream-add-track-btn').on('click', function() {
var $select = $('#fedistream-add-track-select');
var trackId = $select.val();
if (!trackId) return;
var $option = $select.find('option:selected');
var title = $option.data('title');
var artists = $option.data('artists');
var duration = $option.data('duration');
var html = '<li data-track-id="' + trackId + '" style="padding: 8px; margin: 4px 0; background: #f9f9f9; border: 1px solid #ddd; cursor: move;">' +
'<input type="hidden" name="fedistream_playlist_tracks[]" value="' + trackId + '">' +
'<span class="dashicons dashicons-menu" style="vertical-align: middle;"></span> ' +
'<strong>' + title + '</strong>';
if (artists) {
html += ' <span class="description">— ' + artists + '</span>';
}
if (duration) {
html += ' <span class="description">(' + duration + ')</span>';
}
html += '<button type="button" class="button-link fedistream-remove-track" style="color: #a00; float: right;"><?php echo esc_js( __( 'Remove', 'wp-fedistream' ) ); ?></button>' +
'</li>';
$('#fedistream-playlist-track-list').append(html);
$select.val('');
});
// Remove track.
$(document).on('click', '.fedistream-remove-track', function() {
$(this).closest('li').remove();
});
});
</script>
<?php
}
/**
* Render playlist settings meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_settings_meta_box( \WP_Post $post ): void {
$visibility = get_post_meta( $post->ID, self::META_PREFIX . 'visibility', true ) ?: 'public';
$collaborative = get_post_meta( $post->ID, self::META_PREFIX . 'collaborative', true );
$federated = get_post_meta( $post->ID, self::META_PREFIX . 'federated', true );
?>
<p>
<label for="fedistream_playlist_visibility"><?php esc_html_e( 'Visibility', 'wp-fedistream' ); ?></label>
<select name="fedistream_playlist_visibility" id="fedistream_playlist_visibility" class="widefat">
<option value="public" <?php selected( $visibility, 'public' ); ?>><?php esc_html_e( 'Public', 'wp-fedistream' ); ?></option>
<option value="unlisted" <?php selected( $visibility, 'unlisted' ); ?>><?php esc_html_e( 'Unlisted (link only)', 'wp-fedistream' ); ?></option>
<option value="private" <?php selected( $visibility, 'private' ); ?>><?php esc_html_e( 'Private', 'wp-fedistream' ); ?></option>
</select>
</p>
<p>
<label>
<input type="checkbox" name="fedistream_playlist_collaborative" value="1" <?php checked( $collaborative, 1 ); ?>>
<?php esc_html_e( 'Collaborative', 'wp-fedistream' ); ?>
</label>
<br>
<span class="description"><?php esc_html_e( 'Allow others to add tracks.', 'wp-fedistream' ); ?></span>
</p>
<p>
<label>
<input type="checkbox" name="fedistream_playlist_federated" value="1" <?php checked( $federated, 1 ); ?>>
<?php esc_html_e( 'Federated', 'wp-fedistream' ); ?>
</label>
<br>
<span class="description"><?php esc_html_e( 'Allow tracks from other FediStream instances.', 'wp-fedistream' ); ?></span>
</p>
<?php
}
/**
* Render playlist stats meta box.
*
* @param \WP_Post $post Post object.
* @return void
*/
public function render_stats_meta_box( \WP_Post $post ): void {
$track_count = get_post_meta( $post->ID, self::META_PREFIX . 'track_count', true ) ?: 0;
$total_duration = get_post_meta( $post->ID, self::META_PREFIX . 'total_duration', true ) ?: 0;
?>
<p>
<strong><?php esc_html_e( 'Tracks:', 'wp-fedistream' ); ?></strong>
<?php echo esc_html( $track_count ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Total Duration:', 'wp-fedistream' ); ?></strong>
<?php
if ( $total_duration > 3600 ) {
echo esc_html( gmdate( 'H:i:s', (int) $total_duration ) );
} else {
echo esc_html( gmdate( 'i:s', (int) $total_duration ) );
}
?>
</p>
<p class="description"><?php esc_html_e( 'Stats are automatically updated when saved.', 'wp-fedistream' ); ?></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_playlist_save', 'fedistream_playlist_nonce' ) ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// Save visibility.
if ( isset( $_POST['fedistream_playlist_visibility'] ) ) {
$allowed_visibility = array( 'public', 'unlisted', 'private' );
$visibility = sanitize_text_field( wp_unslash( $_POST['fedistream_playlist_visibility'] ) );
if ( in_array( $visibility, $allowed_visibility, true ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'visibility', $visibility );
}
}
// Save settings.
$this->save_bool_meta( $post_id, self::META_PREFIX . 'collaborative', 'fedistream_playlist_collaborative' );
$this->save_bool_meta( $post_id, self::META_PREFIX . 'federated', 'fedistream_playlist_federated' );
// Save tracks.
// First, delete existing tracks for this playlist.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->delete( $table, array( 'playlist_id' => $post_id ), array( '%d' ) );
// Insert new tracks.
if ( isset( $_POST['fedistream_playlist_tracks'] ) && is_array( $_POST['fedistream_playlist_tracks'] ) ) {
$position = 0;
$total_duration = 0;
foreach ( $_POST['fedistream_playlist_tracks'] as $track_id ) {
$track_id = absint( $track_id );
if ( $track_id > 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'playlist_id' => $post_id,
'track_id' => $track_id,
'position' => $position,
),
array( '%d', '%d', '%d' )
);
++$position;
// Sum duration.
$duration = (int) get_post_meta( $track_id, '_fedistream_track_duration', true );
$total_duration += $duration;
}
}
update_post_meta( $post_id, self::META_PREFIX . 'track_count', $position );
update_post_meta( $post_id, self::META_PREFIX . 'total_duration', $total_duration );
} else {
update_post_meta( $post_id, self::META_PREFIX . 'track_count', 0 );
update_post_meta( $post_id, self::META_PREFIX . 'total_duration', 0 );
}
}
/**
* Get playlist by ID with meta.
*
* @param int $post_id Post ID.
* @return array|null Playlist data or null.
*/
public static function get_playlist( int $post_id ): ?array {
$post = get_post( $post_id );
if ( ! $post || 'fedistream_playlist' !== $post->post_type ) {
return null;
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'description' => $post->post_content,
'author_id' => $post->post_author,
'author_name' => get_the_author_meta( 'display_name', $post->post_author ),
'visibility' => get_post_meta( $post_id, self::META_PREFIX . 'visibility', true ) ?: 'public',
'collaborative' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'collaborative', true ),
'federated' => (bool) get_post_meta( $post_id, self::META_PREFIX . 'federated', true ),
'track_count' => (int) get_post_meta( $post_id, self::META_PREFIX . 'track_count', true ),
'total_duration' => (int) get_post_meta( $post_id, self::META_PREFIX . 'total_duration', true ),
'cover' => get_the_post_thumbnail_url( $post_id, 'large' ),
'url' => get_permalink( $post_id ),
);
}
/**
* Get tracks in a playlist.
*
* @param int $playlist_id Playlist post ID.
* @return array Array of track data.
*/
public static function get_playlist_tracks( int $playlist_id ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT track_id FROM $table WHERE playlist_id = %d ORDER BY position ASC",
$playlist_id
)
);
$tracks = array();
foreach ( $results as $row ) {
$track = Track::get_track( (int) $row->track_id );
if ( $track ) {
$tracks[] = $track;
}
}
return $tracks;
}
}

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 ),
);
}
}

View File

@@ -0,0 +1 @@
<?php // Silence is golden.