From 4a5d7b9f4ddf2416d9bcacb71e099efa9979b29a Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 28 Jan 2026 23:23:05 +0100 Subject: [PATCH] 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 --- .editorconfig | 15 + .gitignore | 6 + CHANGELOG.md | 143 + CLAUDE.md | 355 +++ README.md | 156 + assets/css/admin.css | 7 + assets/css/frontend.css | 1408 +++++++++ assets/css/index.php | 1 + assets/images/index.php | 1 + assets/index.php | 1 + assets/js/admin.js | 14 + assets/js/frontend.js | 839 ++++++ assets/js/index.php | 1 + assets/js/library.js | 554 ++++ assets/js/notifications.js | 353 +++ composer.json | 54 + composer.lock | 2637 +++++++++++++++++ includes/ActivityPub/AlbumTransformer.php | 433 +++ includes/ActivityPub/ArtistActor.php | 614 ++++ includes/ActivityPub/FollowerHandler.php | 477 +++ includes/ActivityPub/Integration.php | 480 +++ includes/ActivityPub/Outbox.php | 415 +++ includes/ActivityPub/PlaylistTransformer.php | 433 +++ includes/ActivityPub/RestApi.php | 476 +++ includes/ActivityPub/TrackTransformer.php | 412 +++ includes/Admin/ListColumns.php | 604 ++++ includes/Admin/index.php | 1 + includes/Frontend/Ajax.php | 172 ++ includes/Frontend/Shortcodes.php | 513 ++++ includes/Frontend/TemplateLoader.php | 528 ++++ includes/Frontend/Widgets.php | 38 + .../Frontend/Widgets/FeaturedArtistWidget.php | 162 + .../Frontend/Widgets/NowPlayingWidget.php | 111 + .../Frontend/Widgets/PopularTracksWidget.php | 127 + .../Frontend/Widgets/RecentReleasesWidget.php | 147 + includes/Frontend/template-wrapper.php | 78 + includes/Installer.php | 462 +++ includes/Plugin.php | 603 ++++ includes/PostTypes/AbstractPostType.php | 202 ++ includes/PostTypes/Album.php | 340 +++ includes/PostTypes/Artist.php | 331 +++ includes/PostTypes/Playlist.php | 458 +++ includes/PostTypes/Track.php | 615 ++++ includes/PostTypes/index.php | 1 + includes/Roles/Capabilities.php | 308 ++ includes/Roles/index.php | 1 + includes/Taxonomies/AbstractTaxonomy.php | 67 + includes/Taxonomies/Genre.php | 154 + includes/Taxonomies/License.php | 164 + includes/Taxonomies/Mood.php | 135 + includes/Taxonomies/index.php | 1 + includes/User/Library.php | 794 +++++ includes/User/LibraryPage.php | 324 ++ includes/User/Notifications.php | 828 ++++++ includes/WooCommerce/AlbumProduct.php | 499 ++++ includes/WooCommerce/DigitalDelivery.php | 474 +++ includes/WooCommerce/Integration.php | 738 +++++ includes/WooCommerce/StreamingAccess.php | 416 +++ includes/WooCommerce/TrackProduct.php | 520 ++++ includes/index.php | 1 + index.php | 8 + languages/index.php | 1 + languages/wp-fedistream.pot | 31 + templates/archive/album.twig | 27 + templates/archive/artist.twig | 27 + templates/archive/playlist.twig | 27 + templates/archive/taxonomy.twig | 37 + templates/archive/track.twig | 27 + templates/index.php | 1 + templates/partials/card-album.twig | 31 + templates/partials/card-artist.twig | 28 + templates/partials/card-playlist.twig | 29 + templates/partials/card-track.twig | 37 + templates/shortcodes/album.twig | 64 + templates/shortcodes/artist.twig | 65 + templates/shortcodes/artists-grid.twig | 18 + templates/shortcodes/player.twig | 108 + templates/shortcodes/playlist.twig | 71 + templates/shortcodes/releases-grid.twig | 18 + templates/shortcodes/track.twig | 64 + templates/shortcodes/tracks-list.twig | 48 + templates/single/album.twig | 105 + templates/single/artist.twig | 88 + templates/single/playlist.twig | 107 + templates/single/track.twig | 120 + templates/widgets/featured-artist.twig | 41 + templates/widgets/now-playing.twig | 41 + templates/widgets/popular-tracks.twig | 32 + templates/widgets/recent-releases.twig | 29 + uninstall.php | 18 + wp-fedistream.php | 200 ++ 91 files changed, 22750 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 assets/css/admin.css create mode 100644 assets/css/frontend.css create mode 100644 assets/css/index.php create mode 100644 assets/images/index.php create mode 100644 assets/index.php create mode 100644 assets/js/admin.js create mode 100644 assets/js/frontend.js create mode 100644 assets/js/index.php create mode 100644 assets/js/library.js create mode 100644 assets/js/notifications.js create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 includes/ActivityPub/AlbumTransformer.php create mode 100644 includes/ActivityPub/ArtistActor.php create mode 100644 includes/ActivityPub/FollowerHandler.php create mode 100644 includes/ActivityPub/Integration.php create mode 100644 includes/ActivityPub/Outbox.php create mode 100644 includes/ActivityPub/PlaylistTransformer.php create mode 100644 includes/ActivityPub/RestApi.php create mode 100644 includes/ActivityPub/TrackTransformer.php create mode 100644 includes/Admin/ListColumns.php create mode 100644 includes/Admin/index.php create mode 100644 includes/Frontend/Ajax.php create mode 100644 includes/Frontend/Shortcodes.php create mode 100644 includes/Frontend/TemplateLoader.php create mode 100644 includes/Frontend/Widgets.php create mode 100644 includes/Frontend/Widgets/FeaturedArtistWidget.php create mode 100644 includes/Frontend/Widgets/NowPlayingWidget.php create mode 100644 includes/Frontend/Widgets/PopularTracksWidget.php create mode 100644 includes/Frontend/Widgets/RecentReleasesWidget.php create mode 100644 includes/Frontend/template-wrapper.php create mode 100644 includes/Installer.php create mode 100644 includes/Plugin.php create mode 100644 includes/PostTypes/AbstractPostType.php create mode 100644 includes/PostTypes/Album.php create mode 100644 includes/PostTypes/Artist.php create mode 100644 includes/PostTypes/Playlist.php create mode 100644 includes/PostTypes/Track.php create mode 100644 includes/PostTypes/index.php create mode 100644 includes/Roles/Capabilities.php create mode 100644 includes/Roles/index.php create mode 100644 includes/Taxonomies/AbstractTaxonomy.php create mode 100644 includes/Taxonomies/Genre.php create mode 100644 includes/Taxonomies/License.php create mode 100644 includes/Taxonomies/Mood.php create mode 100644 includes/Taxonomies/index.php create mode 100644 includes/User/Library.php create mode 100644 includes/User/LibraryPage.php create mode 100644 includes/User/Notifications.php create mode 100644 includes/WooCommerce/AlbumProduct.php create mode 100644 includes/WooCommerce/DigitalDelivery.php create mode 100644 includes/WooCommerce/Integration.php create mode 100644 includes/WooCommerce/StreamingAccess.php create mode 100644 includes/WooCommerce/TrackProduct.php create mode 100644 includes/index.php create mode 100644 index.php create mode 100644 languages/index.php create mode 100644 languages/wp-fedistream.pot create mode 100644 templates/archive/album.twig create mode 100644 templates/archive/artist.twig create mode 100644 templates/archive/playlist.twig create mode 100644 templates/archive/taxonomy.twig create mode 100644 templates/archive/track.twig create mode 100644 templates/index.php create mode 100644 templates/partials/card-album.twig create mode 100644 templates/partials/card-artist.twig create mode 100644 templates/partials/card-playlist.twig create mode 100644 templates/partials/card-track.twig create mode 100644 templates/shortcodes/album.twig create mode 100644 templates/shortcodes/artist.twig create mode 100644 templates/shortcodes/artists-grid.twig create mode 100644 templates/shortcodes/player.twig create mode 100644 templates/shortcodes/playlist.twig create mode 100644 templates/shortcodes/releases-grid.twig create mode 100644 templates/shortcodes/track.twig create mode 100644 templates/shortcodes/tracks-list.twig create mode 100644 templates/single/album.twig create mode 100644 templates/single/artist.twig create mode 100644 templates/single/playlist.twig create mode 100644 templates/single/track.twig create mode 100644 templates/widgets/featured-artist.twig create mode 100644 templates/widgets/now-playing.twig create mode 100644 templates/widgets/popular-tracks.twig create mode 100644 templates/widgets/recent-releases.twig create mode 100644 uninstall.php create mode 100644 wp-fedistream.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd3fa9c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +indent_size = 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..445e2e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# For development purposes +# Linked wordpress core and plugin folder +wp-plugins +wp-core +vendor/ +releases/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd8ec6f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,143 @@ +# Changelog + +All notable changes to WP FediStream will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-01-28 + +Initial release of WP FediStream - a WordPress plugin for streaming music over ActivityPub. + +### Added + +#### Core Plugin Structure + +- Plugin structure with WordPress Plugin API +- Composer setup with Twig 3.0 template engine +- Internationalization support with .pot template and German (de_CH) translation + +#### Custom Post Types + +- `fedistream_artist` - Artist/band profiles with social links, biography, and member management +- `fedistream_album` - Albums, EPs, singles, and compilations with release metadata +- `fedistream_track` - Individual tracks with audio upload, duration, BPM, key, and ISRC codes +- `fedistream_playlist` - User-created playlists with drag-drop ordering + +#### Custom Taxonomies + +- `fedistream_genre` - Hierarchical music genres with default terms +- `fedistream_mood` - Non-hierarchical mood tags for tracks and playlists +- `fedistream_license` - Copyright and Creative Commons license options + +#### User Roles + +- `fedistream_artist` - Manage own content, upload files, view stats +- `fedistream_label` - Manage all content, taxonomies, and view all statistics + +#### Admin Interface + +- Dashboard with statistics and quick actions +- Organized menu under "FediStream" +- Meta boxes for all post types with full metadata support +- Settings page for ActivityPub and WooCommerce configuration +- Custom list table columns with sortable fields +- Artwork thumbnails, artist links, and duration display + +#### Frontend Display + +- Archive templates for all post types and taxonomies +- Single templates with full metadata display +- Card partials for responsive grid layouts +- Comprehensive CSS styling with custom properties for theming + +#### Shortcodes + +- `[fedistream_artist]` - Display artist profile +- `[fedistream_album]` - Display album with tracklist +- `[fedistream_track]` - Display track with player +- `[fedistream_playlist]` - Display playlist with tracks +- `[fedistream_latest_releases]` - Recent releases grid +- `[fedistream_popular_tracks]` - Popular tracks list +- `[fedistream_artists]` - Artists grid +- `[fedistream_player]` - Audio player widget +- `[fedistream_library]` - User library page + +#### Widgets + +- Recent Releases Widget +- Popular Tracks Widget +- Featured Artist Widget +- Now Playing Widget + +#### Audio Player + +- Full playback controls (play, pause, next, previous) +- Queue management with add, clear, shuffle functionality +- Repeat modes (none, all, one) +- Shuffle mode with Fisher-Yates algorithm +- Progress bar with seek functionality +- Volume control with mute toggle and localStorage persistence +- Media Session API integration for system controls +- Play count tracking via AJAX + +#### ActivityPub Integration + +- Integration with WordPress ActivityPub plugin +- Artists represented as Person/Group actors +- RSA key generation for HTTP Signatures +- Webfinger support for artist discovery +- Object transformers for tracks, albums, and playlists +- Inbox handling for Follow, Like, Announce, and Create activities +- Outbox publishing with Create, Update, and Delete activities +- Follower management with shared inbox deduplication +- REST API endpoints for actors, inbox, outbox, and collections + +#### WooCommerce Integration (Optional) + +- Album product type extending WC_Product +- Track product type extending WC_Product +- Multiple download formats (MP3, FLAC, WAV, AAC, OGG) +- Pricing models: Fixed, Pay What You Want, Name Your Price +- Secure digital delivery with purchase verification +- Album ZIP downloads with all tracks and cover art +- Streaming access control based on purchases +- 30-second preview for non-purchasers +- Purchase tracking database table + +#### User Library + +- Favorite tracks, albums, and playlists +- Follow local artists +- Listening history tracking with clear option +- Library page with tabs (Favorites, Artists, History) +- Filter favorites by content type +- AJAX endpoints for all library operations + +#### Notification System + +- In-app notifications with admin bar indicator +- Email notifications with HTML templates +- Notification types: new_release, new_follower, fediverse_like, fediverse_boost, playlist_added, purchase, system +- Event triggers for releases, follows, and Fediverse interactions +- User preference settings for email notifications +- Real-time polling and mark as read functionality + +#### Database Tables + +- `fedistream_plays` - Track play statistics +- `fedistream_playlist_tracks` - Playlist-track relationships +- `fedistream_followers` - ActivityPub followers +- `fedistream_purchases` - WooCommerce purchase tracking +- `fedistream_favorites` - User favorites +- `fedistream_user_follows` - Local artist follows +- `fedistream_listening_history` - Track play history +- `fedistream_notifications` - User notifications +- `fedistream_reactions` - Fediverse reactions (likes, boosts) + +--- + +[Unreleased]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.0...HEAD +[0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..362bd9f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,355 @@ +# Wordpress Plugin to stream music over Activity Pub + +**Author:** Marco Graetsch +**Author URL:** +**Author Email:** +**Repository URL:** +**Issues URL:** + +## Project Overview + +This plugin provides a way for Musicians or Music-Labels to cut the ties with Spotify, Youtube Music et al and build their own Streaming-Platform (optionally including selling their work using WooCommerce) base on the ActivityPub protocol. This plugin implements the management of Musicians (Single or Bands), Albums (or Releases), Tracks and Playlists. + +The plugin utilizes the ActivityPub protocol to publish Releases or Tracks, share Playlists and let users create Playlist from different Wordpress instances, which use this plugin. It also employs Fediverse reactions to build comprehensive profile for Musicians and Bands (or the Label at all), including classicla Blogposts from Musicians. The Plugin should serve as a full Fediverse profile for Musicians. Everyone in the Fediverse is able to subscribe to the Label's or Musician's account. + +If WooCommerce is installed, the Musician (or the Label) is allowed to sell the Music either as Album or single track utilizing special WooCommerce product types. + +The goal is to create an alternative to the big tech streaming plaforms and give the musicians their freedom back and concentrate to make music. The first goal is to publish, the second goal is to monetize the platform without ripping of the users or musicians. + +### Key Fact: 100% AI-Generated + +This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase was created through AI assistance. + +## Temporary Roadmap + +**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. + +### Version 0.1.0 + +- Document all relevant implementation details in CLAUDE.md, drop PLAN.md, because we don't need it anymore. All relevent Infos are kept in CLAUDE.md from now on +- Drop the current versioning, as this will be version 0.1.0. Merge the history contents in CHANGELOG.md into as single version 0.1.0 +- Update the README.md according to the last changes and current implementation +- Commit the current sources to dev, merge it to main, tag it as 0.1.0 and push it all to origin +- Cleanup this Version entry from the temporary raodmap and create to empty sections for the next bugfix-version and the minor version. +- Call `/end-session` + +## Technical Stack + +- **Language:** PHP 8.3.x +- **Framework:** Latest WordPress Plugin API +- **E-commerce (optional):** WooCommerce 10.0+ +- **Template Engine:** Twig 3.0 (via Composer) +- **Communication Protocol:** ActivityPub +- **Wordpress Base Theme** twentytwentyfive +- **Frontend:** Vanilla JavaScript +- **Styling:** Custom CSS +- **Dependency Management:** Composer +- **Internationalization:** WordPress i18n (.pot/.po/.mo files) +- **Canonical Plugin Name:** `wp-fedistream` + +### Security Best Practices + +- All user inputs are sanitized (integers for quantities/prices) +- Nonce verification on form submissions +- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`) +- Direct file access prevention via `ABSPATH` check +- XSS-safe DOM construction in JavaScript (no `innerHTML` with user data) +- SQL injection prevention using `$wpdb->prepare()` throughout + +### Translation Ready + +All user-facing strings use: + +```php +__('Text to translate', 'wp-fedistream') +_e('Text to translate', 'wp-fedistream') +``` + +Text domain: `wp-fedistream` + +#### Translation Template + +- Base `.pot` file created: `languages/wp-fedistream.pot` +- Ready for translation to any locale +- All translatable strings properly marked with text domain + +#### Available Translations + +- `en_US` - English (United States) [base language - .pot template] +- `de_CH` - German (Switzerland, formal) + +To compile translations to .mo files for production: + +```bash +for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done +``` + +### Create releases + +- The `vendor/` directory MUST be included in releases (Dependencies required for runtime) +- **Don't create any release files until version 0.1.x and up!** +- **CRITICAL**: Build `vendor/` for the MINIMUM supported PHP version, not the development version + - Use `composer config platform.php 8.3.0` before building release packages + - Run `composer update --no-dev --optimize-autoloader` to rebuild dependencies +- **CRITICAL**: WordPress requires plugins in a subdirectory structure + - Run zip from the `plugins/` parent directory, NOT from within the plugin directory + - Package must extract to `wp-fedistream/` subdirectory with main file at `wp-fedistream/wp-fedistream.php` + - Correct command: `cd /wp-content/plugins/ && zip -r wp-fedistream/releases/wp-fedistream-x.x.x.zip wp-fedistream ...` + - Wrong: Running zip from inside the plugin directory creates files at root level +- **CRITICAL**: Exclude symlinks explicitly - zip follows symlinks by default + - Always use `-x "wp-fedistream/wp-core" -x "wp-fedistream/wp-core/*" -x "wp-fedistream/wp-plugins" -x "wp-fedistream/wp-plugins/*"` to exclude development symlinks + - Otherwise the entire linked directory contents will be included in the package +- Exclusion patterns must match the relative path structure used in zip command +- Always verify the package structure with `unzip -l` before distribution + - Check all files are prefixed with `wp-fedistream/` + - Verify main file is at `wp-fedistream/wp-fedistream.php` + - Check for duplicate entries (indicates multiple builds in same archive) +- Test installation on the minimum supported PHP version before final deployment +- Releases are stored in `releases/` including checksums +- Track release changes in a single `CHANGELOG.md` file +- Bump the version number to either bugfix release versions or on new features minor release versions +- **CRITICAL**: WordPress reads version from TWO places - BOTH must be updated: + 1. Plugin header comment `Version: x.x.x` (line ~6 in wc-licensed-product.php) - WordPress uses THIS for admin display + 2. PHP constant `WP_FEDISTREAM_VERSION` (line ~28) - Used internally by the plugin + - If only the constant is updated, WordPress will show the old version in Plugins list + +**Important Git Notes:** + +- Default branch while development is `dev` +- Create releases from branch `main` after merging branch `dev` +- Tags should use format `vX.X.X` (e.g., `v1.1.22`), start with v0.1.0 +- Use annotated tags (`-a`) not lightweight tags +- Commit messages should follow the established format with Claude Code attribution +- `.claude/settings.local.json` changes are typically local-only (stash before rebasing) + +#### What Gets Released + +- All plugin source files +- Compiled vendor dependencies +- Translation files (.mo compiled from .po) +- Assets (CSS, JS) +- Documentation (README, CHANGELOG, etc.) + +#### What's Excluded + +- Git metadata (`.git/`) +- Development files (`.vscode/`, `.claude/`, `CLAUDE.md`, `wp-core`, `wp-plugins`) +- Logs and cache files +- Previous releases +- `composer.lock` (but `vendor/` is included) + +--- + +**For AI Assistants:** + +When starting a new session on this project: + +1. Read this CLAUDE.md file first +2. Semantic versioning follows the `MAJOR.MINOR.BUGFIX` pattern +3. Check git log for recent changes +4. Verify you're on the `dev` branch before making changes +5. Run `composer install` if vendor/ is missing +6. Test changes before committing +7. Follow commit message format with Claude Code attribution +8. Update this session history section with learnings +9. Never commit backup files (`*.po~`, `*.bak`, etc.) - check `git status` before committing +10. Follow markdown linting rules (see below) + +Always refer to this document when starting work on this project. + +### Markdown Linting Rules + +When editing CLAUDE.md or other markdown files, follow these rules to avoid linting errors: + +1. **MD031 - Blank lines around fenced code blocks**: Always add a blank line before and after fenced code blocks, even when they follow list items. Example of correct format: + + - **Item label**: + + (blank line here) + \`\`\`php + code example + \`\`\` + (blank line here) + +2. **MD056 - Table column count**: Table separators must have matching column counts and proper spacing. Use consistent dash lengths that match column header widths. + +3. **MD009 - No trailing spaces**: Remove trailing whitespace from lines + +4. **MD012 - No multiple consecutive blank lines**: Use only single blank lines between sections + +5. **MD040 - Fenced code blocks should have a language specified**: Always add a language identifier to code blocks (e.g., `txt`, `bash`, `php`). For shortcode examples, use `txt`. + +6. **MD032 - Lists should be surrounded by blank lines**: Add a blank line before AND after list blocks, including after bold labels like `**Attributes:**`. + +7. **MD034 - Bare URLs**: Wrap URLs in angle brackets (e.g., ``) or use markdown link syntax `[text](url)`. + +8. **Author section formatting**: Use a heading (`### Name`) instead of bold (`**Name**`) for the author name to maintain consistent document structure. + +## Project Architecture + +### Directory Structure + +```txt +wp-fedistream/ +├── assets/ +│ ├── css/ +│ │ ├── admin.css # Admin interface styles +│ │ └── frontend.css # Frontend styles +│ ├── js/ +│ │ ├── admin.js # Admin interface scripts +│ │ ├── frontend.js # Frontend scripts +│ │ ├── player.js # Audio player +│ │ ├── library.js # User library page +│ │ └── notifications.js # Notification system +│ └── images/ +├── includes/ +│ ├── ActivityPub/ +│ │ ├── Integration.php # ActivityPub plugin integration +│ │ ├── ArtistActor.php # Artist as ActivityPub actor +│ │ ├── Inbox.php # Process incoming activities +│ │ ├── Outbox.php # Publish outgoing activities +│ │ ├── RestApi.php # REST endpoints for ActivityPub +│ │ └── Transformers/ # Object transformers +│ │ ├── TrackTransformer.php +│ │ ├── AlbumTransformer.php +│ │ └── PlaylistTransformer.php +│ ├── Admin/ +│ │ └── ListColumns.php # Custom list table columns +│ ├── Frontend/ +│ │ ├── Ajax.php # AJAX handlers +│ │ ├── Shortcodes.php # All shortcodes +│ │ ├── TemplateLoader.php # Template loading +│ │ └── Widgets.php # Widget registration +│ ├── PostTypes/ +│ │ ├── Artist.php # fedistream_artist +│ │ ├── Album.php # fedistream_album +│ │ ├── Track.php # fedistream_track +│ │ └── Playlist.php # fedistream_playlist +│ ├── Roles/ +│ │ └── Capabilities.php # User roles and caps +│ ├── Taxonomies/ +│ │ ├── Genre.php # fedistream_genre +│ │ ├── Mood.php # fedistream_mood +│ │ └── License.php # fedistream_license +│ ├── User/ +│ │ ├── Library.php # Favorites, follows, history +│ │ ├── LibraryPage.php # Library shortcode +│ │ └── Notifications.php # Notification system +│ ├── WooCommerce/ +│ │ ├── Integration.php # WooCommerce setup +│ │ ├── AlbumProduct.php # Album product type +│ │ ├── TrackProduct.php # Track product type +│ │ ├── DigitalDelivery.php # Download handling +│ │ └── StreamingAccess.php # Access control +│ ├── Installer.php # Database setup, activation +│ └── Plugin.php # Main singleton class +├── languages/ +│ ├── wp-fedistream.pot # Translation template +│ └── wp-fedistream-de_CH.po # German (Switzerland) +├── templates/ # Twig templates +│ ├── admin/ +│ ├── archive/ +│ ├── single/ +│ └── player/ +├── vendor/ # Composer dependencies +├── composer.json +├── uninstall.php +└── wp-fedistream.php # Plugin entry point +``` + +### Database Tables + +| Table | Purpose | +| ----- | ------- | +| `{prefix}_fedistream_plays` | Track play statistics | +| `{prefix}_fedistream_playlist_tracks` | Playlist-track relationships | +| `{prefix}_fedistream_followers` | ActivityPub followers | +| `{prefix}_fedistream_purchases` | WooCommerce purchase tracking | +| `{prefix}_fedistream_favorites` | User favorites | +| `{prefix}_fedistream_user_follows` | Local artist follows | +| `{prefix}_fedistream_listening_history` | Track play history | +| `{prefix}_fedistream_notifications` | User notifications | +| `{prefix}_fedistream_reactions` | Fediverse reactions | + +### Custom Post Types + +| Post Type | Slug | Description | +| --------- | ---- | ----------- | +| `fedistream_artist` | `/artists/` | Musicians, bands, collectives | +| `fedistream_album` | `/albums/` | Albums, EPs, singles, compilations | +| `fedistream_track` | `/tracks/` | Individual audio tracks | +| `fedistream_playlist` | `/playlists/` | Curated track collections | + +### Custom Taxonomies + +| Taxonomy | Type | Applied To | +| -------- | ---- | ---------- | +| `fedistream_genre` | Hierarchical | Artists, Albums, Tracks | +| `fedistream_mood` | Non-hierarchical | Tracks, Playlists | +| `fedistream_license` | Hierarchical | Albums, Tracks | + +### User Roles + +| Role | Slug | Capabilities | +| ---- | ---- | ------------ | +| Artist | `fedistream_artist` | Manage own content, upload files | +| Label | `fedistream_label` | Manage all content, taxonomies, stats | + +### Shortcodes + +| Shortcode | Description | +| --------- | ----------- | +| `[fedistream_player track_id="123"]` | Single track player | +| `[fedistream_playlist id="456"]` | Playlist display | +| `[fedistream_album id="789"]` | Album display | +| `[fedistream_artist id="101"]` | Artist profile | +| `[fedistream_recent_releases count="5"]` | Recent releases | +| `[fedistream_popular_tracks count="10"]` | Popular tracks | +| `[fedistream_library]` | User library page | + +### Key Classes + +- `Plugin` (singleton) - Main controller, initializes all components +- `Installer` - Database setup, activation/deactivation hooks +- `Artist`, `Album`, `Track`, `Playlist` - Post type registration and meta boxes +- `Genre`, `Mood`, `License` - Taxonomy registration with defaults +- `Capabilities` - User role and capability management +- `ActivityPubIntegration` - Integration with WordPress ActivityPub plugin +- `ArtistActor` - Artist profiles as ActivityPub Person/Group actors +- `Inbox` - Process Follow, Like, Announce, Create activities +- `Outbox` - Publish Create, Update activities to followers +- `WooCommerceIntegration` - Custom product types for albums/tracks +- `DigitalDelivery` - Secure download handling with ZIP support +- `StreamingAccess` - Purchase-based streaming control +- `Library` - User favorites, follows, listening history +- `Notifications` - In-app and email notification system + +### REST API Endpoints + +| Endpoint | Method | Purpose | +| -------- | ------ | ------- | +| `/wp-json/fedistream/v1/artists/{id}/actor` | GET | ActivityPub actor profile | +| `/wp-json/fedistream/v1/artists/{id}/inbox` | POST | ActivityPub inbox | +| `/wp-json/fedistream/v1/artists/{id}/outbox` | GET | ActivityPub outbox | +| `/wp-json/fedistream/v1/artists/{id}/followers` | GET | Followers collection | +| `/wp-json/fedistream/v1/artists/{id}/following` | GET | Following collection | + +### AJAX Actions + +| Action | Purpose | +| ------ | ------- | +| `fedistream_record_play` | Record track play | +| `fedistream_toggle_favorite` | Add/remove favorite | +| `fedistream_toggle_follow` | Follow/unfollow artist | +| `fedistream_get_library` | Get user library | +| `fedistream_get_followed_artists` | Get followed artists | +| `fedistream_get_history` | Get listening history | +| `fedistream_clear_history` | Clear listening history | +| `fedistream_get_notifications` | Get user notifications | +| `fedistream_mark_notification_read` | Mark notification read | +| `fedistream_mark_all_notifications_read` | Mark all read | +| `fedistream_delete_notification` | Delete notification | + +--- + +## Session History diff --git a/README.md b/README.md new file mode 100644 index 0000000..38dc931 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# WP FediStream + +Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels. + +[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](CHANGELOG.md) +[![PHP](https://img.shields.io/badge/PHP-%3E%3D8.3-purple.svg)](https://php.net) +[![WordPress](https://img.shields.io/badge/WordPress-%3E%3D6.4-blue.svg)](https://wordpress.org) +[![License](https://img.shields.io/badge/license-GPL--2.0%2B-green.svg)](https://www.gnu.org/licenses/gpl-2.0.html) + +## Description + +WP FediStream is a WordPress plugin that enables musicians, bands, and labels to create their own music streaming platform. It leverages the ActivityPub protocol to connect with the Fediverse, allowing artists to share their music with followers across Mastodon, Pixelfed, and other federated platforms. + +**This project is proudly "vibe-coded" using Claude AI** - the entire codebase was created through AI assistance. + +### Key Features + +- **Artist Management** - Create profiles for solo artists, bands, duos, and collectives +- **Album/Release Management** - Organize music into albums, EPs, singles, and compilations +- **Track Management** - Upload and manage individual tracks with full metadata support +- **Playlist Creation** - Curate playlists with drag-and-drop track ordering +- **Audio Player** - Full-featured player with queue, shuffle, repeat, and volume controls +- **Genre & Mood Taxonomies** - Organize music by genre and mood for easy discovery +- **Licensing Options** - Support for Creative Commons and traditional copyright +- **ActivityPub Integration** - Share releases to the Fediverse with full actor support +- **WooCommerce Integration** - Sell music directly from your site with custom product types +- **User Library** - Favorites, artist follows, and listening history +- **Notifications** - In-app and email notifications for new releases and interactions + +## Requirements + +- PHP 8.3 or higher +- WordPress 6.4 or higher +- Composer (for development/installation) + +### Optional + +- [ActivityPub Plugin](https://wordpress.org/plugins/activitypub/) - For Fediverse integration +- [WooCommerce](https://woocommerce.com/) 10.0+ - For selling music + +## Installation + +### From Source + +1. Clone or download the repository to your WordPress plugins directory: + + ```bash + cd wp-content/plugins/ + git clone https://src.bundespruefstelle.ch/magdev/wp-fedistream.git + ``` + +2. Install Composer dependencies: + + ```bash + cd wp-fedistream + composer install --no-dev + ``` + +3. Activate the plugin in WordPress admin under **Plugins > Installed Plugins** + +4. Navigate to **FediStream** in the admin menu to get started + +## Usage + +### Getting Started + +1. **Add Artists** - Create profiles for your artists or bands +2. **Create Albums** - Set up albums and assign them to artists +3. **Upload Tracks** - Add tracks to albums with audio files +4. **Create Playlists** - Curate collections of tracks +5. **Share via ActivityPub** - Publish releases to the Fediverse and gain followers + +### Custom Post Types + +| Post Type | Description | URL Slug | +|-----------|-------------|----------| +| Artist | Musicians, bands, collectives | `/artists/` | +| Album | Albums, EPs, singles, compilations | `/albums/` | +| Track | Individual audio tracks | `/tracks/` | +| Playlist | Curated track collections | `/playlists/` | + +### Taxonomies + +| Taxonomy | Type | Description | +|----------|------|-------------| +| Genre | Hierarchical | Music genres (Rock > Indie Rock) | +| Mood | Non-hierarchical | Track/playlist moods (Energetic, Calm) | +| License | Hierarchical | Copyright and Creative Commons licenses | + +### Shortcodes + +| Shortcode | Description | +|-----------|-------------| +| `[fedistream_artist id="123"]` | Display artist profile | +| `[fedistream_album id="456"]` | Display album with tracklist | +| `[fedistream_track id="789"]` | Display track with player | +| `[fedistream_playlist id="101"]` | Display playlist with tracks | +| `[fedistream_latest_releases]` | Recent releases grid | +| `[fedistream_popular_tracks]` | Popular tracks list | +| `[fedistream_player]` | Audio player widget | +| `[fedistream_library]` | User library page | + +### User Roles + +| Role | Capabilities | +|------|--------------| +| FediStream Artist | Manage own content, upload files, view stats | +| FediStream Label | Manage all content, manage taxonomies, view all stats | + +## File Structure + +```txt +wp-fedistream/ +├── assets/ +│ ├── css/ # Stylesheets +│ ├── js/ # JavaScript +│ └── images/ # Images +├── includes/ +│ ├── ActivityPub/ # ActivityPub integration +│ ├── Admin/ # Admin interface classes +│ ├── Frontend/ # Frontend components +│ ├── PostTypes/ # Custom post type classes +│ ├── Taxonomies/ # Custom taxonomy classes +│ ├── Roles/ # User roles and capabilities +│ ├── User/ # User library and notifications +│ ├── WooCommerce/ # WooCommerce integration +│ ├── Plugin.php # Main plugin singleton +│ └── Installer.php # Activation/deactivation +├── languages/ # Translation files +├── templates/ # Twig templates +├── vendor/ # Composer dependencies +├── CHANGELOG.md # Version history +└── wp-fedistream.php # Plugin entry point +``` + +## Contributing + +This project is in early development. Contributions, bug reports, and feature requests are welcome. + +- **Repository:** +- **Issues:** + +## License + +This project is licensed under the GPL v2 or later. + +## Author + +**Marco Graetsch** + +- Website: +- Email: + +--- + +*Built with Claude AI* diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..b0413b9 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,7 @@ +/** + * WP FediStream - Admin Styles + * + * @package WP_FediStream + */ + +/* Admin styles will be added here */ diff --git a/assets/css/frontend.css b/assets/css/frontend.css new file mode 100644 index 0000000..8adff80 --- /dev/null +++ b/assets/css/frontend.css @@ -0,0 +1,1408 @@ +/** + * WP FediStream - Frontend Styles + * + * @package WP_FediStream + */ + +/* ========================================================================== + CSS Custom Properties + ========================================================================== */ + +:root { + --fedistream-primary: #6366f1; + --fedistream-primary-hover: #4f46e5; + --fedistream-secondary: #64748b; + --fedistream-success: #22c55e; + --fedistream-warning: #f59e0b; + --fedistream-danger: #ef4444; + --fedistream-text: #1e293b; + --fedistream-text-muted: #64748b; + --fedistream-bg: #ffffff; + --fedistream-bg-alt: #f8fafc; + --fedistream-border: #e2e8f0; + --fedistream-radius: 8px; + --fedistream-radius-lg: 12px; + --fedistream-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --fedistream-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --fedistream-transition: 0.2s ease; + --fedistream-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +} + +/* ========================================================================== + Base Styles + ========================================================================== */ + +.fedistream-main { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +/* ========================================================================== + Grid System + ========================================================================== */ + +.fedistream-grid { + display: grid; + gap: 1.5rem; +} + +.fedistream-grid--artists, +.fedistream-grid--albums { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); +} + +.fedistream-grid--tracks, +.fedistream-grid--playlists { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} + +.fedistream-grid--mixed { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} + +.fedistream-grid--small { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1rem; +} + +.fedistream-grid--cols-2 { grid-template-columns: repeat(2, 1fr); } +.fedistream-grid--cols-3 { grid-template-columns: repeat(3, 1fr); } +.fedistream-grid--cols-4 { grid-template-columns: repeat(4, 1fr); } +.fedistream-grid--cols-5 { grid-template-columns: repeat(5, 1fr); } +.fedistream-grid--cols-6 { grid-template-columns: repeat(6, 1fr); } + +@media (max-width: 768px) { + .fedistream-grid--cols-3, + .fedistream-grid--cols-4, + .fedistream-grid--cols-5, + .fedistream-grid--cols-6 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .fedistream-grid { + grid-template-columns: 1fr; + } +} + +/* ========================================================================== + Cards + ========================================================================== */ + +.fedistream-card { + background: var(--fedistream-bg); + border-radius: var(--fedistream-radius-lg); + overflow: hidden; + transition: transform var(--fedistream-transition), box-shadow var(--fedistream-transition); +} + +.fedistream-card:hover { + transform: translateY(-4px); + box-shadow: var(--fedistream-shadow-lg); +} + +.fedistream-card__link { + display: block; + text-decoration: none; + color: inherit; +} + +.fedistream-card__image { + position: relative; + aspect-ratio: 1; + overflow: hidden; + background: var(--fedistream-bg-alt); +} + +.fedistream-card__image img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--fedistream-transition); +} + +.fedistream-card:hover .fedistream-card__image img { + transform: scale(1.05); +} + +.fedistream-card__image--circle img, +.fedistream-card__image--circle .fedistream-card__placeholder { + border-radius: 50%; +} + +.fedistream-card__placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--fedistream-bg-alt), var(--fedistream-border)); + color: var(--fedistream-text-muted); +} + +.fedistream-card__placeholder svg { + width: 40%; + height: 40%; +} + +.fedistream-card__badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + background: rgba(0, 0, 0, 0.7); + color: #fff; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.fedistream-card__badge--private { + background: var(--fedistream-secondary); +} + +.fedistream-card__badge--explicit { + background: var(--fedistream-danger); +} + +.fedistream-card__content { + padding: 1rem; +} + +.fedistream-card__title { + margin: 0 0 0.25rem; + font-size: 1rem; + font-weight: 600; + color: var(--fedistream-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-card__artist, +.fedistream-card__author { + margin: 0 0 0.5rem; + font-size: 0.875rem; + color: var(--fedistream-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-card__type { + display: inline-block; + padding: 0.125rem 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--fedistream-bg-alt); + border-radius: 4px; + color: var(--fedistream-text-muted); +} + +.fedistream-card__meta { + display: flex; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--fedistream-text-muted); +} + +.fedistream-card__play-overlay { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--fedistream-primary); + border: none; + border-radius: 50%; + color: #fff; + cursor: pointer; + opacity: 0; + transform: translateY(8px); + transition: opacity var(--fedistream-transition), transform var(--fedistream-transition), background var(--fedistream-transition); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} + +.fedistream-card:hover .fedistream-card__play-overlay { + opacity: 1; + transform: translateY(0); +} + +.fedistream-card__play-overlay:hover { + background: var(--fedistream-primary-hover); +} + +.fedistream-card__play-overlay svg { + width: 24px; + height: 24px; +} + +/* ========================================================================== + Archive Pages + ========================================================================== */ + +.fedistream-archive__header { + margin-bottom: 2rem; +} + +.fedistream-archive__title { + margin: 0 0 0.5rem; + font-size: 2rem; + font-weight: 700; + color: var(--fedistream-text); +} + +.fedistream-archive__description { + color: var(--fedistream-text-muted); + font-size: 1rem; + line-height: 1.6; +} + +/* ========================================================================== + Single Pages + ========================================================================== */ + +.fedistream-single__header { + display: grid; + gap: 2rem; + margin-bottom: 2rem; +} + +.fedistream-single__header--album, +.fedistream-single__header--track, +.fedistream-single__header--playlist { + grid-template-columns: 300px 1fr; + align-items: end; +} + +@media (max-width: 768px) { + .fedistream-single__header--album, + .fedistream-single__header--track, + .fedistream-single__header--playlist { + grid-template-columns: 1fr; + text-align: center; + } +} + +.fedistream-single__hero { + max-width: 300px; + margin: 0 auto 1rem; +} + +.fedistream-single__artwork { + position: relative; +} + +.fedistream-single__image { + width: 100%; + border-radius: var(--fedistream-radius-lg); + box-shadow: var(--fedistream-shadow-lg); +} + +.fedistream-single__image--artist { + border-radius: 50%; +} + +.fedistream-single__placeholder { + width: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--fedistream-bg-alt), var(--fedistream-border)); + border-radius: var(--fedistream-radius-lg); + color: var(--fedistream-text-muted); +} + +.fedistream-single__placeholder--artist { + border-radius: 50%; +} + +.fedistream-single__placeholder svg { + width: 40%; + height: 40%; +} + +.fedistream-single__play-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + color: var(--fedistream-primary); + cursor: pointer; + opacity: 0; + transition: opacity var(--fedistream-transition); + box-shadow: var(--fedistream-shadow-lg); +} + +.fedistream-single__artwork:hover .fedistream-single__play-overlay { + opacity: 1; +} + +.fedistream-single__play-overlay svg { + width: 40px; + height: 40px; + margin-left: 4px; +} + +.fedistream-single__badge { + position: absolute; + top: 1rem; + right: 1rem; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgba(0, 0, 0, 0.7); + border-radius: 4px; + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} + +.fedistream-single__type-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + margin-bottom: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--fedistream-primary); + color: #fff; + border-radius: 4px; +} + +.fedistream-single__title { + margin: 0 0 0.5rem; + font-size: 2rem; + font-weight: 700; + color: var(--fedistream-text); + line-height: 1.2; +} + +.fedistream-single__artist, +.fedistream-single__artists, +.fedistream-single__author { + margin: 0 0 0.5rem; + font-size: 1.125rem; + color: var(--fedistream-text-muted); +} + +.fedistream-single__artist a, +.fedistream-single__artists a, +.fedistream-single__author a { + color: var(--fedistream-text); + text-decoration: none; +} + +.fedistream-single__artist a:hover, +.fedistream-single__artists a:hover, +.fedistream-single__author a:hover { + text-decoration: underline; +} + +.fedistream-single__album { + margin: 0 0 0.75rem; + font-size: 0.875rem; + color: var(--fedistream-text-muted); +} + +.fedistream-single__album a { + color: var(--fedistream-text-muted); + text-decoration: none; +} + +.fedistream-single__album a:hover { + color: var(--fedistream-primary); +} + +.fedistream-single__type { + margin: 0 0 0.5rem; + font-size: 0.875rem; + color: var(--fedistream-text-muted); + text-transform: capitalize; +} + +.fedistream-single__meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--fedistream-text-muted); +} + +.fedistream-single__genres, +.fedistream-single__moods { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.fedistream-single__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.fedistream-single__content, +.fedistream-single__credits, +.fedistream-single__lyrics, +.fedistream-single__license, +.fedistream-single__social, +.fedistream-single__albums, +.fedistream-single__tracks { + margin-bottom: 2rem; +} + +.fedistream-single__description { + line-height: 1.7; + color: var(--fedistream-text); +} + +.fedistream-section__title { + margin: 0 0 1rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--fedistream-text); +} + +/* ========================================================================== + Tracklist + ========================================================================== */ + +.fedistream-tracklist { + display: flex; + flex-direction: column; +} + +.fedistream-tracklist__item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--fedistream-radius); + transition: background var(--fedistream-transition); +} + +.fedistream-tracklist__item:hover { + background: var(--fedistream-bg-alt); +} + +.fedistream-tracklist__item.is-playing { + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-tracklist__item.is-playing .fedistream-tracklist__duration, +.fedistream-tracklist__item.is-playing .fedistream-tracklist__number { + color: rgba(255, 255, 255, 0.8); +} + +.fedistream-tracklist__number, +.fedistream-tracklist__rank { + width: 2rem; + text-align: center; + font-size: 0.875rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; +} + +.fedistream-tracklist__artwork { + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.fedistream-tracklist__artwork--placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--fedistream-bg-alt); + color: var(--fedistream-text-muted); +} + +.fedistream-tracklist__artwork--placeholder svg { + width: 20px; + height: 20px; +} + +.fedistream-tracklist__info { + flex: 1; + min-width: 0; +} + +.fedistream-tracklist__title { + display: block; + font-weight: 500; + color: var(--fedistream-text); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-tracklist__title:hover { + text-decoration: underline; +} + +.fedistream-tracklist__artist, +.fedistream-tracklist__album, +.fedistream-tracklist__featuring { + display: block; + font-size: 0.8125rem; + color: var(--fedistream-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-tracklist__artist a { + color: inherit; + text-decoration: none; +} + +.fedistream-tracklist__artist a:hover { + text-decoration: underline; +} + +.fedistream-tracklist__plays { + font-size: 0.8125rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; +} + +.fedistream-tracklist__duration { + width: 4rem; + text-align: right; + font-size: 0.875rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; +} + +.fedistream-tracklist__play { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--fedistream-text); + cursor: pointer; + opacity: 0; + transition: opacity var(--fedistream-transition), background var(--fedistream-transition); +} + +.fedistream-tracklist__item:hover .fedistream-tracklist__play { + opacity: 1; +} + +.fedistream-tracklist__play:hover { + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-tracklist__play svg { + width: 16px; + height: 16px; +} + +/* ========================================================================== + Player + ========================================================================== */ + +.fedistream-player { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--fedistream-bg); + border-radius: var(--fedistream-radius-lg); + box-shadow: var(--fedistream-shadow); +} + +.fedistream-player--inline { + padding: 0.75rem; +} + +.fedistream-player__controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.fedistream-player__btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--fedistream-text); + cursor: pointer; + transition: background var(--fedistream-transition), color var(--fedistream-transition); +} + +.fedistream-player__btn:hover { + background: var(--fedistream-bg-alt); +} + +.fedistream-player__btn--play { + width: 48px; + height: 48px; + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-player__btn--play:hover { + background: var(--fedistream-primary-hover); +} + +.fedistream-player__btn svg { + width: 24px; + height: 24px; +} + +.fedistream-player__icon--pause { + display: none; +} + +.fedistream-player.is-playing .fedistream-player__icon--play { + display: none; +} + +.fedistream-player.is-playing .fedistream-player__icon--pause { + display: block; +} + +.fedistream-player__progress { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.fedistream-player__time { + font-size: 0.75rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; + min-width: 3rem; +} + +.fedistream-player__time--current { + text-align: right; +} + +.fedistream-player__bar { + flex: 1; + position: relative; + height: 6px; + background: var(--fedistream-border); + border-radius: 3px; + cursor: pointer; +} + +.fedistream-player__bar-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--fedistream-primary); + border-radius: 3px; + width: 0%; + transition: width 0.1s linear; +} + +.fedistream-player__seek { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.fedistream-player__volume { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.fedistream-player__volume-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--fedistream-border); + border-radius: 2px; + cursor: pointer; +} + +.fedistream-player__volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: var(--fedistream-primary); + border-radius: 50%; + cursor: pointer; +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.fedistream-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + border: none; + border-radius: var(--fedistream-radius); + cursor: pointer; + transition: background var(--fedistream-transition), color var(--fedistream-transition), transform var(--fedistream-transition); +} + +.fedistream-btn:active { + transform: scale(0.98); +} + +.fedistream-btn--primary { + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-btn--primary:hover { + background: var(--fedistream-primary-hover); + color: #fff; +} + +.fedistream-btn--secondary { + background: var(--fedistream-bg-alt); + color: var(--fedistream-text); +} + +.fedistream-btn--secondary:hover { + background: var(--fedistream-border); +} + +.fedistream-btn svg { + width: 20px; + height: 20px; +} + +/* ========================================================================== + Tags + ========================================================================== */ + +.fedistream-tag { + display: inline-block; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + text-decoration: none; + background: var(--fedistream-bg-alt); + color: var(--fedistream-text); + border-radius: 9999px; + transition: background var(--fedistream-transition), color var(--fedistream-transition); +} + +.fedistream-tag:hover { + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-tag--mood { + background: #fef3c7; + color: #92400e; +} + +.fedistream-tag--mood:hover { + background: var(--fedistream-warning); + color: #fff; +} + +.fedistream-tag--small { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; +} + +/* ========================================================================== + Badge + ========================================================================== */ + +.fedistream-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.375rem; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + background: var(--fedistream-secondary); + color: #fff; + border-radius: 4px; +} + +.fedistream-badge--explicit { + background: var(--fedistream-danger); +} + +/* ========================================================================== + Social Links + ========================================================================== */ + +.fedistream-social-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.fedistream-social-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + background: var(--fedistream-bg-alt); + color: var(--fedistream-text); + border-radius: var(--fedistream-radius); + transition: background var(--fedistream-transition), color var(--fedistream-transition); +} + +.fedistream-social-link:hover { + background: var(--fedistream-text); + color: #fff; +} + +/* ========================================================================== + Empty State + ========================================================================== */ + +.fedistream-empty { + padding: 3rem; + text-align: center; + color: var(--fedistream-text-muted); +} + +/* ========================================================================== + Error State + ========================================================================== */ + +.fedistream-error { + padding: 1rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--fedistream-radius); + color: var(--fedistream-danger); +} + +/* ========================================================================== + Pagination + ========================================================================== */ + +.fedistream-pagination { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-top: 2rem; +} + +.fedistream-pagination a, +.fedistream-pagination span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + padding: 0 0.75rem; + font-size: 0.875rem; + text-decoration: none; + background: var(--fedistream-bg); + color: var(--fedistream-text); + border: 1px solid var(--fedistream-border); + border-radius: var(--fedistream-radius); + transition: background var(--fedistream-transition), border-color var(--fedistream-transition); +} + +.fedistream-pagination a:hover { + background: var(--fedistream-bg-alt); + border-color: var(--fedistream-primary); +} + +.fedistream-pagination .current { + background: var(--fedistream-primary); + color: #fff; + border-color: var(--fedistream-primary); +} + +/* ========================================================================== + Widgets + ========================================================================== */ + +.fedistream-widget__list { + list-style: none; + margin: 0; + padding: 0; +} + +.fedistream-widget__item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--fedistream-border); +} + +.fedistream-widget__item:last-child { + border-bottom: none; +} + +.fedistream-widget__link { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + text-decoration: none; + color: inherit; +} + +.fedistream-widget__image { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.fedistream-widget__placeholder { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--fedistream-bg-alt); + border-radius: 4px; + color: var(--fedistream-text-muted); + flex-shrink: 0; +} + +.fedistream-widget__placeholder svg { + width: 24px; + height: 24px; +} + +.fedistream-widget__placeholder--large { + width: 100%; + height: auto; + aspect-ratio: 1; +} + +.fedistream-widget__placeholder--large svg { + width: 40%; + height: 40%; +} + +.fedistream-widget__info { + flex: 1; + min-width: 0; +} + +.fedistream-widget__title { + display: block; + font-weight: 500; + color: var(--fedistream-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-widget__artist, +.fedistream-widget__date { + display: block; + font-size: 0.8125rem; + color: var(--fedistream-text-muted); +} + +.fedistream-widget__plays { + font-size: 0.75rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; +} + +.fedistream-widget__play { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--fedistream-text-muted); + cursor: pointer; + transition: background var(--fedistream-transition), color var(--fedistream-transition); +} + +.fedistream-widget__play:hover { + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-widget__play svg { + width: 14px; + height: 14px; +} + +.fedistream-widget__featured { + text-align: center; +} + +.fedistream-widget__featured-link { + display: block; + margin-bottom: 1rem; +} + +.fedistream-widget__featured-image { + width: 100%; + border-radius: var(--fedistream-radius-lg); +} + +.fedistream-widget__featured-name { + margin: 0 0 0.25rem; + font-size: 1.125rem; + font-weight: 600; +} + +.fedistream-widget__featured-name a { + color: var(--fedistream-text); + text-decoration: none; +} + +.fedistream-widget__featured-name a:hover { + text-decoration: underline; +} + +.fedistream-widget__featured-type { + display: block; + font-size: 0.875rem; + color: var(--fedistream-text-muted); + margin-bottom: 0.5rem; +} + +.fedistream-widget__featured-genres { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.25rem; + margin-bottom: 0.5rem; +} + +.fedistream-widget__featured-stats { + display: flex; + justify-content: center; + gap: 1rem; + font-size: 0.8125rem; + color: var(--fedistream-text-muted); +} + +.fedistream-widget__empty { + padding: 1rem 0; + text-align: center; + color: var(--fedistream-text-muted); + font-size: 0.875rem; +} + +/* ========================================================================== + Now Playing Widget + ========================================================================== */ + +.fedistream-now-playing__idle { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1rem; + color: var(--fedistream-text-muted); +} + +.fedistream-now-playing__idle svg { + width: 48px; + height: 48px; + opacity: 0.5; +} + +.fedistream-now-playing__message { + font-size: 0.875rem; +} + +.fedistream-now-playing__track { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.fedistream-now-playing__artwork { + width: 64px; + height: 64px; + border-radius: var(--fedistream-radius); + object-fit: cover; +} + +.fedistream-now-playing__info { + flex: 1; + min-width: 0; +} + +.fedistream-now-playing__title { + display: block; + font-weight: 600; + color: var(--fedistream-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fedistream-now-playing__artist { + display: block; + font-size: 0.875rem; + color: var(--fedistream-text-muted); +} + +.fedistream-now-playing__controls { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.fedistream-now-playing__btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--fedistream-text); + cursor: pointer; + transition: background var(--fedistream-transition); +} + +.fedistream-now-playing__btn:hover { + background: var(--fedistream-bg-alt); +} + +.fedistream-now-playing__btn--play { + width: 44px; + height: 44px; + background: var(--fedistream-primary); + color: #fff; +} + +.fedistream-now-playing__btn--play:hover { + background: var(--fedistream-primary-hover); +} + +.fedistream-now-playing__btn svg { + width: 20px; + height: 20px; +} + +.fedistream-now-playing__icon--pause { + display: none; +} + +.fedistream-now-playing.is-playing .fedistream-now-playing__icon--play { + display: none; +} + +.fedistream-now-playing.is-playing .fedistream-now-playing__icon--pause { + display: block; +} + +.fedistream-now-playing__progress { + padding: 0 0.5rem; +} + +.fedistream-now-playing__bar { + height: 4px; + background: var(--fedistream-border); + border-radius: 2px; + margin-bottom: 0.25rem; +} + +.fedistream-now-playing__bar-progress { + height: 100%; + background: var(--fedistream-primary); + border-radius: 2px; + width: 0%; +} + +.fedistream-now-playing__times { + display: flex; + justify-content: space-between; + font-size: 0.6875rem; + color: var(--fedistream-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ========================================================================== + Shortcodes + ========================================================================== */ + +.fedistream-shortcode { + margin: 1.5rem 0; +} + +.fedistream-shortcode__title { + margin: 0 0 1rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--fedistream-text); +} + +/* Compact layout adjustments */ +.fedistream-shortcode--compact .fedistream-tracklist__item { + padding: 0.5rem; +} + +.fedistream-shortcode--compact .fedistream-tracklist__artwork { + width: 32px; + height: 32px; +} + +/* Player widget styles */ +.fedistream-player-widget--compact .fedistream-player { + padding: 0.5rem; +} + +.fedistream-player-widget--mini .fedistream-player { + padding: 0.5rem; +} + +.fedistream-player-widget--mini .fedistream-player__volume { + display: none; +} + +/* ========================================================================== + License Display + ========================================================================== */ + +.fedistream-license { + font-size: 0.875rem; + color: var(--fedistream-text-muted); +} + +.fedistream-license a { + color: var(--fedistream-primary); + text-decoration: none; +} + +.fedistream-license a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Lyrics + ========================================================================== */ + +.fedistream-lyrics { + font-size: 1rem; + line-height: 1.8; + color: var(--fedistream-text); + white-space: pre-line; +} + +/* ========================================================================== + Credits + ========================================================================== */ + +.fedistream-credits { + font-size: 0.875rem; + line-height: 1.7; + color: var(--fedistream-text-muted); +} + +/* ========================================================================== + Loading States + ========================================================================== */ + +.fedistream-player.is-loading .fedistream-player__btn--play { + position: relative; + color: transparent; +} + +.fedistream-player.is-loading .fedistream-player__btn--play::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: fedistream-spin 0.8s linear infinite; +} + +@keyframes fedistream-spin { + to { + transform: rotate(360deg); + } +} + +/* Active button states */ +.fedistream-player__btn--shuffle.is-active, +.fedistream-player__btn--repeat.is-active { + color: var(--fedistream-primary); +} + +.fedistream-player__btn--repeat.is-repeat-one::after { + content: '1'; + position: absolute; + font-size: 0.5rem; + font-weight: 700; +} diff --git a/assets/css/index.php b/assets/css/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/assets/css/index.php @@ -0,0 +1 @@ + this.onTimeUpdate()); + this.audio.addEventListener('loadedmetadata', () => this.onLoadedMetadata()); + this.audio.addEventListener('ended', () => this.onEnded()); + this.audio.addEventListener('play', () => this.onPlay()); + this.audio.addEventListener('pause', () => this.onPause()); + this.audio.addEventListener('error', (e) => this.onError(e)); + this.audio.addEventListener('waiting', () => this.onWaiting()); + this.audio.addEventListener('canplay', () => this.onCanPlay()); + } + + /** + * Bind UI event listeners + */ + bindUIEvents() { + // Play buttons on cards and tracklists + document.addEventListener('click', (e) => { + const playBtn = e.target.closest('[data-track-id]'); + if (playBtn && (playBtn.classList.contains('fedistream-card__play-overlay') || + playBtn.classList.contains('fedistream-tracklist__play') || + playBtn.classList.contains('fedistream-widget__play') || + playBtn.classList.contains('fedistream-single__play-overlay') || + playBtn.classList.contains('fedistream-track__play-overlay'))) { + e.preventDefault(); + const trackId = playBtn.dataset.trackId; + this.playTrackById(trackId); + } + + // Tracklist item click (not on play button or link) + const tracklistItem = e.target.closest('.fedistream-tracklist__item'); + if (tracklistItem && !e.target.closest('a') && !e.target.closest('button')) { + e.preventDefault(); + const trackId = tracklistItem.dataset.trackId; + if (trackId) { + this.playTrackById(trackId); + } + } + + // Play all button + if (e.target.closest('.fedistream-btn--play-all')) { + e.preventDefault(); + const btn = e.target.closest('.fedistream-btn--play-all'); + const albumId = btn.dataset.albumId; + const playlistId = btn.dataset.playlistId; + if (albumId) { + this.playAlbum(albumId); + } else if (playlistId) { + this.playPlaylist(playlistId); + } + } + + // Shuffle button + if (e.target.closest('.fedistream-btn--shuffle')) { + e.preventDefault(); + const btn = e.target.closest('.fedistream-btn--shuffle'); + const albumId = btn.dataset.albumId; + const playlistId = btn.dataset.playlistId; + if (albumId) { + this.playAlbum(albumId, true); + } else if (playlistId) { + this.playPlaylist(playlistId, true); + } + } + }); + + // Player controls + document.addEventListener('click', (e) => { + // Main play/pause button + if (e.target.closest('.fedistream-player__btn--play')) { + e.preventDefault(); + this.togglePlayPause(); + } + + // Previous button + if (e.target.closest('.fedistream-player__btn--prev')) { + e.preventDefault(); + this.previous(); + } + + // Next button + if (e.target.closest('.fedistream-player__btn--next')) { + e.preventDefault(); + this.next(); + } + + // Shuffle button + if (e.target.closest('.fedistream-player__btn--shuffle')) { + e.preventDefault(); + this.toggleShuffle(); + e.target.closest('.fedistream-player__btn--shuffle').classList.toggle('is-active', this.isShuffle); + } + + // Repeat button + if (e.target.closest('.fedistream-player__btn--repeat')) { + e.preventDefault(); + this.toggleRepeat(); + const btn = e.target.closest('.fedistream-player__btn--repeat'); + btn.classList.remove('is-active', 'is-repeat-one'); + if (this.repeatMode === 'all') { + btn.classList.add('is-active'); + } else if (this.repeatMode === 'one') { + btn.classList.add('is-active', 'is-repeat-one'); + } + } + + // Volume button (mute toggle) + if (e.target.closest('.fedistream-player__btn--volume')) { + e.preventDefault(); + this.toggleMute(); + } + }); + + // Seek slider + document.addEventListener('input', (e) => { + if (e.target.classList.contains('fedistream-player__seek')) { + const value = e.target.value; + const duration = this.audio.duration || 0; + this.audio.currentTime = (value / 100) * duration; + } + + // Volume slider + if (e.target.classList.contains('fedistream-player__volume-slider')) { + const value = e.target.value / 100; + this.setVolume(value); + } + }); + + // Click on progress bar + document.addEventListener('click', (e) => { + if (e.target.classList.contains('fedistream-player__bar')) { + const rect = e.target.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + const duration = this.audio.duration || 0; + this.audio.currentTime = percent * duration; + } + }); + + // Queue item click (in multi-track player) + document.addEventListener('click', (e) => { + const queueItem = e.target.closest('.fedistream-tracklist--queue .fedistream-tracklist__item'); + if (queueItem && !e.target.closest('a')) { + e.preventDefault(); + const index = parseInt(queueItem.dataset.trackIndex, 10); + if (!isNaN(index)) { + this.playAtIndex(index); + } + } + }); + + // Now playing widget controls + document.addEventListener('click', (e) => { + if (e.target.closest('.fedistream-now-playing__btn--play')) { + e.preventDefault(); + this.togglePlayPause(); + } + if (e.target.closest('.fedistream-now-playing__btn--prev')) { + e.preventDefault(); + this.previous(); + } + if (e.target.closest('.fedistream-now-playing__btn--next')) { + e.preventDefault(); + this.next(); + } + }); + } + + /** + * Initialize inline players + */ + initInlinePlayers() { + const players = document.querySelectorAll('.fedistream-player[data-track-id][data-audio-url]'); + players.forEach(player => { + const trackId = player.dataset.trackId; + const audioUrl = player.dataset.audioUrl; + + // Store reference for later + player._fedistream = { + trackId, + audioUrl + }; + }); + } + + /** + * Initialize multi-track players + */ + initMultiTrackPlayers() { + const players = document.querySelectorAll('.fedistream-player--multi[data-tracks]'); + players.forEach(player => { + try { + const tracks = JSON.parse(player.dataset.tracks); + player._fedistream = { tracks }; + } catch (e) { + console.error('Failed to parse tracks data', e); + } + }); + } + + /** + * Initialize now playing widgets + */ + initNowPlayingWidgets() { + this.nowPlayingWidgets = document.querySelectorAll('[data-widget="now-playing"]'); + } + + /** + * Play a track by ID + */ + async playTrackById(trackId) { + // Check if we have track data in the DOM + const trackElement = document.querySelector(`[data-track-id="${trackId}"]`); + let audioUrl = null; + + // Try to get audio URL from inline player + const inlinePlayer = document.querySelector(`.fedistream-player[data-track-id="${trackId}"]`); + if (inlinePlayer && inlinePlayer.dataset.audioUrl) { + audioUrl = inlinePlayer.dataset.audioUrl; + } + + // If no audio URL, try to fetch from API + if (!audioUrl) { + const trackData = await this.fetchTrackData(trackId); + if (trackData && trackData.audio_url) { + audioUrl = trackData.audio_url; + } + } + + if (!audioUrl) { + console.error('No audio URL found for track', trackId); + return; + } + + // Build track object + const track = { + id: trackId, + audio_url: audioUrl, + title: this.getTrackTitle(trackId), + artist: this.getTrackArtist(trackId), + thumbnail: this.getTrackThumbnail(trackId) + }; + + // Clear queue and play single track + this.clearQueue(); + this.addToQueue(track); + this.playAtIndex(0); + } + + /** + * Fetch track data from API + */ + async fetchTrackData(trackId) { + if (!window.wpFediStream || !window.wpFediStream.ajaxUrl) { + return null; + } + + try { + const formData = new FormData(); + formData.append('action', 'fedistream_get_track'); + formData.append('track_id', trackId); + formData.append('nonce', window.wpFediStream.nonce); + + const response = await fetch(window.wpFediStream.ajaxUrl, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + if (data.success) { + return data.data; + } + } catch (e) { + console.error('Failed to fetch track data', e); + } + + return null; + } + + /** + * Get track title from DOM + */ + getTrackTitle(trackId) { + const el = document.querySelector(`[data-track-id="${trackId}"] .fedistream-tracklist__title, [data-track-id="${trackId}"] .fedistream-card__title`); + return el ? el.textContent.trim() : 'Unknown Track'; + } + + /** + * Get track artist from DOM + */ + getTrackArtist(trackId) { + const el = document.querySelector(`[data-track-id="${trackId}"] .fedistream-tracklist__artist, [data-track-id="${trackId}"] .fedistream-card__artist`); + return el ? el.textContent.trim() : 'Unknown Artist'; + } + + /** + * Get track thumbnail from DOM + */ + getTrackThumbnail(trackId) { + const el = document.querySelector(`[data-track-id="${trackId}"] .fedistream-tracklist__artwork, [data-track-id="${trackId}"] .fedistream-card__image img`); + return el ? el.src : ''; + } + + /** + * Play album + */ + async playAlbum(albumId, shuffle = false) { + const tracklist = document.querySelector(`.fedistream-btn--play-all[data-album-id="${albumId}"]`)?.closest('.fedistream-single')?.querySelector('.fedistream-tracklist'); + + if (tracklist) { + this.playTracklist(tracklist, shuffle); + } + } + + /** + * Play playlist + */ + async playPlaylist(playlistId, shuffle = false) { + const tracklist = document.querySelector(`.fedistream-btn--play-all[data-playlist-id="${playlistId}"]`)?.closest('.fedistream-single')?.querySelector('.fedistream-tracklist'); + + if (tracklist) { + this.playTracklist(tracklist, shuffle); + } + } + + /** + * Play all tracks from a tracklist element + */ + playTracklist(tracklistElement, shuffle = false) { + const items = tracklistElement.querySelectorAll('.fedistream-tracklist__item[data-track-id]'); + const tracks = []; + + items.forEach(item => { + const trackId = item.dataset.trackId; + const titleEl = item.querySelector('.fedistream-tracklist__title'); + const artistEl = item.querySelector('.fedistream-tracklist__artist'); + const artworkEl = item.querySelector('.fedistream-tracklist__artwork'); + + tracks.push({ + id: trackId, + title: titleEl ? titleEl.textContent.trim() : 'Unknown Track', + artist: artistEl ? artistEl.textContent.trim() : '', + thumbnail: artworkEl ? artworkEl.src : '' + }); + }); + + if (tracks.length === 0) return; + + this.clearQueue(); + tracks.forEach(track => this.addToQueue(track)); + + if (shuffle) { + this.shuffleQueue(); + } + + this.playAtIndex(0); + } + + /** + * Add track to queue + */ + addToQueue(track) { + this.queue.push(track); + this.originalQueue.push(track); + } + + /** + * Clear queue + */ + clearQueue() { + this.queue = []; + this.originalQueue = []; + this.currentIndex = -1; + } + + /** + * Shuffle queue + */ + shuffleQueue() { + const currentTrack = this.currentIndex >= 0 ? this.queue[this.currentIndex] : null; + + // Fisher-Yates shuffle + for (let i = this.queue.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this.queue[i], this.queue[j]] = [this.queue[j], this.queue[i]]; + } + + // Keep current track at current position if playing + if (currentTrack) { + const newIndex = this.queue.indexOf(currentTrack); + if (newIndex !== this.currentIndex) { + [this.queue[this.currentIndex], this.queue[newIndex]] = [this.queue[newIndex], this.queue[this.currentIndex]]; + } + } + + this.isShuffle = true; + } + + /** + * Restore original queue order + */ + restoreQueueOrder() { + const currentTrack = this.currentIndex >= 0 ? this.queue[this.currentIndex] : null; + this.queue = [...this.originalQueue]; + + if (currentTrack) { + this.currentIndex = this.queue.findIndex(t => t.id === currentTrack.id); + } + + this.isShuffle = false; + } + + /** + * Play track at index + */ + async playAtIndex(index) { + if (index < 0 || index >= this.queue.length) return; + + const track = this.queue[index]; + this.currentIndex = index; + + // Get audio URL if not present + if (!track.audio_url) { + const data = await this.fetchTrackData(track.id); + if (data && data.audio_url) { + track.audio_url = data.audio_url; + } else { + console.error('Could not load audio for track', track.id); + this.next(); + return; + } + } + + this.audio.src = track.audio_url; + this.audio.load(); + this.audio.play().catch(e => { + console.error('Playback failed', e); + }); + + this.updateNowPlaying(track); + this.updateTracklistHighlight(); + this.recordPlay(track.id); + } + + /** + * Toggle play/pause + */ + togglePlayPause() { + if (this.isPlaying) { + this.audio.pause(); + } else { + if (this.audio.src) { + this.audio.play().catch(e => console.error('Playback failed', e)); + } else if (this.queue.length > 0) { + this.playAtIndex(this.currentIndex >= 0 ? this.currentIndex : 0); + } + } + } + + /** + * Play next track + */ + next() { + if (this.queue.length === 0) return; + + let nextIndex = this.currentIndex + 1; + + if (nextIndex >= this.queue.length) { + if (this.repeatMode === 'all') { + nextIndex = 0; + } else { + return; + } + } + + this.playAtIndex(nextIndex); + } + + /** + * Play previous track + */ + previous() { + if (this.queue.length === 0) return; + + // If more than 3 seconds into track, restart it + if (this.audio.currentTime > 3) { + this.audio.currentTime = 0; + return; + } + + let prevIndex = this.currentIndex - 1; + + if (prevIndex < 0) { + if (this.repeatMode === 'all') { + prevIndex = this.queue.length - 1; + } else { + this.audio.currentTime = 0; + return; + } + } + + this.playAtIndex(prevIndex); + } + + /** + * Toggle shuffle mode + */ + toggleShuffle() { + if (this.isShuffle) { + this.restoreQueueOrder(); + } else { + this.shuffleQueue(); + } + } + + /** + * Toggle repeat mode + */ + toggleRepeat() { + const modes = ['none', 'all', 'one']; + const currentModeIndex = modes.indexOf(this.repeatMode); + this.repeatMode = modes[(currentModeIndex + 1) % modes.length]; + } + + /** + * Set volume + */ + setVolume(value) { + this.volume = Math.max(0, Math.min(1, value)); + this.audio.volume = this.volume; + this.audio.muted = false; + this.saveVolumeToStorage(); + this.updateVolumeUI(); + } + + /** + * Toggle mute + */ + toggleMute() { + this.audio.muted = !this.audio.muted; + this.updateVolumeUI(); + } + + /** + * Save volume to localStorage + */ + saveVolumeToStorage() { + try { + localStorage.setItem('fedistream_volume', this.volume.toString()); + } catch (e) { + // Storage not available + } + } + + /** + * Load volume from localStorage + */ + loadVolumeFromStorage() { + try { + const stored = localStorage.getItem('fedistream_volume'); + if (stored !== null) { + this.volume = parseFloat(stored); + this.audio.volume = this.volume; + this.updateVolumeUI(); + } + } catch (e) { + // Storage not available + } + } + + /** + * Update volume UI + */ + updateVolumeUI() { + const sliders = document.querySelectorAll('.fedistream-player__volume-slider'); + sliders.forEach(slider => { + slider.value = this.audio.muted ? 0 : this.volume * 100; + }); + } + + /** + * Format time in mm:ss + */ + formatTime(seconds) { + if (isNaN(seconds) || !isFinite(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + + /** + * Update now playing info in all widgets + */ + updateNowPlaying(track) { + // Update all now playing widgets + this.nowPlayingWidgets.forEach(widget => { + const idle = widget.querySelector('.fedistream-now-playing__idle'); + const active = widget.querySelector('.fedistream-now-playing__active'); + + if (idle) idle.style.display = 'none'; + if (active) active.style.display = 'block'; + + const artwork = widget.querySelector('.fedistream-now-playing__artwork'); + const title = widget.querySelector('.fedistream-now-playing__title'); + const artist = widget.querySelector('.fedistream-now-playing__artist'); + + if (artwork && track.thumbnail) artwork.src = track.thumbnail; + if (title) title.textContent = track.title; + if (artist) artist.textContent = track.artist; + }); + + // Update multi-track player current info + const multiPlayers = document.querySelectorAll('.fedistream-player--multi'); + multiPlayers.forEach(player => { + const artwork = player.querySelector('.fedistream-player__artwork--current'); + const title = player.querySelector('.fedistream-player__title--current'); + const artist = player.querySelector('.fedistream-player__artist--current'); + + if (artwork && track.thumbnail) artwork.src = track.thumbnail; + if (title) title.textContent = track.title; + if (artist) artist.textContent = track.artist; + }); + + // Update document title + if (track.title) { + document.title = `${track.title}${track.artist ? ' - ' + track.artist : ''} | ${document.title.split('|').pop().trim()}`; + } + } + + /** + * Update tracklist item highlighting + */ + updateTracklistHighlight() { + // Remove all highlights + document.querySelectorAll('.fedistream-tracklist__item.is-playing').forEach(el => { + el.classList.remove('is-playing'); + }); + + // Add highlight to current track + if (this.currentIndex >= 0 && this.queue[this.currentIndex]) { + const trackId = this.queue[this.currentIndex].id; + document.querySelectorAll(`.fedistream-tracklist__item[data-track-id="${trackId}"]`).forEach(el => { + el.classList.add('is-playing'); + }); + } + } + + /** + * Record track play via AJAX + */ + recordPlay(trackId) { + if (!window.wpFediStream || !window.wpFediStream.ajaxUrl) return; + + const formData = new FormData(); + formData.append('action', 'fedistream_record_play'); + formData.append('track_id', trackId); + formData.append('nonce', window.wpFediStream.nonce); + + fetch(window.wpFediStream.ajaxUrl, { + method: 'POST', + body: formData + }).catch(e => console.error('Failed to record play', e)); + } + + // Audio event handlers + + onTimeUpdate() { + const current = this.audio.currentTime; + const duration = this.audio.duration || 0; + const percent = duration ? (current / duration) * 100 : 0; + + // Update progress bars + document.querySelectorAll('.fedistream-player__bar-progress').forEach(el => { + el.style.width = `${percent}%`; + }); + + document.querySelectorAll('.fedistream-player__seek').forEach(el => { + el.value = percent; + }); + + // Update time displays + document.querySelectorAll('.fedistream-player__time--current').forEach(el => { + el.textContent = this.formatTime(current); + }); + + // Update now playing widgets progress + this.nowPlayingWidgets.forEach(widget => { + const progress = widget.querySelector('.fedistream-now-playing__bar-progress'); + const currentTime = widget.querySelector('.fedistream-now-playing__time--current'); + const totalTime = widget.querySelector('.fedistream-now-playing__time--total'); + + if (progress) progress.style.width = `${percent}%`; + if (currentTime) currentTime.textContent = this.formatTime(current); + if (totalTime) totalTime.textContent = this.formatTime(duration); + }); + } + + onLoadedMetadata() { + const duration = this.audio.duration || 0; + + document.querySelectorAll('.fedistream-player__time--total').forEach(el => { + el.textContent = this.formatTime(duration); + }); + } + + onEnded() { + if (this.repeatMode === 'one') { + this.audio.currentTime = 0; + this.audio.play().catch(e => console.error('Playback failed', e)); + } else { + this.next(); + } + } + + onPlay() { + this.isPlaying = true; + + // Update all player UIs + document.querySelectorAll('.fedistream-player').forEach(el => { + el.classList.add('is-playing'); + }); + + document.querySelectorAll('.fedistream-now-playing').forEach(el => { + el.classList.add('is-playing'); + }); + } + + onPause() { + this.isPlaying = false; + + document.querySelectorAll('.fedistream-player').forEach(el => { + el.classList.remove('is-playing'); + }); + + document.querySelectorAll('.fedistream-now-playing').forEach(el => { + el.classList.remove('is-playing'); + }); + } + + onError(e) { + console.error('Audio error', e); + // Try next track on error + if (this.queue.length > 1) { + this.next(); + } + } + + onWaiting() { + document.querySelectorAll('.fedistream-player').forEach(el => { + el.classList.add('is-loading'); + }); + } + + onCanPlay() { + document.querySelectorAll('.fedistream-player').forEach(el => { + el.classList.remove('is-loading'); + }); + } + } + + /** + * Initialize when DOM is ready + */ + document.addEventListener('DOMContentLoaded', function() { + // Initialize the global player + window.fediStreamPlayer = new FediStreamPlayer(); + + // Initialize any inline players + window.fediStreamPlayer.initInlinePlayers(); + window.fediStreamPlayer.initMultiTrackPlayers(); + }); + + // Media Session API for system controls + if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', () => { + if (window.fediStreamPlayer) { + window.fediStreamPlayer.togglePlayPause(); + } + }); + + navigator.mediaSession.setActionHandler('pause', () => { + if (window.fediStreamPlayer) { + window.fediStreamPlayer.togglePlayPause(); + } + }); + + navigator.mediaSession.setActionHandler('previoustrack', () => { + if (window.fediStreamPlayer) { + window.fediStreamPlayer.previous(); + } + }); + + navigator.mediaSession.setActionHandler('nexttrack', () => { + if (window.fediStreamPlayer) { + window.fediStreamPlayer.next(); + } + }); + } + +})(); diff --git a/assets/js/index.php b/assets/js/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/assets/js/index.php @@ -0,0 +1 @@ +' + fedistreamLibrary.i18n.noFavorites + '

' ); + $( '.library-pagination[data-tab="favorites"]' ).empty(); + return; + } + + data.items.forEach( function( item ) { + container.append( this.createFavoriteItem( item ) ); + }, this ); + + this.renderPagination( 'favorites', data ); + }, + + renderArtists: function( data ) { + const container = $( '.artists-grid' ); + container.empty(); + + if ( ! data.artists || data.artists.length === 0 ) { + container.html( '

' + fedistreamLibrary.i18n.noArtists + '

' ); + $( '.library-pagination[data-tab="artists"]' ).empty(); + return; + } + + data.artists.forEach( function( artist ) { + container.append( this.createArtistItem( artist ) ); + }, this ); + + this.renderPagination( 'artists', data ); + }, + + renderHistory: function( data ) { + const container = $( '.history-list' ); + container.empty(); + + if ( ! data.tracks || data.tracks.length === 0 ) { + container.html( '

' + fedistreamLibrary.i18n.noHistory + '

' ); + $( '.library-pagination[data-tab="history"]' ).empty(); + return; + } + + data.tracks.forEach( function( track ) { + container.append( this.createHistoryItem( track ) ); + }, this ); + + this.renderPagination( 'history', data ); + }, + + createFavoriteItem: function( item ) { + const thumbnail = item.thumbnail + ? '' + this.escapeHtml( item.title ) + '' + : '
'; + + const playBtn = item.type === 'track' + ? '' + : ''; + + const artistInfo = item.artist + ? '

' + this.escapeHtml( item.artist ) + '

' + : ''; + + let metaInfo = ''; + if ( item.type === 'track' && item.duration ) { + metaInfo = '

' + this.formatDuration( item.duration ) + '

'; + } else if ( item.type === 'album' && item.track_count ) { + metaInfo = '

' + item.track_count + ' tracks

'; + } + + return ` +
+
+ ${thumbnail} + ${playBtn} +
+
+

+ ${this.escapeHtml( item.title )} +

+ ${artistInfo} + ${metaInfo} +
+
+ +
+
+ `; + }, + + createArtistItem: function( artist ) { + const thumbnail = artist.thumbnail + ? '' + this.escapeHtml( artist.name ) + '' + : '
'; + + const typeLabel = artist.type === 'band' ? 'Band' : 'Artist'; + + return ` +
+
+ ${thumbnail} +
+
+

+ ${this.escapeHtml( artist.name )} +

+

${typeLabel}

+
+
+ +
+
+ `; + }, + + createHistoryItem: function( track ) { + const thumbnail = track.thumbnail + ? '' + this.escapeHtml( track.title ) + '' + : '
'; + + const artistInfo = track.artist + ? '

' + this.escapeHtml( track.artist ) + '

' + : ''; + + const duration = track.duration + ? '' + this.formatDuration( track.duration ) + '' + : ''; + + return ` +
+
+ ${thumbnail} + +
+
+

+ ${this.escapeHtml( track.title )} +

+ ${artistInfo} +

${this.formatPlayedTime( track.played_at )}

+
+
+ ${duration} +
+
+ `; + }, + + renderPagination: function( tab, data ) { + const container = $( '.library-pagination[data-tab="' + tab + '"]' ); + container.empty(); + + if ( data.total_pages <= 1 ) { + return; + } + + let html = '
'; + + // Previous button. + if ( data.page > 1 ) { + html += ''; + } + + // Page numbers. + html += 'Page ' + data.page + ' of ' + data.total_pages + ''; + + // Next button. + if ( data.page < data.total_pages ) { + html += ''; + } + + html += '
'; + + container.html( html ); + }, + + goToPage: function( tab, page ) { + this.currentPage[ tab ] = page; + this.loadTabContent( tab ); + }, + + toggleFavorite: function( contentType, contentId, element ) { + const self = this; + + $.ajax( { + url: fedistreamLibrary.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_toggle_favorite', + nonce: fedistreamLibrary.nonce, + content_type: contentType, + content_id: contentId + }, + success: function( response ) { + if ( response.success && response.data.action === 'removed' ) { + element.fadeOut( 300, function() { + $( this ).remove(); + self.updateFavoriteCount(); + } ); + } + }, + error: function() { + self.showError( fedistreamLibrary.i18n.error ); + } + } ); + }, + + toggleFollow: function( artistId, element ) { + const self = this; + + $.ajax( { + url: fedistreamLibrary.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_toggle_follow', + nonce: fedistreamLibrary.nonce, + artist_id: artistId + }, + success: function( response ) { + if ( response.success && response.data.action === 'unfollowed' ) { + element.fadeOut( 300, function() { + $( this ).remove(); + self.updateFollowingCount(); + } ); + } + }, + error: function() { + self.showError( fedistreamLibrary.i18n.error ); + } + } ); + }, + + clearHistory: function() { + const self = this; + + if ( ! confirm( fedistreamLibrary.i18n.confirmClear ) ) { + return; + } + + $.ajax( { + url: fedistreamLibrary.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_clear_history', + nonce: fedistreamLibrary.nonce + }, + success: function( response ) { + if ( response.success ) { + $( '.history-list' ).html( '

' + fedistreamLibrary.i18n.noHistory + '

' ); + $( '.library-pagination[data-tab="history"]' ).empty(); + } else { + self.showError( response.data.message ); + } + }, + error: function() { + self.showError( fedistreamLibrary.i18n.error ); + } + } ); + }, + + playTrack: function( trackId ) { + // Trigger global player if available. + if ( window.FediStreamPlayer && typeof window.FediStreamPlayer.playTrack === 'function' ) { + window.FediStreamPlayer.playTrack( trackId ); + } else { + // Fallback: redirect to track page. + window.location.href = '?p=' + trackId; + } + }, + + updateFavoriteCount: function() { + const count = $( '.favorites-grid .library-item' ).length; + $( '.tab-btn[data-tab="favorites"] .count' ).text( count ); + }, + + updateFollowingCount: function() { + const count = $( '.artists-grid .library-item' ).length; + $( '.tab-btn[data-tab="artists"] .count' ).text( count ); + }, + + showLoading: function() { + this.isLoading = true; + $( '.fedistream-library-loading' ).show(); + }, + + hideLoading: function() { + this.isLoading = false; + $( '.fedistream-library-loading' ).hide(); + }, + + showError: function( message ) { + alert( message ); + }, + + formatDuration: function( seconds ) { + const mins = Math.floor( seconds / 60 ); + const secs = seconds % 60; + return mins + ':' + ( secs < 10 ? '0' : '' ) + secs; + }, + + formatPlayedTime: function( dateString ) { + const date = new Date( dateString ); + const now = new Date(); + const diff = Math.floor( ( now - date ) / 1000 ); + + if ( diff < 60 ) { + return 'Just now'; + } else if ( diff < 3600 ) { + const mins = Math.floor( diff / 60 ); + return mins + ' minute' + ( mins !== 1 ? 's' : '' ) + ' ago'; + } else if ( diff < 86400 ) { + const hours = Math.floor( diff / 3600 ); + return hours + ' hour' + ( hours !== 1 ? 's' : '' ) + ' ago'; + } else { + const days = Math.floor( diff / 86400 ); + return days + ' day' + ( days !== 1 ? 's' : '' ) + ' ago'; + } + }, + + escapeHtml: function( text ) { + const div = document.createElement( 'div' ); + div.textContent = text; + return div.innerHTML; + } + }; + + // Initialize on document ready. + $( document ).ready( function() { + if ( $( '.fedistream-library' ).length ) { + Library.init(); + } + } ); + +} )( jQuery ); diff --git a/assets/js/notifications.js b/assets/js/notifications.js new file mode 100644 index 0000000..5f18271 --- /dev/null +++ b/assets/js/notifications.js @@ -0,0 +1,353 @@ +/** + * FediStream Notifications JavaScript + * + * @package WP_FediStream + */ + +( function( $ ) { + 'use strict'; + + const Notifications = { + unreadCount: 0, + isOpen: false, + pollTimer: null, + + init: function() { + this.createDropdown(); + this.bindEvents(); + this.loadNotifications(); + this.startPolling(); + }, + + createDropdown: function() { + const dropdown = ` + + `; + + $( '.fedistream-notifications-menu' ).append( dropdown ); + }, + + bindEvents: function() { + const self = this; + + // Toggle dropdown. + $( document ).on( 'click', '#wp-admin-bar-fedistream-notifications > a', function( e ) { + e.preventDefault(); + self.toggleDropdown(); + } ); + + // Close dropdown when clicking outside. + $( document ).on( 'click', function( e ) { + if ( ! $( e.target ).closest( '.fedistream-notifications-menu' ).length ) { + self.closeDropdown(); + } + } ); + + // Mark all as read. + $( document ).on( 'click', '.fedistream-notifications-dropdown .mark-all-read', function( e ) { + e.preventDefault(); + e.stopPropagation(); + self.markAllRead(); + } ); + + // Mark single as read. + $( document ).on( 'click', '.notification-item', function() { + const id = $( this ).data( 'id' ); + const isRead = $( this ).hasClass( 'is-read' ); + + if ( ! isRead ) { + self.markRead( id, $( this ) ); + } + } ); + + // Delete notification. + $( document ).on( 'click', '.notification-item .delete-btn', function( e ) { + e.preventDefault(); + e.stopPropagation(); + const item = $( this ).closest( '.notification-item' ); + const id = item.data( 'id' ); + self.deleteNotification( id, item ); + } ); + }, + + toggleDropdown: function() { + if ( this.isOpen ) { + this.closeDropdown(); + } else { + this.openDropdown(); + } + }, + + openDropdown: function() { + $( '.fedistream-notifications-dropdown' ).show(); + this.isOpen = true; + this.loadNotifications(); + }, + + closeDropdown: function() { + $( '.fedistream-notifications-dropdown' ).hide(); + this.isOpen = false; + }, + + loadNotifications: function() { + const self = this; + + $.ajax( { + url: fedistreamNotifications.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_get_notifications', + nonce: fedistreamNotifications.nonce, + limit: 10 + }, + success: function( response ) { + if ( response.success ) { + self.renderNotifications( response.data.notifications ); + self.updateUnreadCount( response.data.unread_count ); + } + }, + error: function() { + $( '.notifications-list' ).html( + '

' + fedistreamNotifications.i18n.error + '

' + ); + } + } ); + }, + + renderNotifications: function( notifications ) { + const container = $( '.notifications-list' ); + container.empty(); + + if ( ! notifications || notifications.length === 0 ) { + container.html( + '

' + fedistreamNotifications.i18n.noNotifications + '

' + ); + return; + } + + notifications.forEach( function( notification ) { + container.append( this.createNotificationItem( notification ) ); + }, this ); + }, + + createNotificationItem: function( notification ) { + const isRead = notification.is_read ? 'is-read' : ''; + const icon = this.getNotificationIcon( notification.type ); + const time = this.formatTime( notification.created_at ); + const link = this.getNotificationLink( notification ); + + return ` +
+
+ +
+
+
${this.escapeHtml( notification.title )}
+
${this.escapeHtml( notification.message )}
+
${time}
+
+
+ +
+ ${link ? `` : ''} +
+ `; + }, + + getNotificationIcon: function( type ) { + const icons = { + 'new_release': 'album', + 'new_follower': 'admin-users', + 'fediverse_like': 'heart', + 'fediverse_boost': 'megaphone', + 'playlist_added': 'playlist-audio', + 'purchase': 'cart', + 'system': 'info' + }; + return icons[ type ] || 'bell'; + }, + + getNotificationLink: function( notification ) { + const data = notification.data || {}; + + if ( data.album_url ) { + return data.album_url; + } + if ( data.track_url ) { + return data.track_url; + } + if ( data.artist_url ) { + return data.artist_url; + } + + return null; + }, + + markRead: function( notificationId, element ) { + const self = this; + + $.ajax( { + url: fedistreamNotifications.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_mark_notification_read', + nonce: fedistreamNotifications.nonce, + notification_id: notificationId + }, + success: function( response ) { + if ( response.success ) { + element.addClass( 'is-read' ); + self.updateUnreadCount( response.data.unread_count ); + } + } + } ); + }, + + markAllRead: function() { + const self = this; + + $.ajax( { + url: fedistreamNotifications.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_mark_all_notifications_read', + nonce: fedistreamNotifications.nonce + }, + success: function( response ) { + if ( response.success ) { + $( '.notification-item' ).addClass( 'is-read' ); + self.updateUnreadCount( 0 ); + } + } + } ); + }, + + deleteNotification: function( notificationId, element ) { + const self = this; + + $.ajax( { + url: fedistreamNotifications.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_delete_notification', + nonce: fedistreamNotifications.nonce, + notification_id: notificationId + }, + success: function( response ) { + if ( response.success ) { + element.fadeOut( 200, function() { + $( this ).remove(); + + // Check if list is empty. + if ( $( '.notification-item' ).length === 0 ) { + $( '.notifications-list' ).html( + '

' + fedistreamNotifications.i18n.noNotifications + '

' + ); + } + } ); + self.updateUnreadCount( response.data.unread_count ); + } + } + } ); + }, + + updateUnreadCount: function( count ) { + this.unreadCount = count; + + const badge = $( '.fedistream-notification-count' ); + + if ( count > 0 ) { + if ( badge.length ) { + badge.text( count ); + } else { + $( '#wp-admin-bar-fedistream-notifications .ab-icon' ).after( + '' + count + '' + ); + } + } else { + badge.remove(); + } + }, + + startPolling: function() { + const self = this; + const interval = fedistreamNotifications.pollInterval || 60000; + + this.pollTimer = setInterval( function() { + if ( ! self.isOpen ) { + self.checkForNewNotifications(); + } + }, interval ); + }, + + checkForNewNotifications: function() { + const self = this; + + $.ajax( { + url: fedistreamNotifications.ajaxUrl, + type: 'POST', + data: { + action: 'fedistream_get_notifications', + nonce: fedistreamNotifications.nonce, + unread_only: true, + limit: 1 + }, + success: function( response ) { + if ( response.success ) { + self.updateUnreadCount( response.data.unread_count ); + } + } + } ); + }, + + formatTime: function( dateString ) { + const date = new Date( dateString ); + const now = new Date(); + const diff = Math.floor( ( now - date ) / 1000 ); + + if ( diff < 60 ) { + return fedistreamNotifications.i18n.justNow || 'Just now'; + } else if ( diff < 3600 ) { + const mins = Math.floor( diff / 60 ); + return mins + 'm ago'; + } else if ( diff < 86400 ) { + const hours = Math.floor( diff / 3600 ); + return hours + 'h ago'; + } else if ( diff < 604800 ) { + const days = Math.floor( diff / 86400 ); + return days + 'd ago'; + } else { + return date.toLocaleDateString(); + } + }, + + escapeHtml: function( text ) { + const div = document.createElement( 'div' ); + div.textContent = text; + return div.innerHTML; + } + }; + + // Initialize on document ready. + $( document ).ready( function() { + if ( $( '.fedistream-notifications-menu' ).length ) { + Notifications.init(); + } + } ); + +} )( jQuery ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bc80e1a --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "magdev/wp-fedistream", + "description": "Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Marco Graetsch", + "email": "magdev3.0@gmail.com", + "homepage": "https://src.bundespruefstelle.ch/magdev" + } + ], + "homepage": "https://src.bundespruefstelle.ch/magdev/wp-fedistream", + "support": { + "issues": "https://src.bundespruefstelle.ch/magdev/wp-fedistream/issues" + }, + "require": { + "php": ">=8.3", + "twig/twig": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^3.7", + "wp-coding-standards/wpcs": "^3.0", + "phpcompatibility/phpcompatibility-wp": "*" + }, + "autoload": { + "psr-4": { + "WP_FediStream\\": "includes/" + } + }, + "autoload-dev": { + "psr-4": { + "WP_FediStream\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3.0" + } + }, + "scripts": { + "phpcs": "phpcs", + "phpcbf": "phpcbf", + "test": "phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d979ca9 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2637 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c8fb50541e5730c8ad92b76392765aca", + "packages": [ + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "twig/twig", + "version": "v3.23.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.23.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-01-23T21:00:41+00:00" + } + ], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-11T04:32:07+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2025-11-25T12:08:04+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.3" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.6.0" +} diff --git a/includes/ActivityPub/AlbumTransformer.php b/includes/ActivityPub/AlbumTransformer.php new file mode 100644 index 0000000..f3aed67 --- /dev/null +++ b/includes/ActivityPub/AlbumTransformer.php @@ -0,0 +1,433 @@ +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; + } +} diff --git a/includes/ActivityPub/ArtistActor.php b/includes/ActivityPub/ArtistActor.php new file mode 100644 index 0000000..16e48b1 --- /dev/null +++ b/includes/ActivityPub/ArtistActor.php @@ -0,0 +1,614 @@ +artist = $artist; + } + + /** + * Get the actor ID (URI). + * + * @return string + */ + public function get_id(): string { + return get_permalink( $this->artist->ID ); + } + + /** + * Get the actor type. + * + * @return string Person for solo, Group for bands. + */ + public function get_type(): string { + $artist_type = get_post_meta( $this->artist->ID, '_fedistream_artist_type', true ); + + // Bands, duos, collectives are Groups. + if ( in_array( $artist_type, array( 'band', 'duo', 'collective', 'orchestra' ), true ) ) { + return 'Group'; + } + + return 'Person'; + } + + /** + * Get the preferred username. + * + * @return string + */ + public function get_preferred_username(): string { + return 'artist-' . $this->artist->ID; + } + + /** + * Get the display name. + * + * @return string + */ + public function get_name(): string { + return $this->artist->post_title; + } + + /** + * Get the actor summary/bio. + * + * @return string + */ + public function get_summary(): string { + if ( ! empty( $this->artist->post_excerpt ) ) { + return wp_kses_post( $this->artist->post_excerpt ); + } + + // Use first paragraph of content as summary. + $content = wp_strip_all_tags( $this->artist->post_content ); + $parts = explode( "\n\n", $content, 2 ); + + return ! empty( $parts[0] ) ? wp_trim_words( $parts[0], 50 ) : ''; + } + + /** + * Get the actor URL (profile page). + * + * @return string + */ + public function get_url(): string { + return get_permalink( $this->artist->ID ); + } + + /** + * Get the inbox URL. + * + * @return string + */ + public function get_inbox(): string { + return trailingslashit( get_permalink( $this->artist->ID ) ) . 'inbox'; + } + + /** + * Get the outbox URL. + * + * @return string + */ + public function get_outbox(): string { + return trailingslashit( get_permalink( $this->artist->ID ) ) . 'outbox'; + } + + /** + * Get the followers URL. + * + * @return string + */ + public function get_followers(): string { + return trailingslashit( get_permalink( $this->artist->ID ) ) . 'followers'; + } + + /** + * Get the following URL. + * + * @return string + */ + public function get_following(): string { + return trailingslashit( get_permalink( $this->artist->ID ) ) . 'following'; + } + + /** + * Get the avatar/icon. + * + * @return array|null + */ + public function get_icon(): ?array { + $thumbnail_id = get_post_thumbnail_id( $this->artist->ID ); + if ( ! $thumbnail_id ) { + return null; + } + + $image = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' ); + if ( ! $image ) { + return null; + } + + return array( + 'type' => 'Image', + 'mediaType' => get_post_mime_type( $thumbnail_id ), + 'url' => $image[0], + 'width' => $image[1], + 'height' => $image[2], + ); + } + + /** + * Get the header/banner image. + * + * @return array|null + */ + public function get_image(): ?array { + $banner_id = get_post_meta( $this->artist->ID, '_fedistream_artist_banner', true ); + if ( ! $banner_id ) { + // Fall back to featured image at larger size. + $banner_id = get_post_thumbnail_id( $this->artist->ID ); + } + + if ( ! $banner_id ) { + return null; + } + + $image = wp_get_attachment_image_src( $banner_id, 'large' ); + if ( ! $image ) { + return null; + } + + return array( + 'type' => 'Image', + 'mediaType' => get_post_mime_type( $banner_id ), + 'url' => $image[0], + 'width' => $image[1], + 'height' => $image[2], + ); + } + + /** + * Get the public key. + * + * @return array + */ + public function get_public_key(): array { + $key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true ); + + if ( ! $key ) { + // Generate key pair if not exists. + $this->generate_keys(); + $key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true ); + } + + return array( + 'id' => $this->get_id() . '#main-key', + 'owner' => $this->get_id(), + 'publicKeyPem' => $key, + ); + } + + /** + * Generate RSA key pair for the artist. + * + * @return bool True on success. + */ + private function generate_keys(): bool { + $config = array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ); + + $resource = openssl_pkey_new( $config ); + if ( ! $resource ) { + return false; + } + + // Export private key. + openssl_pkey_export( $resource, $private_key ); + + // Get public key. + $details = openssl_pkey_get_details( $resource ); + $public_key = $details['key'] ?? ''; + + if ( ! $private_key || ! $public_key ) { + return false; + } + + update_post_meta( $this->artist->ID, '_fedistream_activitypub_private_key', $private_key ); + update_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', $public_key ); + + return true; + } + + /** + * Get attachment properties (social links, etc.). + * + * @return array + */ + public function get_attachment(): array { + $attachments = array(); + + // Add website. + $website = get_post_meta( $this->artist->ID, '_fedistream_artist_website', true ); + if ( $website ) { + $attachments[] = array( + 'type' => 'PropertyValue', + 'name' => __( 'Website', 'wp-fedistream' ), + 'value' => sprintf( '%s', esc_url( $website ), esc_html( $website ) ), + ); + } + + // Add location. + $location = get_post_meta( $this->artist->ID, '_fedistream_artist_location', true ); + if ( $location ) { + $attachments[] = array( + 'type' => 'PropertyValue', + 'name' => __( 'Location', 'wp-fedistream' ), + 'value' => esc_html( $location ), + ); + } + + // Add social links. + $social_links = get_post_meta( $this->artist->ID, '_fedistream_artist_social_links', true ); + if ( is_array( $social_links ) ) { + foreach ( $social_links as $link ) { + $platform = $link['platform'] ?? ''; + $url = $link['url'] ?? ''; + + if ( $platform && $url ) { + $attachments[] = array( + 'type' => 'PropertyValue', + 'name' => esc_html( ucfirst( $platform ) ), + 'value' => sprintf( '%s', esc_url( $url ), esc_html( $url ) ), + ); + } + } + } + + // Add formed date. + $formed = get_post_meta( $this->artist->ID, '_fedistream_artist_formed_date', true ); + if ( $formed ) { + $attachments[] = array( + 'type' => 'PropertyValue', + 'name' => __( 'Active Since', 'wp-fedistream' ), + 'value' => esc_html( $formed ), + ); + } + + return $attachments; + } + + /** + * Get the full actor object as an array. + * + * @return array + */ + public function to_array(): array { + $actor = array( + '@context' => array( + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + array( + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', + 'PropertyValue' => 'schema:PropertyValue', + 'value' => 'schema:value', + ), + ), + 'id' => $this->get_id(), + 'type' => $this->get_type(), + 'preferredUsername' => $this->get_preferred_username(), + 'name' => $this->get_name(), + 'summary' => $this->get_summary(), + 'url' => $this->get_url(), + 'inbox' => $this->get_inbox(), + 'outbox' => $this->get_outbox(), + 'followers' => $this->get_followers(), + 'following' => $this->get_following(), + 'publicKey' => $this->get_public_key(), + 'manuallyApprovesFollowers' => false, + 'published' => get_the_date( 'c', $this->artist ), + 'updated' => get_the_modified_date( 'c', $this->artist ), + ); + + // Add icon if available. + $icon = $this->get_icon(); + if ( $icon ) { + $actor['icon'] = $icon; + } + + // Add image if available. + $image = $this->get_image(); + if ( $image ) { + $actor['image'] = $image; + } + + // Add attachments. + $attachments = $this->get_attachment(); + if ( ! empty( $attachments ) ) { + $actor['attachment'] = $attachments; + } + + return $actor; + } + + /** + * Get webfinger data for the artist. + * + * @return array + */ + public function get_webfinger(): array { + $host = wp_parse_url( home_url(), PHP_URL_HOST ); + $resource = 'acct:' . $this->get_preferred_username() . '@' . $host; + + return array( + 'subject' => $resource, + 'aliases' => array( + $this->get_id(), + $this->get_url(), + ), + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => $this->get_id(), + ), + array( + 'rel' => 'http://webfinger.net/rel/profile-page', + 'type' => 'text/html', + 'href' => $this->get_url(), + ), + ), + ); + } + + /** + * Get the outbox collection. + * + * @param int $page The page number (0 for summary). + * @param int $per_page Items per page. + * @return array + */ + public function get_outbox_collection( int $page = 0, int $per_page = 20 ): array { + // Get all tracks and albums by this artist. + $args = array( + 'post_type' => array( 'fedistream_track', 'fedistream_album' ), + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + array( + 'key' => '_fedistream_album_artist', + 'value' => $this->artist->ID, + ), + array( + 'key' => '_fedistream_artist_ids', + 'value' => $this->artist->ID, + 'compare' => 'LIKE', + ), + ), + ); + + $query = new \WP_Query( $args ); + $total = $query->found_posts; + + // Return summary if page 0. + if ( $page === 0 ) { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $this->get_outbox(), + 'type' => 'OrderedCollection', + 'totalItems' => $total, + 'first' => $this->get_outbox() . '?page=1', + 'last' => $this->get_outbox() . '?page=' . max( 1, ceil( $total / $per_page ) ), + ); + } + + // Get paginated items. + $args['posts_per_page'] = $per_page; + $args['paged'] = $page; + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + + $query = new \WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $items[] = $this->post_to_create_activity( $post ); + } + + $collection = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $this->get_outbox() . '?page=' . $page, + 'type' => 'OrderedCollectionPage', + 'partOf' => $this->get_outbox(), + 'orderedItems' => $items, + ); + + // Add prev/next links. + if ( $page > 1 ) { + $collection['prev'] = $this->get_outbox() . '?page=' . ( $page - 1 ); + } + + $total_pages = ceil( $total / $per_page ); + if ( $page < $total_pages ) { + $collection['next'] = $this->get_outbox() . '?page=' . ( $page + 1 ); + } + + return $collection; + } + + /** + * Convert a post to a Create activity. + * + * @param \WP_Post $post The post. + * @return array + */ + private function post_to_create_activity( \WP_Post $post ): array { + $object = array( + 'type' => 'fedistream_track' === $post->post_type ? 'Audio' : 'Collection', + 'id' => get_permalink( $post->ID ), + 'name' => $post->post_title, + 'content' => wp_strip_all_tags( $post->post_content ), + 'attributedTo' => $this->get_id(), + 'published' => get_the_date( 'c', $post ), + 'url' => get_permalink( $post->ID ), + ); + + // Add track-specific properties. + if ( 'fedistream_track' === $post->post_type ) { + $duration = get_post_meta( $post->ID, '_fedistream_duration', true ); + if ( $duration ) { + $object['duration'] = $this->format_duration_iso8601( (int) $duration ); + } + + $audio_id = get_post_meta( $post->ID, '_fedistream_audio_file', true ); + $audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : ''; + if ( $audio_url ) { + $object['attachment'] = array( + 'type' => 'Audio', + 'mediaType' => 'audio/mpeg', + 'url' => $audio_url, + ); + } + } + + // Add thumbnail. + $thumbnail_id = get_post_thumbnail_id( $post->ID ); + if ( $thumbnail_id ) { + $image = wp_get_attachment_image_src( $thumbnail_id, 'medium' ); + if ( $image ) { + $object['image'] = array( + 'type' => 'Image', + 'mediaType' => get_post_mime_type( $thumbnail_id ), + 'url' => $image[0], + ); + } + } + + return array( + 'type' => 'Create', + 'id' => get_permalink( $post->ID ) . '#activity-create', + 'actor' => $this->get_id(), + 'published' => get_the_date( 'c', $post ), + 'object' => $object, + ); + } + + /** + * Format duration as ISO 8601. + * + * @param int $seconds The duration in seconds. + * @return string ISO 8601 duration. + */ + private function format_duration_iso8601( int $seconds ): string { + $hours = floor( $seconds / 3600 ); + $minutes = floor( ( $seconds % 3600 ) / 60 ); + $secs = $seconds % 60; + + $duration = 'PT'; + if ( $hours > 0 ) { + $duration .= $hours . 'H'; + } + if ( $minutes > 0 ) { + $duration .= $minutes . 'M'; + } + if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) { + $duration .= $secs . 'S'; + } + + return $duration; + } + + /** + * Get the followers collection. + * + * @param int $page The page number (0 for summary). + * @param int $per_page Items per page. + * @return array + */ + public function get_followers_collection( int $page = 0, int $per_page = 20 ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_followers'; + + // Get total count. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE artist_id = %d", + $this->artist->ID + ) + ); + + // Return summary if page 0. + if ( $page === 0 ) { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $this->get_followers(), + 'type' => 'OrderedCollection', + 'totalItems' => $total, + 'first' => $this->get_followers() . '?page=1', + ); + } + + // Get paginated items. + $offset = ( $page - 1 ) * $per_page; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $followers = $wpdb->get_col( + $wpdb->prepare( + "SELECT follower_uri FROM {$table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d", + $this->artist->ID, + $per_page, + $offset + ) + ); + + $collection = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $this->get_followers() . '?page=' . $page, + 'type' => 'OrderedCollectionPage', + 'partOf' => $this->get_followers(), + 'orderedItems' => $followers ?: array(), + ); + + // Add prev/next links. + if ( $page > 1 ) { + $collection['prev'] = $this->get_followers() . '?page=' . ( $page - 1 ); + } + + $total_pages = ceil( $total / $per_page ); + if ( $page < $total_pages ) { + $collection['next'] = $this->get_followers() . '?page=' . ( $page + 1 ); + } + + return $collection; + } +} diff --git a/includes/ActivityPub/FollowerHandler.php b/includes/ActivityPub/FollowerHandler.php new file mode 100644 index 0000000..2ce5847 --- /dev/null +++ b/includes/ActivityPub/FollowerHandler.php @@ -0,0 +1,477 @@ +table = $wpdb->prefix . 'fedistream_followers'; + + // Register inbox handlers for follow activities. + add_action( 'activitypub_inbox_follow', array( $this, 'handle_follow' ), 10, 2 ); + add_action( 'activitypub_inbox_undo', array( $this, 'handle_undo' ), 10, 2 ); + } + + /** + * Handle incoming Follow activity. + * + * @param array $activity The activity data. + * @param int $user_id The local user ID (if applicable). + * @return void + */ + public function handle_follow( array $activity, int $user_id ): void { + $actor = $activity['actor'] ?? ''; + $object = $activity['object'] ?? ''; + + if ( ! $actor || ! $object ) { + return; + } + + // Find the artist being followed. + $artist_id = $this->get_artist_from_object( $object ); + if ( ! $artist_id ) { + return; + } + + // Add follower. + $result = $this->add_follower( $artist_id, $actor, $activity ); + + if ( $result ) { + // Send Accept activity. + $this->send_accept( $artist_id, $activity ); + } + } + + /** + * Handle incoming Undo activity (for Unfollow). + * + * @param array $activity The activity data. + * @param int $user_id The local user ID (if applicable). + * @return void + */ + public function handle_undo( array $activity, int $user_id ): void { + $actor = $activity['actor'] ?? ''; + $object = $activity['object'] ?? array(); + + if ( ! $actor || ! is_array( $object ) ) { + return; + } + + // Check if this is an Undo Follow. + $type = $object['type'] ?? ''; + if ( 'Follow' !== $type ) { + return; + } + + $follow_object = $object['object'] ?? ''; + if ( ! $follow_object ) { + return; + } + + // Find the artist being unfollowed. + $artist_id = $this->get_artist_from_object( $follow_object ); + if ( ! $artist_id ) { + return; + } + + // Remove follower. + $this->remove_follower( $artist_id, $actor ); + } + + /** + * Get artist ID from an object URI. + * + * @param string $object The object URI. + * @return int|null Artist ID or null. + */ + private function get_artist_from_object( string $object ): ?int { + // Try to find by permalink. + $post_id = url_to_postid( $object ); + + if ( $post_id ) { + $post = get_post( $post_id ); + if ( $post && 'fedistream_artist' === $post->post_type ) { + return $post_id; + } + } + + // Try to find by artist handle pattern. + if ( preg_match( '/artist-(\d+)/', $object, $matches ) ) { + $artist_id = absint( $matches[1] ); + $post = get_post( $artist_id ); + if ( $post && 'fedistream_artist' === $post->post_type ) { + return $artist_id; + } + } + + return null; + } + + /** + * Add a follower. + * + * @param int $artist_id The artist ID. + * @param string $follower_uri The follower's actor URI. + * @param array $activity_data The original activity data. + * @return bool True on success. + */ + public function add_follower( int $artist_id, string $follower_uri, array $activity_data = array() ): bool { + global $wpdb; + + // Check if already following. + if ( $this->is_following( $artist_id, $follower_uri ) ) { + return true; + } + + // Fetch follower info. + $follower_info = $this->fetch_actor( $follower_uri ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $this->table, + array( + 'artist_id' => $artist_id, + 'follower_uri' => $follower_uri, + 'follower_name' => $follower_info['name'] ?? '', + 'follower_icon' => $follower_info['icon']['url'] ?? '', + 'inbox' => $follower_info['inbox'] ?? '', + 'shared_inbox' => $follower_info['endpoints']['sharedInbox'] ?? $follower_info['sharedInbox'] ?? '', + 'activity_data' => wp_json_encode( $activity_data ), + 'followed_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + if ( $result ) { + // Update follower count. + $count = $this->get_follower_count( $artist_id ); + update_post_meta( $artist_id, '_fedistream_follower_count', $count ); + + do_action( 'fedistream_artist_followed', $artist_id, $follower_uri, $follower_info ); + } + + return (bool) $result; + } + + /** + * Remove a follower. + * + * @param int $artist_id The artist ID. + * @param string $follower_uri The follower's actor URI. + * @return bool True on success. + */ + public function remove_follower( int $artist_id, string $follower_uri ): bool { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->delete( + $this->table, + array( + 'artist_id' => $artist_id, + 'follower_uri' => $follower_uri, + ), + array( '%d', '%s' ) + ); + + if ( $result ) { + // Update follower count. + $count = $this->get_follower_count( $artist_id ); + update_post_meta( $artist_id, '_fedistream_follower_count', $count ); + + do_action( 'fedistream_artist_unfollowed', $artist_id, $follower_uri ); + } + + return (bool) $result; + } + + /** + * Check if an actor is following an artist. + * + * @param int $artist_id The artist ID. + * @param string $follower_uri The follower's actor URI. + * @return bool + */ + public function is_following( int $artist_id, string $follower_uri ): bool { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d AND follower_uri = %s", + $artist_id, + $follower_uri + ) + ); + + return $exists > 0; + } + + /** + * Get follower count for an artist. + * + * @param int $artist_id The artist ID. + * @return int + */ + public function get_follower_count( int $artist_id ): int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d", + $artist_id + ) + ); + + return (int) $count; + } + + /** + * Get followers for an artist. + * + * @param int $artist_id The artist ID. + * @param int $limit Maximum number to return. + * @param int $offset Offset for pagination. + * @return array + */ + public function get_followers( int $artist_id, int $limit = 20, int $offset = 0 ): array { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $followers = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$this->table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d", + $artist_id, + $limit, + $offset + ) + ); + + return $followers ?: array(); + } + + /** + * Get all follower inboxes for an artist. + * + * @param int $artist_id The artist ID. + * @return array Array of inbox URLs, with shared inboxes deduplicated. + */ + public function get_follower_inboxes( int $artist_id ): array { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT inbox, shared_inbox FROM {$this->table} WHERE artist_id = %d", + $artist_id + ) + ); + + $inboxes = array(); + $shared_inboxes = array(); + + foreach ( $results as $row ) { + // Prefer shared inbox for efficiency. + if ( ! empty( $row->shared_inbox ) ) { + $shared_inboxes[ $row->shared_inbox ] = true; + } elseif ( ! empty( $row->inbox ) ) { + $inboxes[ $row->inbox ] = true; + } + } + + // Return unique inboxes (shared inboxes + individual inboxes). + return array_merge( array_keys( $shared_inboxes ), array_keys( $inboxes ) ); + } + + /** + * Send Accept activity in response to Follow. + * + * @param int $artist_id The artist ID. + * @param array $activity The original Follow activity. + * @return bool True on success. + */ + private function send_accept( int $artist_id, array $activity ): bool { + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return false; + } + + $actor = new ArtistActor( $artist ); + $follower_uri = $activity['actor'] ?? ''; + + if ( ! $follower_uri ) { + return false; + } + + // Create Accept activity. + $accept = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Accept', + 'id' => $actor->get_id() . '#accept-' . time(), + 'actor' => $actor->get_id(), + 'object' => $activity, + ); + + // Get follower's inbox. + $follower_info = $this->fetch_actor( $follower_uri ); + $inbox = $follower_info['inbox'] ?? ''; + + if ( ! $inbox ) { + return false; + } + + // Send the Accept activity. + return $this->send_activity( $accept, $inbox, $artist_id ); + } + + /** + * Fetch an actor from a remote server. + * + * @param string $actor_uri The actor URI. + * @return array The actor data. + */ + private function fetch_actor( string $actor_uri ): array { + $response = wp_remote_get( + $actor_uri, + array( + 'headers' => array( + 'Accept' => 'application/activity+json, application/ld+json', + ), + 'timeout' => 10, + ) + ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + return is_array( $data ) ? $data : array(); + } + + /** + * Send an activity to an inbox. + * + * @param array $activity The activity to send. + * @param string $inbox The inbox URL. + * @param int $artist_id The artist ID (for signing). + * @return bool True on success. + */ + private function send_activity( array $activity, string $inbox, int $artist_id ): bool { + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return false; + } + + // Get the artist's private key for signing. + $private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true ); + if ( ! $private_key ) { + // Generate keys if not exists. + $actor = new ArtistActor( $artist ); + $actor->get_public_key(); // This triggers key generation. + $private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true ); + } + + if ( ! $private_key ) { + return false; + } + + $body = wp_json_encode( $activity ); + $date = gmdate( 'D, d M Y H:i:s T' ); + + // Parse inbox URL. + $parsed = wp_parse_url( $inbox ); + $host = $parsed['host'] ?? ''; + $path = $parsed['path'] ?? '/'; + + // Create signature string. + $string_to_sign = "(request-target): post {$path}\n"; + $string_to_sign .= "host: {$host}\n"; + $string_to_sign .= "date: {$date}\n"; + $string_to_sign .= 'digest: SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + // Sign the string. + $signature = ''; + openssl_sign( $string_to_sign, $signature, $private_key, OPENSSL_ALGO_SHA256 ); + $signature_b64 = base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + // Build signature header. + $actor = new ArtistActor( $artist ); + $key_id = $actor->get_id() . '#main-key'; + $signature_header = sprintf( + 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', + $key_id, + $signature_b64 + ); + + // Send the request. + $response = wp_remote_post( + $inbox, + array( + 'headers' => array( + 'Content-Type' => 'application/activity+json', + 'Accept' => 'application/activity+json', + 'Host' => $host, + 'Date' => $date, + 'Digest' => 'SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'Signature' => $signature_header, + ), + 'body' => $body, + 'timeout' => 30, + ) + ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $code = wp_remote_retrieve_response_code( $response ); + + // Accept responses: 200, 201, 202 are all valid. + return $code >= 200 && $code < 300; + } + + /** + * Broadcast an activity to all followers. + * + * @param int $artist_id The artist ID. + * @param array $activity The activity to broadcast. + * @return array Results with inbox => success/failure. + */ + public function broadcast_activity( int $artist_id, array $activity ): array { + $inboxes = $this->get_follower_inboxes( $artist_id ); + $results = array(); + + foreach ( $inboxes as $inbox ) { + $results[ $inbox ] = $this->send_activity( $activity, $inbox, $artist_id ); + } + + return $results; + } +} diff --git a/includes/ActivityPub/Integration.php b/includes/ActivityPub/Integration.php new file mode 100644 index 0000000..e0991fc --- /dev/null +++ b/includes/ActivityPub/Integration.php @@ -0,0 +1,480 @@ +activitypub_active = defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) + || class_exists( '\Activitypub\Activitypub' ); + } + + /** + * Initialize the ActivityPub integration. + * + * @return void + */ + public function init(): void { + if ( ! $this->activitypub_active ) { + return; + } + + // Register custom post types for ActivityPub. + add_filter( 'activitypub_post_types', array( $this, 'register_post_types' ) ); + + // Register custom transformers. + add_filter( 'activitypub_transformers', array( $this, 'register_transformers' ) ); + + // Register artist actors. + add_action( 'init', array( $this, 'register_artist_actors' ), 20 ); + + // Add custom properties to ActivityPub objects. + add_filter( 'activitypub_activity_object_array', array( $this, 'add_audio_properties' ), 10, 3 ); + + // Handle incoming activities. + add_action( 'activitypub_inbox_like', array( $this, 'handle_like' ), 10, 2 ); + add_action( 'activitypub_inbox_announce', array( $this, 'handle_announce' ), 10, 2 ); + add_action( 'activitypub_inbox_create', array( $this, 'handle_create' ), 10, 2 ); + + // Add hooks for publishing. + add_action( 'publish_fedistream_track', array( $this, 'on_publish_track' ), 10, 2 ); + add_action( 'publish_fedistream_album', array( $this, 'on_publish_album' ), 10, 2 ); + } + + /** + * Check if ActivityPub is active. + * + * @return bool + */ + public function is_active(): bool { + return $this->activitypub_active; + } + + /** + * Register FediStream post types with ActivityPub. + * + * @param array $post_types The registered post types. + * @return array Modified post types. + */ + public function register_post_types( array $post_types ): array { + $post_types[] = 'fedistream_track'; + $post_types[] = 'fedistream_album'; + $post_types[] = 'fedistream_playlist'; + + return array_unique( $post_types ); + } + + /** + * Register custom transformers for FediStream post types. + * + * @param array $transformers The registered transformers. + * @return array Modified transformers. + */ + public function register_transformers( array $transformers ): array { + $transformers['fedistream_track'] = TrackTransformer::class; + $transformers['fedistream_album'] = AlbumTransformer::class; + $transformers['fedistream_playlist'] = PlaylistTransformer::class; + + return $transformers; + } + + /** + * Register artist actors for ActivityPub. + * + * @return void + */ + public function register_artist_actors(): void { + // Hook into ActivityPub actor discovery. + add_filter( 'activitypub_actor', array( $this, 'get_artist_actor' ), 10, 2 ); + + // Add artist webfinger handler. + add_filter( 'webfinger_data', array( $this, 'add_artist_webfinger' ), 10, 2 ); + } + + /** + * Get artist actor for ActivityPub. + * + * @param mixed $actor The current actor. + * @param string $id The actor ID or handle. + * @return mixed The actor object or original. + */ + public function get_artist_actor( $actor, string $id ) { + // Check if this is an artist handle. + if ( strpos( $id, 'artist-' ) === 0 ) { + $artist_id = absint( str_replace( 'artist-', '', $id ) ); + $artist = get_post( $artist_id ); + + if ( $artist && 'fedistream_artist' === $artist->post_type ) { + return new ArtistActor( $artist ); + } + } + + return $actor; + } + + /** + * Add artist to webfinger data. + * + * @param array $data The webfinger data. + * @param string $resource The requested resource. + * @return array Modified webfinger data. + */ + public function add_artist_webfinger( array $data, string $resource ): array { + // Parse acct: URI. + if ( preg_match( '/^acct:artist-(\d+)@/', $resource, $matches ) ) { + $artist_id = absint( $matches[1] ); + $artist = get_post( $artist_id ); + + if ( $artist && 'fedistream_artist' === $artist->post_type ) { + $actor = new ArtistActor( $artist ); + $data = $actor->get_webfinger(); + } + } + + return $data; + } + + /** + * Add audio-specific properties to ActivityPub objects. + * + * @param array $array The object array. + * @param object $object The ActivityPub object. + * @param int $post_id The post ID. + * @return array Modified array. + */ + public function add_audio_properties( array $array, $object, int $post_id ): array { + $post = get_post( $post_id ); + + if ( ! $post ) { + return $array; + } + + // Add audio-specific properties for tracks. + if ( 'fedistream_track' === $post->post_type ) { + $duration = get_post_meta( $post_id, '_fedistream_duration', true ); + if ( $duration ) { + $array['duration'] = $this->format_duration_iso8601( (int) $duration ); + } + + $audio_id = get_post_meta( $post_id, '_fedistream_audio_file', true ); + $audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : ''; + if ( $audio_url ) { + $array['attachment'][] = array( + 'type' => 'Audio', + 'mediaType' => 'audio/mpeg', + 'url' => $audio_url, + 'name' => $post->post_title, + 'duration' => $this->format_duration_iso8601( (int) $duration ), + ); + } + } + + return $array; + } + + /** + * Handle incoming Like activity. + * + * @param array $activity The activity data. + * @param int $user_id The user ID (actor). + * @return void + */ + public function handle_like( array $activity, int $user_id ): void { + $object_id = $activity['object'] ?? ''; + if ( ! $object_id ) { + return; + } + + // Find local post from object ID. + $post_id = url_to_postid( $object_id ); + if ( ! $post_id ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) { + return; + } + + // Record the like. + $this->record_reaction( $post_id, $activity['actor'] ?? '', 'like', $activity ); + } + + /** + * Handle incoming Announce (boost) activity. + * + * @param array $activity The activity data. + * @param int $user_id The user ID (actor). + * @return void + */ + public function handle_announce( array $activity, int $user_id ): void { + $object_id = $activity['object'] ?? ''; + if ( ! $object_id ) { + return; + } + + // Find local post from object ID. + $post_id = url_to_postid( $object_id ); + if ( ! $post_id ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) { + return; + } + + // Record the boost. + $this->record_reaction( $post_id, $activity['actor'] ?? '', 'boost', $activity ); + } + + /** + * Handle incoming Create activity (comments/replies). + * + * @param array $activity The activity data. + * @param int $user_id The user ID (actor). + * @return void + */ + public function handle_create( array $activity, int $user_id ): void { + $object = $activity['object'] ?? array(); + if ( empty( $object ) ) { + return; + } + + // Check if this is a reply to our content. + $in_reply_to = $object['inReplyTo'] ?? ''; + if ( ! $in_reply_to ) { + return; + } + + // Find local post from reply target. + $post_id = url_to_postid( $in_reply_to ); + if ( ! $post_id ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) { + return; + } + + // Record the reply. + $this->record_reaction( $post_id, $activity['actor'] ?? '', 'reply', $activity ); + } + + /** + * Record a reaction from the Fediverse. + * + * @param int $post_id The post ID. + * @param string $actor The actor URI. + * @param string $type The reaction type (like, boost, reply). + * @param array $activity The full activity data. + * @return bool True on success. + */ + private function record_reaction( int $post_id, string $actor, string $type, array $activity ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_reactions'; + + // Check if table exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $table_exists = $wpdb->get_var( + $wpdb->prepare( + 'SHOW TABLES LIKE %s', + $table + ) + ); + + if ( ! $table_exists ) { + $this->create_reactions_table(); + } + + // Insert reaction. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $table, + array( + 'post_id' => $post_id, + 'actor_uri' => $actor, + 'reaction_type' => $type, + 'activity_data' => wp_json_encode( $activity ), + 'created_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%s', '%s', '%s' ) + ); + + // Update reaction count meta. + if ( $result ) { + $meta_key = '_fedistream_' . $type . '_count'; + $count = (int) get_post_meta( $post_id, $meta_key, true ); + update_post_meta( $post_id, $meta_key, $count + 1 ); + } + + return (bool) $result; + } + + /** + * Create the reactions table. + * + * @return void + */ + private function create_reactions_table(): void { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_reactions'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE IF NOT EXISTS {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + actor_uri varchar(2083) NOT NULL, + reaction_type varchar(50) NOT NULL, + activity_data longtext, + created_at datetime NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY actor_uri (actor_uri(191)), + KEY reaction_type (reaction_type) + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * Handle track publishing. + * + * @param int $post_id The post ID. + * @param \WP_Post $post The post object. + * @return void + */ + public function on_publish_track( int $post_id, \WP_Post $post ): void { + // The ActivityPub plugin will handle the publishing automatically. + // This hook is for any additional custom logic. + do_action( 'fedistream_track_published_activitypub', $post_id, $post ); + } + + /** + * Handle album publishing. + * + * @param int $post_id The post ID. + * @param \WP_Post $post The post object. + * @return void + */ + public function on_publish_album( int $post_id, \WP_Post $post ): void { + // The ActivityPub plugin will handle the publishing automatically. + // This hook is for any additional custom logic. + do_action( 'fedistream_album_published_activitypub', $post_id, $post ); + } + + /** + * Format duration as ISO 8601. + * + * @param int $seconds The duration in seconds. + * @return string ISO 8601 duration. + */ + private function format_duration_iso8601( int $seconds ): string { + $hours = floor( $seconds / 3600 ); + $minutes = floor( ( $seconds % 3600 ) / 60 ); + $secs = $seconds % 60; + + $duration = 'PT'; + if ( $hours > 0 ) { + $duration .= $hours . 'H'; + } + if ( $minutes > 0 ) { + $duration .= $minutes . 'M'; + } + if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) { + $duration .= $secs . 'S'; + } + + return $duration; + } + + /** + * Get reactions for a post. + * + * @param int $post_id The post ID. + * @param string $type Optional reaction type filter. + * @return array The reactions. + */ + public function get_reactions( int $post_id, string $type = '' ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_reactions'; + + if ( $type ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE post_id = %d AND reaction_type = %s ORDER BY created_at DESC", + $post_id, + $type + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at DESC", + $post_id + ) + ); + } + + return $results ?: array(); + } + + /** + * Get reaction counts for a post. + * + * @param int $post_id The post ID. + * @return array The reaction counts. + */ + public function get_reaction_counts( int $post_id ): array { + return array( + 'likes' => (int) get_post_meta( $post_id, '_fedistream_like_count', true ), + 'boosts' => (int) get_post_meta( $post_id, '_fedistream_boost_count', true ), + 'replies' => (int) get_post_meta( $post_id, '_fedistream_reply_count', true ), + ); + } +} diff --git a/includes/ActivityPub/Outbox.php b/includes/ActivityPub/Outbox.php new file mode 100644 index 0000000..e03458d --- /dev/null +++ b/includes/ActivityPub/Outbox.php @@ -0,0 +1,415 @@ +follower_handler = new FollowerHandler(); + + // Hook into post publishing. + add_action( 'transition_post_status', array( $this, 'on_post_status_change' ), 10, 3 ); + + // Hook into post update. + add_action( 'post_updated', array( $this, 'on_post_updated' ), 10, 3 ); + } + + /** + * Handle post status changes. + * + * @param string $new_status The new post status. + * @param string $old_status The old post status. + * @param \WP_Post $post The post object. + * @return void + */ + public function on_post_status_change( string $new_status, string $old_status, \WP_Post $post ): void { + // Only handle FediStream post types. + if ( ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) { + return; + } + + // Check if publishing is enabled for this post. + $enabled = get_post_meta( $post->ID, '_fedistream_activitypub_publish', true ); + if ( ! $enabled && $enabled !== '' ) { + // Default to enabled for new posts. + if ( $old_status === 'new' || $old_status === 'auto-draft' ) { + update_post_meta( $post->ID, '_fedistream_activitypub_publish', '1' ); + $enabled = '1'; + } else { + return; + } + } + + // Publish on status change to 'publish'. + if ( 'publish' === $new_status && 'publish' !== $old_status ) { + $this->publish_create( $post ); + } + } + + /** + * Handle post updates. + * + * @param int $post_id The post ID. + * @param \WP_Post $post_after The post object after update. + * @param \WP_Post $post_before The post object before update. + * @return void + */ + public function on_post_updated( int $post_id, \WP_Post $post_after, \WP_Post $post_before ): void { + // Only handle published FediStream post types. + if ( $post_after->post_status !== 'publish' ) { + return; + } + + if ( ! in_array( $post_after->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) { + return; + } + + // Check if already published to ActivityPub. + $published = get_post_meta( $post_id, '_fedistream_activitypub_published', true ); + if ( ! $published ) { + return; + } + + // Check if update publishing is enabled. + $publish_updates = get_post_meta( $post_id, '_fedistream_activitypub_publish_updates', true ); + if ( ! $publish_updates ) { + return; + } + + // Only publish updates for significant changes. + if ( $this->has_significant_changes( $post_before, $post_after ) ) { + $this->publish_update( $post_after ); + } + } + + /** + * Check if a post has significant changes. + * + * @param \WP_Post $before The post before update. + * @param \WP_Post $after The post after update. + * @return bool + */ + private function has_significant_changes( \WP_Post $before, \WP_Post $after ): bool { + // Check title change. + if ( $before->post_title !== $after->post_title ) { + return true; + } + + // Check content change. + if ( $before->post_content !== $after->post_content ) { + return true; + } + + // Check for audio file change (tracks). + if ( 'fedistream_track' === $after->post_type ) { + $audio_before = get_post_meta( $after->ID, '_fedistream_audio_file_previous', true ); + $audio_after = get_post_meta( $after->ID, '_fedistream_audio_file', true ); + if ( $audio_before !== $audio_after ) { + return true; + } + } + + return false; + } + + /** + * Publish a Create activity for a post. + * + * @param \WP_Post $post The post to publish. + * @return bool True on success. + */ + public function publish_create( \WP_Post $post ): bool { + $transformer = $this->get_transformer( $post ); + if ( ! $transformer ) { + return false; + } + + $activity = $transformer->to_create_activity(); + + // Get the artist for this content. + $artist_id = $this->get_artist_for_post( $post ); + if ( ! $artist_id ) { + return false; + } + + // Broadcast to followers. + $results = $this->follower_handler->broadcast_activity( $artist_id, $activity ); + + // Mark as published. + update_post_meta( $post->ID, '_fedistream_activitypub_published', current_time( 'mysql' ) ); + + // Log results. + $success_count = count( array_filter( $results ) ); + $total_count = count( $results ); + + do_action( 'fedistream_activitypub_published', $post->ID, $success_count, $total_count ); + + return $success_count > 0 || $total_count === 0; + } + + /** + * Publish an Update activity for a post. + * + * @param \WP_Post $post The post to update. + * @return bool True on success. + */ + public function publish_update( \WP_Post $post ): bool { + $transformer = $this->get_transformer( $post ); + if ( ! $transformer ) { + return false; + } + + // Build Update activity. + $object = $transformer->to_object(); + $actor = $transformer->get_attributed_to(); + $actor = is_array( $actor ) ? $actor[0] : $actor; + + $activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Update', + 'id' => get_permalink( $post->ID ) . '#activity-update-' . time(), + 'actor' => $actor, + 'published' => gmdate( 'c' ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array( $actor . '/followers' ), + 'object' => $object, + ); + + // Get the artist for this content. + $artist_id = $this->get_artist_for_post( $post ); + if ( ! $artist_id ) { + return false; + } + + // Broadcast to followers. + $results = $this->follower_handler->broadcast_activity( $artist_id, $activity ); + + // Update timestamp. + update_post_meta( $post->ID, '_fedistream_activitypub_updated', current_time( 'mysql' ) ); + + $success_count = count( array_filter( $results ) ); + + return $success_count > 0 || count( $results ) === 0; + } + + /** + * Publish an Announce (boost) activity. + * + * @param int $artist_id The artist ID doing the announcing. + * @param string $object_uri The object URI to announce. + * @return bool True on success. + */ + public function publish_announce( int $artist_id, string $object_uri ): bool { + $artist = get_post( $artist_id ); + if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) { + return false; + } + + $actor = new ArtistActor( $artist ); + + $activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Announce', + 'id' => $actor->get_id() . '#announce-' . time(), + 'actor' => $actor->get_id(), + 'published' => gmdate( 'c' ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array( $actor->get_followers() ), + 'object' => $object_uri, + ); + + // Broadcast to followers. + $results = $this->follower_handler->broadcast_activity( $artist_id, $activity ); + + return count( array_filter( $results ) ) > 0 || count( $results ) === 0; + } + + /** + * Publish a Delete activity for a post. + * + * @param \WP_Post $post The post being deleted. + * @return bool True on success. + */ + public function publish_delete( \WP_Post $post ): bool { + // Check if was published to ActivityPub. + $published = get_post_meta( $post->ID, '_fedistream_activitypub_published', true ); + if ( ! $published ) { + return false; + } + + $actor = $this->get_attributed_to( $post ); + if ( ! $actor ) { + return false; + } + + $activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Delete', + 'id' => get_permalink( $post->ID ) . '#activity-delete-' . time(), + 'actor' => $actor, + 'published' => gmdate( 'c' ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Tombstone', + 'id' => get_permalink( $post->ID ), + ), + ); + + // Get the artist for this content. + $artist_id = $this->get_artist_for_post( $post ); + if ( ! $artist_id ) { + return false; + } + + // Broadcast to followers. + $results = $this->follower_handler->broadcast_activity( $artist_id, $activity ); + + return count( array_filter( $results ) ) > 0 || count( $results ) === 0; + } + + /** + * Get the appropriate transformer for a post. + * + * @param \WP_Post $post The post. + * @return TrackTransformer|AlbumTransformer|PlaylistTransformer|null + */ + private function get_transformer( \WP_Post $post ) { + switch ( $post->post_type ) { + case 'fedistream_track': + return new TrackTransformer( $post ); + + case 'fedistream_album': + return new AlbumTransformer( $post ); + + case 'fedistream_playlist': + return new PlaylistTransformer( $post ); + + default: + return null; + } + } + + /** + * Get the artist ID associated with a post. + * + * @param \WP_Post $post The post. + * @return int|null + */ + private function get_artist_for_post( \WP_Post $post ): ?int { + switch ( $post->post_type ) { + case 'fedistream_track': + $artist_ids = get_post_meta( $post->ID, '_fedistream_artist_ids', true ); + if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) { + return absint( $artist_ids[0] ); + } + + // Fall back to album artist. + $album_id = get_post_meta( $post->ID, '_fedistream_album_id', true ); + if ( $album_id ) { + return absint( get_post_meta( $album_id, '_fedistream_album_artist', true ) ); + } + break; + + case 'fedistream_album': + $artist_id = get_post_meta( $post->ID, '_fedistream_album_artist', true ); + return $artist_id ? absint( $artist_id ) : null; + + case 'fedistream_playlist': + // For playlists, try to find an artist owned by the author. + $artist_args = array( + 'post_type' => 'fedistream_artist', + 'posts_per_page' => 1, + 'author' => $post->post_author, + ); + + $artists = get_posts( $artist_args ); + if ( ! empty( $artists ) ) { + return $artists[0]->ID; + } + break; + } + + return null; + } + + /** + * Get the attributed actor for a post. + * + * @param \WP_Post $post The post. + * @return string|null Actor URI. + */ + private function get_attributed_to( \WP_Post $post ): ?string { + $artist_id = $this->get_artist_for_post( $post ); + + if ( $artist_id ) { + return get_permalink( $artist_id ); + } + + return null; + } + + /** + * Manually trigger publication for a post. + * + * @param int $post_id The post ID. + * @return bool True on success. + */ + public function manual_publish( int $post_id ): bool { + $post = get_post( $post_id ); + + if ( ! $post || 'publish' !== $post->post_status ) { + return false; + } + + // Check if already published. + $published = get_post_meta( $post_id, '_fedistream_activitypub_published', true ); + + if ( $published ) { + return $this->publish_update( $post ); + } + + return $this->publish_create( $post ); + } + + /** + * Get the outbox collection for an artist. + * + * @param int $artist_id The artist ID. + * @param int $page The page number (0 for summary). + * @param int $per_page Items per page. + * @return array + */ + public function get_collection( int $artist_id, int $page = 0, int $per_page = 20 ): array { + $artist = get_post( $artist_id ); + if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) { + return array(); + } + + $actor = new ArtistActor( $artist ); + + return $actor->get_outbox_collection( $page, $per_page ); + } +} diff --git a/includes/ActivityPub/PlaylistTransformer.php b/includes/ActivityPub/PlaylistTransformer.php new file mode 100644 index 0000000..522e750 --- /dev/null +++ b/includes/ActivityPub/PlaylistTransformer.php @@ -0,0 +1,433 @@ +post = $post; + } + + /** + * Get the ActivityPub object type. + * + * @return string + */ + public function get_type(): string { + return 'OrderedCollection'; + } + + /** + * Get the object ID (URI). + * + * @return string + */ + public function get_id(): string { + return get_permalink( $this->post->ID ); + } + + /** + * Get the object name (title). + * + * @return string + */ + public function get_name(): string { + return $this->post->post_title; + } + + /** + * Get the content (description). + * + * @return string + */ + public function get_content(): string { + $content = $this->post->post_content; + + // Apply content filters for proper formatting. + $content = apply_filters( 'the_content', $content ); + + return wp_kses_post( $content ); + } + + /** + * Get the summary (excerpt). + * + * @return string + */ + public function get_summary(): string { + if ( ! empty( $this->post->post_excerpt ) ) { + return wp_strip_all_tags( $this->post->post_excerpt ); + } + + // Generate excerpt from content. + return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 ); + } + + /** + * Get the URL (permalink). + * + * @return string + */ + public function get_url(): string { + return get_permalink( $this->post->ID ); + } + + /** + * Get the attributed actor (playlist creator). + * + * @return string User's ActivityPub actor URI. + */ + public function get_attributed_to(): string { + $user_id = $this->post->post_author; + + // Check if there's an artist associated with this user. + $artist_args = array( + 'post_type' => 'fedistream_artist', + 'posts_per_page' => 1, + 'author' => $user_id, + ); + + $artists = get_posts( $artist_args ); + + if ( ! empty( $artists ) ) { + return get_permalink( $artists[0]->ID ); + } + + // Fall back to user profile URL. + return get_author_posts_url( $user_id ); + } + + /** + * Get the published date. + * + * @return string ISO 8601 date. + */ + public function get_published(): string { + return get_the_date( 'c', $this->post ); + } + + /** + * Get the updated date. + * + * @return string ISO 8601 date. + */ + public function get_updated(): string { + return get_the_modified_date( 'c', $this->post ); + } + + /** + * Get the total duration. + * + * @return string ISO 8601 duration. + */ + public function get_duration(): string { + $seconds = (int) get_post_meta( $this->post->ID, '_fedistream_playlist_duration', true ); + + if ( ! $seconds ) { + // Calculate from tracks. + $tracks = $this->get_tracks(); + foreach ( $tracks as $track ) { + $seconds += (int) get_post_meta( $track->ID, '_fedistream_duration', true ); + } + } + + if ( ! $seconds ) { + return ''; + } + + return $this->format_duration_iso8601( $seconds ); + } + + /** + * Get the tracks in this playlist. + * + * @return array Array of WP_Post objects. + */ + public function get_tracks(): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_playlist_tracks'; + + // Get track IDs in order. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $track_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT track_id FROM {$table} WHERE playlist_id = %d ORDER BY position ASC", + $this->post->ID + ) + ); + + if ( empty( $track_ids ) ) { + return array(); + } + + // Get track posts in order. + $args = array( + 'post_type' => 'fedistream_track', + 'post_status' => 'publish', + 'post__in' => $track_ids, + 'orderby' => 'post__in', + 'posts_per_page' => -1, + ); + + $query = new \WP_Query( $args ); + + return $query->posts; + } + + /** + * Get the total item count. + * + * @return int + */ + public function get_total_items(): int { + $count = (int) get_post_meta( $this->post->ID, '_fedistream_track_count', true ); + + if ( ! $count ) { + $count = count( $this->get_tracks() ); + } + + return $count; + } + + /** + * Get the ordered items (track objects). + * + * @return array + */ + public function get_ordered_items(): array { + $tracks = $this->get_tracks(); + $items = array(); + + foreach ( $tracks as $track ) { + $transformer = new TrackTransformer( $track ); + $items[] = $transformer->to_object(); + } + + return $items; + } + + /** + * Get the image/cover attachment. + * + * @return array|null + */ + public function get_image_attachment(): ?array { + $thumbnail_id = get_post_thumbnail_id( $this->post->ID ); + + if ( ! $thumbnail_id ) { + return null; + } + + $image = wp_get_attachment_image_src( $thumbnail_id, 'medium' ); + if ( ! $image ) { + return null; + } + + return array( + 'type' => 'Image', + 'mediaType' => get_post_mime_type( $thumbnail_id ), + 'url' => $image[0], + 'width' => $image[1], + 'height' => $image[2], + ); + } + + /** + * Get tags (moods). + * + * @return array + */ + public function get_tags(): array { + $tags = array(); + + // Get moods. + $moods = get_the_terms( $this->post->ID, 'fedistream_mood' ); + if ( $moods && ! is_wp_error( $moods ) ) { + foreach ( $moods as $mood ) { + $tags[] = array( + 'type' => 'Hashtag', + 'name' => '#' . sanitize_title( $mood->name ), + 'href' => get_term_link( $mood ), + ); + } + } + + return $tags; + } + + /** + * Check if the playlist is public. + * + * @return bool + */ + public function is_public(): bool { + $visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true ); + + return 'public' === $visibility || empty( $visibility ); + } + + /** + * Transform to ActivityPub object array. + * + * @return array + */ + public function to_object(): array { + $object = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => $this->get_type(), + 'id' => $this->get_id(), + 'name' => $this->get_name(), + 'summary' => $this->get_summary(), + 'content' => $this->get_content(), + 'url' => $this->get_url(), + 'attributedTo' => $this->get_attributed_to(), + 'published' => $this->get_published(), + 'updated' => $this->get_updated(), + 'totalItems' => $this->get_total_items(), + 'orderedItems' => $this->get_ordered_items(), + ); + + // Add duration. + $duration = $this->get_duration(); + if ( $duration ) { + $object['duration'] = $duration; + } + + // Add image. + $image = $this->get_image_attachment(); + if ( $image ) { + $object['image'] = $image; + } + + // Add tags. + $tags = $this->get_tags(); + if ( ! empty( $tags ) ) { + $object['tag'] = $tags; + } + + // Add visibility indicator. + if ( ! $this->is_public() ) { + $object['sensitive'] = false; + $visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true ); + if ( 'unlisted' === $visibility ) { + $object['visibility'] = 'unlisted'; + } + } + + // Add collaborative flag. + $collaborative = get_post_meta( $this->post->ID, '_fedistream_playlist_collaborative', true ); + if ( $collaborative ) { + $object['collaborative'] = true; + } + + return $object; + } + + /** + * Create a Create activity for this playlist. + * + * @return array + */ + public function to_create_activity(): array { + $actor = $this->get_attributed_to(); + + // Determine audience based on visibility. + $visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true ); + + $to = array(); + $cc = array(); + + if ( 'public' === $visibility || empty( $visibility ) ) { + $to[] = 'https://www.w3.org/ns/activitystreams#Public'; + $cc[] = $actor . '/followers'; + } elseif ( 'unlisted' === $visibility ) { + $to[] = $actor . '/followers'; + $cc[] = 'https://www.w3.org/ns/activitystreams#Public'; + } else { + // Private - only followers. + $to[] = $actor . '/followers'; + } + + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Create', + 'id' => $this->get_id() . '#activity-create', + 'actor' => $actor, + 'published' => $this->get_published(), + 'to' => $to, + 'cc' => $cc, + 'object' => $this->to_object(), + ); + } + + /** + * Create an Update activity for this playlist. + * + * @return array + */ + public function to_update_activity(): array { + $actor = $this->get_attributed_to(); + + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Update', + 'id' => $this->get_id() . '#activity-update-' . time(), + 'actor' => $actor, + 'published' => gmdate( 'c' ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array( $actor . '/followers' ), + 'object' => $this->to_object(), + ); + } + + /** + * Format duration as ISO 8601. + * + * @param int $seconds The duration in seconds. + * @return string ISO 8601 duration. + */ + private function format_duration_iso8601( int $seconds ): string { + $hours = floor( $seconds / 3600 ); + $minutes = floor( ( $seconds % 3600 ) / 60 ); + $secs = $seconds % 60; + + $duration = 'PT'; + if ( $hours > 0 ) { + $duration .= $hours . 'H'; + } + if ( $minutes > 0 ) { + $duration .= $minutes . 'M'; + } + if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) { + $duration .= $secs . 'S'; + } + + return $duration; + } +} diff --git a/includes/ActivityPub/RestApi.php b/includes/ActivityPub/RestApi.php new file mode 100644 index 0000000..3e09f36 --- /dev/null +++ b/includes/ActivityPub/RestApi.php @@ -0,0 +1,476 @@ +follower_handler = new FollowerHandler(); + $this->outbox = new Outbox(); + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + + // Add ActivityPub content type to allowed responses. + add_filter( 'rest_pre_serve_request', array( $this, 'serve_activitypub' ), 10, 4 ); + } + + /** + * Register REST API routes. + * + * @return void + */ + public function register_routes(): void { + // Artist actor endpoint. + register_rest_route( + self::NAMESPACE, + '/artist/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_artist_actor' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => array( $this, 'validate_artist_id' ), + ), + ), + ) + ); + + // Artist inbox endpoint. + register_rest_route( + self::NAMESPACE, + '/artist/(?P\d+)/inbox', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_inbox' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => array( $this, 'validate_artist_id' ), + ), + ), + ) + ); + + // Artist outbox endpoint. + register_rest_route( + self::NAMESPACE, + '/artist/(?P\d+)/outbox', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_outbox' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => array( $this, 'validate_artist_id' ), + ), + 'page' => array( + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + // Artist followers endpoint. + register_rest_route( + self::NAMESPACE, + '/artist/(?P\d+)/followers', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_followers' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => array( $this, 'validate_artist_id' ), + ), + 'page' => array( + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + // Track/Album/Playlist object endpoint. + register_rest_route( + self::NAMESPACE, + '/object/(?Ptrack|album|playlist)/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_object' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'type' => array( + 'required' => true, + ), + 'id' => array( + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + // Reactions endpoint. + register_rest_route( + self::NAMESPACE, + '/reactions/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_reactions' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'post_id' => array( + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'type' => array( + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + + // Manual publish endpoint (requires auth). + register_rest_route( + self::NAMESPACE, + '/publish/(?P\d+)', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'manual_publish' ), + 'permission_callback' => array( $this, 'can_edit_post' ), + 'args' => array( + 'post_id' => array( + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * Validate artist ID. + * + * @param mixed $id The ID to validate. + * @return bool + */ + public function validate_artist_id( $id ): bool { + $artist = get_post( absint( $id ) ); + + return $artist && 'fedistream_artist' === $artist->post_type && 'publish' === $artist->post_status; + } + + /** + * Check if user can edit the post. + * + * @param \WP_REST_Request $request The request. + * @return bool + */ + public function can_edit_post( \WP_REST_Request $request ): bool { + $post_id = absint( $request->get_param( 'post_id' ) ); + + return current_user_can( 'edit_post', $post_id ); + } + + /** + * Get artist actor. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function get_artist_actor( \WP_REST_Request $request ): \WP_REST_Response { + $artist_id = absint( $request->get_param( 'id' ) ); + $artist = get_post( $artist_id ); + + if ( ! $artist ) { + return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 ); + } + + $actor = new ArtistActor( $artist ); + + $response = new \WP_REST_Response( $actor->to_array() ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Handle inbox requests. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function handle_inbox( \WP_REST_Request $request ): \WP_REST_Response { + $artist_id = absint( $request->get_param( 'id' ) ); + $artist = get_post( $artist_id ); + + if ( ! $artist ) { + return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 ); + } + + // Get the activity from request body. + $activity = $request->get_json_params(); + + if ( empty( $activity ) ) { + return new \WP_REST_Response( array( 'error' => 'Invalid activity' ), 400 ); + } + + // Verify HTTP signature (basic verification). + $signature = $request->get_header( 'Signature' ); + if ( ! $signature ) { + // Allow unsigned requests for now, but log it. + do_action( 'fedistream_unsigned_activity', $activity, $artist_id ); + } + + // Process the activity based on type. + $type = $activity['type'] ?? ''; + + switch ( $type ) { + case 'Follow': + $this->follower_handler->handle_follow( $activity, 0 ); + break; + + case 'Undo': + $this->follower_handler->handle_undo( $activity, 0 ); + break; + + case 'Like': + do_action( 'activitypub_inbox_like', $activity, 0 ); + break; + + case 'Announce': + do_action( 'activitypub_inbox_announce', $activity, 0 ); + break; + + case 'Create': + do_action( 'activitypub_inbox_create', $activity, 0 ); + break; + + case 'Delete': + do_action( 'activitypub_inbox_delete', $activity, 0 ); + break; + + default: + do_action( 'fedistream_inbox_activity', $activity, $artist_id, $type ); + break; + } + + return new \WP_REST_Response( null, 202 ); + } + + /** + * Get outbox collection. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function get_outbox( \WP_REST_Request $request ): \WP_REST_Response { + $artist_id = absint( $request->get_param( 'id' ) ); + $page = absint( $request->get_param( 'page' ) ); + + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 ); + } + + $actor = new ArtistActor( $artist ); + $collection = $actor->get_outbox_collection( $page ); + + $response = new \WP_REST_Response( $collection ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Get followers collection. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function get_followers( \WP_REST_Request $request ): \WP_REST_Response { + $artist_id = absint( $request->get_param( 'id' ) ); + $page = absint( $request->get_param( 'page' ) ); + + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 ); + } + + $actor = new ArtistActor( $artist ); + $collection = $actor->get_followers_collection( $page ); + + $response = new \WP_REST_Response( $collection ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Get ActivityPub object. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function get_object( \WP_REST_Request $request ): \WP_REST_Response { + $type = $request->get_param( 'type' ); + $post_id = absint( $request->get_param( 'id' ) ); + + $post_type = 'fedistream_' . $type; + $post = get_post( $post_id ); + + if ( ! $post || $post->post_type !== $post_type || 'publish' !== $post->post_status ) { + return new \WP_REST_Response( array( 'error' => 'Object not found' ), 404 ); + } + + $transformer = null; + switch ( $type ) { + case 'track': + $transformer = new TrackTransformer( $post ); + break; + + case 'album': + $transformer = new AlbumTransformer( $post ); + break; + + case 'playlist': + $transformer = new PlaylistTransformer( $post ); + break; + } + + if ( ! $transformer ) { + return new \WP_REST_Response( array( 'error' => 'Invalid object type' ), 400 ); + } + + $object = $transformer->to_object(); + $response = new \WP_REST_Response( $object ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Get reactions for a post. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function get_reactions( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = absint( $request->get_param( 'post_id' ) ); + $type = sanitize_text_field( $request->get_param( 'type' ) ); + + $post = get_post( $post_id ); + if ( ! $post ) { + return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 ); + } + + $integration = new Integration(); + $reactions = $integration->get_reactions( $post_id, $type ); + $counts = $integration->get_reaction_counts( $post_id ); + + return new \WP_REST_Response( + array( + 'reactions' => $reactions, + 'counts' => $counts, + ) + ); + } + + /** + * Manually publish a post to ActivityPub. + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function manual_publish( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = absint( $request->get_param( 'post_id' ) ); + + $result = $this->outbox->manual_publish( $post_id ); + + if ( $result ) { + return new \WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Published to ActivityPub', 'wp-fedistream' ), + ) + ); + } + + return new \WP_REST_Response( + array( + 'success' => false, + 'message' => __( 'Failed to publish', 'wp-fedistream' ), + ), + 500 + ); + } + + /** + * Serve ActivityPub content type when requested. + * + * @param bool $served Whether the request has been served. + * @param \WP_REST_Response $result The response. + * @param \WP_REST_Request $request The request. + * @param \WP_REST_Server $server The server. + * @return bool + */ + public function serve_activitypub( bool $served, $result, \WP_REST_Request $request, \WP_REST_Server $server ): bool { + // Check if this is a FediStream route. + $route = $request->get_route(); + if ( strpos( $route, '/fedistream/' ) === false ) { + return $served; + } + + // Check Accept header for ActivityPub. + $accept = $request->get_header( 'Accept' ); + if ( $accept && ( strpos( $accept, 'application/activity+json' ) !== false || strpos( $accept, 'application/ld+json' ) !== false ) ) { + // Will be handled by our response headers. + } + + return $served; + } +} diff --git a/includes/ActivityPub/TrackTransformer.php b/includes/ActivityPub/TrackTransformer.php new file mode 100644 index 0000000..17a6039 --- /dev/null +++ b/includes/ActivityPub/TrackTransformer.php @@ -0,0 +1,412 @@ +post = $post; + } + + /** + * Get the ActivityPub object type. + * + * @return string + */ + public function get_type(): string { + return 'Audio'; + } + + /** + * Get the object ID (URI). + * + * @return string + */ + public function get_id(): string { + return get_permalink( $this->post->ID ); + } + + /** + * Get the object name (title). + * + * @return string + */ + public function get_name(): string { + return $this->post->post_title; + } + + /** + * Get the content (lyrics or description). + * + * @return string + */ + public function get_content(): string { + $content = $this->post->post_content; + + // Apply content filters for proper formatting. + $content = apply_filters( 'the_content', $content ); + + return wp_kses_post( $content ); + } + + /** + * Get the summary (excerpt). + * + * @return string + */ + public function get_summary(): string { + if ( ! empty( $this->post->post_excerpt ) ) { + return wp_strip_all_tags( $this->post->post_excerpt ); + } + + // Generate excerpt from content. + return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 ); + } + + /** + * Get the URL (permalink). + * + * @return string + */ + public function get_url(): string { + return get_permalink( $this->post->ID ); + } + + /** + * Get the attributed actor(s). + * + * @return array|string Artist(s) URIs. + */ + public function get_attributed_to() { + $artist_ids = get_post_meta( $this->post->ID, '_fedistream_artist_ids', true ); + + if ( ! is_array( $artist_ids ) || empty( $artist_ids ) ) { + // Fall back to album artist. + $album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true ); + $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; + + if ( $artist_id ) { + return get_permalink( $artist_id ); + } + + return array(); + } + + // Return single artist or array of artists. + if ( count( $artist_ids ) === 1 ) { + return get_permalink( $artist_ids[0] ); + } + + return array_map( 'get_permalink', $artist_ids ); + } + + /** + * Get the published date. + * + * @return string ISO 8601 date. + */ + public function get_published(): string { + return get_the_date( 'c', $this->post ); + } + + /** + * Get the updated date. + * + * @return string ISO 8601 date. + */ + public function get_updated(): string { + return get_the_modified_date( 'c', $this->post ); + } + + /** + * Get the duration. + * + * @return string ISO 8601 duration. + */ + public function get_duration(): string { + $seconds = (int) get_post_meta( $this->post->ID, '_fedistream_duration', true ); + + if ( ! $seconds ) { + return ''; + } + + return $this->format_duration_iso8601( $seconds ); + } + + /** + * Get the audio attachment. + * + * @return array|null + */ + public function get_audio_attachment(): ?array { + $audio_id = get_post_meta( $this->post->ID, '_fedistream_audio_file', true ); + + if ( ! $audio_id ) { + return null; + } + + $audio_url = wp_get_attachment_url( $audio_id ); + $mime_type = get_post_mime_type( $audio_id ); + $audio_meta = wp_get_attachment_metadata( $audio_id ); + + if ( ! $audio_url ) { + return null; + } + + $attachment = array( + 'type' => 'Audio', + 'mediaType' => $mime_type ?: 'audio/mpeg', + 'url' => $audio_url, + 'name' => $this->post->post_title, + ); + + // Add duration if available. + $duration = $this->get_duration(); + if ( $duration ) { + $attachment['duration'] = $duration; + } + + return $attachment; + } + + /** + * Get the image/thumbnail attachment. + * + * @return array|null + */ + public function get_image_attachment(): ?array { + $thumbnail_id = get_post_thumbnail_id( $this->post->ID ); + + // Fall back to album artwork. + if ( ! $thumbnail_id ) { + $album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true ); + $thumbnail_id = $album_id ? get_post_thumbnail_id( $album_id ) : 0; + } + + if ( ! $thumbnail_id ) { + return null; + } + + $image = wp_get_attachment_image_src( $thumbnail_id, 'medium' ); + if ( ! $image ) { + return null; + } + + return array( + 'type' => 'Image', + 'mediaType' => get_post_mime_type( $thumbnail_id ), + 'url' => $image[0], + 'width' => $image[1], + 'height' => $image[2], + ); + } + + /** + * Get tags (genres, moods). + * + * @return array + */ + public function get_tags(): array { + $tags = array(); + + // Get genres. + $genres = get_the_terms( $this->post->ID, 'fedistream_genre' ); + if ( $genres && ! is_wp_error( $genres ) ) { + foreach ( $genres as $genre ) { + $tags[] = array( + 'type' => 'Hashtag', + 'name' => '#' . sanitize_title( $genre->name ), + 'href' => get_term_link( $genre ), + ); + } + } + + // Get moods. + $moods = get_the_terms( $this->post->ID, 'fedistream_mood' ); + if ( $moods && ! is_wp_error( $moods ) ) { + foreach ( $moods as $mood ) { + $tags[] = array( + 'type' => 'Hashtag', + 'name' => '#' . sanitize_title( $mood->name ), + 'href' => get_term_link( $mood ), + ); + } + } + + return $tags; + } + + /** + * Get the context (album). + * + * @return string|null Album URI if available. + */ + public function get_context(): ?string { + $album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true ); + + if ( ! $album_id ) { + return null; + } + + return get_permalink( $album_id ); + } + + /** + * Transform to ActivityPub object array. + * + * @return array + */ + public function to_object(): array { + $object = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => $this->get_type(), + 'id' => $this->get_id(), + 'name' => $this->get_name(), + 'summary' => $this->get_summary(), + 'content' => $this->get_content(), + 'url' => $this->get_url(), + 'attributedTo' => $this->get_attributed_to(), + 'published' => $this->get_published(), + 'updated' => $this->get_updated(), + ); + + // Add duration. + $duration = $this->get_duration(); + if ( $duration ) { + $object['duration'] = $duration; + } + + // Add attachments. + $attachments = array(); + + $audio = $this->get_audio_attachment(); + if ( $audio ) { + $attachments[] = $audio; + } + + $image = $this->get_image_attachment(); + if ( $image ) { + $attachments[] = $image; + } + + if ( ! empty( $attachments ) ) { + $object['attachment'] = $attachments; + } + + // Add tags. + $tags = $this->get_tags(); + if ( ! empty( $tags ) ) { + $object['tag'] = $tags; + } + + // Add context (album). + $context = $this->get_context(); + if ( $context ) { + $object['context'] = $context; + } + + // Add additional metadata. + $explicit = get_post_meta( $this->post->ID, '_fedistream_explicit', true ); + if ( $explicit ) { + $object['sensitive'] = true; + } + + // Add ISRC if available. + $isrc = get_post_meta( $this->post->ID, '_fedistream_isrc', true ); + if ( $isrc ) { + $object['isrc'] = $isrc; + } + + return $object; + } + + /** + * Create a Create activity for this track. + * + * @return array + */ + public function to_create_activity(): array { + $attributed_to = $this->get_attributed_to(); + $actor = is_array( $attributed_to ) ? $attributed_to[0] : $attributed_to; + + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Create', + 'id' => $this->get_id() . '#activity-create', + 'actor' => $actor, + 'published' => $this->get_published(), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array( $actor . '/followers' ), + 'object' => $this->to_object(), + ); + } + + /** + * Create an Announce activity for this track. + * + * @param string $actor_uri The actor announcing the track. + * @return array + */ + public function to_announce_activity( string $actor_uri ): array { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Announce', + 'id' => $this->get_id() . '#activity-announce-' . time(), + 'actor' => $actor_uri, + 'published' => gmdate( 'c' ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array( $actor_uri . '/followers' ), + 'object' => $this->get_id(), + ); + } + + /** + * Format duration as ISO 8601. + * + * @param int $seconds The duration in seconds. + * @return string ISO 8601 duration. + */ + private function format_duration_iso8601( int $seconds ): string { + $hours = floor( $seconds / 3600 ); + $minutes = floor( ( $seconds % 3600 ) / 60 ); + $secs = $seconds % 60; + + $duration = 'PT'; + if ( $hours > 0 ) { + $duration .= $hours . 'H'; + } + if ( $minutes > 0 ) { + $duration .= $minutes . 'M'; + } + if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) { + $duration .= $secs . 'S'; + } + + return $duration; + } +} diff --git a/includes/Admin/ListColumns.php b/includes/Admin/ListColumns.php new file mode 100644 index 0000000..7dfbff6 --- /dev/null +++ b/includes/Admin/ListColumns.php @@ -0,0 +1,604 @@ + $value ) { + if ( 'title' === $key ) { + $new_columns['fedistream_photo'] = ''; + } + $new_columns[ $key ] = $value; + + if ( 'title' === $key ) { + $new_columns['fedistream_type'] = __( 'Type', 'wp-fedistream' ); + $new_columns['fedistream_albums'] = __( 'Albums', 'wp-fedistream' ); + $new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' ); + } + } + + // Remove date, we'll add it back at the end. + unset( $new_columns['date'] ); + $new_columns['date'] = __( 'Date', 'wp-fedistream' ); + + return $new_columns; + } + + /** + * Render artist column content. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public function artist_column_content( string $column, int $post_id ): void { + switch ( $column ) { + case 'fedistream_photo': + $thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) ); + if ( $thumbnail ) { + echo wp_kses_post( $thumbnail ); + } else { + echo ''; + } + break; + + case 'fedistream_type': + $type = get_post_meta( $post_id, '_fedistream_artist_type', true ); + $types = array( + 'solo' => __( 'Solo', 'wp-fedistream' ), + 'band' => __( 'Band', 'wp-fedistream' ), + 'duo' => __( 'Duo', 'wp-fedistream' ), + 'collective' => __( 'Collective', 'wp-fedistream' ), + ); + echo esc_html( $types[ $type ] ?? __( 'Solo', 'wp-fedistream' ) ); + break; + + case 'fedistream_albums': + $count = $this->count_posts_by_meta( 'fedistream_album', '_fedistream_album_artist', $post_id ); + echo '' . esc_html( $count ) . ''; + break; + + case 'fedistream_tracks': + $count = $this->count_tracks_by_artist( $post_id ); + echo esc_html( $count ); + break; + } + } + + /** + * Define sortable artist columns. + * + * @param array $columns Sortable columns. + * @return array Modified columns. + */ + public function artist_sortable_columns( array $columns ): array { + $columns['fedistream_type'] = 'fedistream_type'; + return $columns; + } + + /** + * Define album list columns. + * + * @param array $columns Default columns. + * @return array Modified columns. + */ + public function album_columns( array $columns ): array { + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + if ( 'title' === $key ) { + $new_columns['fedistream_artwork'] = ''; + } + $new_columns[ $key ] = $value; + + if ( 'title' === $key ) { + $new_columns['fedistream_artist'] = __( 'Artist', 'wp-fedistream' ); + $new_columns['fedistream_type'] = __( 'Type', 'wp-fedistream' ); + $new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' ); + $new_columns['fedistream_release_date'] = __( 'Release Date', 'wp-fedistream' ); + } + } + + unset( $new_columns['date'] ); + $new_columns['date'] = __( 'Date', 'wp-fedistream' ); + + return $new_columns; + } + + /** + * Render album column content. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public function album_column_content( string $column, int $post_id ): void { + switch ( $column ) { + case 'fedistream_artwork': + $thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) ); + if ( $thumbnail ) { + echo wp_kses_post( $thumbnail ); + } else { + echo ''; + } + break; + + case 'fedistream_artist': + $artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true ); + if ( $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist ) { + echo '' . esc_html( $artist->post_title ) . ''; + } + } else { + echo '' . esc_html__( 'No artist', 'wp-fedistream' ) . ''; + } + break; + + case 'fedistream_type': + $type = get_post_meta( $post_id, '_fedistream_album_type', true ); + $types = array( + 'album' => __( 'Album', 'wp-fedistream' ), + 'ep' => __( 'EP', 'wp-fedistream' ), + 'single' => __( 'Single', 'wp-fedistream' ), + 'compilation' => __( 'Compilation', 'wp-fedistream' ), + 'live' => __( 'Live', 'wp-fedistream' ), + 'remix' => __( 'Remix', 'wp-fedistream' ), + ); + echo esc_html( $types[ $type ] ?? __( 'Album', 'wp-fedistream' ) ); + break; + + case 'fedistream_tracks': + $count = get_post_meta( $post_id, '_fedistream_album_total_tracks', true ); + echo '' . esc_html( $count ?: 0 ) . ''; + break; + + case 'fedistream_release_date': + $date = get_post_meta( $post_id, '_fedistream_album_release_date', true ); + if ( $date ) { + echo esc_html( date_i18n( get_option( 'date_format' ), strtotime( $date ) ) ); + } else { + echo ''; + } + break; + } + } + + /** + * Define sortable album columns. + * + * @param array $columns Sortable columns. + * @return array Modified columns. + */ + public function album_sortable_columns( array $columns ): array { + $columns['fedistream_type'] = 'fedistream_type'; + $columns['fedistream_release_date'] = 'fedistream_release_date'; + $columns['fedistream_artist'] = 'fedistream_artist'; + return $columns; + } + + /** + * Define track list columns. + * + * @param array $columns Default columns. + * @return array Modified columns. + */ + public function track_columns( array $columns ): array { + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + if ( 'title' === $key ) { + $new_columns['fedistream_artwork'] = ''; + } + $new_columns[ $key ] = $value; + + if ( 'title' === $key ) { + $new_columns['fedistream_artists'] = __( 'Artists', 'wp-fedistream' ); + $new_columns['fedistream_album'] = __( 'Album', 'wp-fedistream' ); + $new_columns['fedistream_duration'] = __( 'Duration', 'wp-fedistream' ); + $new_columns['fedistream_plays'] = __( 'Plays', 'wp-fedistream' ); + } + } + + unset( $new_columns['date'] ); + $new_columns['date'] = __( 'Date', 'wp-fedistream' ); + + return $new_columns; + } + + /** + * Render track column content. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public function track_column_content( string $column, int $post_id ): void { + switch ( $column ) { + case 'fedistream_artwork': + $thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) ); + if ( ! $thumbnail ) { + // Try album artwork. + $album_id = get_post_meta( $post_id, '_fedistream_track_album', true ); + if ( $album_id ) { + $thumbnail = get_the_post_thumbnail( $album_id, array( 40, 40 ) ); + } + } + if ( $thumbnail ) { + echo wp_kses_post( $thumbnail ); + } else { + echo ''; + } + break; + + case 'fedistream_artists': + $artists = get_post_meta( $post_id, '_fedistream_track_artists', true ); + if ( is_array( $artists ) && ! empty( $artists ) ) { + $artist_links = array(); + foreach ( $artists as $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist ) { + $artist_links[] = '' . esc_html( $artist->post_title ) . ''; + } + } + echo wp_kses_post( implode( ', ', $artist_links ) ); + } else { + echo '' . esc_html__( 'No artist', 'wp-fedistream' ) . ''; + } + break; + + case 'fedistream_album': + $album_id = get_post_meta( $post_id, '_fedistream_track_album', true ); + if ( $album_id ) { + $album = get_post( $album_id ); + if ( $album ) { + echo '' . esc_html( $album->post_title ) . ''; + } + } else { + echo '' . esc_html__( 'Single', 'wp-fedistream' ) . ''; + } + break; + + case 'fedistream_duration': + $duration = get_post_meta( $post_id, '_fedistream_track_duration', true ); + if ( $duration ) { + $minutes = floor( $duration / 60 ); + $seconds = $duration % 60; + echo esc_html( sprintf( '%d:%02d', $minutes, $seconds ) ); + } else { + echo ''; + } + break; + + case 'fedistream_plays': + $plays = $this->get_track_plays( $post_id ); + echo esc_html( number_format_i18n( $plays ) ); + break; + } + } + + /** + * Define sortable track columns. + * + * @param array $columns Sortable columns. + * @return array Modified columns. + */ + public function track_sortable_columns( array $columns ): array { + $columns['fedistream_duration'] = 'fedistream_duration'; + $columns['fedistream_plays'] = 'fedistream_plays'; + $columns['fedistream_album'] = 'fedistream_album'; + return $columns; + } + + /** + * Define playlist list columns. + * + * @param array $columns Default columns. + * @return array Modified columns. + */ + public function playlist_columns( array $columns ): array { + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + if ( 'title' === $key ) { + $new_columns['fedistream_cover'] = ''; + } + $new_columns[ $key ] = $value; + + if ( 'title' === $key ) { + $new_columns['fedistream_tracks'] = __( 'Tracks', 'wp-fedistream' ); + $new_columns['fedistream_duration'] = __( 'Duration', 'wp-fedistream' ); + $new_columns['fedistream_visibility'] = __( 'Visibility', 'wp-fedistream' ); + } + } + + unset( $new_columns['date'] ); + $new_columns['date'] = __( 'Date', 'wp-fedistream' ); + + return $new_columns; + } + + /** + * Render playlist column content. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public function playlist_column_content( string $column, int $post_id ): void { + switch ( $column ) { + case 'fedistream_cover': + $thumbnail = get_the_post_thumbnail( $post_id, array( 40, 40 ) ); + if ( $thumbnail ) { + echo wp_kses_post( $thumbnail ); + } else { + echo ''; + } + break; + + case 'fedistream_tracks': + $count = get_post_meta( $post_id, '_fedistream_playlist_track_count', true ); + echo esc_html( $count ?: 0 ); + break; + + case 'fedistream_duration': + $duration = get_post_meta( $post_id, '_fedistream_playlist_total_duration', true ); + if ( $duration ) { + if ( $duration >= 3600 ) { + $hours = floor( $duration / 3600 ); + $minutes = floor( ( $duration % 3600 ) / 60 ); + echo esc_html( sprintf( '%d:%02d:%02d', $hours, $minutes, $duration % 60 ) ); + } else { + $minutes = floor( $duration / 60 ); + $seconds = $duration % 60; + echo esc_html( sprintf( '%d:%02d', $minutes, $seconds ) ); + } + } else { + echo ''; + } + break; + + case 'fedistream_visibility': + $visibility = get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public'; + $labels = array( + 'public' => __( 'Public', 'wp-fedistream' ), + 'unlisted' => __( 'Unlisted', 'wp-fedistream' ), + 'private' => __( 'Private', 'wp-fedistream' ), + ); + $icons = array( + 'public' => 'dashicons-visibility', + 'unlisted' => 'dashicons-hidden', + 'private' => 'dashicons-lock', + ); + echo ' '; + echo esc_html( $labels[ $visibility ] ?? __( 'Public', 'wp-fedistream' ) ); + break; + } + } + + /** + * Define sortable playlist columns. + * + * @param array $columns Sortable columns. + * @return array Modified columns. + */ + public function playlist_sortable_columns( array $columns ): array { + $columns['fedistream_tracks'] = 'fedistream_track_count'; + $columns['fedistream_duration'] = 'fedistream_duration'; + $columns['fedistream_visibility'] = 'fedistream_visibility'; + return $columns; + } + + /** + * Handle custom column sorting. + * + * @param \WP_Query $query The query object. + * @return void + */ + public function handle_sorting( \WP_Query $query ): void { + if ( ! is_admin() || ! $query->is_main_query() ) { + return; + } + + $orderby = $query->get( 'orderby' ); + + switch ( $orderby ) { + case 'fedistream_type': + $post_type = $query->get( 'post_type' ); + if ( 'fedistream_artist' === $post_type ) { + $query->set( 'meta_key', '_fedistream_artist_type' ); + } elseif ( 'fedistream_album' === $post_type ) { + $query->set( 'meta_key', '_fedistream_album_type' ); + } + $query->set( 'orderby', 'meta_value' ); + break; + + case 'fedistream_release_date': + $query->set( 'meta_key', '_fedistream_album_release_date' ); + $query->set( 'orderby', 'meta_value' ); + break; + + case 'fedistream_artist': + $query->set( 'meta_key', '_fedistream_album_artist' ); + $query->set( 'orderby', 'meta_value_num' ); + break; + + case 'fedistream_duration': + $post_type = $query->get( 'post_type' ); + if ( 'fedistream_track' === $post_type ) { + $query->set( 'meta_key', '_fedistream_track_duration' ); + } elseif ( 'fedistream_playlist' === $post_type ) { + $query->set( 'meta_key', '_fedistream_playlist_total_duration' ); + } + $query->set( 'orderby', 'meta_value_num' ); + break; + + case 'fedistream_track_count': + $query->set( 'meta_key', '_fedistream_playlist_track_count' ); + $query->set( 'orderby', 'meta_value_num' ); + break; + + case 'fedistream_visibility': + $query->set( 'meta_key', '_fedistream_playlist_visibility' ); + $query->set( 'orderby', 'meta_value' ); + break; + + case 'fedistream_album': + $query->set( 'meta_key', '_fedistream_track_album' ); + $query->set( 'orderby', 'meta_value_num' ); + break; + } + } + + /** + * Count posts by meta value. + * + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return int Count. + */ + private function count_posts_by_meta( string $post_type, string $meta_key, $meta_value ): int { + $query = new \WP_Query( + array( + 'post_type' => $post_type, + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_key' => $meta_key, + 'meta_value' => $meta_value, + 'fields' => 'ids', + ) + ); + + return $query->found_posts; + } + + /** + * Count tracks by artist. + * + * @param int $artist_id Artist post ID. + * @return int Track count. + */ + private function count_tracks_by_artist( int $artist_id ): int { + global $wpdb; + + // Count tracks where artist is in the artists array. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->posts p + INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id + WHERE p.post_type = 'fedistream_track' + AND p.post_status = 'publish' + AND pm.meta_key = '_fedistream_track_artists' + AND pm.meta_value LIKE %s", + '%"' . $artist_id . '"%' + ) + ); + + // Also check serialized format. + if ( ! $count ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->posts p + INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id + WHERE p.post_type = 'fedistream_track' + AND p.post_status = 'publish' + AND pm.meta_key = '_fedistream_track_artists' + AND pm.meta_value LIKE %s", + '%i:' . $artist_id . ';%' + ) + ); + } + + return (int) $count; + } + + /** + * Get track play count. + * + * @param int $track_id Track post ID. + * @return int Play count. + */ + private function get_track_plays( int $track_id ): int { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_plays'; + + // Check if table exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $table_exists = $wpdb->get_var( + $wpdb->prepare( + 'SHOW TABLES LIKE %s', + $table + ) + ); + + if ( ! $table_exists ) { + return 0; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $table WHERE track_id = %d", + $track_id + ) + ); + + return (int) $count; + } +} diff --git a/includes/Admin/index.php b/includes/Admin/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/includes/Admin/index.php @@ -0,0 +1 @@ + __( 'Invalid nonce.', 'wp-fedistream' ) ) ); + } + + $track_id = isset( $_POST['track_id'] ) ? absint( $_POST['track_id'] ) : 0; + + if ( ! $track_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid track ID.', 'wp-fedistream' ) ) ); + } + + $track = get_post( $track_id ); + + if ( ! $track || 'fedistream_track' !== $track->post_type ) { + wp_send_json_error( array( 'message' => __( 'Track not found.', 'wp-fedistream' ) ) ); + } + + // Check if track is published. + if ( 'publish' !== $track->post_status ) { + wp_send_json_error( array( 'message' => __( 'Track not available.', 'wp-fedistream' ) ) ); + } + + // Get audio file. + $audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true ); + $audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : ''; + + if ( ! $audio_url ) { + wp_send_json_error( array( 'message' => __( 'No audio file available.', 'wp-fedistream' ) ) ); + } + + // Get track metadata. + $thumbnail_id = get_post_thumbnail_id( $track_id ); + $thumbnail = $thumbnail_id ? wp_get_attachment_image_url( $thumbnail_id, 'medium' ) : ''; + + // Get album info. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + $album = $album_id ? get_post( $album_id ) : null; + + // Get artists. + $artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true ); + $artists = array(); + + if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) { + foreach ( $artist_ids as $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist && 'fedistream_artist' === $artist->post_type ) { + $artists[] = array( + 'id' => $artist->ID, + 'name' => $artist->post_title, + 'link' => get_permalink( $artist->ID ), + ); + } + } + } + + // Get duration. + $duration = get_post_meta( $track_id, '_fedistream_duration', true ); + $duration_formatted = ''; + if ( $duration ) { + $mins = floor( $duration / 60 ); + $secs = $duration % 60; + $duration_formatted = $mins . ':' . str_pad( $secs, 2, '0', STR_PAD_LEFT ); + } + + $data = array( + 'id' => $track->ID, + 'title' => $track->post_title, + 'permalink' => get_permalink( $track->ID ), + 'audio_url' => $audio_url, + 'thumbnail' => $thumbnail, + 'artists' => $artists, + 'artist' => ! empty( $artists ) ? $artists[0]['name'] : '', + 'album' => $album ? $album->post_title : '', + 'album_id' => $album ? $album->ID : 0, + 'album_link' => $album ? get_permalink( $album->ID ) : '', + 'duration' => $duration, + 'duration_formatted' => $duration_formatted, + 'explicit' => (bool) get_post_meta( $track_id, '_fedistream_explicit', true ), + ); + + wp_send_json_success( $data ); + } + + /** + * Record a track play via AJAX. + * + * @return void + */ + public function record_play(): void { + // Verify nonce. + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) ); + } + + $track_id = isset( $_POST['track_id'] ) ? absint( $_POST['track_id'] ) : 0; + + if ( ! $track_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid track ID.', 'wp-fedistream' ) ) ); + } + + $track = get_post( $track_id ); + + if ( ! $track || 'fedistream_track' !== $track->post_type ) { + wp_send_json_error( array( 'message' => __( 'Track not found.', 'wp-fedistream' ) ) ); + } + + global $wpdb; + + // Insert play record. + $table = $wpdb->prefix . 'fedistream_plays'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'track_id' => $track_id, + 'user_id' => get_current_user_id() ?: null, + 'played_at' => current_time( 'mysql' ), + ), + array( '%d', '%d', '%s' ) + ); + + // Update play count in post meta. + $play_count = (int) get_post_meta( $track_id, '_fedistream_play_count', true ); + update_post_meta( $track_id, '_fedistream_play_count', $play_count + 1 ); + + wp_send_json_success( + array( + 'message' => __( 'Play recorded.', 'wp-fedistream' ), + 'play_count' => $play_count + 1, + ) + ); + } +} diff --git a/includes/Frontend/Shortcodes.php b/includes/Frontend/Shortcodes.php new file mode 100644 index 0000000..12c0759 --- /dev/null +++ b/includes/Frontend/Shortcodes.php @@ -0,0 +1,513 @@ +plugin = Plugin::get_instance(); + $this->register_shortcodes(); + } + + /** + * Register all shortcodes. + * + * @return void + */ + private function register_shortcodes(): void { + add_shortcode( 'fedistream_artist', array( $this, 'render_artist' ) ); + add_shortcode( 'fedistream_album', array( $this, 'render_album' ) ); + add_shortcode( 'fedistream_track', array( $this, 'render_track' ) ); + add_shortcode( 'fedistream_playlist', array( $this, 'render_playlist' ) ); + add_shortcode( 'fedistream_latest_releases', array( $this, 'render_latest_releases' ) ); + add_shortcode( 'fedistream_popular_tracks', array( $this, 'render_popular_tracks' ) ); + add_shortcode( 'fedistream_artists', array( $this, 'render_artists_grid' ) ); + add_shortcode( 'fedistream_player', array( $this, 'render_player' ) ); + } + + /** + * Render single artist shortcode. + * + * [fedistream_artist id="123" show_albums="true" show_tracks="true"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_artist( array $atts ): string { + $atts = shortcode_atts( + array( + 'id' => 0, + 'slug' => '', + 'show_albums' => 'true', + 'show_tracks' => 'true', + 'layout' => 'full', // full, card, compact + ), + $atts, + 'fedistream_artist' + ); + + $post = $this->get_post( $atts, 'fedistream_artist' ); + if ( ! $post ) { + return ''; + } + + $context = array( + 'post' => TemplateLoader::get_artist_data( $post ), + 'show_albums' => filter_var( $atts['show_albums'], FILTER_VALIDATE_BOOLEAN ), + 'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ), + 'layout' => sanitize_key( $atts['layout'] ), + ); + + $template = 'card' === $atts['layout'] ? 'partials/card-artist' : 'shortcodes/artist'; + + return $this->render_template( $template, $context ); + } + + /** + * Render single album shortcode. + * + * [fedistream_album id="123" show_tracks="true"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_album( array $atts ): string { + $atts = shortcode_atts( + array( + 'id' => 0, + 'slug' => '', + 'show_tracks' => 'true', + 'layout' => 'full', // full, card, compact + ), + $atts, + 'fedistream_album' + ); + + $post = $this->get_post( $atts, 'fedistream_album' ); + if ( ! $post ) { + return ''; + } + + $context = array( + 'post' => TemplateLoader::get_album_data( $post ), + 'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ), + 'layout' => sanitize_key( $atts['layout'] ), + ); + + $template = 'card' === $atts['layout'] ? 'partials/card-album' : 'shortcodes/album'; + + return $this->render_template( $template, $context ); + } + + /** + * Render single track shortcode. + * + * [fedistream_track id="123" show_player="true"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_track( array $atts ): string { + $atts = shortcode_atts( + array( + 'id' => 0, + 'slug' => '', + 'show_player' => 'true', + 'layout' => 'full', // full, card, compact + ), + $atts, + 'fedistream_track' + ); + + $post = $this->get_post( $atts, 'fedistream_track' ); + if ( ! $post ) { + return ''; + } + + $context = array( + 'post' => TemplateLoader::get_track_data( $post ), + 'show_player' => filter_var( $atts['show_player'], FILTER_VALIDATE_BOOLEAN ), + 'layout' => sanitize_key( $atts['layout'] ), + ); + + $template = 'card' === $atts['layout'] ? 'partials/card-track' : 'shortcodes/track'; + + return $this->render_template( $template, $context ); + } + + /** + * Render playlist shortcode. + * + * [fedistream_playlist id="123" show_tracks="true"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_playlist( array $atts ): string { + $atts = shortcode_atts( + array( + 'id' => 0, + 'slug' => '', + 'show_tracks' => 'true', + 'layout' => 'full', // full, card, compact + ), + $atts, + 'fedistream_playlist' + ); + + $post = $this->get_post( $atts, 'fedistream_playlist' ); + if ( ! $post ) { + return ''; + } + + $context = array( + 'post' => TemplateLoader::get_playlist_data( $post ), + 'show_tracks' => filter_var( $atts['show_tracks'], FILTER_VALIDATE_BOOLEAN ), + 'layout' => sanitize_key( $atts['layout'] ), + ); + + $template = 'card' === $atts['layout'] ? 'partials/card-playlist' : 'shortcodes/playlist'; + + return $this->render_template( $template, $context ); + } + + /** + * Render latest releases shortcode. + * + * [fedistream_latest_releases count="6" type="album" columns="3"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_latest_releases( array $atts ): string { + $atts = shortcode_atts( + array( + 'count' => 6, + 'type' => '', // album, ep, single, compilation or empty for all + 'columns' => 3, + 'artist' => 0, // Filter by artist ID + ), + $atts, + 'fedistream_latest_releases' + ); + + $query_args = array( + 'post_type' => 'fedistream_album', + 'posts_per_page' => absint( $atts['count'] ), + 'orderby' => 'meta_value', + 'meta_key' => '_fedistream_release_date', + 'order' => 'DESC', + 'post_status' => 'publish', + ); + + // Filter by album type. + if ( ! empty( $atts['type'] ) ) { + $query_args['meta_query'][] = array( + 'key' => '_fedistream_album_type', + 'value' => sanitize_key( $atts['type'] ), + ); + } + + // Filter by artist. + if ( ! empty( $atts['artist'] ) ) { + $query_args['meta_query'][] = array( + 'key' => '_fedistream_artist_id', + 'value' => absint( $atts['artist'] ), + ); + } + + $query = new \WP_Query( $query_args ); + $posts = array(); + + if ( $query->have_posts() ) { + while ( $query->have_posts() ) { + $query->the_post(); + $posts[] = TemplateLoader::get_album_data( get_post() ); + } + wp_reset_postdata(); + } + + $context = array( + 'posts' => $posts, + 'columns' => absint( $atts['columns'] ), + 'title' => __( 'Latest Releases', 'wp-fedistream' ), + ); + + return $this->render_template( 'shortcodes/releases-grid', $context ); + } + + /** + * Render popular tracks shortcode. + * + * [fedistream_popular_tracks count="10" columns="1"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_popular_tracks( array $atts ): string { + $atts = shortcode_atts( + array( + 'count' => 10, + 'columns' => 1, + 'artist' => 0, // Filter by artist ID + 'genre' => '', // Filter by genre slug + ), + $atts, + 'fedistream_popular_tracks' + ); + + $query_args = array( + 'post_type' => 'fedistream_track', + 'posts_per_page' => absint( $atts['count'] ), + 'orderby' => 'meta_value_num', + 'meta_key' => '_fedistream_play_count', + 'order' => 'DESC', + 'post_status' => 'publish', + ); + + // Filter by artist. + if ( ! empty( $atts['artist'] ) ) { + $query_args['meta_query'][] = array( + 'key' => '_fedistream_artist_ids', + 'value' => absint( $atts['artist'] ), + 'compare' => 'LIKE', + ); + } + + // Filter by genre. + if ( ! empty( $atts['genre'] ) ) { + $query_args['tax_query'][] = array( + 'taxonomy' => 'fedistream_genre', + 'field' => 'slug', + 'terms' => sanitize_title( $atts['genre'] ), + ); + } + + $query = new \WP_Query( $query_args ); + $posts = array(); + + if ( $query->have_posts() ) { + while ( $query->have_posts() ) { + $query->the_post(); + $posts[] = TemplateLoader::get_track_data( get_post() ); + } + wp_reset_postdata(); + } + + $context = array( + 'posts' => $posts, + 'columns' => absint( $atts['columns'] ), + 'title' => __( 'Popular Tracks', 'wp-fedistream' ), + ); + + return $this->render_template( 'shortcodes/tracks-list', $context ); + } + + /** + * Render artists grid shortcode. + * + * [fedistream_artists count="12" columns="4" type="band"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_artists_grid( array $atts ): string { + $atts = shortcode_atts( + array( + 'count' => 12, + 'columns' => 4, + 'type' => '', // solo, band, duo, collective, or empty for all + 'genre' => '', // Filter by genre slug + 'orderby' => 'title', + 'order' => 'ASC', + ), + $atts, + 'fedistream_artists' + ); + + $query_args = array( + 'post_type' => 'fedistream_artist', + 'posts_per_page' => absint( $atts['count'] ), + 'orderby' => sanitize_key( $atts['orderby'] ), + 'order' => 'DESC' === strtoupper( $atts['order'] ) ? 'DESC' : 'ASC', + 'post_status' => 'publish', + ); + + // Filter by artist type. + if ( ! empty( $atts['type'] ) ) { + $query_args['meta_query'][] = array( + 'key' => '_fedistream_artist_type', + 'value' => sanitize_key( $atts['type'] ), + ); + } + + // Filter by genre. + if ( ! empty( $atts['genre'] ) ) { + $query_args['tax_query'][] = array( + 'taxonomy' => 'fedistream_genre', + 'field' => 'slug', + 'terms' => sanitize_title( $atts['genre'] ), + ); + } + + $query = new \WP_Query( $query_args ); + $posts = array(); + + if ( $query->have_posts() ) { + while ( $query->have_posts() ) { + $query->the_post(); + $posts[] = TemplateLoader::get_artist_data( get_post() ); + } + wp_reset_postdata(); + } + + $context = array( + 'posts' => $posts, + 'columns' => absint( $atts['columns'] ), + 'title' => __( 'Artists', 'wp-fedistream' ), + ); + + return $this->render_template( 'shortcodes/artists-grid', $context ); + } + + /** + * Render audio player shortcode. + * + * [fedistream_player track="123" autoplay="false"] + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_player( array $atts ): string { + $atts = shortcode_atts( + array( + 'track' => 0, + 'playlist' => 0, + 'album' => 0, + 'autoplay' => 'false', + 'style' => 'default', // default, compact, mini + ), + $atts, + 'fedistream_player' + ); + + $tracks = array(); + + // Get tracks based on source. + if ( ! empty( $atts['track'] ) ) { + $post = get_post( absint( $atts['track'] ) ); + if ( $post && 'fedistream_track' === $post->post_type ) { + $tracks[] = TemplateLoader::get_track_data( $post ); + } + } elseif ( ! empty( $atts['album'] ) ) { + $album_id = absint( $atts['album'] ); + $track_ids = get_post_meta( $album_id, '_fedistream_track_ids', true ); + if ( is_array( $track_ids ) ) { + foreach ( $track_ids as $track_id ) { + $post = get_post( $track_id ); + if ( $post && 'fedistream_track' === $post->post_type ) { + $tracks[] = TemplateLoader::get_track_data( $post ); + } + } + } + } elseif ( ! empty( $atts['playlist'] ) ) { + $playlist_id = absint( $atts['playlist'] ); + $track_ids = get_post_meta( $playlist_id, '_fedistream_track_ids', true ); + if ( is_array( $track_ids ) ) { + foreach ( $track_ids as $track_id ) { + $post = get_post( $track_id ); + if ( $post && 'fedistream_track' === $post->post_type ) { + $tracks[] = TemplateLoader::get_track_data( $post ); + } + } + } + } + + if ( empty( $tracks ) ) { + return ''; + } + + $context = array( + 'tracks' => $tracks, + 'autoplay' => filter_var( $atts['autoplay'], FILTER_VALIDATE_BOOLEAN ), + 'style' => sanitize_key( $atts['style'] ), + ); + + return $this->render_template( 'shortcodes/player', $context ); + } + + /** + * Get post by ID or slug. + * + * @param array $atts Shortcode attributes. + * @param string $post_type Post type. + * @return \WP_Post|null + */ + private function get_post( array $atts, string $post_type ): ?\WP_Post { + if ( ! empty( $atts['id'] ) ) { + $post = get_post( absint( $atts['id'] ) ); + if ( $post && $post->post_type === $post_type ) { + return $post; + } + } + + if ( ! empty( $atts['slug'] ) ) { + $posts = get_posts( + array( + 'name' => sanitize_title( $atts['slug'] ), + 'post_type' => $post_type, + 'posts_per_page' => 1, + 'post_status' => 'publish', + ) + ); + if ( ! empty( $posts ) ) { + return $posts[0]; + } + } + + return null; + } + + /** + * Render a Twig template. + * + * @param string $template Template name. + * @param array $context Template context. + * @return string + */ + private function render_template( string $template, array $context ): string { + try { + return $this->plugin->render( $template, $context ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + return '

' . esc_html( $e->getMessage() ) . '

'; + } + return ''; + } + } +} diff --git a/includes/Frontend/TemplateLoader.php b/includes/Frontend/TemplateLoader.php new file mode 100644 index 0000000..936a5e9 --- /dev/null +++ b/includes/Frontend/TemplateLoader.php @@ -0,0 +1,528 @@ +get_template( 'single-artist' ); + } + + if ( is_singular( 'fedistream_album' ) ) { + return $this->get_template( 'single-album' ); + } + + if ( is_singular( 'fedistream_track' ) ) { + return $this->get_template( 'single-track' ); + } + + if ( is_singular( 'fedistream_playlist' ) ) { + return $this->get_template( 'single-playlist' ); + } + + if ( is_post_type_archive( 'fedistream_artist' ) ) { + return $this->get_template( 'archive-artist' ); + } + + if ( is_post_type_archive( 'fedistream_album' ) ) { + return $this->get_template( 'archive-album' ); + } + + if ( is_post_type_archive( 'fedistream_track' ) ) { + return $this->get_template( 'archive-track' ); + } + + if ( is_post_type_archive( 'fedistream_playlist' ) ) { + return $this->get_template( 'archive-playlist' ); + } + + if ( is_tax( 'fedistream_genre' ) ) { + return $this->get_template( 'taxonomy-genre' ); + } + + if ( is_tax( 'fedistream_mood' ) ) { + return $this->get_template( 'taxonomy-mood' ); + } + + return $template; + } + + /** + * Get template file path. + * + * First checks theme for override, then uses plugin template. + * + * @param string $template_name Template name without extension. + * @return string Template path. + */ + private function get_template( string $template_name ): string { + // Check theme for override. + $theme_template = locate_template( + array( + "fedistream/{$template_name}.php", + "fedistream/{$template_name}.twig", + ) + ); + + if ( $theme_template ) { + return $theme_template; + } + + // Use plugin template wrapper. + return WP_FEDISTREAM_PATH . 'includes/Frontend/template-wrapper.php'; + } + + /** + * Add FediStream classes to body. + * + * @param array $classes Body classes. + * @return array Modified classes. + */ + public function body_class( array $classes ): array { + if ( $this->is_fedistream_page() ) { + $classes[] = 'fedistream'; + + if ( is_singular( 'fedistream_artist' ) ) { + $classes[] = 'fedistream-artist'; + $classes[] = 'fedistream-single'; + } elseif ( is_singular( 'fedistream_album' ) ) { + $classes[] = 'fedistream-album'; + $classes[] = 'fedistream-single'; + } elseif ( is_singular( 'fedistream_track' ) ) { + $classes[] = 'fedistream-track'; + $classes[] = 'fedistream-single'; + } elseif ( is_singular( 'fedistream_playlist' ) ) { + $classes[] = 'fedistream-playlist'; + $classes[] = 'fedistream-single'; + } elseif ( is_post_type_archive( 'fedistream_artist' ) ) { + $classes[] = 'fedistream-archive'; + $classes[] = 'fedistream-artists'; + } elseif ( is_post_type_archive( 'fedistream_album' ) ) { + $classes[] = 'fedistream-archive'; + $classes[] = 'fedistream-albums'; + } elseif ( is_post_type_archive( 'fedistream_track' ) ) { + $classes[] = 'fedistream-archive'; + $classes[] = 'fedistream-tracks'; + } elseif ( is_post_type_archive( 'fedistream_playlist' ) ) { + $classes[] = 'fedistream-archive'; + $classes[] = 'fedistream-playlists'; + } elseif ( is_tax( 'fedistream_genre' ) || is_tax( 'fedistream_mood' ) ) { + $classes[] = 'fedistream-archive'; + $classes[] = 'fedistream-taxonomy'; + } + } + + return $classes; + } + + /** + * Check if current page is a FediStream page. + * + * @return bool + */ + public function is_fedistream_page(): bool { + return is_singular( array( 'fedistream_artist', 'fedistream_album', 'fedistream_track', 'fedistream_playlist' ) ) + || is_post_type_archive( array( 'fedistream_artist', 'fedistream_album', 'fedistream_track', 'fedistream_playlist' ) ) + || is_tax( array( 'fedistream_genre', 'fedistream_mood', 'fedistream_license' ) ); + } + + /** + * Get template context for current page. + * + * @return array Template context. + */ + public static function get_context(): array { + $context = array( + 'site_name' => get_bloginfo( 'name' ), + 'site_url' => home_url(), + 'is_singular' => is_singular(), + 'is_archive' => is_archive(), + 'current_url' => get_permalink(), + ); + + if ( is_singular() ) { + global $post; + $context['post'] = self::get_post_data( $post ); + } + + if ( is_post_type_archive() || is_tax() ) { + $context['posts'] = self::get_archive_posts(); + $context['pagination'] = self::get_pagination(); + $context['archive_title'] = self::get_archive_title(); + $context['archive_description'] = self::get_archive_description(); + } + + return $context; + } + + /** + * Get post data for template. + * + * @param \WP_Post $post Post object. + * @return array Post data. + */ + public static function get_post_data( \WP_Post $post ): array { + $data = array( + 'id' => $post->ID, + 'title' => get_the_title( $post ), + 'content' => apply_filters( 'the_content', $post->post_content ), + 'excerpt' => get_the_excerpt( $post ), + 'permalink' => get_permalink( $post ), + 'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ), + 'date' => get_the_date( '', $post ), + 'author' => get_the_author_meta( 'display_name', $post->post_author ), + ); + + // Add post type specific data. + switch ( $post->post_type ) { + case 'fedistream_artist': + $data = array_merge( $data, self::get_artist_data( $post->ID ) ); + break; + case 'fedistream_album': + $data = array_merge( $data, self::get_album_data( $post->ID ) ); + break; + case 'fedistream_track': + $data = array_merge( $data, self::get_track_data( $post->ID ) ); + break; + case 'fedistream_playlist': + $data = array_merge( $data, self::get_playlist_data( $post->ID ) ); + break; + } + + // Add taxonomies. + $data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' ); + $data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' ); + + return $data; + } + + /** + * Get artist-specific data. + * + * @param int $post_id Post ID. + * @return array Artist data. + */ + private static function get_artist_data( int $post_id ): array { + $type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo'; + $types = array( + 'solo' => __( 'Solo Artist', 'wp-fedistream' ), + 'band' => __( 'Band', 'wp-fedistream' ), + 'duo' => __( 'Duo', 'wp-fedistream' ), + 'collective' => __( 'Collective', 'wp-fedistream' ), + ); + + $albums = get_posts( + array( + 'post_type' => 'fedistream_album', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_key' => '_fedistream_album_artist', + 'meta_value' => $post_id, + 'orderby' => 'meta_value', + 'meta_query' => array( + array( + 'key' => '_fedistream_album_release_date', + 'compare' => 'EXISTS', + ), + ), + 'order' => 'DESC', + ) + ); + + return array( + 'artist_type' => $type, + 'artist_type_label' => $types[ $type ] ?? $types['solo'], + 'formed_date' => get_post_meta( $post_id, '_fedistream_artist_formed_date', true ), + 'location' => get_post_meta( $post_id, '_fedistream_artist_location', true ), + 'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ), + 'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(), + 'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(), + 'albums' => array_map( array( __CLASS__, 'get_post_data' ), $albums ), + 'album_count' => count( $albums ), + ); + } + + /** + * Get album-specific data. + * + * @param int $post_id Post ID. + * @return array Album data. + */ + private static function get_album_data( int $post_id ): array { + $type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album'; + $types = array( + 'album' => __( 'Album', 'wp-fedistream' ), + 'ep' => __( 'EP', 'wp-fedistream' ), + 'single' => __( 'Single', 'wp-fedistream' ), + 'compilation' => __( 'Compilation', 'wp-fedistream' ), + 'live' => __( 'Live Album', 'wp-fedistream' ), + 'remix' => __( 'Remix Album', 'wp-fedistream' ), + ); + $artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true ); + + $tracks = get_posts( + array( + 'post_type' => 'fedistream_track', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_key' => '_fedistream_track_album', + 'meta_value' => $post_id, + 'orderby' => 'meta_value_num', + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_fedistream_track_number', + 'compare' => 'EXISTS', + ), + ), + 'order' => 'ASC', + ) + ); + + return array( + 'album_type' => $type, + 'album_type_label' => $types[ $type ] ?? $types['album'], + 'release_date' => get_post_meta( $post_id, '_fedistream_album_release_date', true ), + 'release_year' => date( 'Y', strtotime( get_post_meta( $post_id, '_fedistream_album_release_date', true ) ?: 'now' ) ), + 'artist_id' => $artist_id, + 'artist_name' => $artist_id ? get_the_title( $artist_id ) : '', + 'artist_url' => $artist_id ? get_permalink( $artist_id ) : '', + 'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ), + 'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ), + 'total_tracks' => count( $tracks ), + 'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ), + 'tracks' => array_map( array( __CLASS__, 'get_post_data' ), $tracks ), + ); + } + + /** + * Get track-specific data. + * + * @param int $post_id Post ID. + * @return array Track data. + */ + private static function get_track_data( int $post_id ): array { + $album_id = get_post_meta( $post_id, '_fedistream_track_album', true ); + $audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true ); + $artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array(); + $duration = (int) get_post_meta( $post_id, '_fedistream_track_duration', true ); + + $artist_data = array(); + foreach ( $artists as $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist ) { + $artist_data[] = array( + 'id' => $artist_id, + 'name' => $artist->post_title, + 'url' => get_permalink( $artist_id ), + ); + } + } + + return array( + 'track_number' => (int) get_post_meta( $post_id, '_fedistream_track_number', true ), + 'disc_number' => (int) get_post_meta( $post_id, '_fedistream_track_disc_number', true ) ?: 1, + 'duration' => $duration, + 'duration_formatted' => $duration ? sprintf( '%d:%02d', floor( $duration / 60 ), $duration % 60 ) : '', + 'audio_url' => $audio_file ? wp_get_attachment_url( $audio_file ) : '', + 'audio_format' => get_post_meta( $post_id, '_fedistream_track_audio_format', true ), + 'bpm' => (int) get_post_meta( $post_id, '_fedistream_track_bpm', true ), + 'key' => get_post_meta( $post_id, '_fedistream_track_key', true ), + 'explicit' => (bool) get_post_meta( $post_id, '_fedistream_track_explicit', true ), + 'isrc' => get_post_meta( $post_id, '_fedistream_track_isrc', true ), + 'album_id' => $album_id, + 'album_title' => $album_id ? get_the_title( $album_id ) : '', + 'album_url' => $album_id ? get_permalink( $album_id ) : '', + 'album_artwork' => $album_id ? get_the_post_thumbnail_url( $album_id, 'medium' ) : '', + 'artists' => $artist_data, + ); + } + + /** + * Get playlist-specific data. + * + * @param int $post_id Post ID. + * @return array Playlist data. + */ + private static function get_playlist_data( int $post_id ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_playlist_tracks'; + $duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true ); + + // Get tracks. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $track_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT track_id FROM $table WHERE playlist_id = %d ORDER BY position ASC", + $post_id + ) + ); + + $tracks = array(); + foreach ( $track_ids as $track_id ) { + $track = get_post( $track_id ); + if ( $track && 'publish' === $track->post_status ) { + $tracks[] = self::get_post_data( $track ); + } + } + + return array( + 'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public', + 'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ), + 'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ), + 'track_count' => count( $tracks ), + 'total_duration' => $duration, + 'duration_formatted' => $duration >= 3600 + ? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 ) + : sprintf( '%d:%02d', floor( $duration / 60 ), $duration % 60 ), + 'tracks' => $tracks, + ); + } + + /** + * Get taxonomy terms for post. + * + * @param int $post_id Post ID. + * @param string $taxonomy Taxonomy name. + * @return array Terms with name and URL. + */ + private static function get_terms( int $post_id, string $taxonomy ): array { + $terms = get_the_terms( $post_id, $taxonomy ); + + if ( ! $terms || is_wp_error( $terms ) ) { + return array(); + } + + return array_map( + function ( $term ) { + return array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'url' => get_term_link( $term ), + ); + }, + $terms + ); + } + + /** + * Get archive posts. + * + * @return array Posts for archive. + */ + private static function get_archive_posts(): array { + global $wp_query; + + $posts = array(); + if ( $wp_query->have_posts() ) { + while ( $wp_query->have_posts() ) { + $wp_query->the_post(); + $posts[] = self::get_post_data( get_post() ); + } + wp_reset_postdata(); + } + + return $posts; + } + + /** + * Get pagination data. + * + * @return array Pagination data. + */ + private static function get_pagination(): array { + global $wp_query; + + $total_pages = $wp_query->max_num_pages; + $current = max( 1, get_query_var( 'paged' ) ); + + return array( + 'total_pages' => $total_pages, + 'current_page' => $current, + 'has_prev' => $current > 1, + 'has_next' => $current < $total_pages, + 'prev_url' => $current > 1 ? get_pagenum_link( $current - 1 ) : '', + 'next_url' => $current < $total_pages ? get_pagenum_link( $current + 1 ) : '', + 'links' => paginate_links( + array( + 'total' => $total_pages, + 'current' => $current, + 'type' => 'array', + 'prev_text' => __( '« Previous', 'wp-fedistream' ), + 'next_text' => __( 'Next »', 'wp-fedistream' ), + ) + ) ?: array(), + ); + } + + /** + * Get archive title. + * + * @return string Archive title. + */ + private static function get_archive_title(): string { + if ( is_post_type_archive( 'fedistream_artist' ) ) { + return __( 'Artists', 'wp-fedistream' ); + } + if ( is_post_type_archive( 'fedistream_album' ) ) { + return __( 'Albums', 'wp-fedistream' ); + } + if ( is_post_type_archive( 'fedistream_track' ) ) { + return __( 'Tracks', 'wp-fedistream' ); + } + if ( is_post_type_archive( 'fedistream_playlist' ) ) { + return __( 'Playlists', 'wp-fedistream' ); + } + if ( is_tax() ) { + return single_term_title( '', false ); + } + return get_the_archive_title(); + } + + /** + * Get archive description. + * + * @return string Archive description. + */ + private static function get_archive_description(): string { + if ( is_tax() ) { + return term_description(); + } + return get_the_archive_description(); + } +} diff --git a/includes/Frontend/Widgets.php b/includes/Frontend/Widgets.php new file mode 100644 index 0000000..4fbccd1 --- /dev/null +++ b/includes/Frontend/Widgets.php @@ -0,0 +1,38 @@ + __( 'Display a featured artist.', 'wp-fedistream' ), + 'classname' => 'fedistream-widget fedistream-widget--featured-artist', + ) + ); + } + + /** + * Front-end display. + * + * @param array $args Widget arguments. + * @param array $instance Saved values from database. + * @return void + */ + public function widget( $args, $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Featured Artist', 'wp-fedistream' ); + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + $artist_id = ! empty( $instance['artist_id'] ) ? absint( $instance['artist_id'] ) : 0; + $random = ! empty( $instance['random'] ) && $instance['random']; + + $post = null; + + if ( $random ) { + // Get a random artist. + $posts = get_posts( + array( + 'post_type' => 'fedistream_artist', + 'posts_per_page' => 1, + 'orderby' => 'rand', + 'post_status' => 'publish', + ) + ); + if ( ! empty( $posts ) ) { + $post = $posts[0]; + } + } elseif ( $artist_id ) { + $post = get_post( $artist_id ); + if ( $post && 'fedistream_artist' !== $post->post_type ) { + $post = null; + } + } + + if ( ! $post ) { + return; + } + + $artist_data = TemplateLoader::get_artist_data( $post ); + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( $title ) { + echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + try { + $plugin = Plugin::get_instance(); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $plugin->render( + 'widgets/featured-artist', + array( + 'post' => $artist_data, + ) + ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + echo '

' . esc_html( $e->getMessage() ) . '

'; + } + } + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Back-end widget form. + * + * @param array $instance Previously saved values from database. + * @return void + */ + public function form( $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Featured Artist', 'wp-fedistream' ); + $artist_id = ! empty( $instance['artist_id'] ) ? absint( $instance['artist_id'] ) : 0; + $random = ! empty( $instance['random'] ) && $instance['random']; + + // Get all artists for dropdown. + $artists = get_posts( + array( + 'post_type' => 'fedistream_artist', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + 'post_status' => 'publish', + ) + ); + ?> +

+ + +

+

+ +

+

+ + +

+ __( 'Display the currently playing track.', 'wp-fedistream' ), + 'classname' => 'fedistream-widget fedistream-widget--now-playing', + ) + ); + } + + /** + * Front-end display. + * + * @param array $args Widget arguments. + * @param array $instance Saved values from database. + * @return void + */ + public function widget( $args, $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Now Playing', 'wp-fedistream' ); + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + $show_player = ! empty( $instance['show_player'] ); + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( $title ) { + echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + try { + $plugin = Plugin::get_instance(); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $plugin->render( + 'widgets/now-playing', + array( + 'show_player' => $show_player, + ) + ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + echo '

' . esc_html( $e->getMessage() ) . '

'; + } + } + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Back-end widget form. + * + * @param array $instance Previously saved values from database. + * @return void + */ + public function form( $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Now Playing', 'wp-fedistream' ); + $show_player = ! empty( $instance['show_player'] ); + ?> +

+ + +

+

+ +

+

+ +

+ __( 'Display popular tracks by play count.', 'wp-fedistream' ), + 'classname' => 'fedistream-widget fedistream-widget--popular-tracks', + ) + ); + } + + /** + * Front-end display. + * + * @param array $args Widget arguments. + * @param array $instance Saved values from database. + * @return void + */ + public function widget( $args, $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Popular Tracks', 'wp-fedistream' ); + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + $count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5; + + $query_args = array( + 'post_type' => 'fedistream_track', + 'posts_per_page' => $count, + 'orderby' => 'meta_value_num', + 'meta_key' => '_fedistream_play_count', + 'order' => 'DESC', + 'post_status' => 'publish', + ); + + $query = new \WP_Query( $query_args ); + $posts = array(); + + if ( $query->have_posts() ) { + while ( $query->have_posts() ) { + $query->the_post(); + $posts[] = TemplateLoader::get_track_data( get_post() ); + } + wp_reset_postdata(); + } + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( $title ) { + echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + try { + $plugin = Plugin::get_instance(); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $plugin->render( + 'widgets/popular-tracks', + array( + 'posts' => $posts, + ) + ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + echo '

' . esc_html( $e->getMessage() ) . '

'; + } + } + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Back-end widget form. + * + * @param array $instance Previously saved values from database. + * @return void + */ + public function form( $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Popular Tracks', 'wp-fedistream' ); + $count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5; + ?> +

+ + +

+

+ + +

+ __( 'Display recent album releases.', 'wp-fedistream' ), + 'classname' => 'fedistream-widget fedistream-widget--recent-releases', + ) + ); + } + + /** + * Front-end display. + * + * @param array $args Widget arguments. + * @param array $instance Saved values from database. + * @return void + */ + public function widget( $args, $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Releases', 'wp-fedistream' ); + $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); + $count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5; + $type = ! empty( $instance['type'] ) ? $instance['type'] : ''; + + $query_args = array( + 'post_type' => 'fedistream_album', + 'posts_per_page' => $count, + 'orderby' => 'meta_value', + 'meta_key' => '_fedistream_release_date', + 'order' => 'DESC', + 'post_status' => 'publish', + ); + + if ( ! empty( $type ) ) { + $query_args['meta_query'][] = array( + 'key' => '_fedistream_album_type', + 'value' => $type, + ); + } + + $query = new \WP_Query( $query_args ); + $posts = array(); + + if ( $query->have_posts() ) { + while ( $query->have_posts() ) { + $query->the_post(); + $posts[] = TemplateLoader::get_album_data( get_post() ); + } + wp_reset_postdata(); + } + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( $title ) { + echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + try { + $plugin = Plugin::get_instance(); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $plugin->render( + 'widgets/recent-releases', + array( + 'posts' => $posts, + ) + ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + echo '

' . esc_html( $e->getMessage() ) . '

'; + } + } + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Back-end widget form. + * + * @param array $instance Previously saved values from database. + * @return void + */ + public function form( $instance ): void { + $title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Releases', 'wp-fedistream' ); + $count = ! empty( $instance['count'] ) ? absint( $instance['count'] ) : 5; + $type = ! empty( $instance['type'] ) ? $instance['type'] : ''; + ?> +

+ + +

+

+ + +

+

+ + +

+ + +
+ render( $template_name, $context ); + } catch ( \Exception $e ) { + if ( WP_DEBUG ) { + echo '
'; + echo '

' . esc_html__( 'Template Error:', 'wp-fedistream' ) . ' ' . esc_html( $e->getMessage() ) . '

'; + echo '
'; + } + } + } else { + // Fallback to default content. + if ( have_posts() ) { + while ( have_posts() ) { + the_post(); + the_content(); + } + } + } + ?> +
+ + $post_type, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ) + ); + + foreach ( $posts as $post_id ) { + wp_delete_post( $post_id, true ); + } + } + } + + /** + * Delete all plugin taxonomy terms. + * + * @return void + */ + private static function delete_terms(): void { + $taxonomies = array( + 'fedistream_genre', + 'fedistream_mood', + 'fedistream_license', + ); + + foreach ( $taxonomies as $taxonomy ) { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'ids', + ) + ); + + if ( ! is_wp_error( $terms ) ) { + foreach ( $terms as $term_id ) { + wp_delete_term( $term_id, $taxonomy ); + } + } + } + } + + /** + * Install default taxonomy terms. + * + * Called on first load after activation when taxonomies are registered. + * + * @return void + */ + public static function install_defaults(): void { + if ( ! get_option( 'wp_fedistream_install_defaults' ) ) { + return; + } + + // Install default genres. + Genre::install_defaults(); + + // Install default moods. + Mood::install_defaults(); + + // Install default licenses. + License::install_defaults(); + + // Clear the flag. + delete_option( 'wp_fedistream_install_defaults' ); + } + + /** + * Create custom database tables. + * + * @return void + */ + private static function create_tables(): void { + global $wpdb; + + $charset_collate = $wpdb->get_charset_collate(); + + // Track plays table. + $table_plays = $wpdb->prefix . 'fedistream_plays'; + $sql_plays = "CREATE TABLE $table_plays ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + track_id bigint(20) unsigned NOT NULL, + user_id bigint(20) unsigned DEFAULT NULL, + remote_actor varchar(255) DEFAULT NULL, + played_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + play_duration int(11) unsigned DEFAULT 0, + PRIMARY KEY (id), + KEY track_id (track_id), + KEY user_id (user_id), + KEY played_at (played_at) + ) $charset_collate;"; + + // Playlist tracks table (many-to-many relationship). + $table_playlist_tracks = $wpdb->prefix . 'fedistream_playlist_tracks'; + $sql_playlist_tracks = "CREATE TABLE $table_playlist_tracks ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + playlist_id bigint(20) unsigned NOT NULL, + track_id bigint(20) unsigned NOT NULL, + position int(11) unsigned NOT NULL DEFAULT 0, + added_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY playlist_track (playlist_id, track_id), + KEY playlist_id (playlist_id), + KEY track_id (track_id), + KEY position (position) + ) $charset_collate;"; + + // ActivityPub followers table. + $table_followers = $wpdb->prefix . 'fedistream_followers'; + $sql_followers = "CREATE TABLE $table_followers ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + artist_id bigint(20) unsigned NOT NULL, + follower_uri varchar(2083) NOT NULL, + follower_name varchar(255) DEFAULT NULL, + follower_icon varchar(2083) DEFAULT NULL, + inbox varchar(2083) DEFAULT NULL, + shared_inbox varchar(2083) DEFAULT NULL, + activity_data longtext DEFAULT NULL, + followed_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY artist_follower (artist_id, follower_uri(191)), + KEY artist_id (artist_id), + KEY followed_at (followed_at) + ) $charset_collate;"; + + // WooCommerce purchases table. + $table_purchases = $wpdb->prefix . 'fedistream_purchases'; + $sql_purchases = "CREATE TABLE $table_purchases ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL, + content_type varchar(50) NOT NULL, + content_id bigint(20) unsigned NOT NULL, + order_id bigint(20) unsigned NOT NULL, + purchased_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY user_content (user_id, content_type, content_id), + KEY user_id (user_id), + KEY content_type (content_type), + KEY content_id (content_id), + KEY order_id (order_id) + ) $charset_collate;"; + + // User favorites table. + $table_favorites = $wpdb->prefix . 'fedistream_favorites'; + $sql_favorites = "CREATE TABLE $table_favorites ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL, + content_type varchar(50) NOT NULL, + content_id bigint(20) unsigned NOT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY user_content (user_id, content_type, content_id), + KEY user_id (user_id), + KEY content_type (content_type), + KEY content_id (content_id), + KEY created_at (created_at) + ) $charset_collate;"; + + // User follows table (local follows, not ActivityPub). + $table_user_follows = $wpdb->prefix . 'fedistream_user_follows'; + $sql_user_follows = "CREATE TABLE $table_user_follows ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL, + artist_id bigint(20) unsigned NOT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY user_artist (user_id, artist_id), + KEY user_id (user_id), + KEY artist_id (artist_id), + KEY created_at (created_at) + ) $charset_collate;"; + + // Listening history table. + $table_history = $wpdb->prefix . 'fedistream_listening_history'; + $sql_history = "CREATE TABLE $table_history ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL, + track_id bigint(20) unsigned NOT NULL, + played_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY user_id (user_id), + KEY track_id (track_id), + KEY played_at (played_at), + KEY user_played (user_id, played_at) + ) $charset_collate;"; + + // Notifications table. + $table_notifications = $wpdb->prefix . 'fedistream_notifications'; + $sql_notifications = "CREATE TABLE $table_notifications ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL, + type varchar(50) NOT NULL, + title varchar(255) NOT NULL, + message text NOT NULL, + data longtext DEFAULT NULL, + is_read tinyint(1) unsigned NOT NULL DEFAULT 0, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + read_at datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY user_id (user_id), + KEY type (type), + KEY is_read (is_read), + KEY created_at (created_at), + KEY user_unread (user_id, is_read) + ) $charset_collate;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + dbDelta( $sql_plays ); + dbDelta( $sql_playlist_tracks ); + dbDelta( $sql_followers ); + dbDelta( $sql_purchases ); + dbDelta( $sql_favorites ); + dbDelta( $sql_user_follows ); + dbDelta( $sql_history ); + dbDelta( $sql_notifications ); + } + + /** + * Create required directories. + * + * @return void + */ + private static function create_directories(): void { + $directories = array( + WP_FEDISTREAM_PATH . 'cache/twig', + ); + + foreach ( $directories as $directory ) { + if ( ! file_exists( $directory ) ) { + wp_mkdir_p( $directory ); + } + } + + // Create .htaccess to protect cache directory. + $htaccess = WP_FEDISTREAM_PATH . 'cache/.htaccess'; + if ( ! file_exists( $htaccess ) ) { + file_put_contents( $htaccess, 'Deny from all' ); + } + } + + /** + * Set default plugin options. + * + * @return void + */ + private static function set_default_options(): void { + $defaults = array( + 'wp_fedistream_enable_activitypub' => 1, + 'wp_fedistream_enable_woocommerce' => 0, + 'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ), + 'wp_fedistream_max_upload_size' => 50, // MB + 'wp_fedistream_default_license' => 'all-rights-reserved', + ); + + foreach ( $defaults as $option => $value ) { + if ( false === get_option( $option ) ) { + add_option( $option, $value ); + } + } + } + + /** + * Schedule cron events. + * + * @return void + */ + private static function schedule_events(): void { + if ( ! wp_next_scheduled( 'wp_fedistream_daily_cleanup' ) ) { + wp_schedule_event( time(), 'daily', 'wp_fedistream_daily_cleanup' ); + } + } + + /** + * Unschedule cron events. + * + * @return void + */ + private static function unschedule_events(): void { + $timestamp = wp_next_scheduled( 'wp_fedistream_daily_cleanup' ); + if ( $timestamp ) { + wp_unschedule_event( $timestamp, 'wp_fedistream_daily_cleanup' ); + } + } + + /** + * Delete custom database tables. + * + * @return void + */ + private static function delete_tables(): void { + global $wpdb; + + $tables = array( + $wpdb->prefix . 'fedistream_plays', + $wpdb->prefix . 'fedistream_playlist_tracks', + $wpdb->prefix . 'fedistream_followers', + $wpdb->prefix . 'fedistream_purchases', + $wpdb->prefix . 'fedistream_reactions', + $wpdb->prefix . 'fedistream_favorites', + $wpdb->prefix . 'fedistream_user_follows', + $wpdb->prefix . 'fedistream_listening_history', + $wpdb->prefix . 'fedistream_notifications', + ); + + foreach ( $tables as $table ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS $table" ); + } + } + + /** + * Delete plugin options. + * + * @return void + */ + private static function delete_options(): void { + global $wpdb; + + // Delete all options starting with wp_fedistream_. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM $wpdb->options WHERE option_name LIKE %s", + 'wp_fedistream_%' + ) + ); + } + + /** + * Delete user meta. + * + * @return void + */ + private static function delete_user_meta(): void { + global $wpdb; + + // Delete all user meta starting with wp_fedistream_. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM $wpdb->usermeta WHERE meta_key LIKE %s", + 'wp_fedistream_%' + ) + ); + } + + /** + * Delete transients. + * + * @return void + */ + private static function delete_transients(): void { + global $wpdb; + + // Delete all transients starting with wp_fedistream_. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s", + '_transient_wp_fedistream_%', + '_transient_timeout_wp_fedistream_%' + ) + ); + } +} diff --git a/includes/Plugin.php b/includes/Plugin.php new file mode 100644 index 0000000..2970d1b --- /dev/null +++ b/includes/Plugin.php @@ -0,0 +1,603 @@ +init_twig(); + $this->init_components(); + $this->init_hooks(); + $this->load_textdomain(); + } + + /** + * Prevent cloning. + * + * @return void + */ + private function __clone() {} + + /** + * Prevent unserialization. + * + * @throws \Exception Always throws to prevent unserialization. + * @return void + */ + public function __wakeup(): void { + throw new \Exception( 'Cannot unserialize singleton' ); + } + + /** + * Initialize Twig template engine. + * + * @return void + */ + private function init_twig(): void { + $loader = new \Twig\Loader\FilesystemLoader( WP_FEDISTREAM_PATH . 'templates' ); + + $this->twig = new \Twig\Environment( + $loader, + array( + 'cache' => WP_FEDISTREAM_PATH . 'cache/twig', + 'auto_reload' => WP_DEBUG, + ) + ); + + // Add WordPress escaping functions. + $this->twig->addFunction( new \Twig\TwigFunction( 'esc_html', 'esc_html' ) ); + $this->twig->addFunction( new \Twig\TwigFunction( 'esc_attr', 'esc_attr' ) ); + $this->twig->addFunction( new \Twig\TwigFunction( 'esc_url', 'esc_url' ) ); + $this->twig->addFunction( new \Twig\TwigFunction( 'esc_js', 'esc_js' ) ); + $this->twig->addFunction( new \Twig\TwigFunction( 'wp_nonce_field', 'wp_nonce_field', array( 'is_safe' => array( 'html' ) ) ) ); + $this->twig->addFunction( new \Twig\TwigFunction( '__', '__' ) ); + $this->twig->addFunction( new \Twig\TwigFunction( '_e', '_e' ) ); + } + + /** + * Initialize plugin components. + * + * @return void + */ + private function init_components(): void { + // Initialize post types. + $this->post_types['artist'] = new Artist(); + $this->post_types['album'] = new Album(); + $this->post_types['track'] = new Track(); + $this->post_types['playlist'] = new Playlist(); + + // Initialize taxonomies. + $this->taxonomies['genre'] = new Genre(); + $this->taxonomies['mood'] = new Mood(); + $this->taxonomies['license'] = new License(); + + // Initialize admin components. + if ( is_admin() ) { + new ListColumns(); + } + + // Initialize frontend components. + if ( ! is_admin() ) { + new TemplateLoader(); + new Shortcodes(); + } + + // Initialize widgets (always needed for admin widget management). + new Widgets(); + + // Initialize AJAX handlers. + new Ajax(); + + // Initialize ActivityPub integration. + if ( get_option( 'wp_fedistream_enable_activitypub', 1 ) ) { + new ActivityPubIntegration(); + new ActivityPubRestApi(); + } + + // Initialize WooCommerce integration. + if ( get_option( 'wp_fedistream_enable_woocommerce', 0 ) && $this->is_woocommerce_active() ) { + new WooCommerceIntegration(); + new DigitalDelivery(); + new StreamingAccess(); + } + + // Initialize user library and notifications. + new UserLibrary(); + new LibraryPage(); + new Notifications(); + } + + /** + * Initialize WordPress hooks. + * + * @return void + */ + private function init_hooks(): void { + add_action( 'init', array( $this, 'maybe_install_defaults' ), 20 ); + add_action( 'admin_menu', array( $this, 'add_admin_menu' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) ); + } + + /** + * Maybe install default taxonomy terms. + * + * @return void + */ + public function maybe_install_defaults(): void { + Installer::install_defaults(); + } + + /** + * Load plugin textdomain. + * + * @return void + */ + private function load_textdomain(): void { + load_plugin_textdomain( + 'wp-fedistream', + false, + dirname( WP_FEDISTREAM_BASENAME ) . '/languages' + ); + } + + /** + * Add admin menu. + * + * @return void + */ + public function add_admin_menu(): void { + // Main menu. + add_menu_page( + __( 'FediStream', 'wp-fedistream' ), + __( 'FediStream', 'wp-fedistream' ), + 'edit_fedistream_tracks', + 'fedistream', + array( $this, 'render_dashboard_page' ), + 'dashicons-format-audio', + 30 + ); + + // Dashboard submenu. + add_submenu_page( + 'fedistream', + __( 'Dashboard', 'wp-fedistream' ), + __( 'Dashboard', 'wp-fedistream' ), + 'edit_fedistream_tracks', + 'fedistream', + array( $this, 'render_dashboard_page' ) + ); + + // Artists submenu. + add_submenu_page( + 'fedistream', + __( 'Artists', 'wp-fedistream' ), + __( 'Artists', 'wp-fedistream' ), + 'edit_fedistream_artists', + 'edit.php?post_type=fedistream_artist' + ); + + // Albums submenu. + add_submenu_page( + 'fedistream', + __( 'Albums', 'wp-fedistream' ), + __( 'Albums', 'wp-fedistream' ), + 'edit_fedistream_albums', + 'edit.php?post_type=fedistream_album' + ); + + // Tracks submenu. + add_submenu_page( + 'fedistream', + __( 'Tracks', 'wp-fedistream' ), + __( 'Tracks', 'wp-fedistream' ), + 'edit_fedistream_tracks', + 'edit.php?post_type=fedistream_track' + ); + + // Playlists submenu. + add_submenu_page( + 'fedistream', + __( 'Playlists', 'wp-fedistream' ), + __( 'Playlists', 'wp-fedistream' ), + 'edit_fedistream_playlists', + 'edit.php?post_type=fedistream_playlist' + ); + + // Genres submenu. + add_submenu_page( + 'fedistream', + __( 'Genres', 'wp-fedistream' ), + __( 'Genres', 'wp-fedistream' ), + 'manage_fedistream_genres', + 'edit-tags.php?taxonomy=fedistream_genre' + ); + + // Settings submenu. + add_submenu_page( + 'fedistream', + __( 'Settings', 'wp-fedistream' ), + __( 'Settings', 'wp-fedistream' ), + 'manage_fedistream_settings', + 'fedistream-settings', + array( $this, 'render_settings_page' ) + ); + } + + /** + * Render dashboard page. + * + * @return void + */ + public function render_dashboard_page(): void { + // Get stats. + $artist_count = wp_count_posts( 'fedistream_artist' )->publish ?? 0; + $album_count = wp_count_posts( 'fedistream_album' )->publish ?? 0; + $track_count = wp_count_posts( 'fedistream_track' )->publish ?? 0; + $playlist_count = wp_count_posts( 'fedistream_playlist' )->publish ?? 0; + ?> +
+

+ +
+
+
+

+

+ +
+ +
+

+

+ +
+ +
+

+

+ +
+ +
+

+

+ +
+
+ +
+

+

+ + + + +

+
+ +
+

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
+
+
+
+

' . esc_html__( 'Settings saved.', 'wp-fedistream' ) . '

'; + } + + // Get current settings. + $enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 ); + $enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 ); + $max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 ); + $default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' ); + ?> +
+

+ +
+ + + + + + + + + + + + + + + + + + + +
+ +

+
+ + is_woocommerce_active() ) : ?> +

+ +

+ +
+ + + MB +

+
+ + + +

+
+ + +
+
+ id, $fedistream_screens, true ) ) { + return; + } + + wp_enqueue_style( + 'wp-fedistream-admin', + WP_FEDISTREAM_URL . 'assets/css/admin.css', + array(), + WP_FEDISTREAM_VERSION + ); + + wp_enqueue_script( + 'wp-fedistream-admin', + WP_FEDISTREAM_URL . 'assets/js/admin.js', + array( 'jquery', 'jquery-ui-sortable' ), + WP_FEDISTREAM_VERSION, + true + ); + } + + /** + * Enqueue frontend assets. + * + * @return void + */ + public function enqueue_frontend_assets(): void { + // Always enqueue as shortcodes/widgets can be used anywhere. + // Assets are lightweight and properly cached. + wp_enqueue_style( + 'wp-fedistream', + WP_FEDISTREAM_URL . 'assets/css/frontend.css', + array(), + WP_FEDISTREAM_VERSION + ); + + wp_enqueue_script( + 'wp-fedistream', + WP_FEDISTREAM_URL . 'assets/js/frontend.js', + array(), + WP_FEDISTREAM_VERSION, + true + ); + + wp_localize_script( + 'wp-fedistream', + 'wpFediStream', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wp-fedistream-nonce' ), + ) + ); + } + + /** + * Get Twig environment. + * + * @return \Twig\Environment + */ + public function get_twig(): \Twig\Environment { + return $this->twig; + } + + /** + * Render a Twig template. + * + * @param string $template Template name (without .twig extension). + * @param array $context Template context variables. + * @return string Rendered template. + */ + public function render( string $template, array $context = array() ): string { + return $this->twig->render( $template . '.twig', $context ); + } + + /** + * Get a post type instance. + * + * @param string $name Post type name. + * @return object|null Post type instance or null. + */ + public function get_post_type( string $name ): ?object { + return $this->post_types[ $name ] ?? null; + } + + /** + * Get a taxonomy instance. + * + * @param string $name Taxonomy name. + * @return object|null Taxonomy instance or null. + */ + public function get_taxonomy( string $name ): ?object { + return $this->taxonomies[ $name ] ?? null; + } + + /** + * Check if WooCommerce is active. + * + * @return bool + */ + public function is_woocommerce_active(): bool { + return class_exists( 'WooCommerce' ); + } + + /** + * Check if ActivityPub plugin is active. + * + * @return bool + */ + public function is_activitypub_active(): bool { + return class_exists( 'Activitypub\Activitypub' ); + } +} diff --git a/includes/PostTypes/AbstractPostType.php b/includes/PostTypes/AbstractPostType.php new file mode 100644 index 0000000..7d168cc --- /dev/null +++ b/includes/PostTypes/AbstractPostType.php @@ -0,0 +1,202 @@ +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 ); + } + } +} diff --git a/includes/PostTypes/Album.php b/includes/PostTypes/Album.php new file mode 100644 index 0000000..70db466 --- /dev/null +++ b/includes/PostTypes/Album.php @@ -0,0 +1,340 @@ + _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 ); + ?> + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +

+
+ + + + +
+ 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', + ) + ); + ?> +

+ +

+ +

+ ' . esc_html__( 'Add an artist first.', 'wp-fedistream' ) . '' + ); + } + ?> +

+ ID, self::META_PREFIX . 'upc', true ); + $catalog_number = get_post_meta( $post->ID, self::META_PREFIX . 'catalog_number', true ); + ?> +

+ + +

+

+ + +

+ 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 ); + } +} diff --git a/includes/PostTypes/Artist.php b/includes/PostTypes/Artist.php new file mode 100644 index 0000000..a763b73 --- /dev/null +++ b/includes/PostTypes/Artist.php @@ -0,0 +1,331 @@ + _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 ); + ?> + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +

+
+ + + +

+
+ + + +

+
+ 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' ), + ); + ?> + + $label ) : ?> + + + + + +
+ + + +
+ ID, self::META_PREFIX . 'members', true ); + if ( ! is_array( $members ) ) { + $members = array(); + } + + $artist_type = get_post_meta( $post->ID, self::META_PREFIX . 'type', true ); + ?> +
+

+ +

+
+ + 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 ), + ); + } +} diff --git a/includes/PostTypes/Playlist.php b/includes/PostTypes/Playlist.php new file mode 100644 index 0000000..e3701d6 --- /dev/null +++ b/includes/PostTypes/Playlist.php @@ -0,0 +1,458 @@ + _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', + ) + ); + ?> +
+

+
    + +
  • + + + post_title ); ?> + + + — + + + + () + + +
  • + +
+ +
+ +

+

+ +

+

+ +

+
+ + + + + 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 ); + ?> +

+ + +

+

+ +
+ +

+

+ +
+ +

+ ID, self::META_PREFIX . 'track_count', true ) ?: 0; + $total_duration = get_post_meta( $post->ID, self::META_PREFIX . 'total_duration', true ) ?: 0; + ?> +

+ + +

+

+ + 3600 ) { + echo esc_html( gmdate( 'H:i:s', (int) $total_duration ) ); + } else { + echo esc_html( gmdate( 'i:s', (int) $total_duration ) ); + } + ?> +

+

+ 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; + } +} diff --git a/includes/PostTypes/Track.php b/includes/PostTypes/Track.php new file mode 100644 index 0000000..acc72ed --- /dev/null +++ b/includes/PostTypes/Track.php @@ -0,0 +1,615 @@ + _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(); + ?> +
+

+ +

+ +
+ + + +

+ + +
+

+ + +

+

+
+ + + + + + + + + + +
+ + + +
+ + + + + + +

+
+ + + 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', + ); + ?> + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +

+
+ + + +
+ + + +
+ + + +
+ +

+
+ 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', + ) + ); + ?> +

+ +

+ 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', + ) + ); + ?> +

+
+ + + +
+ +

+ ' . esc_html__( 'Add an artist first.', 'wp-fedistream' ) . '' + ); + ?> +

+ + ID, self::META_PREFIX . 'isrc', true ); + ?> +

+ + + +

+ 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 ), + ); + } +} diff --git a/includes/PostTypes/index.php b/includes/PostTypes/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/includes/PostTypes/index.php @@ -0,0 +1 @@ + array( + 'edit_post' => 'edit_fedistream_artist', + 'read_post' => 'read_fedistream_artist', + 'delete_post' => 'delete_fedistream_artist', + 'edit_posts' => 'edit_fedistream_artists', + 'edit_others_posts' => 'edit_others_fedistream_artists', + 'publish_posts' => 'publish_fedistream_artists', + 'read_private_posts' => 'read_private_fedistream_artists', + ), + 'fedistream_album' => array( + 'edit_post' => 'edit_fedistream_album', + 'read_post' => 'read_fedistream_album', + 'delete_post' => 'delete_fedistream_album', + 'edit_posts' => 'edit_fedistream_albums', + 'edit_others_posts' => 'edit_others_fedistream_albums', + 'publish_posts' => 'publish_fedistream_albums', + 'read_private_posts' => 'read_private_fedistream_albums', + ), + 'fedistream_track' => array( + 'edit_post' => 'edit_fedistream_track', + 'read_post' => 'read_fedistream_track', + 'delete_post' => 'delete_fedistream_track', + 'edit_posts' => 'edit_fedistream_tracks', + 'edit_others_posts' => 'edit_others_fedistream_tracks', + 'publish_posts' => 'publish_fedistream_tracks', + 'read_private_posts' => 'read_private_fedistream_tracks', + ), + 'fedistream_playlist' => array( + 'edit_post' => 'edit_fedistream_playlist', + 'read_post' => 'read_fedistream_playlist', + 'delete_post' => 'delete_fedistream_playlist', + 'edit_posts' => 'edit_fedistream_playlists', + 'edit_others_posts' => 'edit_others_fedistream_playlists', + 'publish_posts' => 'publish_fedistream_playlists', + 'read_private_posts' => 'read_private_fedistream_playlists', + ), + ); + + /** + * Taxonomy capabilities. + * + * @var array + */ + private const TAXONOMY_CAPS = array( + 'manage_fedistream_genres', + 'manage_fedistream_moods', + 'manage_fedistream_licenses', + ); + + /** + * Custom capabilities. + * + * @var array + */ + private const CUSTOM_CAPS = array( + 'view_fedistream_stats', + 'manage_fedistream_settings', + ); + + /** + * Get all custom capabilities. + * + * @return array Array of all custom capabilities. + */ + public static function get_all_capabilities(): array { + $caps = array(); + + // Post type capabilities. + foreach ( self::POST_TYPE_CAPS as $post_type_caps ) { + $caps = array_merge( $caps, array_values( $post_type_caps ) ); + } + + // Delete capabilities. + $caps[] = 'delete_fedistream_artists'; + $caps[] = 'delete_others_fedistream_artists'; + $caps[] = 'delete_published_fedistream_artists'; + $caps[] = 'delete_private_fedistream_artists'; + $caps[] = 'delete_fedistream_albums'; + $caps[] = 'delete_others_fedistream_albums'; + $caps[] = 'delete_published_fedistream_albums'; + $caps[] = 'delete_private_fedistream_albums'; + $caps[] = 'delete_fedistream_tracks'; + $caps[] = 'delete_others_fedistream_tracks'; + $caps[] = 'delete_published_fedistream_tracks'; + $caps[] = 'delete_private_fedistream_tracks'; + $caps[] = 'delete_fedistream_playlists'; + $caps[] = 'delete_others_fedistream_playlists'; + $caps[] = 'delete_published_fedistream_playlists'; + $caps[] = 'delete_private_fedistream_playlists'; + + // Taxonomy capabilities. + $caps = array_merge( $caps, self::TAXONOMY_CAPS ); + + // Custom capabilities. + $caps = array_merge( $caps, self::CUSTOM_CAPS ); + + return array_unique( $caps ); + } + + /** + * Get Artist role capabilities. + * + * @return array Array of capabilities for the Artist role. + */ + public static function get_artist_capabilities(): array { + return array( + // Core WordPress. + 'read' => true, + 'upload_files' => true, + + // Own artists. + 'edit_fedistream_artists' => true, + 'edit_published_fedistream_artists' => true, + 'publish_fedistream_artists' => true, + 'delete_fedistream_artists' => true, + 'delete_published_fedistream_artists' => true, + + // Own albums. + 'edit_fedistream_albums' => true, + 'edit_published_fedistream_albums' => true, + 'publish_fedistream_albums' => true, + 'delete_fedistream_albums' => true, + 'delete_published_fedistream_albums' => true, + + // Own tracks. + 'edit_fedistream_tracks' => true, + 'edit_published_fedistream_tracks' => true, + 'publish_fedistream_tracks' => true, + 'delete_fedistream_tracks' => true, + 'delete_published_fedistream_tracks' => true, + + // Own playlists. + 'edit_fedistream_playlists' => true, + 'edit_published_fedistream_playlists' => true, + 'publish_fedistream_playlists' => true, + 'delete_fedistream_playlists' => true, + 'delete_published_fedistream_playlists' => true, + + // View stats. + 'view_fedistream_stats' => true, + ); + } + + /** + * Get Label role capabilities. + * + * @return array Array of capabilities for the Label role. + */ + public static function get_label_capabilities(): array { + $caps = self::get_artist_capabilities(); + + // Add label-specific capabilities. + $label_caps = array( + // Manage others' content. + 'edit_others_fedistream_artists' => true, + 'edit_others_fedistream_albums' => true, + 'edit_others_fedistream_tracks' => true, + 'edit_others_fedistream_playlists' => true, + 'delete_others_fedistream_artists' => true, + 'delete_others_fedistream_albums' => true, + 'delete_others_fedistream_tracks' => true, + 'delete_others_fedistream_playlists' => true, + 'read_private_fedistream_artists' => true, + 'read_private_fedistream_albums' => true, + 'read_private_fedistream_tracks' => true, + 'read_private_fedistream_playlists' => true, + 'delete_private_fedistream_artists' => true, + 'delete_private_fedistream_albums' => true, + 'delete_private_fedistream_tracks' => true, + 'delete_private_fedistream_playlists' => true, + + // Manage taxonomies. + 'manage_fedistream_genres' => true, + 'manage_fedistream_moods' => true, + 'manage_fedistream_licenses' => true, + + // View stats. + 'view_fedistream_stats' => true, + ); + + return array_merge( $caps, $label_caps ); + } + + /** + * Add custom roles. + * + * @return void + */ + public static function add_roles(): void { + // Remove existing roles first to ensure clean slate. + remove_role( 'fedistream_artist' ); + remove_role( 'fedistream_label' ); + + // Add Artist role. + add_role( + 'fedistream_artist', + __( 'FediStream Artist', 'wp-fedistream' ), + self::get_artist_capabilities() + ); + + // Add Label role. + add_role( + 'fedistream_label', + __( 'FediStream Label', 'wp-fedistream' ), + self::get_label_capabilities() + ); + } + + /** + * Remove custom roles. + * + * @return void + */ + public static function remove_roles(): void { + remove_role( 'fedistream_artist' ); + remove_role( 'fedistream_label' ); + } + + /** + * Add capabilities to administrator role. + * + * @return void + */ + public static function add_admin_capabilities(): void { + $admin = get_role( 'administrator' ); + + if ( ! $admin ) { + return; + } + + // Add all custom capabilities. + $all_caps = self::get_all_capabilities(); + foreach ( $all_caps as $cap ) { + $admin->add_cap( $cap ); + } + + // Add management capability. + $admin->add_cap( 'manage_fedistream_settings' ); + } + + /** + * Remove capabilities from administrator role. + * + * @return void + */ + public static function remove_admin_capabilities(): void { + $admin = get_role( 'administrator' ); + + if ( ! $admin ) { + return; + } + + // Remove all custom capabilities. + $all_caps = self::get_all_capabilities(); + foreach ( $all_caps as $cap ) { + $admin->remove_cap( $cap ); + } + + $admin->remove_cap( 'manage_fedistream_settings' ); + } + + /** + * Install roles and capabilities. + * + * @return void + */ + public static function install(): void { + self::add_roles(); + self::add_admin_capabilities(); + } + + /** + * Uninstall roles and capabilities. + * + * @return void + */ + public static function uninstall(): void { + self::remove_roles(); + self::remove_admin_capabilities(); + } +} diff --git a/includes/Roles/index.php b/includes/Roles/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/includes/Roles/index.php @@ -0,0 +1 @@ +taxonomy; + } + + /** + * Get the post types this taxonomy is registered for. + * + * @return array + */ + public function get_post_types(): array { + return $this->post_types; + } +} diff --git a/includes/Taxonomies/Genre.php b/includes/Taxonomies/Genre.php new file mode 100644 index 0000000..d1d6484 --- /dev/null +++ b/includes/Taxonomies/Genre.php @@ -0,0 +1,154 @@ + _x( 'Genres', 'taxonomy general name', 'wp-fedistream' ), + 'singular_name' => _x( 'Genre', 'taxonomy singular name', 'wp-fedistream' ), + 'search_items' => __( 'Search Genres', 'wp-fedistream' ), + 'popular_items' => __( 'Popular Genres', 'wp-fedistream' ), + 'all_items' => __( 'All Genres', 'wp-fedistream' ), + 'parent_item' => __( 'Parent Genre', 'wp-fedistream' ), + 'parent_item_colon' => __( 'Parent Genre:', 'wp-fedistream' ), + 'edit_item' => __( 'Edit Genre', 'wp-fedistream' ), + 'view_item' => __( 'View Genre', 'wp-fedistream' ), + 'update_item' => __( 'Update Genre', 'wp-fedistream' ), + 'add_new_item' => __( 'Add New Genre', 'wp-fedistream' ), + 'new_item_name' => __( 'New Genre Name', 'wp-fedistream' ), + 'separate_items_with_commas' => __( 'Separate genres with commas', 'wp-fedistream' ), + 'add_or_remove_items' => __( 'Add or remove genres', 'wp-fedistream' ), + 'choose_from_most_used' => __( 'Choose from the most used genres', 'wp-fedistream' ), + 'not_found' => __( 'No genres found.', 'wp-fedistream' ), + 'no_terms' => __( 'No genres', 'wp-fedistream' ), + 'menu_name' => __( 'Genres', 'wp-fedistream' ), + 'items_list_navigation' => __( 'Genres list navigation', 'wp-fedistream' ), + 'items_list' => __( 'Genres list', 'wp-fedistream' ), + 'back_to_items' => __( '← Back to Genres', 'wp-fedistream' ), + ); + + $args = array( + 'labels' => $labels, + 'hierarchical' => true, // Like categories. + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => false, // Will be added to custom menu. + 'show_admin_column' => true, + 'show_in_nav_menus' => true, + 'show_tagcloud' => true, + 'show_in_rest' => true, + 'rest_base' => 'genres', + 'query_var' => true, + 'rewrite' => array( 'slug' => 'genre' ), + 'capabilities' => array( + 'manage_terms' => 'manage_fedistream_genres', + 'edit_terms' => 'manage_fedistream_genres', + 'delete_terms' => 'manage_fedistream_genres', + 'assign_terms' => 'edit_fedistream_tracks', + ), + ); + + register_taxonomy( $this->taxonomy, $this->post_types, $args ); + } + + /** + * Get default genres. + * + * @return array Array of genres with children. + */ + public static function get_default_genres(): array { + return array( + 'Rock' => array( 'Alternative Rock', 'Hard Rock', 'Indie Rock', 'Progressive Rock', 'Punk Rock' ), + 'Pop' => array( 'Dance Pop', 'Electropop', 'Indie Pop', 'Synth Pop' ), + 'Electronic' => array( 'Ambient', 'Drum and Bass', 'Dubstep', 'House', 'Techno', 'Trance' ), + 'Hip Hop' => array( 'Rap', 'Trap', 'Lo-Fi Hip Hop' ), + 'Jazz' => array( 'Bebop', 'Cool Jazz', 'Free Jazz', 'Fusion' ), + 'Classical' => array( 'Baroque', 'Chamber Music', 'Opera', 'Orchestral', 'Romantic' ), + 'R&B' => array( 'Contemporary R&B', 'Neo Soul', 'Soul' ), + 'Country' => array( 'Americana', 'Bluegrass', 'Country Rock' ), + 'Metal' => array( 'Black Metal', 'Death Metal', 'Heavy Metal', 'Thrash Metal' ), + 'Folk' => array( 'Acoustic', 'Folk Rock', 'Traditional Folk' ), + 'Blues' => array( 'Chicago Blues', 'Delta Blues', 'Electric Blues' ), + 'Reggae' => array( 'Dancehall', 'Dub', 'Roots Reggae', 'Ska' ), + 'Latin' => array( 'Bossa Nova', 'Salsa', 'Reggaeton', 'Tango' ), + 'World' => array( 'African', 'Asian', 'Celtic', 'Middle Eastern' ), + 'Soundtrack' => array( 'Film Score', 'Video Game', 'Musical Theatre' ), + 'Other' => array(), + ); + } + + /** + * Install default genres. + * + * @return void + */ + public static function install_defaults(): void { + $genres = self::get_default_genres(); + + foreach ( $genres as $parent => $children ) { + // Check if parent exists. + $parent_term = term_exists( $parent, 'fedistream_genre' ); + + if ( ! $parent_term ) { + $parent_term = wp_insert_term( $parent, 'fedistream_genre' ); + } + + if ( is_wp_error( $parent_term ) ) { + continue; + } + + $parent_id = is_array( $parent_term ) ? $parent_term['term_id'] : $parent_term; + + // Insert children. + foreach ( $children as $child ) { + if ( ! term_exists( $child, 'fedistream_genre' ) ) { + wp_insert_term( + $child, + 'fedistream_genre', + array( 'parent' => (int) $parent_id ) + ); + } + } + } + } +} diff --git a/includes/Taxonomies/License.php b/includes/Taxonomies/License.php new file mode 100644 index 0000000..752b641 --- /dev/null +++ b/includes/Taxonomies/License.php @@ -0,0 +1,164 @@ + _x( 'Licenses', 'taxonomy general name', 'wp-fedistream' ), + 'singular_name' => _x( 'License', 'taxonomy singular name', 'wp-fedistream' ), + 'search_items' => __( 'Search Licenses', 'wp-fedistream' ), + 'popular_items' => __( 'Popular Licenses', 'wp-fedistream' ), + 'all_items' => __( 'All Licenses', 'wp-fedistream' ), + 'parent_item' => __( 'Parent License', 'wp-fedistream' ), + 'parent_item_colon' => __( 'Parent License:', 'wp-fedistream' ), + 'edit_item' => __( 'Edit License', 'wp-fedistream' ), + 'view_item' => __( 'View License', 'wp-fedistream' ), + 'update_item' => __( 'Update License', 'wp-fedistream' ), + 'add_new_item' => __( 'Add New License', 'wp-fedistream' ), + 'new_item_name' => __( 'New License Name', 'wp-fedistream' ), + 'separate_items_with_commas' => __( 'Separate licenses with commas', 'wp-fedistream' ), + 'add_or_remove_items' => __( 'Add or remove licenses', 'wp-fedistream' ), + 'choose_from_most_used' => __( 'Choose from the most used licenses', 'wp-fedistream' ), + 'not_found' => __( 'No licenses found.', 'wp-fedistream' ), + 'no_terms' => __( 'No licenses', 'wp-fedistream' ), + 'menu_name' => __( 'Licenses', 'wp-fedistream' ), + 'items_list_navigation' => __( 'Licenses list navigation', 'wp-fedistream' ), + 'items_list' => __( 'Licenses list', 'wp-fedistream' ), + 'back_to_items' => __( '← Back to Licenses', 'wp-fedistream' ), + ); + + $args = array( + 'labels' => $labels, + 'hierarchical' => true, // Like categories. + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => false, // Will be added to custom menu. + 'show_admin_column' => true, + 'show_in_nav_menus' => false, + 'show_tagcloud' => false, + 'show_in_rest' => true, + 'rest_base' => 'licenses', + 'query_var' => true, + 'rewrite' => array( 'slug' => 'license' ), + 'capabilities' => array( + 'manage_terms' => 'manage_fedistream_licenses', + 'edit_terms' => 'manage_fedistream_licenses', + 'delete_terms' => 'manage_fedistream_licenses', + 'assign_terms' => 'edit_fedistream_tracks', + ), + ); + + register_taxonomy( $this->taxonomy, $this->post_types, $args ); + } + + /** + * Get default licenses. + * + * @return array Array of licenses with descriptions. + */ + public static function get_default_licenses(): array { + return array( + 'All Rights Reserved' => array( + 'description' => __( 'Standard copyright. All rights reserved by the creator.', 'wp-fedistream' ), + 'children' => array(), + ), + 'Creative Commons' => array( + 'description' => __( 'Creative Commons licenses for sharing and reuse.', 'wp-fedistream' ), + 'children' => array( + 'CC0' => __( 'Public Domain Dedication - No rights reserved', 'wp-fedistream' ), + 'CC BY' => __( 'Attribution - Credit must be given', 'wp-fedistream' ), + 'CC BY-SA' => __( 'Attribution-ShareAlike - Credit and share under same terms', 'wp-fedistream' ), + 'CC BY-ND' => __( 'Attribution-NoDerivs - Credit, no modifications', 'wp-fedistream' ), + 'CC BY-NC' => __( 'Attribution-NonCommercial - Credit, non-commercial only', 'wp-fedistream' ), + 'CC BY-NC-SA' => __( 'Attribution-NonCommercial-ShareAlike', 'wp-fedistream' ), + 'CC BY-NC-ND' => __( 'Attribution-NonCommercial-NoDerivs', 'wp-fedistream' ), + ), + ), + 'Public Domain' => array( + 'description' => __( 'Works in the public domain with no copyright restrictions.', 'wp-fedistream' ), + 'children' => array(), + ), + ); + } + + /** + * Install default licenses. + * + * @return void + */ + public static function install_defaults(): void { + $licenses = self::get_default_licenses(); + + foreach ( $licenses as $name => $data ) { + // Check if parent exists. + $parent_term = term_exists( $name, 'fedistream_license' ); + + if ( ! $parent_term ) { + $parent_term = wp_insert_term( + $name, + 'fedistream_license', + array( 'description' => $data['description'] ) + ); + } + + if ( is_wp_error( $parent_term ) ) { + continue; + } + + $parent_id = is_array( $parent_term ) ? $parent_term['term_id'] : $parent_term; + + // Insert children. + foreach ( $data['children'] as $child_name => $child_desc ) { + if ( ! term_exists( $child_name, 'fedistream_license' ) ) { + wp_insert_term( + $child_name, + 'fedistream_license', + array( + 'parent' => (int) $parent_id, + 'description' => $child_desc, + ) + ); + } + } + } + } +} diff --git a/includes/Taxonomies/Mood.php b/includes/Taxonomies/Mood.php new file mode 100644 index 0000000..78465a5 --- /dev/null +++ b/includes/Taxonomies/Mood.php @@ -0,0 +1,135 @@ + _x( 'Moods', 'taxonomy general name', 'wp-fedistream' ), + 'singular_name' => _x( 'Mood', 'taxonomy singular name', 'wp-fedistream' ), + 'search_items' => __( 'Search Moods', 'wp-fedistream' ), + 'popular_items' => __( 'Popular Moods', 'wp-fedistream' ), + 'all_items' => __( 'All Moods', 'wp-fedistream' ), + 'edit_item' => __( 'Edit Mood', 'wp-fedistream' ), + 'view_item' => __( 'View Mood', 'wp-fedistream' ), + 'update_item' => __( 'Update Mood', 'wp-fedistream' ), + 'add_new_item' => __( 'Add New Mood', 'wp-fedistream' ), + 'new_item_name' => __( 'New Mood Name', 'wp-fedistream' ), + 'separate_items_with_commas' => __( 'Separate moods with commas', 'wp-fedistream' ), + 'add_or_remove_items' => __( 'Add or remove moods', 'wp-fedistream' ), + 'choose_from_most_used' => __( 'Choose from the most used moods', 'wp-fedistream' ), + 'not_found' => __( 'No moods found.', 'wp-fedistream' ), + 'no_terms' => __( 'No moods', 'wp-fedistream' ), + 'menu_name' => __( 'Moods', 'wp-fedistream' ), + 'items_list_navigation' => __( 'Moods list navigation', 'wp-fedistream' ), + 'items_list' => __( 'Moods list', 'wp-fedistream' ), + 'back_to_items' => __( '← Back to Moods', 'wp-fedistream' ), + ); + + $args = array( + 'labels' => $labels, + 'hierarchical' => false, // Like tags. + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => false, // Will be added to custom menu. + 'show_admin_column' => true, + 'show_in_nav_menus' => true, + 'show_tagcloud' => true, + 'show_in_rest' => true, + 'rest_base' => 'moods', + 'query_var' => true, + 'rewrite' => array( 'slug' => 'mood' ), + 'capabilities' => array( + 'manage_terms' => 'manage_fedistream_moods', + 'edit_terms' => 'manage_fedistream_moods', + 'delete_terms' => 'manage_fedistream_moods', + 'assign_terms' => 'edit_fedistream_tracks', + ), + ); + + register_taxonomy( $this->taxonomy, $this->post_types, $args ); + } + + /** + * Get default moods. + * + * @return array Array of moods. + */ + public static function get_default_moods(): array { + return array( + __( 'Energetic', 'wp-fedistream' ), + __( 'Calm', 'wp-fedistream' ), + __( 'Uplifting', 'wp-fedistream' ), + __( 'Melancholic', 'wp-fedistream' ), + __( 'Aggressive', 'wp-fedistream' ), + __( 'Romantic', 'wp-fedistream' ), + __( 'Happy', 'wp-fedistream' ), + __( 'Sad', 'wp-fedistream' ), + __( 'Relaxing', 'wp-fedistream' ), + __( 'Intense', 'wp-fedistream' ), + __( 'Dreamy', 'wp-fedistream' ), + __( 'Dark', 'wp-fedistream' ), + __( 'Groovy', 'wp-fedistream' ), + __( 'Epic', 'wp-fedistream' ), + __( 'Peaceful', 'wp-fedistream' ), + __( 'Motivational', 'wp-fedistream' ), + __( 'Nostalgic', 'wp-fedistream' ), + __( 'Playful', 'wp-fedistream' ), + __( 'Sensual', 'wp-fedistream' ), + __( 'Suspenseful', 'wp-fedistream' ), + ); + } + + /** + * Install default moods. + * + * @return void + */ + public static function install_defaults(): void { + $moods = self::get_default_moods(); + + foreach ( $moods as $mood ) { + if ( ! term_exists( $mood, 'fedistream_mood' ) ) { + wp_insert_term( $mood, 'fedistream_mood' ); + } + } + } +} diff --git a/includes/Taxonomies/index.php b/includes/Taxonomies/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/includes/Taxonomies/index.php @@ -0,0 +1 @@ + __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $content_type = isset( $_POST['content_type'] ) ? sanitize_text_field( wp_unslash( $_POST['content_type'] ) ) : ''; + $content_id = isset( $_POST['content_id'] ) ? absint( $_POST['content_id'] ) : 0; + + if ( ! in_array( $content_type, array( 'track', 'album', 'playlist' ), true ) || ! $content_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid request.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $is_favorited = self::is_favorited( $user_id, $content_type, $content_id ); + + if ( $is_favorited ) { + $result = self::remove_favorite( $user_id, $content_type, $content_id ); + $action = 'removed'; + } else { + $result = self::add_favorite( $user_id, $content_type, $content_id ); + $action = 'added'; + } + + if ( $result ) { + wp_send_json_success( + array( + 'action' => $action, + 'is_favorited' => ! $is_favorited, + 'message' => 'added' === $action + ? __( 'Added to your library.', 'wp-fedistream' ) + : __( 'Removed from your library.', 'wp-fedistream' ), + ) + ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to update library.', 'wp-fedistream' ) ) ); + } + } + + /** + * Toggle follow via AJAX. + * + * @return void + */ + public function ajax_toggle_follow(): void { + check_ajax_referer( 'fedistream_library', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $artist_id = isset( $_POST['artist_id'] ) ? absint( $_POST['artist_id'] ) : 0; + + if ( ! $artist_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid artist.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $is_following = self::is_following( $user_id, $artist_id ); + + if ( $is_following ) { + $result = self::unfollow_artist( $user_id, $artist_id ); + $action = 'unfollowed'; + } else { + $result = self::follow_artist( $user_id, $artist_id ); + $action = 'followed'; + } + + if ( $result ) { + wp_send_json_success( + array( + 'action' => $action, + 'is_following' => ! $is_following, + 'message' => 'followed' === $action + ? __( 'You are now following this artist.', 'wp-fedistream' ) + : __( 'You unfollowed this artist.', 'wp-fedistream' ), + ) + ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to update follow status.', 'wp-fedistream' ) ) ); + } + } + + /** + * Get user library via AJAX. + * + * @return void + */ + public function ajax_get_library(): void { + check_ajax_referer( 'fedistream_library', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $type = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'all'; + $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; + $per_page = 20; + + $library = self::get_user_library( $user_id, $type, $page, $per_page ); + + wp_send_json_success( $library ); + } + + /** + * Get followed artists via AJAX. + * + * @return void + */ + public function ajax_get_followed_artists(): void { + check_ajax_referer( 'fedistream_library', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; + $per_page = 20; + + $artists = self::get_followed_artists( $user_id, $page, $per_page ); + + wp_send_json_success( $artists ); + } + + /** + * Get listening history via AJAX. + * + * @return void + */ + public function ajax_get_history(): void { + check_ajax_referer( 'fedistream_library', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; + $per_page = 50; + + $history = self::get_listening_history( $user_id, $page, $per_page ); + + wp_send_json_success( $history ); + } + + /** + * Clear listening history via AJAX. + * + * @return void + */ + public function ajax_clear_history(): void { + check_ajax_referer( 'fedistream_library', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $result = self::clear_listening_history( $user_id ); + + if ( $result ) { + wp_send_json_success( array( 'message' => __( 'History cleared.', 'wp-fedistream' ) ) ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to clear history.', 'wp-fedistream' ) ) ); + } + } + + /** + * Add a favorite. + * + * @param int $user_id User ID. + * @param string $content_type Content type (track, album, playlist). + * @param int $content_id Content ID. + * @return bool + */ + public static function add_favorite( int $user_id, string $content_type, int $content_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_favorites'; + + $result = $wpdb->insert( + $table, + array( + 'user_id' => $user_id, + 'content_type' => $content_type, + 'content_id' => $content_id, + 'created_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%d', '%s' ) + ); + + if ( $result ) { + do_action( 'fedistream_favorite_added', $user_id, $content_type, $content_id ); + } + + return (bool) $result; + } + + /** + * Remove a favorite. + * + * @param int $user_id User ID. + * @param string $content_type Content type (track, album, playlist). + * @param int $content_id Content ID. + * @return bool + */ + public static function remove_favorite( int $user_id, string $content_type, int $content_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_favorites'; + + $result = $wpdb->delete( + $table, + array( + 'user_id' => $user_id, + 'content_type' => $content_type, + 'content_id' => $content_id, + ), + array( '%d', '%s', '%d' ) + ); + + if ( $result ) { + do_action( 'fedistream_favorite_removed', $user_id, $content_type, $content_id ); + } + + return (bool) $result; + } + + /** + * Check if content is favorited. + * + * @param int $user_id User ID. + * @param string $content_type Content type. + * @param int $content_id Content ID. + * @return bool + */ + public static function is_favorited( int $user_id, string $content_type, int $content_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_favorites'; + + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND content_type = %s AND content_id = %d", + $user_id, + $content_type, + $content_id + ) + ); + + return (bool) $exists; + } + + /** + * Follow an artist. + * + * @param int $user_id User ID. + * @param int $artist_id Artist post ID. + * @return bool + */ + public static function follow_artist( int $user_id, int $artist_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + + $result = $wpdb->insert( + $table, + array( + 'user_id' => $user_id, + 'artist_id' => $artist_id, + 'created_at' => current_time( 'mysql' ), + ), + array( '%d', '%d', '%s' ) + ); + + if ( $result ) { + do_action( 'fedistream_artist_followed', $user_id, $artist_id ); + } + + return (bool) $result; + } + + /** + * Unfollow an artist. + * + * @param int $user_id User ID. + * @param int $artist_id Artist post ID. + * @return bool + */ + public static function unfollow_artist( int $user_id, int $artist_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + + $result = $wpdb->delete( + $table, + array( + 'user_id' => $user_id, + 'artist_id' => $artist_id, + ), + array( '%d', '%d' ) + ); + + if ( $result ) { + do_action( 'fedistream_artist_unfollowed', $user_id, $artist_id ); + } + + return (bool) $result; + } + + /** + * Check if user is following an artist. + * + * @param int $user_id User ID. + * @param int $artist_id Artist post ID. + * @return bool + */ + public static function is_following( int $user_id, int $artist_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND artist_id = %d", + $user_id, + $artist_id + ) + ); + + return (bool) $exists; + } + + /** + * Record play history. + * + * @param int $track_id Track post ID. + * @param int $user_id User ID (0 for anonymous). + * @return void + */ + public function record_play_history( int $track_id, int $user_id ): void { + if ( ! $user_id ) { + return; + } + + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_listening_history'; + + // Check if recently played (within last 30 seconds) to avoid duplicates. + $recent = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND track_id = %d AND played_at > DATE_SUB(NOW(), INTERVAL 30 SECOND)", + $user_id, + $track_id + ) + ); + + if ( $recent ) { + return; + } + + $wpdb->insert( + $table, + array( + 'user_id' => $user_id, + 'track_id' => $track_id, + 'played_at' => current_time( 'mysql' ), + ), + array( '%d', '%d', '%s' ) + ); + } + + /** + * Get user library. + * + * @param int $user_id User ID. + * @param string $type Content type filter (all, tracks, albums, playlists). + * @param int $page Page number. + * @param int $per_page Items per page. + * @return array + */ + public static function get_user_library( int $user_id, string $type = 'all', int $page = 1, int $per_page = 20 ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_favorites'; + $offset = ( $page - 1 ) * $per_page; + + $where = 'WHERE user_id = %d'; + $params = array( $user_id ); + + if ( 'all' !== $type && in_array( $type, array( 'track', 'album', 'playlist' ), true ) ) { + $where .= ' AND content_type = %s'; + $params[] = $type; + } + + $total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} {$where}", + ...$params + ) + ); + + $params[] = $per_page; + $params[] = $offset; + + $favorites = $wpdb->get_results( + $wpdb->prepare( + "SELECT content_type, content_id, created_at FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d", + ...$params + ) + ); + + $items = array(); + + foreach ( $favorites as $favorite ) { + $post = get_post( $favorite->content_id ); + + if ( ! $post || 'publish' !== $post->post_status ) { + continue; + } + + $item = array( + 'id' => $post->ID, + 'type' => $favorite->content_type, + 'title' => $post->post_title, + 'permalink' => get_permalink( $post ), + 'added_at' => $favorite->created_at, + 'thumbnail' => get_the_post_thumbnail_url( $post, 'medium' ), + ); + + if ( 'track' === $favorite->content_type ) { + $item['duration'] = get_post_meta( $post->ID, '_fedistream_duration', true ); + $item['artist'] = self::get_track_artist_name( $post->ID ); + } elseif ( 'album' === $favorite->content_type ) { + $item['artist'] = self::get_album_artist_name( $post->ID ); + $item['track_count'] = get_post_meta( $post->ID, '_fedistream_total_tracks', true ); + } + + $items[] = $item; + } + + return array( + 'items' => $items, + 'total' => (int) $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => (int) ceil( $total / $per_page ), + ); + } + + /** + * Get followed artists. + * + * @param int $user_id User ID. + * @param int $page Page number. + * @param int $per_page Items per page. + * @return array + */ + public static function get_followed_artists( int $user_id, int $page = 1, int $per_page = 20 ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + $offset = ( $page - 1 ) * $per_page; + + $total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d", + $user_id + ) + ); + + $follows = $wpdb->get_results( + $wpdb->prepare( + "SELECT artist_id, created_at FROM {$table} WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", + $user_id, + $per_page, + $offset + ) + ); + + $artists = array(); + + foreach ( $follows as $follow ) { + $post = get_post( $follow->artist_id ); + + if ( ! $post || 'publish' !== $post->post_status ) { + continue; + } + + $artists[] = array( + 'id' => $post->ID, + 'name' => $post->post_title, + 'permalink' => get_permalink( $post ), + 'followed_at' => $follow->created_at, + 'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ), + 'type' => get_post_meta( $post->ID, '_fedistream_artist_type', true ) ?: 'solo', + ); + } + + return array( + 'artists' => $artists, + 'total' => (int) $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => (int) ceil( $total / $per_page ), + ); + } + + /** + * Get listening history. + * + * @param int $user_id User ID. + * @param int $page Page number. + * @param int $per_page Items per page. + * @return array + */ + public static function get_listening_history( int $user_id, int $page = 1, int $per_page = 50 ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_listening_history'; + $offset = ( $page - 1 ) * $per_page; + + $total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d", + $user_id + ) + ); + + $history = $wpdb->get_results( + $wpdb->prepare( + "SELECT track_id, played_at FROM {$table} WHERE user_id = %d ORDER BY played_at DESC LIMIT %d OFFSET %d", + $user_id, + $per_page, + $offset + ) + ); + + $tracks = array(); + + foreach ( $history as $item ) { + $post = get_post( $item->track_id ); + + if ( ! $post || 'publish' !== $post->post_status ) { + continue; + } + + $tracks[] = array( + 'id' => $post->ID, + 'title' => $post->post_title, + 'permalink' => get_permalink( $post ), + 'played_at' => $item->played_at, + 'thumbnail' => get_the_post_thumbnail_url( $post, 'thumbnail' ), + 'duration' => get_post_meta( $post->ID, '_fedistream_duration', true ), + 'artist' => self::get_track_artist_name( $post->ID ), + ); + } + + return array( + 'tracks' => $tracks, + 'total' => (int) $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => (int) ceil( $total / $per_page ), + ); + } + + /** + * Clear listening history. + * + * @param int $user_id User ID. + * @return bool + */ + public static function clear_listening_history( int $user_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_listening_history'; + $result = $wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) ); + + return false !== $result; + } + + /** + * Get track artist name. + * + * @param int $track_id Track post ID. + * @return string + */ + private static function get_track_artist_name( int $track_id ): string { + $artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true ); + + if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) { + $names = array(); + foreach ( $artist_ids as $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist ) { + $names[] = $artist->post_title; + } + } + return implode( ', ', $names ); + } + + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; + + if ( $artist_id ) { + $artist = get_post( $artist_id ); + return $artist ? $artist->post_title : ''; + } + + return ''; + } + + /** + * Get album artist name. + * + * @param int $album_id Album post ID. + * @return string + */ + private static function get_album_artist_name( int $album_id ): string { + $artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true ); + + if ( $artist_id ) { + $artist = get_post( $artist_id ); + return $artist ? $artist->post_title : ''; + } + + return ''; + } + + /** + * Add favorite button to track/album actions. + * + * @param string $actions HTML actions. + * @param int $post_id Post ID. + * @return string + */ + public function add_favorite_button( string $actions, int $post_id ): string { + if ( ! is_user_logged_in() ) { + return $actions; + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return $actions; + } + + $content_type = str_replace( 'fedistream_', '', $post->post_type ); + $user_id = get_current_user_id(); + $is_favorited = self::is_favorited( $user_id, $content_type, $post_id ); + + $button = sprintf( + '', + $is_favorited ? ' is-favorited' : '', + esc_attr( $content_type ), + $post_id, + $is_favorited ? esc_attr__( 'Remove from library', 'wp-fedistream' ) : esc_attr__( 'Add to library', 'wp-fedistream' ) + ); + + return $actions . $button; + } + + /** + * Add follow button to artist actions. + * + * @param string $actions HTML actions. + * @param int $artist_id Artist post ID. + * @return string + */ + public function add_follow_button( string $actions, int $artist_id ): string { + if ( ! is_user_logged_in() ) { + return $actions; + } + + $user_id = get_current_user_id(); + $is_following = self::is_following( $user_id, $artist_id ); + + $button = sprintf( + '', + $is_following ? ' is-following' : '', + $artist_id, + $is_following ? 'yes' : 'plus', + $is_following ? esc_html__( 'Following', 'wp-fedistream' ) : esc_html__( 'Follow', 'wp-fedistream' ) + ); + + return $actions . $button; + } + + /** + * Get user's favorite count. + * + * @param int $user_id User ID. + * @param string $content_type Optional content type filter. + * @return int + */ + public static function get_favorite_count( int $user_id, string $content_type = '' ): int { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_favorites'; + + if ( $content_type ) { + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND content_type = %s", + $user_id, + $content_type + ) + ); + } else { + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d", + $user_id + ) + ); + } + + return (int) $count; + } + + /** + * Get user's followed artist count. + * + * @param int $user_id User ID. + * @return int + */ + public static function get_following_count( int $user_id ): int { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d", + $user_id + ) + ); + + return (int) $count; + } +} diff --git a/includes/User/LibraryPage.php b/includes/User/LibraryPage.php new file mode 100644 index 0000000..4cd862a --- /dev/null +++ b/includes/User/LibraryPage.php @@ -0,0 +1,324 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'fedistream_library' ), + 'i18n' => array( + 'loading' => __( 'Loading...', 'wp-fedistream' ), + 'noFavorites' => __( 'No favorites yet.', 'wp-fedistream' ), + 'noArtists' => __( 'Not following any artists yet.', 'wp-fedistream' ), + 'noHistory' => __( 'No listening history.', 'wp-fedistream' ), + 'confirmClear' => __( 'Are you sure you want to clear your listening history?', 'wp-fedistream' ), + 'historyCleared' => __( 'History cleared.', 'wp-fedistream' ), + 'error' => __( 'An error occurred. Please try again.', 'wp-fedistream' ), + ), + ) + ); + } + + /** + * Render the library shortcode. + * + * @param array $atts Shortcode attributes. + * @return string + */ + public function render_library_shortcode( array $atts = array() ): string { + $atts = shortcode_atts( + array( + 'tab' => 'favorites', + ), + $atts, + 'fedistream_library' + ); + + if ( ! is_user_logged_in() ) { + return $this->render_login_required(); + } + + $user_id = get_current_user_id(); + + // Get counts for tabs. + $favorite_count = Library::get_favorite_count( $user_id ); + $following_count = Library::get_following_count( $user_id ); + + ob_start(); + ?> +
+ + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+ + + +
+
+ + <?php echo esc_attr( $item['title'] ); ?> + +
+ +
+ + + + +
+
+

+ +

+ +

+ + +

+ +

+ +

+ +
+
+ +
+
+ +
+
+ + <?php echo esc_attr( $artist['name'] ); ?> + +
+ +
+ +
+
+

+ +

+

+ +

+
+
+ +
+
+ +
+
+ + <?php echo esc_attr( $track['title'] ); ?> + +
+ +
+ + +
+
+

+ +

+ +

+ +

+ +

+
+
+ + + +
+
+ admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'fedistream_notifications' ), + 'pollInterval' => 60000, // 1 minute. + 'i18n' => array( + 'noNotifications' => __( 'No notifications', 'wp-fedistream' ), + 'markAllRead' => __( 'Mark all as read', 'wp-fedistream' ), + 'viewAll' => __( 'View all notifications', 'wp-fedistream' ), + 'justNow' => __( 'Just now', 'wp-fedistream' ), + 'error' => __( 'An error occurred.', 'wp-fedistream' ), + ), + ) + ); + } + + /** + * Create a notification. + * + * @param int $user_id User ID. + * @param string $type Notification type. + * @param string $title Notification title. + * @param string $message Notification message. + * @param array $data Additional data. + * @return int|false Notification ID or false on failure. + */ + public static function create( int $user_id, string $type, string $title, string $message, array $data = array() ) { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + + $result = $wpdb->insert( + $table, + array( + 'user_id' => $user_id, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'data' => wp_json_encode( $data ), + 'is_read' => 0, + 'created_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%s', '%s', '%s', '%d', '%s' ) + ); + + if ( $result ) { + $notification_id = $wpdb->insert_id; + + /** + * Fires when a notification is created. + * + * @param int $notification_id Notification ID. + * @param array $notification Notification data. + */ + do_action( + 'fedistream_notification_created', + $notification_id, + array( + 'user_id' => $user_id, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'data' => $data, + ) + ); + + return $notification_id; + } + + return false; + } + + /** + * Get notifications for a user. + * + * @param int $user_id User ID. + * @param bool $unread_only Only get unread notifications. + * @param int $limit Number of notifications to retrieve. + * @param int $offset Offset for pagination. + * @return array + */ + public static function get_for_user( int $user_id, bool $unread_only = false, int $limit = 20, int $offset = 0 ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + $where = 'WHERE user_id = %d'; + $params = array( $user_id ); + + if ( $unread_only ) { + $where .= ' AND is_read = 0'; + } + + $params[] = $limit; + $params[] = $offset; + + $notifications = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d", + ...$params + ) + ); + + $result = array(); + + foreach ( $notifications as $notification ) { + $result[] = array( + 'id' => (int) $notification->id, + 'type' => $notification->type, + 'title' => $notification->title, + 'message' => $notification->message, + 'data' => json_decode( $notification->data, true ) ?: array(), + 'is_read' => (bool) $notification->is_read, + 'created_at' => $notification->created_at, + 'read_at' => $notification->read_at, + ); + } + + return $result; + } + + /** + * Get unread count for a user. + * + * @param int $user_id User ID. + * @return int + */ + public static function get_unread_count( int $user_id ): int { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND is_read = 0", + $user_id + ) + ); + + return (int) $count; + } + + /** + * Mark a notification as read. + * + * @param int $notification_id Notification ID. + * @param int $user_id User ID (for verification). + * @return bool + */ + public static function mark_read( int $notification_id, int $user_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + + $result = $wpdb->update( + $table, + array( + 'is_read' => 1, + 'read_at' => current_time( 'mysql' ), + ), + array( + 'id' => $notification_id, + 'user_id' => $user_id, + ), + array( '%d', '%s' ), + array( '%d', '%d' ) + ); + + return false !== $result; + } + + /** + * Mark all notifications as read for a user. + * + * @param int $user_id User ID. + * @return bool + */ + public static function mark_all_read( int $user_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + + $result = $wpdb->update( + $table, + array( + 'is_read' => 1, + 'read_at' => current_time( 'mysql' ), + ), + array( + 'user_id' => $user_id, + 'is_read' => 0, + ), + array( '%d', '%s' ), + array( '%d', '%d' ) + ); + + return false !== $result; + } + + /** + * Delete a notification. + * + * @param int $notification_id Notification ID. + * @param int $user_id User ID (for verification). + * @return bool + */ + public static function delete( int $notification_id, int $user_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_notifications'; + + $result = $wpdb->delete( + $table, + array( + 'id' => $notification_id, + 'user_id' => $user_id, + ), + array( '%d', '%d' ) + ); + + return (bool) $result; + } + + /** + * AJAX: Get notifications. + * + * @return void + */ + public function ajax_get_notifications(): void { + check_ajax_referer( 'fedistream_notifications', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $unread_only = isset( $_POST['unread_only'] ) && $_POST['unread_only']; + $limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 20; + $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; + + $notifications = self::get_for_user( $user_id, $unread_only, $limit, $offset ); + $unread_count = self::get_unread_count( $user_id ); + + wp_send_json_success( + array( + 'notifications' => $notifications, + 'unread_count' => $unread_count, + ) + ); + } + + /** + * AJAX: Mark notification as read. + * + * @return void + */ + public function ajax_mark_read(): void { + check_ajax_referer( 'fedistream_notifications', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); + } + + $notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0; + + if ( ! $notification_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $result = self::mark_read( $notification_id, $user_id ); + + if ( $result ) { + wp_send_json_success( + array( + 'unread_count' => self::get_unread_count( $user_id ), + ) + ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to update notification.', 'wp-fedistream' ) ) ); + } + } + + /** + * AJAX: Mark all notifications as read. + * + * @return void + */ + public function ajax_mark_all_read(): void { + check_ajax_referer( 'fedistream_notifications', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $result = self::mark_all_read( $user_id ); + + if ( $result ) { + wp_send_json_success( array( 'unread_count' => 0 ) ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to update notifications.', 'wp-fedistream' ) ) ); + } + } + + /** + * AJAX: Delete notification. + * + * @return void + */ + public function ajax_delete(): void { + check_ajax_referer( 'fedistream_notifications', 'nonce' ); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'Not logged in.', 'wp-fedistream' ) ) ); + } + + $notification_id = isset( $_POST['notification_id'] ) ? absint( $_POST['notification_id'] ) : 0; + + if ( ! $notification_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid notification.', 'wp-fedistream' ) ) ); + } + + $user_id = get_current_user_id(); + $result = self::delete( $notification_id, $user_id ); + + if ( $result ) { + wp_send_json_success( + array( + 'unread_count' => self::get_unread_count( $user_id ), + ) + ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to delete notification.', 'wp-fedistream' ) ) ); + } + } + + /** + * Notify followers of a new album release. + * + * @param int $album_id Album post ID. + * @return void + */ + public function notify_new_release( int $album_id ): void { + $album = get_post( $album_id ); + if ( ! $album ) { + return; + } + + $artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true ); + if ( ! $artist_id ) { + return; + } + + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return; + } + + // Get all local followers of this artist. + $followers = $this->get_artist_local_followers( $artist_id ); + + foreach ( $followers as $user_id ) { + self::create( + $user_id, + self::TYPE_NEW_RELEASE, + sprintf( + /* translators: %s: artist name */ + __( 'New release from %s', 'wp-fedistream' ), + $artist->post_title + ), + sprintf( + /* translators: 1: artist name, 2: album title */ + __( '%1$s released a new album: %2$s', 'wp-fedistream' ), + $artist->post_title, + $album->post_title + ), + array( + 'album_id' => $album_id, + 'artist_id' => $artist_id, + 'album_url' => get_permalink( $album ), + 'artist_url' => get_permalink( $artist ), + 'thumbnail' => get_the_post_thumbnail_url( $album, 'thumbnail' ), + ) + ); + } + } + + /** + * Notify followers of a new track. + * + * @param int $track_id Track post ID. + * @return void + */ + public function notify_new_track( int $track_id ): void { + $track = get_post( $track_id ); + if ( ! $track ) { + return; + } + + // Get artist from track or album. + $artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true ); + if ( empty( $artist_ids ) ) { + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; + $artist_ids = $artist_id ? array( $artist_id ) : array(); + } + + if ( empty( $artist_ids ) ) { + return; + } + + $artist_id = $artist_ids[0]; + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return; + } + + // Only notify for single releases (tracks without album). + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + if ( $album_id ) { + return; // Album release handles notification. + } + + $followers = $this->get_artist_local_followers( $artist_id ); + + foreach ( $followers as $user_id ) { + self::create( + $user_id, + self::TYPE_NEW_RELEASE, + sprintf( + /* translators: %s: artist name */ + __( 'New track from %s', 'wp-fedistream' ), + $artist->post_title + ), + sprintf( + /* translators: 1: artist name, 2: track title */ + __( '%1$s released a new track: %2$s', 'wp-fedistream' ), + $artist->post_title, + $track->post_title + ), + array( + 'track_id' => $track_id, + 'artist_id' => $artist_id, + 'track_url' => get_permalink( $track ), + 'artist_url' => get_permalink( $artist ), + ) + ); + } + } + + /** + * Notify artist when they get a new local follower. + * + * @param int $user_id User ID who followed. + * @param int $artist_id Artist post ID. + * @return void + */ + public function notify_artist_followed( int $user_id, int $artist_id ): void { + $artist = get_post( $artist_id ); + if ( ! $artist ) { + return; + } + + // Get the artist's WordPress user ID. + $artist_user_id = get_post_meta( $artist_id, '_fedistream_user_id', true ); + if ( ! $artist_user_id ) { + return; + } + + $follower = get_user_by( 'id', $user_id ); + if ( ! $follower ) { + return; + } + + self::create( + $artist_user_id, + self::TYPE_NEW_FOLLOWER, + __( 'New follower', 'wp-fedistream' ), + sprintf( + /* translators: %s: follower display name */ + __( '%s started following you', 'wp-fedistream' ), + $follower->display_name + ), + array( + 'follower_id' => $user_id, + 'follower_name' => $follower->display_name, + ) + ); + } + + /** + * Notify of a Fediverse like. + * + * @param int $content_id Content post ID. + * @param array $actor Actor data. + * @return void + */ + public function notify_fediverse_like( int $content_id, array $actor ): void { + $post = get_post( $content_id ); + if ( ! $post ) { + return; + } + + $artist_user_id = $this->get_content_owner_user_id( $content_id ); + if ( ! $artist_user_id ) { + return; + } + + $actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' ); + + self::create( + $artist_user_id, + self::TYPE_FEDIVERSE_LIKE, + __( 'New like from Fediverse', 'wp-fedistream' ), + sprintf( + /* translators: 1: actor name, 2: content title */ + __( '%1$s liked your %2$s', 'wp-fedistream' ), + $actor_name, + $post->post_title + ), + array( + 'content_id' => $content_id, + 'content_type' => $post->post_type, + 'actor_uri' => $actor['id'] ?? '', + 'actor_name' => $actor_name, + 'actor_icon' => $actor['icon']['url'] ?? '', + ) + ); + } + + /** + * Notify of a Fediverse boost/announce. + * + * @param int $content_id Content post ID. + * @param array $actor Actor data. + * @return void + */ + public function notify_fediverse_boost( int $content_id, array $actor ): void { + $post = get_post( $content_id ); + if ( ! $post ) { + return; + } + + $artist_user_id = $this->get_content_owner_user_id( $content_id ); + if ( ! $artist_user_id ) { + return; + } + + $actor_name = $actor['name'] ?? $actor['preferredUsername'] ?? __( 'Someone', 'wp-fedistream' ); + + self::create( + $artist_user_id, + self::TYPE_FEDIVERSE_BOOST, + __( 'New boost from Fediverse', 'wp-fedistream' ), + sprintf( + /* translators: 1: actor name, 2: content title */ + __( '%1$s boosted your %2$s', 'wp-fedistream' ), + $actor_name, + $post->post_title + ), + array( + 'content_id' => $content_id, + 'content_type' => $post->post_type, + 'actor_uri' => $actor['id'] ?? '', + 'actor_name' => $actor_name, + 'actor_icon' => $actor['icon']['url'] ?? '', + ) + ); + } + + /** + * Maybe send email notification. + * + * @param int $notification_id Notification ID. + * @param array $notification Notification data. + * @return void + */ + public function maybe_send_email( int $notification_id, array $notification ): void { + $user_id = $notification['user_id']; + $type = $notification['type']; + + // Check user preference for email notifications. + $email_enabled = get_user_meta( $user_id, 'fedistream_email_notifications', true ); + if ( '0' === $email_enabled ) { + return; + } + + // Check specific notification type preference. + $type_enabled = get_user_meta( $user_id, 'fedistream_email_' . $type, true ); + if ( '0' === $type_enabled ) { + return; + } + + $user = get_user_by( 'id', $user_id ); + if ( ! $user || ! $user->user_email ) { + return; + } + + $subject = sprintf( + /* translators: 1: site name, 2: notification title */ + __( '[%1$s] %2$s', 'wp-fedistream' ), + get_bloginfo( 'name' ), + $notification['title'] + ); + + $message = $this->build_email_message( $notification ); + + $headers = array( + 'Content-Type: text/html; charset=UTF-8', + ); + + wp_mail( $user->user_email, $subject, $message, $headers ); + } + + /** + * Build email message HTML. + * + * @param array $notification Notification data. + * @return string + */ + private function build_email_message( array $notification ): string { + $site_name = get_bloginfo( 'name' ); + $site_url = home_url(); + + $html = ''; + $html .= '
'; + $html .= '

' . esc_html( $notification['title'] ) . '

'; + $html .= '

' . esc_html( $notification['message'] ) . '

'; + + // Add action link if available. + $data = $notification['data']; + $link = ''; + + if ( ! empty( $data['album_url'] ) ) { + $link = $data['album_url']; + } elseif ( ! empty( $data['track_url'] ) ) { + $link = $data['track_url']; + } elseif ( ! empty( $data['artist_url'] ) ) { + $link = $data['artist_url']; + } + + if ( $link ) { + $html .= '

' . esc_html__( 'View Details', 'wp-fedistream' ) . '

'; + } + + $html .= '
'; + $html .= '

' . sprintf( + /* translators: %s: site name */ + esc_html__( 'This email was sent by %s.', 'wp-fedistream' ), + '' . esc_html( $site_name ) . '' + ) . '

'; + $html .= '
'; + + return $html; + } + + /** + * Add notification indicator to admin bar. + * + * @param \WP_Admin_Bar $admin_bar Admin bar instance. + * @return void + */ + public function add_notification_indicator( \WP_Admin_Bar $admin_bar ): void { + if ( ! is_user_logged_in() ) { + return; + } + + $user_id = get_current_user_id(); + $unread_count = self::get_unread_count( $user_id ); + + $title = ''; + if ( $unread_count > 0 ) { + $title .= '' . esc_html( $unread_count ) . ''; + } + + $admin_bar->add_node( + array( + 'id' => 'fedistream-notifications', + 'title' => $title, + 'href' => '#', + 'meta' => array( + 'class' => 'fedistream-notifications-menu', + ), + ) + ); + } + + /** + * Get local followers for an artist. + * + * @param int $artist_id Artist post ID. + * @return array User IDs. + */ + private function get_artist_local_followers( int $artist_id ): array { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_user_follows'; + + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT user_id FROM {$table} WHERE artist_id = %d", + $artist_id + ) + ); + + return array_map( 'intval', $user_ids ); + } + + /** + * Get the WordPress user ID who owns a piece of content. + * + * @param int $content_id Content post ID. + * @return int|null User ID or null. + */ + private function get_content_owner_user_id( int $content_id ): ?int { + $post = get_post( $content_id ); + if ( ! $post ) { + return null; + } + + // For tracks, get artist. + if ( 'fedistream_track' === $post->post_type ) { + $artist_ids = get_post_meta( $content_id, '_fedistream_artist_ids', true ); + if ( ! empty( $artist_ids ) ) { + $artist_id = $artist_ids[0]; + return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null; + } + + $album_id = get_post_meta( $content_id, '_fedistream_album_id', true ); + $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; + if ( $artist_id ) { + return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null; + } + } + + // For albums, get artist. + if ( 'fedistream_album' === $post->post_type ) { + $artist_id = get_post_meta( $content_id, '_fedistream_album_artist', true ); + if ( $artist_id ) { + return (int) get_post_meta( $artist_id, '_fedistream_user_id', true ) ?: null; + } + } + + // For artists. + if ( 'fedistream_artist' === $post->post_type ) { + return (int) get_post_meta( $content_id, '_fedistream_user_id', true ) ?: null; + } + + return null; + } +} diff --git a/includes/WooCommerce/AlbumProduct.php b/includes/WooCommerce/AlbumProduct.php new file mode 100644 index 0000000..9ca473a --- /dev/null +++ b/includes/WooCommerce/AlbumProduct.php @@ -0,0 +1,499 @@ +get_meta( '_fedistream_linked_album', true ); + } + + /** + * Get the linked album post. + * + * @return \WP_Post|null + */ + public function get_linked_album(): ?\WP_Post { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return null; + } + + $album = get_post( $album_id ); + + if ( ! $album || 'fedistream_album' !== $album->post_type ) { + return null; + } + + return $album; + } + + /** + * Get the pricing type. + * + * @return string fixed, pwyw, or nyp + */ + public function get_pricing_type(): string { + return $this->get_meta( '_fedistream_pricing_type', true ) ?: 'fixed'; + } + + /** + * Get minimum price for PWYW. + * + * @return float + */ + public function get_min_price(): float { + return (float) $this->get_meta( '_fedistream_min_price', true ); + } + + /** + * Get suggested price for PWYW. + * + * @return float + */ + public function get_suggested_price(): float { + return (float) $this->get_meta( '_fedistream_suggested_price', true ); + } + + /** + * Check if streaming is included. + * + * @return bool + */ + public function includes_streaming(): bool { + return 'yes' === $this->get_meta( '_fedistream_include_streaming', true ); + } + + /** + * Get available download formats. + * + * @return array + */ + public function get_available_formats(): array { + $formats = $this->get_meta( '_fedistream_available_formats', true ); + + return is_array( $formats ) ? $formats : array( 'mp3' ); + } + + /** + * Get tracks in this album. + * + * @return array Array of WP_Post objects. + */ + public function get_tracks(): array { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return array(); + } + + $tracks = get_posts( + 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' => $album_id, + ), + ), + ) + ); + + return $tracks; + } + + /** + * Get track count. + * + * @return int + */ + public function get_track_count(): int { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return 0; + } + + $count = get_post_meta( $album_id, '_fedistream_total_tracks', true ); + + if ( $count ) { + return (int) $count; + } + + return count( $this->get_tracks() ); + } + + /** + * Get total duration in seconds. + * + * @return int + */ + public function get_total_duration(): int { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return 0; + } + + $duration = get_post_meta( $album_id, '_fedistream_total_duration', true ); + + if ( $duration ) { + return (int) $duration; + } + + // Calculate from tracks. + $tracks = $this->get_tracks(); + $duration = 0; + + foreach ( $tracks as $track ) { + $duration += (int) get_post_meta( $track->ID, '_fedistream_duration', true ); + } + + return $duration; + } + + /** + * Get formatted duration. + * + * @return string + */ + public function get_formatted_duration(): string { + $seconds = $this->get_total_duration(); + + if ( ! $seconds ) { + return ''; + } + + $hours = floor( $seconds / 3600 ); + $mins = floor( ( $seconds % 3600 ) / 60 ); + $secs = $seconds % 60; + + if ( $hours > 0 ) { + return sprintf( '%d:%02d:%02d', $hours, $mins, $secs ); + } + + return sprintf( '%d:%02d', $mins, $secs ); + } + + /** + * Get artist name(s). + * + * @return string + */ + public function get_artist_name(): string { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return ''; + } + + $artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true ); + + if ( ! $artist_id ) { + return ''; + } + + $artist = get_post( $artist_id ); + + return $artist ? $artist->post_title : ''; + } + + /** + * Get album artwork URL. + * + * @param string $size Image size. + * @return string + */ + public function get_album_artwork( string $size = 'medium' ): string { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return ''; + } + + $thumbnail_id = get_post_thumbnail_id( $album_id ); + + if ( ! $thumbnail_id ) { + return ''; + } + + $image = wp_get_attachment_image_url( $thumbnail_id, $size ); + + return $image ?: ''; + } + + /** + * Get release date. + * + * @return string + */ + public function get_release_date(): string { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return ''; + } + + return get_post_meta( $album_id, '_fedistream_release_date', true ) ?: ''; + } + + /** + * Get album type (album, ep, single, compilation). + * + * @return string + */ + public function get_album_type(): string { + $album_id = $this->get_linked_album_id(); + + if ( ! $album_id ) { + return ''; + } + + return get_post_meta( $album_id, '_fedistream_album_type', true ) ?: 'album'; + } + + /** + * Get downloads for this product. + * + * Generates downloadable files based on available formats. + * + * @param string $context View or edit context. + * @return array + */ + public function get_downloads( $context = 'view' ): array { + $downloads = parent::get_downloads( $context ); + + // If no manual downloads set, generate from linked album. + if ( empty( $downloads ) && $this->get_linked_album_id() ) { + $downloads = $this->generate_album_downloads(); + } + + return $downloads; + } + + /** + * Generate download files from linked album. + * + * @return array + */ + private function generate_album_downloads(): array { + $downloads = array(); + $tracks = $this->get_tracks(); + $formats = $this->get_available_formats(); + $album = $this->get_linked_album(); + + if ( empty( $tracks ) || ! $album ) { + return $downloads; + } + + // For each format, create a download entry. + foreach ( $formats as $format ) { + $format_label = strtoupper( $format ); + + // Create album ZIP download entry. + $download_id = 'album-' . $album->ID . '-' . $format; + + $downloads[ $download_id ] = array( + 'id' => $download_id, + 'name' => sprintf( + /* translators: 1: Album name, 2: Format name */ + __( '%1$s (%2$s)', 'wp-fedistream' ), + $album->post_title, + $format_label + ), + 'file' => add_query_arg( + array( + 'fedistream_download' => 'album', + 'album_id' => $album->ID, + 'format' => $format, + ), + home_url( '/' ) + ), + ); + } + + return $downloads; + } + + /** + * Check if purchasable. + * + * @return bool + */ + public function is_purchasable(): bool { + // Must have a linked album. + if ( ! $this->get_linked_album_id() ) { + return false; + } + + // Check price for fixed pricing. + if ( 'fixed' === $this->get_pricing_type() ) { + return $this->get_price() !== '' && $this->get_price() >= 0; + } + + // PWYW and NYP are always purchasable. + return true; + } + + /** + * Get price HTML. + * + * @param string $price Price HTML. + * @return string + */ + public function get_price_html( $price = '' ): string { + $pricing_type = $this->get_pricing_type(); + + if ( 'nyp' === $pricing_type ) { + return '' . esc_html__( 'Name Your Price', 'wp-fedistream' ) . ''; + } + + if ( 'pwyw' === $pricing_type ) { + $min_price = $this->get_min_price(); + $suggested = $this->get_suggested_price(); + + $html = ''; + + if ( $min_price > 0 ) { + $html .= sprintf( + /* translators: %s: Minimum price */ + esc_html__( 'From %s', 'wp-fedistream' ), + wc_price( $min_price ) + ); + } else { + $html .= esc_html__( 'Pay What You Want', 'wp-fedistream' ); + } + + if ( $suggested > 0 ) { + $html .= ' '; + $html .= sprintf( + /* translators: %s: Suggested price */ + esc_html__( '(Suggested: %s)', 'wp-fedistream' ), + wc_price( $suggested ) + ); + $html .= ''; + } + + $html .= ''; + + return $html; + } + + return parent::get_price_html( $price ); + } + + /** + * Add to cart validation for PWYW products. + * + * @param bool $passed Validation passed. + * @param int $product_id Product ID. + * @param int $quantity Quantity. + * @return bool + */ + public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity ): bool { + $product = wc_get_product( $product_id ); + + if ( ! $product || 'fedistream_album' !== $product->get_type() ) { + return $passed; + } + + $pricing_type = $product->get_pricing_type(); + + if ( 'pwyw' === $pricing_type || 'nyp' === $pricing_type ) { + // Check if custom price is set. + $custom_price = isset( $_POST['fedistream_custom_price'] ) ? // phpcs:ignore WordPress.Security.NonceVerification.Missing + wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['fedistream_custom_price'] ) ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Missing + 0; + + $min_price = $product->get_min_price(); + + if ( 'pwyw' === $pricing_type && $custom_price < $min_price ) { + wc_add_notice( + sprintf( + /* translators: %s: Minimum price */ + __( 'Please enter at least %s', 'wp-fedistream' ), + wc_price( $min_price ) + ), + 'error' + ); + return false; + } + + // Store custom price in session for cart. + WC()->session->set( 'fedistream_custom_price_' . $product_id, $custom_price ); + } + + return $passed; + } +} diff --git a/includes/WooCommerce/DigitalDelivery.php b/includes/WooCommerce/DigitalDelivery.php new file mode 100644 index 0000000..5d2ac4d --- /dev/null +++ b/includes/WooCommerce/DigitalDelivery.php @@ -0,0 +1,474 @@ +handle_track_download( $user_id ); + } elseif ( 'album' === $type ) { + $this->handle_album_download( $user_id ); + } + } + + /** + * Handle track download. + * + * @param int $user_id User ID. + * @return void + */ + private function handle_track_download( int $user_id ): void { + $track_id = isset( $_GET['track_id'] ) ? absint( $_GET['track_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : 'mp3'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $track_id ) { + wp_die( esc_html__( 'Invalid track.', 'wp-fedistream' ) ); + } + + // Verify purchase. + if ( ! Integration::user_has_purchased( $user_id, 'track', $track_id ) ) { + wp_die( esc_html__( 'You have not purchased this track.', 'wp-fedistream' ) ); + } + + $track = get_post( $track_id ); + if ( ! $track || 'fedistream_track' !== $track->post_type ) { + wp_die( esc_html__( 'Track not found.', 'wp-fedistream' ) ); + } + + // Get audio file. + $audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true ); + if ( ! $audio_id ) { + wp_die( esc_html__( 'No audio file available.', 'wp-fedistream' ) ); + } + + $file_path = get_attached_file( $audio_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + wp_die( esc_html__( 'File not found.', 'wp-fedistream' ) ); + } + + // Convert format if needed. + $converted_file = $this->get_converted_file( $file_path, $format, $track_id ); + + // Generate filename. + $filename = sanitize_file_name( $track->post_title ) . '.' . $format; + + // Serve the file. + $this->serve_file( $converted_file, $filename, $format ); + } + + /** + * Handle album download (ZIP of all tracks). + * + * @param int $user_id User ID. + * @return void + */ + private function handle_album_download( int $user_id ): void { + $album_id = isset( $_GET['album_id'] ) ? absint( $_GET['album_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : 'mp3'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $album_id ) { + wp_die( esc_html__( 'Invalid album.', 'wp-fedistream' ) ); + } + + // Verify purchase. + if ( ! Integration::user_has_purchased( $user_id, 'album', $album_id ) ) { + wp_die( esc_html__( 'You have not purchased this album.', 'wp-fedistream' ) ); + } + + $album = get_post( $album_id ); + if ( ! $album || 'fedistream_album' !== $album->post_type ) { + wp_die( esc_html__( 'Album not found.', 'wp-fedistream' ) ); + } + + // Get tracks. + $tracks = get_posts( + 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' => $album_id, + ), + ), + ) + ); + + if ( empty( $tracks ) ) { + wp_die( esc_html__( 'No tracks found in this album.', 'wp-fedistream' ) ); + } + + // Create ZIP file. + $zip_file = $this->create_album_zip( $album, $tracks, $format ); + + if ( ! $zip_file ) { + wp_die( esc_html__( 'Failed to create download package.', 'wp-fedistream' ) ); + } + + // Generate filename. + $artist_id = get_post_meta( $album_id, '_fedistream_album_artist', true ); + $artist = $artist_id ? get_post( $artist_id ) : null; + $artist_name = $artist ? $artist->post_title : 'Unknown Artist'; + + $filename = sanitize_file_name( $artist_name . ' - ' . $album->post_title ) . '.' . strtoupper( $format ) . '.zip'; + + // Serve the file. + $this->serve_file( $zip_file, $filename, 'zip' ); + + // Clean up temp file. + wp_delete_file( $zip_file ); + } + + /** + * Get converted file path. + * + * @param string $source_path Source file path. + * @param string $format Target format. + * @param int $track_id Track ID for caching. + * @return string Converted file path. + */ + private function get_converted_file( string $source_path, string $format, int $track_id ): string { + $source_ext = strtolower( pathinfo( $source_path, PATHINFO_EXTENSION ) ); + + // If same format, return source. + if ( $source_ext === $format ) { + return $source_path; + } + + // Check for cached conversion. + $cache_dir = wp_upload_dir()['basedir'] . '/fedistream-cache/'; + $cache_file = $cache_dir . 'track-' . $track_id . '.' . $format; + + if ( file_exists( $cache_file ) ) { + return $cache_file; + } + + // Create cache directory. + if ( ! file_exists( $cache_dir ) ) { + wp_mkdir_p( $cache_dir ); + } + + // For now, return source file (format conversion would require FFmpeg). + // In production, you'd use FFmpeg or similar for conversion. + // This is a placeholder for the conversion logic. + return $source_path; + } + + /** + * Create ZIP file for album download. + * + * @param \WP_Post $album Album post. + * @param array $tracks Track posts. + * @param string $format Audio format. + * @return string|null ZIP file path or null on failure. + */ + private function create_album_zip( \WP_Post $album, array $tracks, string $format ): ?string { + if ( ! class_exists( 'ZipArchive' ) ) { + return null; + } + + $temp_dir = get_temp_dir(); + $zip_path = $temp_dir . 'fedistream-album-' . $album->ID . '-' . time() . '.zip'; + + $zip = new \ZipArchive(); + if ( $zip->open( $zip_path, \ZipArchive::CREATE ) !== true ) { + return null; + } + + $track_number = 0; + foreach ( $tracks as $track ) { + ++$track_number; + + $audio_id = get_post_meta( $track->ID, '_fedistream_audio_file', true ); + if ( ! $audio_id ) { + continue; + } + + $file_path = get_attached_file( $audio_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + continue; + } + + // Get converted file. + $converted_file = $this->get_converted_file( $file_path, $format, $track->ID ); + + // Create filename with track number. + $filename = sprintf( + '%02d - %s.%s', + $track_number, + sanitize_file_name( $track->post_title ), + $format + ); + + $zip->addFile( $converted_file, $filename ); + } + + // Add cover art if available. + $thumbnail_id = get_post_thumbnail_id( $album->ID ); + if ( $thumbnail_id ) { + $cover_path = get_attached_file( $thumbnail_id ); + if ( $cover_path && file_exists( $cover_path ) ) { + $cover_ext = pathinfo( $cover_path, PATHINFO_EXTENSION ); + $zip->addFile( $cover_path, 'cover.' . $cover_ext ); + } + } + + $zip->close(); + + return file_exists( $zip_path ) ? $zip_path : null; + } + + /** + * Serve a file for download. + * + * @param string $file_path File path. + * @param string $filename Download filename. + * @param string $format File format. + * @return void + */ + private function serve_file( string $file_path, string $filename, string $format ): void { + if ( ! file_exists( $file_path ) ) { + wp_die( esc_html__( 'File not found.', 'wp-fedistream' ) ); + } + + // Get MIME type. + $mime_types = array( + 'mp3' => 'audio/mpeg', + 'flac' => 'audio/flac', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'aac' => 'audio/aac', + 'zip' => 'application/zip', + ); + + $mime_type = $mime_types[ $format ] ?? 'application/octet-stream'; + + // Clean output buffer. + while ( ob_get_level() ) { + ob_end_clean(); + } + + // Set headers. + nocache_headers(); + header( 'Content-Type: ' . $mime_type ); + header( 'Content-Description: File Transfer' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Content-Transfer-Encoding: binary' ); + header( 'Content-Length: ' . filesize( $file_path ) ); + + // Read file. + readfile( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile + + exit; + } + + /** + * Add download links to order confirmation email. + * + * @param \WC_Order $order Order object. + * @param bool $sent_to_admin Whether sent to admin. + * @param bool $plain_text Whether plain text. + * @param object $email Email object. + * @return void + */ + public function add_download_links_to_email( \WC_Order $order, bool $sent_to_admin, bool $plain_text, $email ): void { + if ( $sent_to_admin || 'completed' !== $order->get_status() ) { + return; + } + + $has_fedistream = false; + foreach ( $order->get_items() as $item ) { + $product_type = \WC_Product_Factory::get_product_type( $item->get_product_id() ); + if ( in_array( $product_type, array( 'fedistream_album', 'fedistream_track' ), true ) ) { + $has_fedistream = true; + break; + } + } + + if ( ! $has_fedistream ) { + return; + } + + $downloads_url = wc_get_account_endpoint_url( 'downloads' ); + + if ( $plain_text ) { + echo "\n\n"; + echo esc_html__( 'Your FediStream Downloads', 'wp-fedistream' ) . "\n"; + echo esc_html__( 'Access your purchased music at:', 'wp-fedistream' ) . ' ' . esc_url( $downloads_url ) . "\n"; + } else { + ?> +

+

+ ' . esc_html__( 'account downloads', 'wp-fedistream' ) . '' + ); + ?> +

+ get_user_purchases( $user_id ); + + if ( empty( $purchases ) ) { + return; + } + + ?> +

+ + + + + + + + + + + + content_id ); + if ( ! $content ) { + continue; + } + + $formats = array( 'mp3', 'flac' ); // Default formats. + ?> + + + + + + + + +
post_title ); ?>content_type ) ); ?>purchased_at ) ) ); ?> + + $purchase->content_type, + $purchase->content_type . '_id' => $purchase->content_id, + 'format' => $format, + ), + home_url( '/' ) + ); + ?> + + + + +
+ prefix . 'fedistream_purchases'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $purchases = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE user_id = %d ORDER BY purchased_at DESC", + $user_id + ) + ); + + return $purchases ?: array(); + } + + /** + * Force download for audio files. + * + * @param bool $force Force download. + * @param string $file_path File path. + * @return bool + */ + public function force_download_for_audio( bool $force, string $file_path ): bool { + $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); + + $audio_extensions = array( 'mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a' ); + + if ( in_array( $ext, $audio_extensions, true ) ) { + return true; + } + + return $force; + } +} diff --git a/includes/WooCommerce/Integration.php b/includes/WooCommerce/Integration.php new file mode 100644 index 0000000..960ce66 --- /dev/null +++ b/includes/WooCommerce/Integration.php @@ -0,0 +1,738 @@ +woocommerce_active = class_exists( 'WooCommerce' ); + } + + /** + * Initialize WooCommerce integration. + * + * @return void + */ + public function init(): void { + if ( ! $this->woocommerce_active ) { + return; + } + + // Register custom product types. + add_filter( 'product_type_selector', array( $this, 'add_product_types' ) ); + add_filter( 'woocommerce_product_class', array( $this, 'product_class' ), 10, 2 ); + + // Initialize product type classes. + add_action( 'init', array( $this, 'register_product_types' ), 5 ); + + // Add product data tabs. + add_filter( 'woocommerce_product_data_tabs', array( $this, 'add_product_data_tabs' ) ); + add_action( 'woocommerce_product_data_panels', array( $this, 'add_product_data_panels' ) ); + + // Save product meta. + add_action( 'woocommerce_process_product_meta', array( $this, 'save_product_meta' ) ); + + // Frontend hooks. + add_action( 'woocommerce_single_product_summary', array( $this, 'display_track_preview' ), 25 ); + + // Purchase access hooks. + add_action( 'woocommerce_order_status_completed', array( $this, 'grant_access_on_purchase' ) ); + + // Download hooks. + add_filter( 'woocommerce_downloadable_file_allowed_mime_types', array( $this, 'allowed_audio_mimes' ) ); + + // Admin columns. + add_filter( 'manage_edit-product_columns', array( $this, 'add_product_columns' ) ); + add_action( 'manage_product_posts_custom_column', array( $this, 'render_product_columns' ), 10, 2 ); + } + + /** + * Check if WooCommerce is active. + * + * @return bool + */ + public function is_active(): bool { + return $this->woocommerce_active; + } + + /** + * Register custom product types. + * + * @return void + */ + public function register_product_types(): void { + // Product types are registered via class loading. + } + + /** + * Add custom product types to the selector. + * + * @param array $types Product types. + * @return array Modified product types. + */ + public function add_product_types( array $types ): array { + $types['fedistream_album'] = __( 'FediStream Album', 'wp-fedistream' ); + $types['fedistream_track'] = __( 'FediStream Track', 'wp-fedistream' ); + + return $types; + } + + /** + * Get product class for custom types. + * + * @param string $classname Product class name. + * @param string $product_type Product type. + * @return string Modified class name. + */ + public function product_class( string $classname, string $product_type ): string { + if ( 'fedistream_album' === $product_type ) { + return AlbumProduct::class; + } + + if ( 'fedistream_track' === $product_type ) { + return TrackProduct::class; + } + + return $classname; + } + + /** + * Add product data tabs. + * + * @param array $tabs Product data tabs. + * @return array Modified tabs. + */ + public function add_product_data_tabs( array $tabs ): array { + $tabs['fedistream'] = array( + 'label' => __( 'FediStream', 'wp-fedistream' ), + 'target' => 'fedistream_product_data', + 'class' => array( 'show_if_fedistream_album', 'show_if_fedistream_track' ), + 'priority' => 21, + ); + + $tabs['fedistream_formats'] = array( + 'label' => __( 'Audio Formats', 'wp-fedistream' ), + 'target' => 'fedistream_formats_data', + 'class' => array( 'show_if_fedistream_album', 'show_if_fedistream_track' ), + 'priority' => 22, + ); + + return $tabs; + } + + /** + * Add product data panels. + * + * @return void + */ + public function add_product_data_panels(): void { + global $post; + + $product_id = $post->ID; + + // Get linked content. + $linked_album = get_post_meta( $product_id, '_fedistream_linked_album', true ); + $linked_track = get_post_meta( $product_id, '_fedistream_linked_track', true ); + + // Get pricing options. + $pricing_type = get_post_meta( $product_id, '_fedistream_pricing_type', true ) ?: 'fixed'; + $min_price = get_post_meta( $product_id, '_fedistream_min_price', true ); + $suggested_price = get_post_meta( $product_id, '_fedistream_suggested_price', true ); + + // Get format options. + $available_formats = get_post_meta( $product_id, '_fedistream_available_formats', true ) ?: array( 'mp3' ); + $include_streaming = get_post_meta( $product_id, '_fedistream_include_streaming', true ); + + // Get albums for dropdown. + $albums = get_posts( + array( + 'post_type' => 'fedistream_album', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + // Get tracks for dropdown. + $tracks = get_posts( + array( + 'post_type' => 'fedistream_track', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + ?> +
+
+

+ + + +

+
+ +
+

+ + + +

+
+ +
+

+ + +

+
+ +
+ '_fedistream_min_price', + 'label' => __( 'Minimum Price', 'wp-fedistream' ) . ' (' . get_woocommerce_currency_symbol() . ')', + 'desc_tip' => true, + 'description' => __( 'Minimum price for Pay What You Want. Leave empty for no minimum.', 'wp-fedistream' ), + 'type' => 'text', + 'data_type' => 'price', + 'value' => $min_price, + ) + ); + + woocommerce_wp_text_input( + array( + 'id' => '_fedistream_suggested_price', + 'label' => __( 'Suggested Price', 'wp-fedistream' ) . ' (' . get_woocommerce_currency_symbol() . ')', + 'desc_tip' => true, + 'description' => __( 'Suggested price shown to customers.', 'wp-fedistream' ), + 'type' => 'text', + 'data_type' => 'price', + 'value' => $suggested_price, + ) + ); + ?> +
+ +
+ '_fedistream_include_streaming', + 'label' => __( 'Include Streaming', 'wp-fedistream' ), + 'description' => __( 'Purchase unlocks full-quality streaming access.', 'wp-fedistream' ), + 'value' => $include_streaming, + ) + ); + ?> +
+
+ +
+
+

+ + + 'MP3 (320kbps)', + 'flac' => 'FLAC (Lossless)', + 'wav' => 'WAV (Uncompressed)', + 'aac' => 'AAC (256kbps)', + 'ogg' => 'OGG Vorbis', + ); + + foreach ( $formats as $format => $label ) : + $checked = is_array( $available_formats ) && in_array( $format, $available_formats, true ); + ?> + + + +

+

+ +

+
+
+ + + get_type(); + + if ( 'fedistream_track' === $product_type ) { + $track_id = get_post_meta( $product->get_id(), '_fedistream_linked_track', true ); + if ( $track_id ) { + $this->render_track_preview( $track_id ); + } + } elseif ( 'fedistream_album' === $product_type ) { + $album_id = get_post_meta( $product->get_id(), '_fedistream_linked_album', true ); + if ( $album_id ) { + $this->render_album_preview( $album_id ); + } + } + } + + /** + * Render track preview player. + * + * @param int $track_id Track ID. + * @return void + */ + private function render_track_preview( int $track_id ): void { + $audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true ); + $audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : ''; + + if ( ! $audio_url ) { + return; + } + + $duration = get_post_meta( $track_id, '_fedistream_duration', true ); + + ?> +
+

+
+ +
+
+
+ + + +
+
+ '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' => $album_id, + ), + ), + ) + ); + + if ( empty( $tracks ) ) { + return; + } + + ?> +
+

+
    + + ID, '_fedistream_duration', true ); + ?> +
  1. + post_title ); ?> + + + +
  2. + +
+
+ get_customer_id(); + if ( ! $customer_id ) { + return; + } + + foreach ( $order->get_items() as $item ) { + $product_id = $item->get_product_id(); + $product_type = \WC_Product_Factory::get_product_type( $product_id ); + + if ( 'fedistream_album' === $product_type ) { + $album_id = get_post_meta( $product_id, '_fedistream_linked_album', true ); + if ( $album_id ) { + $this->grant_album_access( $customer_id, $album_id, $order_id ); + } + } elseif ( 'fedistream_track' === $product_type ) { + $track_id = get_post_meta( $product_id, '_fedistream_linked_track', true ); + if ( $track_id ) { + $this->grant_track_access( $customer_id, $track_id, $order_id ); + } + } + } + } + + /** + * Grant album access to a customer. + * + * @param int $customer_id Customer ID. + * @param int $album_id Album ID. + * @param int $order_id Order ID. + * @return void + */ + private function grant_album_access( int $customer_id, int $album_id, int $order_id ): void { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_purchases'; + + // Check if access already exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND content_type = 'album' AND content_id = %d", + $customer_id, + $album_id + ) + ); + + if ( $exists ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'user_id' => $customer_id, + 'content_type' => 'album', + 'content_id' => $album_id, + 'order_id' => $order_id, + 'purchased_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%d', '%d', '%s' ) + ); + + // Also grant access to all tracks in the album. + $tracks = get_posts( + array( + 'post_type' => 'fedistream_track', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_fedistream_album_id', + 'value' => $album_id, + ), + ), + ) + ); + + foreach ( $tracks as $track ) { + $this->grant_track_access( $customer_id, $track->ID, $order_id ); + } + } + + /** + * Grant track access to a customer. + * + * @param int $customer_id Customer ID. + * @param int $track_id Track ID. + * @param int $order_id Order ID. + * @return void + */ + private function grant_track_access( int $customer_id, int $track_id, int $order_id ): void { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_purchases'; + + // Check if access already exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND content_type = 'track' AND content_id = %d", + $customer_id, + $track_id + ) + ); + + if ( $exists ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'user_id' => $customer_id, + 'content_type' => 'track', + 'content_id' => $track_id, + 'order_id' => $order_id, + 'purchased_at' => current_time( 'mysql' ), + ), + array( '%d', '%s', '%d', '%d', '%s' ) + ); + } + + /** + * Check if user has purchased content. + * + * @param int $user_id User ID. + * @param string $content_type Content type (album or track). + * @param int $content_id Content ID. + * @return bool + */ + public static function user_has_purchased( int $user_id, string $content_type, int $content_id ): bool { + global $wpdb; + + $table = $wpdb->prefix . 'fedistream_purchases'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND content_type = %s AND content_id = %d", + $user_id, + $content_type, + $content_id + ) + ); + + return (bool) $exists; + } + + /** + * Add allowed audio MIME types. + * + * @param array $types Allowed MIME types. + * @return array Modified MIME types. + */ + public function allowed_audio_mimes( array $types ): array { + $types['flac'] = 'audio/flac'; + $types['wav'] = 'audio/wav'; + $types['ogg'] = 'audio/ogg'; + $types['aac'] = 'audio/aac'; + + return $types; + } + + /** + * Add product columns. + * + * @param array $columns Columns. + * @return array Modified columns. + */ + public function add_product_columns( array $columns ): array { + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + $new_columns[ $key ] = $value; + + if ( 'product_type' === $key ) { + $new_columns['fedistream_linked'] = __( 'FediStream', 'wp-fedistream' ); + } + } + + return $new_columns; + } + + /** + * Render product columns. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public function render_product_columns( string $column, int $post_id ): void { + if ( 'fedistream_linked' !== $column ) { + return; + } + + $product_type = \WC_Product_Factory::get_product_type( $post_id ); + + if ( 'fedistream_album' === $product_type ) { + $album_id = get_post_meta( $post_id, '_fedistream_linked_album', true ); + if ( $album_id ) { + $album = get_post( $album_id ); + if ( $album ) { + echo '' . esc_html( $album->post_title ) . ''; + } + } else { + echo ''; + } + } elseif ( 'fedistream_track' === $product_type ) { + $track_id = get_post_meta( $post_id, '_fedistream_linked_track', true ); + if ( $track_id ) { + $track = get_post( $track_id ); + if ( $track ) { + echo '' . esc_html( $track->post_title ) . ''; + } + } else { + echo ''; + } + } else { + echo ''; + } + } +} diff --git a/includes/WooCommerce/StreamingAccess.php b/includes/WooCommerce/StreamingAccess.php new file mode 100644 index 0000000..f004c1b --- /dev/null +++ b/includes/WooCommerce/StreamingAccess.php @@ -0,0 +1,416 @@ +track_requires_purchase( $track_id ); + + if ( ! $requires_purchase ) { + return true; + } + + // Guest users can't stream paid content. + if ( ! $user_id ) { + return false; + } + + // Check if user has purchased this track. + if ( Integration::user_has_purchased( $user_id, 'track', $track_id ) ) { + return true; + } + + // Check if user has purchased the album containing this track. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + if ( $album_id && Integration::user_has_purchased( $user_id, 'album', $album_id ) ) { + return true; + } + + return false; + } + + /** + * Check if a track requires purchase to stream. + * + * @param int $track_id Track ID. + * @return bool + */ + private function track_requires_purchase( int $track_id ): bool { + // Find WooCommerce products linked to this track. + $products = $this->get_products_for_track( $track_id ); + + if ( empty( $products ) ) { + // Also check album products. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + if ( $album_id ) { + $products = $this->get_products_for_album( $album_id ); + } + } + + if ( empty( $products ) ) { + return false; + } + + // Check if any product includes streaming. + foreach ( $products as $product ) { + $include_streaming = get_post_meta( $product->ID, '_fedistream_include_streaming', true ); + if ( 'yes' === $include_streaming ) { + return true; + } + } + + return false; + } + + /** + * Get WooCommerce products linked to a track. + * + * @param int $track_id Track ID. + * @return array + */ + private function get_products_for_track( int $track_id ): array { + return get_posts( + array( + 'post_type' => 'product', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_fedistream_linked_track', + 'value' => $track_id, + ), + ), + ) + ); + } + + /** + * Get WooCommerce products linked to an album. + * + * @param int $album_id Album ID. + * @return array + */ + private function get_products_for_album( int $album_id ): array { + return get_posts( + array( + 'post_type' => 'product', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_fedistream_linked_album', + 'value' => $album_id, + ), + ), + ) + ); + } + + /** + * Filter track data for AJAX responses. + * + * @param array $data Track data. + * @param int $track_id Track ID. + * @return array Modified track data. + */ + public function filter_track_data( array $data, int $track_id ): array { + $user_id = get_current_user_id(); + $can_stream = apply_filters( 'fedistream_can_stream_track', true, $track_id, $user_id ); + + if ( ! $can_stream ) { + // Return preview URL instead of full audio. + $data['audio_url'] = $this->get_preview_url( $track_id ); + $data['preview_only'] = true; + $data['purchase_url'] = $this->get_purchase_url( $track_id ); + } else { + $data['preview_only'] = false; + } + + // Add purchase status. + $data['user_has_purchased'] = $user_id && ( + Integration::user_has_purchased( $user_id, 'track', $track_id ) || + Integration::user_has_purchased( $user_id, 'album', get_post_meta( $track_id, '_fedistream_album_id', true ) ) + ); + + return $data; + } + + /** + * Get preview URL for a track. + * + * @param int $track_id Track ID. + * @return string + */ + private function get_preview_url( int $track_id ): string { + return add_query_arg( + array( + 'fedistream_preview' => $track_id, + '_' => time(), // Cache buster. + ), + home_url( '/' ) + ); + } + + /** + * Get purchase URL for a track. + * + * @param int $track_id Track ID. + * @return string + */ + private function get_purchase_url( int $track_id ): string { + // Find product for this track. + $products = $this->get_products_for_track( $track_id ); + + if ( ! empty( $products ) ) { + return get_permalink( $products[0]->ID ); + } + + // Check for album product. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + if ( $album_id ) { + $album_products = $this->get_products_for_album( $album_id ); + if ( ! empty( $album_products ) ) { + return get_permalink( $album_products[0]->ID ); + } + } + + return ''; + } + + /** + * Handle preview request. + * + * @return void + */ + public function handle_preview_request(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['fedistream_preview'] ) ) { + return; + } + + $track_id = absint( $_GET['fedistream_preview'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $track_id ) { + wp_die( esc_html__( 'Invalid track.', 'wp-fedistream' ) ); + } + + $track = get_post( $track_id ); + if ( ! $track || 'fedistream_track' !== $track->post_type ) { + wp_die( esc_html__( 'Track not found.', 'wp-fedistream' ) ); + } + + // Get audio file. + $audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true ); + if ( ! $audio_id ) { + wp_die( esc_html__( 'No audio file available.', 'wp-fedistream' ) ); + } + + $file_path = get_attached_file( $audio_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + wp_die( esc_html__( 'File not found.', 'wp-fedistream' ) ); + } + + // Get preview settings. + $preview_start = (int) get_post_meta( $track_id, '_fedistream_preview_start', true ); + $preview_duration = (int) get_post_meta( $track_id, '_fedistream_preview_duration', true ); + + // Default 30 seconds preview. + if ( ! $preview_duration ) { + $preview_duration = 30; + } + + // For now, serve the full file with range headers. + // In production, you'd use FFmpeg to extract a preview clip. + $this->serve_audio_preview( $file_path, $preview_start, $preview_duration ); + } + + /** + * Serve audio preview with limited duration. + * + * @param string $file_path File path. + * @param int $start_seconds Start time in seconds. + * @param int $duration_seconds Duration in seconds. + * @return void + */ + private function serve_audio_preview( string $file_path, int $start_seconds, int $duration_seconds ): void { + $file_size = filesize( $file_path ); + $mime_type = wp_check_filetype( $file_path )['type'] ?: 'audio/mpeg'; + + // Calculate byte range for preview (rough approximation). + // This is a simplified approach; proper implementation would use FFmpeg. + $duration_total = (int) get_post_meta( $this->get_track_id_from_path( $file_path ), '_fedistream_duration', true ); + + if ( $duration_total > 0 ) { + $bytes_per_second = $file_size / $duration_total; + $start_byte = (int) ( $start_seconds * $bytes_per_second ); + $end_byte = (int) min( ( $start_seconds + $duration_seconds ) * $bytes_per_second, $file_size - 1 ); + } else { + // Serve first 30% of file as fallback. + $start_byte = 0; + $end_byte = (int) ( $file_size * 0.3 ); + } + + // Clean output buffer. + while ( ob_get_level() ) { + ob_end_clean(); + } + + // Set headers for partial content. + header( 'HTTP/1.1 206 Partial Content' ); + header( 'Content-Type: ' . $mime_type ); + header( 'Accept-Ranges: bytes' ); + header( 'Content-Length: ' . ( $end_byte - $start_byte + 1 ) ); + header( "Content-Range: bytes {$start_byte}-{$end_byte}/{$file_size}" ); + header( 'Content-Disposition: inline' ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + + // Serve partial content. + $fp = fopen( $file_path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + if ( $fp ) { + fseek( $fp, $start_byte ); + echo fread( $fp, $end_byte - $start_byte + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.Security.EscapeOutput.OutputNotEscaped + fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + } + + exit; + } + + /** + * Get track ID from file path (for cached lookups). + * + * @param string $file_path File path. + * @return int Track ID or 0. + */ + private function get_track_id_from_path( string $file_path ): int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $attachment_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE guid LIKE %s", + '%' . $wpdb->esc_like( basename( $file_path ) ) + ) + ); + + if ( ! $attachment_id ) { + return 0; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $track_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_fedistream_audio_file' AND meta_value = %d", + $attachment_id + ) + ); + + return (int) $track_id; + } + + /** + * Add purchase button after track player. + * + * @param int $track_id Track ID. + * @return void + */ + public function add_purchase_button( int $track_id ): void { + if ( ! get_option( 'wp_fedistream_enable_woocommerce', 0 ) ) { + return; + } + + $user_id = get_current_user_id(); + + // Check if already purchased. + if ( $user_id ) { + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + + if ( Integration::user_has_purchased( $user_id, 'track', $track_id ) ) { + echo '

' . esc_html__( 'You own this track.', 'wp-fedistream' ) . '

'; + return; + } + + if ( $album_id && Integration::user_has_purchased( $user_id, 'album', $album_id ) ) { + echo '

' . esc_html__( 'You own this album.', 'wp-fedistream' ) . '

'; + return; + } + } + + // Find product for this track. + $products = $this->get_products_for_track( $track_id ); + + if ( ! empty( $products ) ) { + $product = wc_get_product( $products[0]->ID ); + if ( $product ) { + echo ''; + } + } + + // Also show album option if available. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + if ( $album_id ) { + $album_products = $this->get_products_for_album( $album_id ); + if ( ! empty( $album_products ) ) { + $album_product = wc_get_product( $album_products[0]->ID ); + $album = get_post( $album_id ); + if ( $album_product && $album ) { + echo ''; + } + } + } + } +} diff --git a/includes/WooCommerce/TrackProduct.php b/includes/WooCommerce/TrackProduct.php new file mode 100644 index 0000000..b85edbf --- /dev/null +++ b/includes/WooCommerce/TrackProduct.php @@ -0,0 +1,520 @@ +get_meta( '_fedistream_linked_track', true ); + } + + /** + * Get the linked track post. + * + * @return \WP_Post|null + */ + public function get_linked_track(): ?\WP_Post { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return null; + } + + $track = get_post( $track_id ); + + if ( ! $track || 'fedistream_track' !== $track->post_type ) { + return null; + } + + return $track; + } + + /** + * Get the pricing type. + * + * @return string fixed, pwyw, or nyp + */ + public function get_pricing_type(): string { + return $this->get_meta( '_fedistream_pricing_type', true ) ?: 'fixed'; + } + + /** + * Get minimum price for PWYW. + * + * @return float + */ + public function get_min_price(): float { + return (float) $this->get_meta( '_fedistream_min_price', true ); + } + + /** + * Get suggested price for PWYW. + * + * @return float + */ + public function get_suggested_price(): float { + return (float) $this->get_meta( '_fedistream_suggested_price', true ); + } + + /** + * Check if streaming is included. + * + * @return bool + */ + public function includes_streaming(): bool { + return 'yes' === $this->get_meta( '_fedistream_include_streaming', true ); + } + + /** + * Get available download formats. + * + * @return array + */ + public function get_available_formats(): array { + $formats = $this->get_meta( '_fedistream_available_formats', true ); + + return is_array( $formats ) ? $formats : array( 'mp3' ); + } + + /** + * Get track duration in seconds. + * + * @return int + */ + public function get_duration(): int { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return 0; + } + + return (int) get_post_meta( $track_id, '_fedistream_duration', true ); + } + + /** + * Get formatted duration. + * + * @return string + */ + public function get_formatted_duration(): string { + $seconds = $this->get_duration(); + + if ( ! $seconds ) { + return ''; + } + + $mins = floor( $seconds / 60 ); + $secs = $seconds % 60; + + return sprintf( '%d:%02d', $mins, $secs ); + } + + /** + * Get artist name(s). + * + * @return string + */ + public function get_artist_name(): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + $artist_ids = get_post_meta( $track_id, '_fedistream_artist_ids', true ); + + if ( ! is_array( $artist_ids ) || empty( $artist_ids ) ) { + // Fall back to album artist. + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + $artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0; + + if ( $artist_id ) { + $artist = get_post( $artist_id ); + return $artist ? $artist->post_title : ''; + } + + return ''; + } + + $names = array(); + foreach ( $artist_ids as $artist_id ) { + $artist = get_post( $artist_id ); + if ( $artist ) { + $names[] = $artist->post_title; + } + } + + return implode( ', ', $names ); + } + + /** + * Get album name. + * + * @return string + */ + public function get_album_name(): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + + if ( ! $album_id ) { + return ''; + } + + $album = get_post( $album_id ); + + return $album ? $album->post_title : ''; + } + + /** + * Get track artwork URL. + * + * Falls back to album artwork if track has none. + * + * @param string $size Image size. + * @return string + */ + public function get_track_artwork( string $size = 'medium' ): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + // Try track thumbnail first. + $thumbnail_id = get_post_thumbnail_id( $track_id ); + + // Fall back to album artwork. + if ( ! $thumbnail_id ) { + $album_id = get_post_meta( $track_id, '_fedistream_album_id', true ); + $thumbnail_id = $album_id ? get_post_thumbnail_id( $album_id ) : 0; + } + + if ( ! $thumbnail_id ) { + return ''; + } + + $image = wp_get_attachment_image_url( $thumbnail_id, $size ); + + return $image ?: ''; + } + + /** + * Get audio file URL. + * + * @return string + */ + public function get_audio_url(): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + $audio_id = get_post_meta( $track_id, '_fedistream_audio_file', true ); + + if ( ! $audio_id ) { + return ''; + } + + return wp_get_attachment_url( $audio_id ) ?: ''; + } + + /** + * Check if track is explicit. + * + * @return bool + */ + public function is_explicit(): bool { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return false; + } + + return (bool) get_post_meta( $track_id, '_fedistream_explicit', true ); + } + + /** + * Get track BPM. + * + * @return int + */ + public function get_bpm(): int { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return 0; + } + + return (int) get_post_meta( $track_id, '_fedistream_bpm', true ); + } + + /** + * Get track musical key. + * + * @return string + */ + public function get_musical_key(): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + return get_post_meta( $track_id, '_fedistream_key', true ) ?: ''; + } + + /** + * Get ISRC code. + * + * @return string + */ + public function get_isrc(): string { + $track_id = $this->get_linked_track_id(); + + if ( ! $track_id ) { + return ''; + } + + return get_post_meta( $track_id, '_fedistream_isrc', true ) ?: ''; + } + + /** + * Get downloads for this product. + * + * Generates downloadable files based on available formats. + * + * @param string $context View or edit context. + * @return array + */ + public function get_downloads( $context = 'view' ): array { + $downloads = parent::get_downloads( $context ); + + // If no manual downloads set, generate from linked track. + if ( empty( $downloads ) && $this->get_linked_track_id() ) { + $downloads = $this->generate_track_downloads(); + } + + return $downloads; + } + + /** + * Generate download files from linked track. + * + * @return array + */ + private function generate_track_downloads(): array { + $downloads = array(); + $track = $this->get_linked_track(); + $formats = $this->get_available_formats(); + + if ( ! $track ) { + return $downloads; + } + + // For each format, create a download entry. + foreach ( $formats as $format ) { + $format_label = strtoupper( $format ); + $download_id = 'track-' . $track->ID . '-' . $format; + + $downloads[ $download_id ] = array( + 'id' => $download_id, + 'name' => sprintf( + /* translators: 1: Track name, 2: Format name */ + __( '%1$s (%2$s)', 'wp-fedistream' ), + $track->post_title, + $format_label + ), + 'file' => add_query_arg( + array( + 'fedistream_download' => 'track', + 'track_id' => $track->ID, + 'format' => $format, + ), + home_url( '/' ) + ), + ); + } + + return $downloads; + } + + /** + * Check if purchasable. + * + * @return bool + */ + public function is_purchasable(): bool { + // Must have a linked track. + if ( ! $this->get_linked_track_id() ) { + return false; + } + + // Check price for fixed pricing. + if ( 'fixed' === $this->get_pricing_type() ) { + return $this->get_price() !== '' && $this->get_price() >= 0; + } + + // PWYW and NYP are always purchasable. + return true; + } + + /** + * Get price HTML. + * + * @param string $price Price HTML. + * @return string + */ + public function get_price_html( $price = '' ): string { + $pricing_type = $this->get_pricing_type(); + + if ( 'nyp' === $pricing_type ) { + return '' . esc_html__( 'Name Your Price', 'wp-fedistream' ) . ''; + } + + if ( 'pwyw' === $pricing_type ) { + $min_price = $this->get_min_price(); + $suggested = $this->get_suggested_price(); + + $html = ''; + + if ( $min_price > 0 ) { + $html .= sprintf( + /* translators: %s: Minimum price */ + esc_html__( 'From %s', 'wp-fedistream' ), + wc_price( $min_price ) + ); + } else { + $html .= esc_html__( 'Pay What You Want', 'wp-fedistream' ); + } + + if ( $suggested > 0 ) { + $html .= ' '; + $html .= sprintf( + /* translators: %s: Suggested price */ + esc_html__( '(Suggested: %s)', 'wp-fedistream' ), + wc_price( $suggested ) + ); + $html .= ''; + } + + $html .= ''; + + return $html; + } + + return parent::get_price_html( $price ); + } + + /** + * Add to cart validation for PWYW products. + * + * @param bool $passed Validation passed. + * @param int $product_id Product ID. + * @param int $quantity Quantity. + * @return bool + */ + public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity ): bool { + $product = wc_get_product( $product_id ); + + if ( ! $product || 'fedistream_track' !== $product->get_type() ) { + return $passed; + } + + $pricing_type = $product->get_pricing_type(); + + if ( 'pwyw' === $pricing_type || 'nyp' === $pricing_type ) { + $custom_price = isset( $_POST['fedistream_custom_price'] ) ? // phpcs:ignore WordPress.Security.NonceVerification.Missing + wc_format_decimal( sanitize_text_field( wp_unslash( $_POST['fedistream_custom_price'] ) ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Missing + 0; + + $min_price = $product->get_min_price(); + + if ( 'pwyw' === $pricing_type && $custom_price < $min_price ) { + wc_add_notice( + sprintf( + /* translators: %s: Minimum price */ + __( 'Please enter at least %s', 'wp-fedistream' ), + wc_price( $min_price ) + ), + 'error' + ); + return false; + } + + WC()->session->set( 'fedistream_custom_price_' . $product_id, $custom_price ); + } + + return $passed; + } +} diff --git a/includes/index.php b/includes/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/includes/index.php @@ -0,0 +1 @@ +\n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: wp-fedistream.php:93 +msgid "WP FediStream requires PHP version %1$s or higher. You are running PHP %2$s." +msgstr "" + +#: wp-fedistream.php:105 +msgid "WP FediStream requires WordPress version %1$s or higher. You are running WordPress %2$s." +msgstr "" + +#: wp-fedistream.php:114 +msgid "WP FediStream requires Composer dependencies to be installed. Please run \"composer install\" in the plugin directory." +msgstr "" + +#: wp-fedistream.php:127 +msgid "WP FediStream requires PHP version %s or higher." +msgstr "" diff --git a/templates/archive/album.twig b/templates/archive/album.twig new file mode 100644 index 0000000..4d75f41 --- /dev/null +++ b/templates/archive/album.twig @@ -0,0 +1,27 @@ +{# Album archive template #} +
+
+

{{ __('Albums', 'wp-fedistream') }}

+ {% if archive_description %} +
{{ archive_description }}
+ {% endif %} +
+ + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-album.twig' with { post: post } %} + {% endfor %} +
+ + {% if pagination %} + + {% endif %} + {% else %} +
+

{{ __('No albums found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/archive/artist.twig b/templates/archive/artist.twig new file mode 100644 index 0000000..4a77c38 --- /dev/null +++ b/templates/archive/artist.twig @@ -0,0 +1,27 @@ +{# Artist archive template #} +
+
+

{{ __('Artists', 'wp-fedistream') }}

+ {% if archive_description %} +
{{ archive_description }}
+ {% endif %} +
+ + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-artist.twig' with { post: post } %} + {% endfor %} +
+ + {% if pagination %} + + {% endif %} + {% else %} +
+

{{ __('No artists found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/archive/playlist.twig b/templates/archive/playlist.twig new file mode 100644 index 0000000..19ab615 --- /dev/null +++ b/templates/archive/playlist.twig @@ -0,0 +1,27 @@ +{# Playlist archive template #} +
+
+

{{ __('Playlists', 'wp-fedistream') }}

+ {% if archive_description %} +
{{ archive_description }}
+ {% endif %} +
+ + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-playlist.twig' with { post: post } %} + {% endfor %} +
+ + {% if pagination %} + + {% endif %} + {% else %} +
+

{{ __('No playlists found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/archive/taxonomy.twig b/templates/archive/taxonomy.twig new file mode 100644 index 0000000..f92d2b0 --- /dev/null +++ b/templates/archive/taxonomy.twig @@ -0,0 +1,37 @@ +{# Taxonomy archive template (Genre, Mood) #} +
+
+

+ {% if taxonomy_name %}{{ taxonomy_name }}: {% endif %}{{ term.name }} +

+ {% if term.description %} +
{{ term.description }}
+ {% endif %} +
+ + {% if posts is not empty %} +
+ {% for post in posts %} + {% if post.post_type == 'fedistream_artist' %} + {% include 'partials/card-artist.twig' with { post: post } %} + {% elseif post.post_type == 'fedistream_album' %} + {% include 'partials/card-album.twig' with { post: post } %} + {% elseif post.post_type == 'fedistream_track' %} + {% include 'partials/card-track.twig' with { post: post } %} + {% elseif post.post_type == 'fedistream_playlist' %} + {% include 'partials/card-playlist.twig' with { post: post } %} + {% endif %} + {% endfor %} +
+ + {% if pagination %} + + {% endif %} + {% else %} +
+

{{ __('No content found in this category.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/archive/track.twig b/templates/archive/track.twig new file mode 100644 index 0000000..f849fb3 --- /dev/null +++ b/templates/archive/track.twig @@ -0,0 +1,27 @@ +{# Track archive template #} +
+
+

{{ __('Tracks', 'wp-fedistream') }}

+ {% if archive_description %} +
{{ archive_description }}
+ {% endif %} +
+ + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-track.twig' with { post: post } %} + {% endfor %} +
+ + {% if pagination %} + + {% endif %} + {% else %} +
+

{{ __('No tracks found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/index.php b/templates/index.php new file mode 100644 index 0000000..49d255d --- /dev/null +++ b/templates/index.php @@ -0,0 +1 @@ + + +
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} +
+
+

{{ post.title }}

+ {% if post.artist_name %} +

{{ post.artist_name }}

+ {% endif %} +

+ {{ post.album_type_label }} + {% if post.release_year %} + {{ post.release_year }} + {% endif %} +

+ {% if post.total_tracks > 0 %} +

+ {{ post.total_tracks }} {{ post.total_tracks == 1 ? 'track' : 'tracks' }} +

+ {% endif %} +
+
+ diff --git a/templates/partials/card-artist.twig b/templates/partials/card-artist.twig new file mode 100644 index 0000000..06282fc --- /dev/null +++ b/templates/partials/card-artist.twig @@ -0,0 +1,28 @@ +{# Artist card partial #} + diff --git a/templates/partials/card-playlist.twig b/templates/partials/card-playlist.twig new file mode 100644 index 0000000..5303252 --- /dev/null +++ b/templates/partials/card-playlist.twig @@ -0,0 +1,29 @@ +{# Playlist card partial #} + diff --git a/templates/partials/card-track.twig b/templates/partials/card-track.twig new file mode 100644 index 0000000..8f72457 --- /dev/null +++ b/templates/partials/card-track.twig @@ -0,0 +1,37 @@ +{# Track card partial #} + diff --git a/templates/shortcodes/album.twig b/templates/shortcodes/album.twig new file mode 100644 index 0000000..2467660 --- /dev/null +++ b/templates/shortcodes/album.twig @@ -0,0 +1,64 @@ +{# Album shortcode template #} +
+
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} +
+
+ {% if post.album_type %} + {{ post.album_type }} + {% endif %} +

+ {{ post.title }} +

+ {% if post.artist %} +

+ {{ post.artist }} +

+ {% endif %} +
+ {% if post.release_date %} + {{ post.release_date }} + {% endif %} + {% if post.track_count %} + {{ post.track_count }} {{ post.track_count == 1 ? 'track' : 'tracks' }} + {% endif %} +
+
+ +
+
+
+ + {% if show_tracks and post.tracks is not empty %} +
+
+ {% for track in post.tracks %} +
+ {{ track.track_number|default(loop.index) }} + + {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+
+ {% endif %} +
+
diff --git a/templates/shortcodes/artist.twig b/templates/shortcodes/artist.twig new file mode 100644 index 0000000..31c4305 --- /dev/null +++ b/templates/shortcodes/artist.twig @@ -0,0 +1,65 @@ +{# Artist shortcode template #} +
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} +
+

+ {{ post.title }} +

+ {% if post.artist_type %} + {{ post.artist_type }} + {% endif %} + {% if post.genres is not empty %} +
+ {% for genre in post.genres %} + {{ genre.name }} + {% endfor %} +
+ {% endif %} +
+
+ + {% if layout == 'full' and post.content %} +
+ {{ post.content|raw }} +
+ {% endif %} + + {% if show_albums and post.albums is not empty %} +
+

{{ __('Albums', 'wp-fedistream') }}

+
+ {% for album in post.albums|slice(0, 4) %} + {% include 'partials/card-album.twig' with { post: album } %} + {% endfor %} +
+
+ {% endif %} + + {% if show_tracks and post.tracks is not empty %} +
+

{{ __('Popular Tracks', 'wp-fedistream') }}

+
+ {% for track in post.tracks|slice(0, 5) %} +
+ {{ loop.index }} + + {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+
diff --git a/templates/shortcodes/artists-grid.twig b/templates/shortcodes/artists-grid.twig new file mode 100644 index 0000000..b8abf11 --- /dev/null +++ b/templates/shortcodes/artists-grid.twig @@ -0,0 +1,18 @@ +{# Artists grid shortcode template #} +
+ {% if title %} +

{{ title }}

+ {% endif %} + + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-artist.twig' with { post: post } %} + {% endfor %} +
+ {% else %} +
+

{{ __('No artists found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/shortcodes/player.twig b/templates/shortcodes/player.twig new file mode 100644 index 0000000..6f1cad4 --- /dev/null +++ b/templates/shortcodes/player.twig @@ -0,0 +1,108 @@ +{# Audio player shortcode template #} +
+ {% if tracks|length == 1 %} + {# Single track player #} + {% set track = tracks[0] %} +
+
+ {% if track.thumbnail %} + {{ track.title|e('html_attr') }} + {% endif %} +
+ {{ track.title }} + {% if track.artist %} + {{ track.artist }} + {% endif %} +
+
+
+ +
+
+ 0:00 +
+
+ +
+ {{ track.duration_formatted|default('0:00') }} +
+
+ + +
+
+ {% else %} + {# Multi-track player (playlist/album) #} +
+
+
+ +
+
+ + +
+
+
+ + + +
+
+ 0:00 +
+
+ +
+ 0:00 +
+
+ + +
+ + +
+
+
+ + {# Track list #} +
+
+ {% for track in tracks %} +
+ {{ loop.index }} + {% if track.thumbnail %} + + {% endif %} +
+ {{ track.title }} + {{ track.artist }} +
+ {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
diff --git a/templates/shortcodes/playlist.twig b/templates/shortcodes/playlist.twig new file mode 100644 index 0000000..9fe5241 --- /dev/null +++ b/templates/shortcodes/playlist.twig @@ -0,0 +1,71 @@ +{# Playlist shortcode template #} +
+
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} + {% if post.visibility == 'private' %} + + + + {% endif %} +
+
+ {{ __('Playlist', 'wp-fedistream') }} +

+ {{ post.title }} +

+ {% if post.author %} +

+ {{ __('by', 'wp-fedistream') }} {{ post.author }} +

+ {% endif %} +
+ {% if post.track_count %} + {{ post.track_count }} {{ post.track_count == 1 ? 'track' : 'tracks' }} + {% endif %} + {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} +
+
+ +
+
+
+ + {% if show_tracks and post.tracks is not empty %} +
+
+ {% for track in post.tracks %} +
+ {{ loop.index }} + {% if track.thumbnail %} + + {% endif %} +
+ {{ track.title }} + {{ track.artist }} +
+ {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+
+ {% endif %} +
+
diff --git a/templates/shortcodes/releases-grid.twig b/templates/shortcodes/releases-grid.twig new file mode 100644 index 0000000..3accf3d --- /dev/null +++ b/templates/shortcodes/releases-grid.twig @@ -0,0 +1,18 @@ +{# Latest releases grid shortcode template #} +
+ {% if title %} +

{{ title }}

+ {% endif %} + + {% if posts is not empty %} +
+ {% for post in posts %} + {% include 'partials/card-album.twig' with { post: post } %} + {% endfor %} +
+ {% else %} +
+

{{ __('No releases found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/shortcodes/track.twig b/templates/shortcodes/track.twig new file mode 100644 index 0000000..e04cd90 --- /dev/null +++ b/templates/shortcodes/track.twig @@ -0,0 +1,64 @@ +{# Track shortcode template #} +
+
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} + {% if show_player %} + + {% endif %} +
+
+

+ {{ post.title }} +

+ {% if post.artists is not empty %} +

+ {% for artist in post.artists %} + {{ artist.name }}{% if not loop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} + {% if post.album %} +

+ {{ __('From', 'wp-fedistream') }} {{ post.album }} +

+ {% endif %} +
+ {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} + {% if post.play_count %} + {{ post.play_count }} {{ post.play_count == 1 ? 'play' : 'plays' }} + {% endif %} +
+
+
+ + {% if show_player and post.audio_url %} +
+
+ +
+
+
+ +
+
+ {{ post.duration_formatted|default('0:00') }} +
+
+ {% endif %} +
+
diff --git a/templates/shortcodes/tracks-list.twig b/templates/shortcodes/tracks-list.twig new file mode 100644 index 0000000..659da4e --- /dev/null +++ b/templates/shortcodes/tracks-list.twig @@ -0,0 +1,48 @@ +{# Popular tracks list shortcode template #} +
+ {% if title %} +

{{ title }}

+ {% endif %} + + {% if posts is not empty %} +
+ {% for post in posts %} +
+ {{ loop.index }} + {% if post.thumbnail %} + + {% else %} +
+ +
+ {% endif %} +
+ {{ post.title }} + + {% if post.artists is iterable %} + {% for artist in post.artists %} + {{ artist.name }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + {{ post.artist }} + {% endif %} + +
+ {% if post.play_count %} + {{ post.play_count|number_format }} + {% endif %} + {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+ {% else %} +
+

{{ __('No tracks found.', 'wp-fedistream') }}

+
+ {% endif %} +
diff --git a/templates/single/album.twig b/templates/single/album.twig new file mode 100644 index 0000000..85007d6 --- /dev/null +++ b/templates/single/album.twig @@ -0,0 +1,105 @@ +{# Single album template #} +
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} +
+
+ {{ post.album_type|default('Album') }} +

{{ post.title }}

+ {% if post.artist %} +

+ {{ post.artist }} +

+ {% endif %} +
+ {% if post.release_date %} + {{ post.release_date }} + {% endif %} + {% if post.track_count %} + {{ post.track_count }} {{ post.track_count == 1 ? __('track', 'wp-fedistream') : __('tracks', 'wp-fedistream') }} + {% endif %} + {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} +
+ {% if post.genres is not empty %} +
+ {% for genre in post.genres %} + {{ genre.name }} + {% endfor %} +
+ {% endif %} +
+ + +
+
+
+ + {% if post.tracks is not empty %} +
+
+ {% for track in post.tracks %} +
+ {{ track.track_number|default(loop.index) }} +
+ {{ track.title }} + {% if track.featured_artists %} + {{ __('feat.', 'wp-fedistream') }} {{ track.featured_artists }} + {% endif %} +
+ {% if track.explicit %} + E + {% endif %} + {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+
+ {% endif %} + + {% if post.content %} +
+

{{ __('About This Album', 'wp-fedistream') }}

+
+ {{ post.content|raw }} +
+
+ {% endif %} + + {% if post.credits %} +
+

{{ __('Credits', 'wp-fedistream') }}

+
+ {{ post.credits|raw }} +
+
+ {% endif %} + + {% if post.license %} +
+

+ {{ __('License:', 'wp-fedistream') }} + {{ post.license.name }} +

+
+ {% endif %} +
diff --git a/templates/single/artist.twig b/templates/single/artist.twig new file mode 100644 index 0000000..e958c0f --- /dev/null +++ b/templates/single/artist.twig @@ -0,0 +1,88 @@ +{# Single artist template #} +
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} +
+
+

{{ post.title }}

+ {% if post.artist_type %} +

{{ post.artist_type }}

+ {% endif %} + {% if post.genres is not empty %} +
+ {% for genre in post.genres %} + {{ genre.name }} + {% endfor %} +
+ {% endif %} +
+
+ + {% if post.content %} +
+

{{ __('About', 'wp-fedistream') }}

+
+ {{ post.content|raw }} +
+
+ {% endif %} + + {% if post.social_links is not empty %} + + {% endif %} + + {% if post.albums is not empty %} +
+

{{ __('Discography', 'wp-fedistream') }}

+
+ {% for album in post.albums %} + {% include 'partials/card-album.twig' with { post: album } %} + {% endfor %} +
+
+ {% endif %} + + {% if post.tracks is not empty %} +
+

{{ __('Popular Tracks', 'wp-fedistream') }}

+
+ {% for track in post.tracks %} +
+ {{ loop.index }} + {% if track.thumbnail %} + + {% endif %} +
+ {{ track.title }} + {% if track.album %} + {{ track.album }} + {% endif %} +
+ {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+
+ {% endif %} +
diff --git a/templates/single/playlist.twig b/templates/single/playlist.twig new file mode 100644 index 0000000..3ce4d6d --- /dev/null +++ b/templates/single/playlist.twig @@ -0,0 +1,107 @@ +{# Single playlist template #} +
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} + {% if post.visibility == 'private' %} + + + {{ __('Private', 'wp-fedistream') }} + + {% endif %} +
+
+ {{ __('Playlist', 'wp-fedistream') }} +

{{ post.title }}

+ {% if post.author %} +

+ {{ __('Created by', 'wp-fedistream') }} {{ post.author }} +

+ {% endif %} +
+ {% if post.track_count %} + {{ post.track_count }} {{ post.track_count == 1 ? __('track', 'wp-fedistream') : __('tracks', 'wp-fedistream') }} + {% endif %} + {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} + {% if post.updated_date %} + {{ __('Updated', 'wp-fedistream') }} {{ post.updated_date }} + {% endif %} +
+ {% if post.moods is not empty %} +
+ {% for mood in post.moods %} + {{ mood.name }} + {% endfor %} +
+ {% endif %} +
+ + +
+
+
+ + {% if post.content %} +
+
+ {{ post.content|raw }} +
+
+ {% endif %} + + {% if post.tracks is not empty %} +
+
+ {% for track in post.tracks %} +
+ {{ loop.index }} + {% if track.thumbnail %} + + {% endif %} +
+ {{ track.title }} + + {% if track.artists is iterable %} + {% for artist in track.artists %} + {{ artist.name }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + {{ track.artist }} + {% endif %} + +
+ {% if track.explicit %} + E + {% endif %} + {% if track.duration_formatted %} + {{ track.duration_formatted }} + {% endif %} + +
+ {% endfor %} +
+
+ {% else %} +
+
+

{{ __('This playlist is empty.', 'wp-fedistream') }}

+
+
+ {% endif %} +
diff --git a/templates/single/track.twig b/templates/single/track.twig new file mode 100644 index 0000000..102fe32 --- /dev/null +++ b/templates/single/track.twig @@ -0,0 +1,120 @@ +{# Single track template #} +
+
+
+ {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} +
+ +
+ {% endif %} + +
+
+

{{ post.title }}

+ {% if post.artists is not empty %} +

+ {% for artist in post.artists %} + {{ artist.name }}{% if not loop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} + {% if post.album %} +

+ {{ __('From', 'wp-fedistream') }} {{ post.album }} +

+ {% endif %} +
+ {% if post.duration_formatted %} + {{ post.duration_formatted }} + {% endif %} + {% if post.play_count %} + {{ post.play_count }} {{ post.play_count == 1 ? __('play', 'wp-fedistream') : __('plays', 'wp-fedistream') }} + {% endif %} + {% if post.explicit %} + {{ __('Explicit', 'wp-fedistream') }} + {% endif %} +
+ {% if post.genres is not empty %} +
+ {% for genre in post.genres %} + {{ genre.name }} + {% endfor %} +
+ {% endif %} + {% if post.moods is not empty %} +
+ {% for mood in post.moods %} + {{ mood.name }} + {% endfor %} +
+ {% endif %} +
+
+ + {% if post.audio_url %} +
+
+
+ +
+
+ 0:00 +
+
+ +
+ {{ post.duration_formatted|default('0:00') }} +
+
+ + +
+
+
+ {% endif %} + + {% if post.content %} +
+

{{ __('About This Track', 'wp-fedistream') }}

+
+ {{ post.content|raw }} +
+
+ {% endif %} + + {% if post.lyrics %} +
+

{{ __('Lyrics', 'wp-fedistream') }}

+
+ {{ post.lyrics|nl2br }} +
+
+ {% endif %} + + {% if post.credits %} +
+

{{ __('Credits', 'wp-fedistream') }}

+
+ {{ post.credits|raw }} +
+
+ {% endif %} + + {% if post.license %} +
+

+ {{ __('License:', 'wp-fedistream') }} + {{ post.license.name }} +

+
+ {% endif %} +
diff --git a/templates/widgets/featured-artist.twig b/templates/widgets/featured-artist.twig new file mode 100644 index 0000000..1fc1fa7 --- /dev/null +++ b/templates/widgets/featured-artist.twig @@ -0,0 +1,41 @@ +{# Featured Artist Widget Template #} +{% if post %} + +{% else %} +

{{ __('No artist selected.', 'wp-fedistream') }}

+{% endif %} diff --git a/templates/widgets/now-playing.twig b/templates/widgets/now-playing.twig new file mode 100644 index 0000000..473a60c --- /dev/null +++ b/templates/widgets/now-playing.twig @@ -0,0 +1,41 @@ +{# Now Playing Widget Template #} +
+
+ + + + {{ __('Nothing playing', 'wp-fedistream') }} +
+ +
diff --git a/templates/widgets/popular-tracks.twig b/templates/widgets/popular-tracks.twig new file mode 100644 index 0000000..c4afdaa --- /dev/null +++ b/templates/widgets/popular-tracks.twig @@ -0,0 +1,32 @@ +{# Popular Tracks Widget Template #} +{% if posts is not empty %} +
    + {% for post in posts %} +
  1. + + {% if post.thumbnail %} + {{ post.title|e('html_attr') }} + {% else %} + + + + {% endif %} + + {{ post.title }} + {% if post.artist %} + {{ post.artist }} + {% endif %} + + {% if post.play_count %} + {{ post.play_count|number_format }} + {% endif %} + + +
  2. + {% endfor %} +
+{% else %} +

{{ __('No tracks yet.', 'wp-fedistream') }}

+{% endif %} diff --git a/templates/widgets/recent-releases.twig b/templates/widgets/recent-releases.twig new file mode 100644 index 0000000..61c79f7 --- /dev/null +++ b/templates/widgets/recent-releases.twig @@ -0,0 +1,29 @@ +{# Recent Releases Widget Template #} +{% if posts is not empty %} + +{% else %} +

{{ __('No releases yet.', 'wp-fedistream') }}

+{% endif %} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..73dd8f7 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,18 @@ +

%s

', esc_html( $message ) ); +} + +/** + * Display WordPress version notice. + * + * @return void + */ +function wp_fedistream_wp_version_notice(): void { + $message = sprintf( + /* translators: 1: Required WordPress version, 2: Current WordPress version */ + __( 'WP FediStream requires WordPress version %1$s or higher. You are running WordPress %2$s.', 'wp-fedistream' ), + WP_FEDISTREAM_MIN_WP_VERSION, + get_bloginfo( 'version' ) + ); + printf( '

%s

', esc_html( $message ) ); +} + +/** + * Display autoloader notice. + * + * @return void + */ +function wp_fedistream_autoloader_notice(): void { + $message = __( 'WP FediStream requires Composer dependencies to be installed. Please run "composer install" in the plugin directory.', 'wp-fedistream' ); + printf( '

%s

', esc_html( $message ) ); +} + +/** + * Plugin activation hook. + * + * @return void + */ +function wp_fedistream_activate(): void { + // Check requirements before activation. + if ( version_compare( PHP_VERSION, WP_FEDISTREAM_MIN_PHP_VERSION, '<' ) ) { + deactivate_plugins( WP_FEDISTREAM_BASENAME ); + wp_die( + sprintf( + /* translators: %s: Required PHP version */ + esc_html__( 'WP FediStream requires PHP version %s or higher.', 'wp-fedistream' ), + WP_FEDISTREAM_MIN_PHP_VERSION + ) + ); + } + + $autoloader = WP_FEDISTREAM_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \WP_FediStream\Installer::activate(); + } +} + +/** + * Plugin deactivation hook. + * + * @return void + */ +function wp_fedistream_deactivate(): void { + $autoloader = WP_FEDISTREAM_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \WP_FediStream\Installer::deactivate(); + } +} + +/** + * Plugin uninstall hook. + * + * @return void + */ +function wp_fedistream_uninstall(): void { + $autoloader = WP_FEDISTREAM_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \WP_FediStream\Installer::uninstall(); + } +} + +// Register activation/deactivation hooks. +register_activation_hook( __FILE__, 'wp_fedistream_activate' ); +register_deactivation_hook( __FILE__, 'wp_fedistream_deactivate' ); + +// Initialize plugin after all plugins are loaded. +add_action( 'plugins_loaded', 'wp_fedistream_init' );