From 45d2dae94d0028edae6a01151e91bf09616f3c6d Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Wed, 1 Apr 2026 00:30:42 +0530 Subject: [PATCH 1/7] added sample data profile for english community radio station --- .../en_community_radio/categories.json | 22 ++ .../profiles/en_community_radio/genres.json | 307 ++++++++++++++++++ .../profiles/en_community_radio/groups.json | 22 ++ .../profiles/en_community_radio/manifest.json | 8 + .../en_community_radio/metadata_fields.json | 38 +++ .../en_community_radio/playlists.json | 26 ++ .../profiles/en_community_radio/schedule.json | 76 +++++ .../profiles/en_community_radio/settings.json | 13 + .../profiles/en_community_radio/users.json | 30 ++ 9 files changed, 542 insertions(+) create mode 100644 core/data/profiles/en_community_radio/categories.json create mode 100644 core/data/profiles/en_community_radio/genres.json create mode 100644 core/data/profiles/en_community_radio/groups.json create mode 100644 core/data/profiles/en_community_radio/manifest.json create mode 100644 core/data/profiles/en_community_radio/metadata_fields.json create mode 100644 core/data/profiles/en_community_radio/playlists.json create mode 100644 core/data/profiles/en_community_radio/schedule.json create mode 100644 core/data/profiles/en_community_radio/settings.json create mode 100644 core/data/profiles/en_community_radio/users.json diff --git a/core/data/profiles/en_community_radio/categories.json b/core/data/profiles/en_community_radio/categories.json new file mode 100644 index 00000000..f535ebdc --- /dev/null +++ b/core/data/profiles/en_community_radio/categories.json @@ -0,0 +1,22 @@ +[ + "Commercial Ad", + "Community Events", + "Documents", + "Images", + "Interviews", + "Lecture Talk or Discussion", + "Music", + "News", + "Pop Vox", + "Priority Broadcast", + "PSA Audio", + "PSA Image", + "PSA Video", + "SFX", + "Show Promotion", + "Show Sponsor", + "Shows Complete", + "Station ID", + "Video", + "VoiceTrack" +] diff --git a/core/data/profiles/en_community_radio/genres.json b/core/data/profiles/en_community_radio/genres.json new file mode 100644 index 00000000..d9774f9f --- /dev/null +++ b/core/data/profiles/en_community_radio/genres.json @@ -0,0 +1,307 @@ +{ + "Music": [ + {"name": "Blues", "description": "Blues"}, + {"name": "Classic Rock", "description": "Classic Rock"}, + {"name": "Country", "description": "Country"}, + {"name": "Dance", "description": "Dance"}, + {"name": "Disco", "description": "Disco"}, + {"name": "Funk", "description": "Funk"}, + {"name": "Grunge", "description": "Grunge"}, + {"name": "Hip Hop", "description": "Hip Hop"}, + {"name": "Jazz", "description": "Jazz"}, + {"name": "Metal", "description": "Metal"}, + {"name": "New Age", "description": "New Age"}, + {"name": "Oldies", "description": "Oldies"}, + {"name": "Other", "description": "Other"}, + {"name": "Pop", "description": "Pop"}, + {"name": "R&B", "description": "R&B"}, + {"name": "Rap", "description": "Rap"}, + {"name": "Reggae", "description": "Reggae"}, + {"name": "Rock", "description": "Rock"}, + {"name": "Techno", "description": "Techno"}, + {"name": "Industrial", "description": "Industrial"}, + {"name": "Alternative", "description": "Alternative"}, + {"name": "Ska", "description": "Ska"}, + {"name": "Death Metal", "description": "Death Metal"}, + {"name": "Pranks", "description": "Pranks"}, + {"name": "Soundtrack", "description": "Soundtrack"}, + {"name": "Euro-Techno", "description": "Euro-Techno"}, + {"name": "Ambient", "description": "Ambient"}, + {"name": "Trip Hop", "description": "Trip Hop"}, + {"name": "Vocal", "description": "Vocal"}, + {"name": "Jazz+Funk", "description": "Jazz+Funk"}, + {"name": "Fusion", "description": "Fusion"}, + {"name": "Trance", "description": "Trance"}, + {"name": "Classical", "description": "Classical"}, + {"name": "Instrumental", "description": "Instrumental"}, + {"name": "Acid", "description": "Acid"}, + {"name": "House", "description": "House"}, + {"name": "Game", "description": "Game"}, + {"name": "Sound Clip", "description": "Sound Clip"}, + {"name": "Gospel", "description": "Gospel"}, + {"name": "Noise", "description": "Noise"}, + {"name": "AlternRock", "description": "Alternative Rock"}, + {"name": "Bass", "description": "Bass"}, + {"name": "Soul", "description": "Soul"}, + {"name": "Punk", "description": "Punk"}, + {"name": "Space", "description": "Space"}, + {"name": "Meditative", "description": "Meditative"}, + {"name": "Instrumental Pop", "description": "Instrumental Pop"}, + {"name": "Instrumental Rock", "description": "Instrumental Rock"}, + {"name": "Ethnic", "description": "Ethnic"}, + {"name": "Gothic", "description": "Gothic"}, + {"name": "Darkwave", "description": "Darkwave"}, + {"name": "Techno-Industrial", "description": "Techno-Industrial"}, + {"name": "Electronic", "description": "Electronic"}, + {"name": "Pop-Folk", "description": "Pop-Folk"}, + {"name": "Eurodance", "description": "Eurodance"}, + {"name": "Dream", "description": "Dream"}, + {"name": "Southern Rock", "description": "Southern Rock"}, + {"name": "Comedy", "description": "Comedy"}, + {"name": "Cult", "description": "Cult"}, + {"name": "Gangsta", "description": "Gangsta"}, + {"name": "Top 40", "description": "Top 40"}, + {"name": "Christian Rap", "description": "Christian Rap"}, + {"name": "Pop/Funk", "description": "Pop/Funk"}, + {"name": "Jungle", "description": "Jungle"}, + {"name": "Native American", "description": "Native American"}, + {"name": "Cabaret", "description": "Cabaret"}, + {"name": "New Wave", "description": "New Wave"}, + {"name": "Psychedelic", "description": "Psychedelic"}, + {"name": "Rave", "description": "Rave"}, + {"name": "Showtunes", "description": "Showtunes"}, + {"name": "Trailer", "description": "Trailer"}, + {"name": "Lo-Fi", "description": "Lo-Fi"}, + {"name": "Tribal", "description": "Tribal"}, + {"name": "Acid Punk", "description": "Acid Punk"}, + {"name": "Acid Jazz", "description": "Acid Jazz"}, + {"name": "Polka", "description": "Polka"}, + {"name": "Retro", "description": "Retro"}, + {"name": "Musical", "description": "Musical"}, + {"name": "Rock & Roll", "description": "Rock & Roll"}, + {"name": "Hard Rock", "description": "Hard Rock"}, + {"name": "Folk", "description": "Folk"}, + {"name": "Folk-Rock", "description": "Folk-Rock"}, + {"name": "National Folk", "description": "National Folk"}, + {"name": "Swing", "description": "Swing"}, + {"name": "Fast Fusion", "description": "Fast Fusion"}, + {"name": "Bebop", "description": "Bebop"}, + {"name": "Latin", "description": "Latin"}, + {"name": "Revival", "description": "Revival"}, + {"name": "Celtic", "description": "Celtic"}, + {"name": "Bluegrass", "description": "Bluegrass"}, + {"name": "Avantgarde", "description": "Avantgarde"}, + {"name": "Gothic Rock", "description": "Gothic Rock"}, + {"name": "Progressive Rock", "description": "Progressive Rock"}, + {"name": "Psychedelic Rock", "description": "Psychedelic Rock"}, + {"name": "Symphonic Rock", "description": "Symphonic Rock"}, + {"name": "Slow Rock", "description": "Slow Rock"}, + {"name": "Big Band", "description": "Big Band"}, + {"name": "Chorus", "description": "Chorus"}, + {"name": "Easy Listening", "description": "Easy Listening"}, + {"name": "Acoustic", "description": "Acoustic"}, + {"name": "Humour", "description": "Humour"}, + {"name": "Speech", "description": "Speech"}, + {"name": "Chanson", "description": "Chanson"}, + {"name": "Opera", "description": "Opera"}, + {"name": "Chamber Music", "description": "Chamber Music"}, + {"name": "Sonata", "description": "Sonata"}, + {"name": "Symphony", "description": "Symphony"}, + {"name": "Booty Bass", "description": "Booty Bass"}, + {"name": "Primus", "description": "Primus"}, + {"name": "Porn Groove", "description": "Porn Groove"}, + {"name": "Satire", "description": "Satire"}, + {"name": "Slow Jam", "description": "Slow Jam"}, + {"name": "Club", "description": "Club"}, + {"name": "Tango", "description": "Tango"}, + {"name": "Samba", "description": "Samba"}, + {"name": "Folklore", "description": "Folklore"}, + {"name": "Ballad", "description": "Ballad"}, + {"name": "Power Ballad", "description": "Power Ballad"}, + {"name": "Rhythmic Soul", "description": "Rhythmic Soul"}, + {"name": "Freestyle", "description": "Freestyle"}, + {"name": "Duet", "description": "Duet"}, + {"name": "Punk Rock", "description": "Punk Rock"}, + {"name": "Drum Solo", "description": "Drum Solo"}, + {"name": "A capella", "description": "A capella"}, + {"name": "Euro-House", "description": "Euro-House"}, + {"name": "Dance Hall", "description": "Dance Hall"}, + {"name": "Goa", "description": "Goa"}, + {"name": "Drum & Bass", "description": "Drum & Bass"}, + {"name": "Club-House", "description": "Club-House"}, + {"name": "Hardcore", "description": "Hardcore"}, + {"name": "Terror", "description": "Terror"}, + {"name": "Indie", "description": "Indie"}, + {"name": "BritPop", "description": "BritPop"}, + {"name": "Negerpunk", "description": "Negerpunk"}, + {"name": "Polsk Punk", "description": "Polsk Punk"}, + {"name": "Beat", "description": "Beat"}, + {"name": "Christian Gangsta", "description": "Christian Gangsta"}, + {"name": "Heavy Metal", "description": "Heavy Metal"}, + {"name": "Black Metal", "description": "Black Metal"}, + {"name": "Crossover", "description": "Crossover"}, + {"name": "Contemporary Christian", "description": "Contemporary Christian"}, + {"name": "Christian Rock", "description": "Christian Rock"}, + {"name": "Merengue", "description": "Merengue"}, + {"name": "Salsa", "description": "Salsa"}, + {"name": "Trash Metal", "description": "Trash Metal"}, + {"name": "Anime", "description": "Anime"}, + {"name": "JPop", "description": "JPop"}, + {"name": "Synthpop", "description": "Synthpop"}, + {"name": "World Music", "description": "World Music"}, + {"name": "Yukon", "description": "Local Yukon musicians"} + ], + + "Images": [ + {"name": "Abstract", "description": "Abstract"}, + {"name": "Adults", "description": "Adults"}, + {"name": "Agriculture", "description": "Agriculture"}, + {"name": "Animal", "description": "Animal"}, + {"name": "Architecture", "description": "Architecture"}, + {"name": "Arctic", "description": "Arctic"}, + {"name": "Arts", "description": "Arts"}, + {"name": "Astro Photography", "description": "Astro Photography"}, + {"name": "Baby", "description": "Baby"}, + {"name": "Backgrounds", "description": "Backgrounds"}, + {"name": "Business", "description": "Business"}, + {"name": "Celebrations", "description": "Celebrations"}, + {"name": "Children", "description": "Children"}, + {"name": "City", "description": "City"}, + {"name": "Comedy Funny", "description": "Comedy Funny"}, + {"name": "Communications", "description": "Communications"}, + {"name": "Computers", "description": "Computers"}, + {"name": "Culture", "description": "Culture"}, + {"name": "Documentary", "description": "Documentary"}, + {"name": "Domestic", "description": "Domestic"}, + {"name": "Earth Photos", "description": "Earth Photos"}, + {"name": "Education", "description": "Education"}, + {"name": "Entertainment", "description": "Entertainment"}, + {"name": "Environmental", "description": "Environmental"}, + {"name": "Families", "description": "Families"}, + {"name": "Fantasy", "description": "Fantasy"}, + {"name": "Fine Art", "description": "Fine Art"}, + {"name": "Flowers", "description": "Flowers"}, + {"name": "Food", "description": "Food"}, + {"name": "General", "description": "General"}, + {"name": "Glamour", "description": "Glamour"}, + {"name": "Government", "description": "Government"}, + {"name": "Health", "description": "Health"}, + {"name": "Historic", "description": "Historic"}, + {"name": "Holidays", "description": "Holidays"}, + {"name": "Homes", "description": "Homes"}, + {"name": "Industrial", "description": "Industrial"}, + {"name": "International", "description": "International"}, + {"name": "Landscape", "description": "Landscape"}, + {"name": "Landscapes", "description": "Landscapes"}, + {"name": "Leisure", "description": "Leisure"}, + {"name": "Lifestyles", "description": "Lifestyles"}, + {"name": "Logo", "description": "Logo"}, + {"name": "Love Romance", "description": "Love Romance"}, + {"name": "Manufacturing", "description": "Manufacturing"}, + {"name": "Medical", "description": "Medical"}, + {"name": "Meetings", "description": "Meetings"}, + {"name": "Men", "description": "Men"}, + {"name": "Military", "description": "Military"}, + {"name": "Models", "description": "Models"}, + {"name": "Money", "description": "Money"}, + {"name": "Music", "description": "Music"}, + {"name": "Nature", "description": "Nature"}, + {"name": "Nautical", "description": "Nautical"}, + {"name": "Newspaper", "description": "Newspaper"}, + {"name": "Northern", "description": "Northern"}, + {"name": "Nostalgia", "description": "Nostalgia"}, + {"name": "Office", "description": "Office"}, + {"name": "Other Images", "description": "Other Images"}, + {"name": "Outdoors", "description": "Outdoors"}, + {"name": "Patriotic", "description": "Patriotic"}, + {"name": "Patterns", "description": "Patterns"}, + {"name": "People", "description": "People"}, + {"name": "Personality", "description": "Personality"}, + {"name": "Pets", "description": "Pets"}, + {"name": "Photo Essay", "description": "Photo Essay"}, + {"name": "Political", "description": "Political"}, + {"name": "Portrait", "description": "Portrait"}, + {"name": "Recreation", "description": "Recreation"}, + {"name": "Religion", "description": "Religion"}, + {"name": "Science", "description": "Science"}, + {"name": "Shopping", "description": "Shopping"}, + {"name": "Signs", "description": "Signs"}, + {"name": "Space", "description": "Space"}, + {"name": "Sports", "description": "Sports"}, + {"name": "Still Life", "description": "Still Life"}, + {"name": "Symbols", "description": "Symbols"}, + {"name": "Teamwork", "description": "Teamwork"}, + {"name": "Technology", "description": "Technology"}, + {"name": "Tourism", "description": "Tourism"}, + {"name": "Traditional", "description": "Traditional"}, + {"name": "Trains", "description": "Trains"}, + {"name": "Transportation", "description": "Transportation"}, + {"name": "Travel", "description": "Travel"}, + {"name": "Underwater", "description": "Underwater"}, + {"name": "Vintage", "description": "Vintage"}, + {"name": "Weather", "description": "Weather"}, + {"name": "Weird Animals", "description": "Weird Animals"}, + {"name": "Wildlife", "description": "Wildlife"}, + {"name": "Women", "description": "Women"}, + {"name": "Workplace", "description": "Workplace"} + ], + + "Video": [ + {"name": "Action", "description": "Action"}, + {"name": "Adventure", "description": "Adventure"}, + {"name": "Amateur", "description": "Amateur"}, + {"name": "Animation", "description": "Animation"}, + {"name": "Aviation", "description": "Aviation"}, + {"name": "Biography", "description": "Biography"}, + {"name": "Chick Flicks", "description": "Chick Flicks"}, + {"name": "Christmas Video", "description": "Christmas Video"}, + {"name": "Classic Hollywood", "description": "Classic Hollywood"}, + {"name": "Comedy Funny", "description": "Comedy Funny"}, + {"name": "Crime", "description": "Crime"}, + {"name": "Cult Movies", "description": "Cult Movies"}, + {"name": "Detective Mystery", "description": "Detective Mystery"}, + {"name": "Disaster Films", "description": "Disaster Films"}, + {"name": "Documentary", "description": "Documentary"}, + {"name": "Drama", "description": "Drama"}, + {"name": "Experimental", "description": "Experimental"}, + {"name": "Family", "description": "Family"}, + {"name": "Fantasy", "description": "Fantasy"}, + {"name": "History", "description": "History"}, + {"name": "Horror", "description": "Horror"}, + {"name": "Mockumentary", "description": "Mockumentary"}, + {"name": "Music Concerts", "description": "Music Concerts"}, + {"name": "Musical", "description": "Musical"}, + {"name": "News", "description": "News"}, + {"name": "Northern", "description": "Northern"}, + {"name": "Organized Crime", "description": "Organized Crime"}, + {"name": "Other Video", "description": "Other Video"}, + {"name": "Road Films", "description": "Road Films"}, + {"name": "Satire", "description": "Satire"}, + {"name": "SciFi", "description": "SciFi"}, + {"name": "Short", "description": "Short"}, + {"name": "Silent Movies", "description": "Silent Movies"}, + {"name": "Sport", "description": "Sport"}, + {"name": "Supernatural", "description": "Supernatural"}, + {"name": "Thrillers Suspense", "description": "Thrillers Suspense"}, + {"name": "Trailers", "description": "Trailers"}, + {"name": "War", "description": "War"}, + {"name": "Western", "description": "Western"} + ], + + "SFX": [ + {"name": "Animal Sounds", "description": "Animal Sounds"}, + {"name": "Beatle Bits", "description": "Beatle Bits"}, + {"name": "Christmas", "description": "Christmas"}, + {"name": "Halloween", "description": "Halloween"}, + {"name": "Machine Recordings", "description": "Machine Recordings"}, + {"name": "Movie Bits", "description": "Movie Bits"}, + {"name": "Star Trek", "description": "Star Trek"}, + {"name": "TTS", "description": "Text to Speech"} + ], + + "PSA Audio": [ + {"name": "Disabled", "description": "Disabled"}, + {"name": "Enabled", "description": "Enabled"}, + {"name": "PSA Audio Regular", "description": "PSA Audio Regular"} + ] +} diff --git a/core/data/profiles/en_community_radio/groups.json b/core/data/profiles/en_community_radio/groups.json new file mode 100644 index 00000000..68b14240 --- /dev/null +++ b/core/data/profiles/en_community_radio/groups.json @@ -0,0 +1,22 @@ +[ + { + "name": "Basic", + "permissions": [ + "create_own_media", + "create_own_playlists" + ] + }, + { + "name": "Manager", + "permissions": [ + "create_own_media", + "approve_own_media", + "manage_media", + "create_own_playlists", + "manage_playlists", + "manage_schedule_permissions", + "view_device_monitor", + "download_media" + ] + } +] diff --git a/core/data/profiles/en_community_radio/manifest.json b/core/data/profiles/en_community_radio/manifest.json new file mode 100644 index 00000000..d0f0e1f0 --- /dev/null +++ b/core/data/profiles/en_community_radio/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "English Community Radio Station", + "description": "Sample data profile for an English-language community radio station. Populates categories, genres, users, permission groups, settings, custom metadata fields, playlists, and a sample schedule.", + "version": "1.0.0", + "author": "OpenBroadcaster", + "locale": "en", + "created": "2026-04-01" +} diff --git a/core/data/profiles/en_community_radio/metadata_fields.json b/core/data/profiles/en_community_radio/metadata_fields.json new file mode 100644 index 00000000..1111a450 --- /dev/null +++ b/core/data/profiles/en_community_radio/metadata_fields.json @@ -0,0 +1,38 @@ +[ + { + "name": "internal_memo", + "description": "Internal Memo (Private)", + "type": "textarea", + "visibility": "visible", + "mode": "optional", + "default": "", + "id3_key": "" + }, + { + "name": "physical_tags", + "description": "Physical Tags", + "type": "tags", + "visibility": "public", + "mode": "optional", + "default": "", + "id3_key": "", + "tag_suggestions": [ + "CD", + "Vinyl", + "Cassette", + "Digital", + "Donated", + "Library" + ] + }, + { + "name": "day_parting", + "description": "Day Parting Tags", + "type": "select", + "visibility": "public", + "mode": "optional", + "default": "Anytime", + "id3_key": "", + "select_options": "Morning\nMidday\nAfternoon\nEvening\nOvernight\nAnytime" + } +] diff --git a/core/data/profiles/en_community_radio/playlists.json b/core/data/profiles/en_community_radio/playlists.json new file mode 100644 index 00000000..2d3be17f --- /dev/null +++ b/core/data/profiles/en_community_radio/playlists.json @@ -0,0 +1,26 @@ +[ + { + "name": "Default Playlist", + "description": "Default playlist for the station. Add your most-played content here.", + "type": "standard", + "status": "public" + }, + { + "name": "Sample Music Mix", + "description": "A sample music playlist. Replace with your own music selections.", + "type": "standard", + "status": "public" + }, + { + "name": "Station IDs", + "description": "Station identification audio clips for broadcast compliance.", + "type": "standard", + "status": "public" + }, + { + "name": "Live Assist Board", + "description": "Live assist playlist with quick-access buttons for on-air use.", + "type": "live_assist", + "status": "public" + } +] diff --git a/core/data/profiles/en_community_radio/schedule.json b/core/data/profiles/en_community_radio/schedule.json new file mode 100644 index 00000000..8057b15d --- /dev/null +++ b/core/data/profiles/en_community_radio/schedule.json @@ -0,0 +1,76 @@ +{ + "player": { + "name": "Sample Player", + "timezone": "America/Toronto", + "support_audio": true, + "support_video": true, + "support_images": true + }, + + "shows": [ + { + "title": "Morning Melodies", + "description": "Kickstart your day with the best mix of classic and contemporary tunes.", + "start_time": "06:00", + "duration": 120, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "News Hour", + "description": "Stay informed with the latest local, national, and international news stories.", + "start_time": "08:00", + "duration": 60, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Community Spotlight", + "description": "Highlighting local events, organizations, and people making a difference.", + "start_time": "09:00", + "duration": 120, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Lunchtime Jazz", + "description": "Enjoy a selection of smooth jazz tracks during your lunch break.", + "start_time": "11:00", + "duration": 60, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Indie Hour", + "description": "Discover emerging indie artists and bands from around the world.", + "start_time": "12:00", + "duration": 60, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Drive Time", + "description": "Get the latest traffic updates and upbeat tunes for your drive home.", + "start_time": "15:00", + "duration": 180, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Evening Chill", + "description": "Wind down with a mix of calming tracks and ambient sounds.", + "start_time": "18:00", + "duration": 120, + "mode": "daily", + "recurring_days": 180 + }, + { + "title": "Late Night Talk", + "description": "Join the conversation on various topics with expert guests and callers.", + "start_time": "20:00", + "duration": 240, + "mode": "daily", + "recurring_days": 180 + } + ] +} diff --git a/core/data/profiles/en_community_radio/settings.json b/core/data/profiles/en_community_radio/settings.json new file mode 100644 index 00000000..dd866b00 --- /dev/null +++ b/core/data/profiles/en_community_radio/settings.json @@ -0,0 +1,13 @@ +{ + "settings": { + "audio_formats": "flac,mp3,ogg,wav", + "video_formats": "avi,mpg,ogg,mp4,webm", + "image_formats": "jpg,png,gif,webp", + "document_formats": "pdf", + "core_metadata": "{\"artist\":\"required\",\"album\":\"required\",\"year\":\"required\",\"category_id\":\"required\",\"country_id\":\"enabled\",\"language_id\":\"enabled\",\"comments\":\"enabled\"}" + }, + + "client_login_message": "Welcome to your Community Radio Station powered by OpenBroadcaster. Please log in to manage your station content, playlists, and schedules.", + + "client_welcome_page": "

Welcome to OpenBroadcaster

Your community radio station is ready to go! Here are some things you can do:

This station has been pre-configured with sample categories, genres, and playlists to help you get started. Feel free to modify or remove them as needed.

Need help? Visit openbroadcaster.com for documentation and support.

" +} diff --git a/core/data/profiles/en_community_radio/users.json b/core/data/profiles/en_community_radio/users.json new file mode 100644 index 00000000..0df12eb0 --- /dev/null +++ b/core/data/profiles/en_community_radio/users.json @@ -0,0 +1,30 @@ +[ + { + "name": "Admin", + "username": "admin", + "email": "admin@example.com", + "display_name": "Admin", + "password": "changeme", + "enabled": true, + "group": "Administrator", + "skip_if_exists": true + }, + { + "name": "Basic User", + "username": "basic_user", + "email": "basic@example.com", + "display_name": "Basic User", + "password": "changeme", + "enabled": true, + "group": "Basic" + }, + { + "name": "Manager", + "username": "manager", + "email": "manager@example.com", + "display_name": "Manager", + "password": "changeme", + "enabled": true, + "group": "Manager" + } +] From d758e50ad125551cf058121e7d82f351a13807a8 Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Tue, 7 Apr 2026 18:53:32 +0530 Subject: [PATCH 2/7] cli seeder for sample data profiles --- core/cli/Seed.php | 75 +++ core/cli/SeedRun.php | 508 ++++++++++++++++++ .../profiles/en_community_radio/groups.json | 4 +- 3 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 core/cli/Seed.php create mode 100644 core/cli/SeedRun.php diff --git a/core/cli/Seed.php b/core/cli/Seed.php new file mode 100644 index 00000000..e10db118 --- /dev/null +++ b/core/cli/Seed.php @@ -0,0 +1,75 @@ + Populate database from a profile" . PHP_EOL; + return false; + } + + switch ($args[0]) { + case 'list': + return $this->listProfiles(); + default: + echo "Unknown seed command: {$args[0]}" . PHP_EOL; + echo "Run 'ob seed help' for usage." . PHP_EOL; + return false; + } + } + + private function listProfiles(): bool + { + $profilesDir = OB_LOCAL . '/core/data/profiles'; + + if (!is_dir($profilesDir)) { + echo "No profiles directory found." . PHP_EOL; + return false; + } + + $directories = array_filter( + scandir($profilesDir), + fn ($f) => $f[0] !== '.' && is_dir($profilesDir . '/' . $f) + ); + + if (empty($directories)) { + echo "No profiles found." . PHP_EOL; + return false; + } + + echo "Available sample data profiles:" . PHP_EOL . PHP_EOL; + + foreach ($directories as $dir) { + $manifestPath = $profilesDir . '/' . $dir . '/manifest.json'; + if (file_exists($manifestPath)) { + $manifest = json_decode(file_get_contents($manifestPath), true); + $name = $manifest['name'] ?? $dir; + $description = $manifest['description'] ?? ''; + echo Helpers::bold($dir) . PHP_EOL; + echo " " . $name . PHP_EOL; + if ($description) { + echo " " . $description . PHP_EOL; + } + echo PHP_EOL; + } else { + echo Helpers::bold($dir) . " (no manifest)" . PHP_EOL; + } + } + + return true; + } +} diff --git a/core/cli/SeedRun.php b/core/cli/SeedRun.php new file mode 100644 index 00000000..353d2bc1 --- /dev/null +++ b/core/cli/SeedRun.php @@ -0,0 +1,508 @@ +', 'populate database with sample data from profile'], + ]; + + private string $profileDir; + private int $adminUserId; + + public function run(array $args): bool + { + if (count($args) < 1) { + echo "Usage: ob seed run " . PHP_EOL; + return false; + } + + $profile = $args[0]; + $this->profileDir = OB_LOCAL . '/core/data/profiles/' . $profile; + + if (!is_dir($this->profileDir)) { + echo "Profile not found: {$profile}" . PHP_EOL; + echo "Run 'ob seed list' to see available profiles." . PHP_EOL; + return false; + } + + $manifestPath = $this->profileDir . '/manifest.json'; + if (!file_exists($manifestPath)) { + echo "Profile manifest not found: {$manifestPath}" . PHP_EOL; + return false; + } + + $manifest = json_decode(file_get_contents($manifestPath), true); + if (!$manifest) { + echo "Invalid manifest JSON." . PHP_EOL; + return false; + } + + echo PHP_EOL; + echo Helpers::bold("Seeding profile: " . ($manifest['name'] ?? $profile)) . PHP_EOL; + echo str_repeat('-', 60) . PHP_EOL; + + // Get the admin user ID (first user in the system). + $this->db->query('SELECT * FROM `users` ORDER BY `id` ASC LIMIT 1'); + $adminUser = $this->db->assoc_list(); + $this->adminUserId = $adminUser[0]['id'] ?? 1; + + $steps = [ + ['seedCategories', 'categories.json', 'Categories'], + ['seedGenres', 'genres.json', 'Genres'], + ['seedGroups', 'groups.json', 'Permission Groups'], + ['seedUsers', 'users.json', 'Users'], + ['seedSettings', 'settings.json', 'Settings'], + ['seedMetadataFields', 'metadata_fields.json', 'Custom Metadata Fields'], + ['seedPlaylists', 'playlists.json', 'Playlists'], + ['seedSchedule', 'schedule.json', 'Player & Schedule'], + ]; + + foreach ($steps as $step) { + $method = $step[0]; + $file = $step[1]; + $label = $step[2]; + + $filePath = $this->profileDir . '/' . $file; + if (!file_exists($filePath)) { + echo "[SKIP] {$label} — {$file} not found." . PHP_EOL; + continue; + } + + $data = json_decode(file_get_contents($filePath), true); + if ($data === null) { + echo "[ERROR] {$label} — invalid JSON in {$file}." . PHP_EOL; + return false; + } + + echo PHP_EOL . Helpers::bold("[{$label}]") . PHP_EOL; + $this->$method($data); + } + + echo PHP_EOL . str_repeat('-', 60) . PHP_EOL; + echo Helpers::bold("Seed complete.") . PHP_EOL . PHP_EOL; + + return true; + } + + /** + * Seed media categories. Skips categories that already exist by name. + */ + private function seedCategories(array $categories): void + { + $inserted = 0; + $skipped = 0; + + foreach ($categories as $name) { + $this->db->where('name', $name); + $existing = $this->db->get_one('media_categories'); + + if ($existing) { + $skipped++; + continue; + } + + $this->db->insert('media_categories', ['name' => $name]); + $inserted++; + } + + echo " Inserted: {$inserted}, Skipped (already exist): {$skipped}" . PHP_EOL; + } + + /** + * Seed genres grouped by category name. Skips genres that already exist + * for the given category. + */ + private function seedGenres(array $genresByCategory): void + { + $inserted = 0; + $skipped = 0; + $errors = 0; + + foreach ($genresByCategory as $categoryName => $genres) { + // Look up category ID. + $this->db->where('name', $categoryName); + $category = $this->db->get_one('media_categories'); + + if (!$category) { + echo " Warning: Category '{$categoryName}' not found, skipping its genres." . PHP_EOL; + $errors += count($genres); + continue; + } + + $categoryId = $category['id']; + + foreach ($genres as $genre) { + $this->db->where('name', $genre['name']); + $this->db->where('media_category_id', $categoryId); + $existing = $this->db->get_one('media_genres'); + + if ($existing) { + $skipped++; + continue; + } + + $this->db->insert('media_genres', [ + 'name' => $genre['name'], + 'description' => $genre['description'] ?? $genre['name'], + 'media_category_id' => $categoryId, + ]); + $inserted++; + } + } + + echo " Inserted: {$inserted}, Skipped: {$skipped}"; + if ($errors > 0) { + echo ", Errors: {$errors}"; + } + echo PHP_EOL; + } + + /** + * Seed permission groups with their permission assignments. + * The Administrator group (ID 1) already exists and is skipped. + */ + private function seedGroups(array $groups): void + { + $inserted = 0; + $skipped = 0; + + foreach ($groups as $group) { + $this->db->where('name', $group['name']); + $existing = $this->db->get_one('users_groups'); + + if ($existing) { + echo " Group '{$group['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $groupId = $this->db->insert('users_groups', ['name' => $group['name']]); + + if (!$groupId) { + echo " Error inserting group '{$group['name']}'." . PHP_EOL; + continue; + } + + // Assign permissions to this group. + $permCount = 0; + foreach ($group['permissions'] as $permName) { + $this->db->where('name', $permName); + $permission = $this->db->get_one('users_permissions'); + + if (!$permission) { + echo " Warning: Permission '{$permName}' not found." . PHP_EOL; + continue; + } + + $this->db->insert('users_permissions_to_groups', [ + 'permission_id' => $permission['id'], + 'group_id' => $groupId, + ]); + $permCount++; + } + + echo " Created group '{$group['name']}' with {$permCount} permissions." . PHP_EOL; + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } + } + + /** + * Seed users and assign them to groups. Skips users whose username already exists. + */ + private function seedUsers(array $users): void + { + $user = \OBFUser::get_instance(); + $inserted = 0; + $skipped = 0; + + foreach ($users as $userData) { + $this->db->where('username', $userData['username']); + $existing = $this->db->get_one('users'); + + if ($existing) { + echo " User '{$userData['username']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $hashedPassword = $user->password_hash($userData['password']); + + $userId = $this->db->insert('users', [ + 'name' => $userData['name'], + 'username' => $userData['username'], + 'email' => $userData['email'], + 'password' => $hashedPassword, + 'display_name' => $userData['display_name'], + 'enabled' => $userData['enabled'] ? 1 : 0, + 'created' => time(), + 'last_access' => 0, + ]); + + if (!$userId) { + echo " Error inserting user '{$userData['username']}'." . PHP_EOL; + continue; + } + + // Assign user to group. + if (!empty($userData['group'])) { + $this->db->where('name', $userData['group']); + $group = $this->db->get_one('users_groups'); + + if ($group) { + $this->db->insert('users_to_groups', [ + 'user_id' => $userId, + 'group_id' => $group['id'], + ]); + echo " Created user '{$userData['username']}' in group '{$userData['group']}'." . PHP_EOL; + } else { + echo " Created user '{$userData['username']}' (group '{$userData['group']}' not found)." . PHP_EOL; + } + } + + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } + } + + /** + * Seed application settings (formats, core metadata, client messages). + * Uses delete+insert pattern matching the Settings model. + */ + private function seedSettings(array $data): void + { + // Seed settings table entries. + if (!empty($data['settings'])) { + foreach ($data['settings'] as $name => $value) { + $this->db->where('name', $name); + $this->db->delete('settings'); + $this->db->insert('settings', [ + 'name' => $name, + 'value' => $value, + ]); + echo " Set '{$name}'." . PHP_EOL; + } + } + + // Seed login message. + if (!empty($data['client_login_message'])) { + $this->db->where('name', 'client_login_message'); + $this->db->delete('settings'); + $this->db->insert('settings', [ + 'name' => 'client_login_message', + 'value' => $data['client_login_message'], + ]); + echo " Set login message." . PHP_EOL; + } + + // Seed welcome page via client_storage (user_id=0, global setting). + if (!empty($data['client_welcome_page'])) { + $this->db->where('user_id', 0); + $this->db->where('client_name', 'obapp_web_client'); + $existing = $this->db->get_one('client_storage'); + + $storageData = []; + if ($existing) { + $storageData = json_decode($existing['data'], true) ?: []; + } + $storageData['welcome_message'] = $data['client_welcome_page']; + + if ($existing) { + $this->db->where('id', $existing['id']); + $this->db->update('client_storage', [ + 'data' => json_encode($storageData), + ]); + } else { + $this->db->insert('client_storage', [ + 'user_id' => 0, + 'client_name' => 'obapp_web_client', + 'data' => json_encode($storageData), + ]); + } + echo " Set welcome page." . PHP_EOL; + } + } + + /** + * Seed custom metadata fields. Skips fields that already exist by name. + * Uses the MediaMetadata model save method which handles ALTER TABLE. + */ + private function seedMetadataFields(array $fields): void + { + $inserted = 0; + $skipped = 0; + + foreach ($fields as $field) { + $this->db->where('name', $field['name']); + $existing = $this->db->get_one('media_metadata'); + + if ($existing) { + echo " Field '{$field['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + // Use the model save method which handles ALTER TABLE on media. + $result = $this->models->mediametadata('save', $field, null); + + if (!$result) { + echo " Error inserting field '{$field['name']}'." . PHP_EOL; + continue; + } + + echo " Created field '{$field['name']}' ({$field['type']})." . PHP_EOL; + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } + } + + /** + * Seed playlists. Skips playlists that already exist by name. + */ + private function seedPlaylists(array $playlists): void + { + $inserted = 0; + $skipped = 0; + + foreach ($playlists as $playlist) { + $this->db->where('name', $playlist['name']); + $existing = $this->db->get_one('playlists'); + + if ($existing) { + echo " Playlist '{$playlist['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $now = time(); + $playlistId = $this->db->insert('playlists', [ + 'owner_id' => $this->adminUserId, + 'type' => $playlist['type'] ?? 'standard', + 'name' => $playlist['name'], + 'description' => $playlist['description'] ?? '', + 'status' => $playlist['status'] ?? 'public', + 'created' => $now, + 'updated' => $now, + ]); + + if ($playlistId) { + echo " Created playlist '{$playlist['name']}' ({$playlist['type']})." . PHP_EOL; + $inserted++; + } else { + echo " Error creating playlist '{$playlist['name']}'." . PHP_EOL; + } + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } + } + + /** + * Seed a sample player and schedule. Creates playlists for each show + * and schedules them as recurring daily shows. + */ + private function seedSchedule(array $data): void + { + if (empty($data['player']) || empty($data['shows'])) { + echo " No player or shows defined." . PHP_EOL; + return; + } + + $playerData = $data['player']; + + // Check if player already exists. + $this->db->where('name', $playerData['name']); + $existingPlayer = $this->db->get_one('players'); + + if ($existingPlayer) { + echo " Player '{$playerData['name']}' already exists, skipping schedule." . PHP_EOL; + return; + } + + // Create the player directly (CLI has no authenticated user for the model). + $playerId = $this->db->insert('players', [ + 'name' => $playerData['name'], + 'description' => 'Sample player created by seed profile.', + 'timezone' => $playerData['timezone'] ?? 'America/Toronto', + 'support_audio' => $playerData['support_audio'] ? 1 : 0, + 'support_video' => $playerData['support_video'] ? 1 : 0, + 'support_images' => $playerData['support_images'] ? 1 : 0, + 'support_linein' => 0, + 'password' => password_hash('changeme' . OB_HASH_SALT, PASSWORD_DEFAULT), + 'owner_id' => $this->adminUserId, + 'use_parent_schedule' => 0, + 'use_parent_ids' => 0, + 'use_parent_dynamic' => 0, + 'use_parent_playlist' => 0, + 'stream_url' => '', + 'version' => '', + ]); + + if (!$playerId) { + echo " Error creating player." . PHP_EOL; + return; + } + + echo " Created player '{$playerData['name']}'." . PHP_EOL; + + // Create a playlist for each show and schedule it. + $showCount = 0; + foreach ($data['shows'] as $show) { + // Create playlist for this show. + $now = time(); + $playlistId = $this->db->insert('playlists', [ + 'owner_id' => $this->adminUserId, + 'type' => 'standard', + 'name' => $show['title'], + 'description' => $show['description'] ?? '', + 'status' => 'public', + 'created' => $now, + 'updated' => $now, + ]); + + if (!$playlistId) { + echo " Error creating playlist for show '{$show['title']}'." . PHP_EOL; + continue; + } + + // Schedule the show using the Shows model. + $startDate = date('Y-m-d') . ' ' . $show['start_time'] . ':00'; + $recurringDays = $show['recurring_days'] ?? 180; + $stopDate = date('Y-m-d', strtotime("+{$recurringDays} days")); + + $this->models->shows('save_show', [ + 'player_id' => $playerId, + 'user_id' => $this->adminUserId, + 'item_id' => $playlistId, + 'item_type' => 'playlist', + 'mode' => $show['mode'] ?? 'daily', + 'x_data' => 1, + 'start' => $startDate, + 'duration' => intval($show['duration']) * 60, + 'stop' => $stopDate, + ]); + + echo " Scheduled '{$show['title']}' at {$show['start_time']} ({$show['duration']}min, {$show['mode']})." . PHP_EOL; + $showCount++; + } + + echo " Scheduled {$showCount} shows on player '{$playerData['name']}'." . PHP_EOL; + } +} diff --git a/core/data/profiles/en_community_radio/groups.json b/core/data/profiles/en_community_radio/groups.json index 68b14240..8e47f8c9 100644 --- a/core/data/profiles/en_community_radio/groups.json +++ b/core/data/profiles/en_community_radio/groups.json @@ -14,8 +14,8 @@ "manage_media", "create_own_playlists", "manage_playlists", - "manage_schedule_permissions", - "view_device_monitor", + "manage_timeslots", + "view_player_monitor", "download_media" ] } From 508ddea49d5062273f61b852d51ab8b9a99fa9a4 Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Tue, 7 Apr 2026 19:02:27 +0530 Subject: [PATCH 3/7] added admin UI for sample data import --- public/admin/index.php | 7 +++++++ public/admin/run.php | 7 ++++++- public/admin/script.js | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/public/admin/index.php b/public/admin/index.php index 15bfb8ba..b720088d 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -34,6 +34,13 @@ +
+ + + +
diff --git a/public/admin/run.php b/public/admin/run.php index da8ada8a..27ae79fc 100644 --- a/public/admin/run.php +++ b/public/admin/run.php @@ -32,7 +32,12 @@ exit(); } -$validCommands = ['check', 'cron run', 'updates list all', 'updates run all']; +$validCommands = ['check', 'cron run', 'updates list all', 'updates run all', 'seed list']; + +// Allow seed run with a validated profile name. +if (preg_match('/^seed run ([a-z0-9_]+)$/', $json->command, $matches)) { + $validCommands[] = $json->command; +} if (in_array($json->command, $validCommands)) { $output = []; $resultCode = 0; diff --git a/public/admin/script.js b/public/admin/script.js index 26688dbf..847a46fa 100644 --- a/public/admin/script.js +++ b/public/admin/script.js @@ -57,5 +57,28 @@ async function cliUpdatesRun() command: "updates run all" }; + run(data); +} + +async function cliSeedList() +{ + const data = { + command: "seed list" + }; + + run(data); +} + +async function cliSeedRun() +{ + const profile = document.querySelector("#seed-profile").value; + if (! confirm("Import sample data from profile: " + profile + "?\n\nThis will add categories, genres, users, playlists, and schedules. Existing data will not be overwritten.")) { + return; + } + + const data = { + command: "seed run " + profile + }; + run(data); } \ No newline at end of file From 010192f4d3477f722dea52ab6748198ee455c01c Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Tue, 7 Apr 2026 19:09:27 +0530 Subject: [PATCH 4/7] corrected cli path in admin run.php --- public/admin/run.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/admin/run.php b/public/admin/run.php index 27ae79fc..5d1959be 100644 --- a/public/admin/run.php +++ b/public/admin/run.php @@ -41,7 +41,7 @@ if (in_array($json->command, $validCommands)) { $output = []; $resultCode = 0; - exec(__DIR__ . "/../../tools/cli/ob {$json->command}", $output); + exec(__DIR__ . "/../../cli/ob {$json->command}", $output); $output = $converter->convert(implode(PHP_EOL, $output)); if ($output === "" && $resultCode === 0) { From f8a646669545a9b2bcc6a5f5782a2e5614490f62 Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Sun, 3 May 2026 11:40:09 +0530 Subject: [PATCH 5/7] move sample data seeder to tools/sampledata, use Observer API via curl --- core/cli/Seed.php | 75 -- core/cli/SeedRun.php | 508 ------------ tools/sampledata/README.md | 90 +++ .../en_community_radio/categories.json | 0 .../profiles/en_community_radio/genres.json | 0 .../profiles/en_community_radio/groups.json | 0 .../profiles/en_community_radio/manifest.json | 0 .../en_community_radio/metadata_fields.json | 0 .../en_community_radio/playlists.json | 0 .../profiles/en_community_radio/schedule.json | 0 .../profiles/en_community_radio/settings.json | 4 +- .../profiles/en_community_radio/users.json | 0 tools/sampledata/seed.php | 726 ++++++++++++++++++ 13 files changed, 818 insertions(+), 585 deletions(-) delete mode 100644 core/cli/Seed.php delete mode 100644 core/cli/SeedRun.php create mode 100644 tools/sampledata/README.md rename {core/data => tools/sampledata}/profiles/en_community_radio/categories.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/genres.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/groups.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/manifest.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/metadata_fields.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/playlists.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/schedule.json (100%) rename {core/data => tools/sampledata}/profiles/en_community_radio/settings.json (93%) rename {core/data => tools/sampledata}/profiles/en_community_radio/users.json (100%) create mode 100644 tools/sampledata/seed.php diff --git a/core/cli/Seed.php b/core/cli/Seed.php deleted file mode 100644 index e10db118..00000000 --- a/core/cli/Seed.php +++ /dev/null @@ -1,75 +0,0 @@ - Populate database from a profile" . PHP_EOL; - return false; - } - - switch ($args[0]) { - case 'list': - return $this->listProfiles(); - default: - echo "Unknown seed command: {$args[0]}" . PHP_EOL; - echo "Run 'ob seed help' for usage." . PHP_EOL; - return false; - } - } - - private function listProfiles(): bool - { - $profilesDir = OB_LOCAL . '/core/data/profiles'; - - if (!is_dir($profilesDir)) { - echo "No profiles directory found." . PHP_EOL; - return false; - } - - $directories = array_filter( - scandir($profilesDir), - fn ($f) => $f[0] !== '.' && is_dir($profilesDir . '/' . $f) - ); - - if (empty($directories)) { - echo "No profiles found." . PHP_EOL; - return false; - } - - echo "Available sample data profiles:" . PHP_EOL . PHP_EOL; - - foreach ($directories as $dir) { - $manifestPath = $profilesDir . '/' . $dir . '/manifest.json'; - if (file_exists($manifestPath)) { - $manifest = json_decode(file_get_contents($manifestPath), true); - $name = $manifest['name'] ?? $dir; - $description = $manifest['description'] ?? ''; - echo Helpers::bold($dir) . PHP_EOL; - echo " " . $name . PHP_EOL; - if ($description) { - echo " " . $description . PHP_EOL; - } - echo PHP_EOL; - } else { - echo Helpers::bold($dir) . " (no manifest)" . PHP_EOL; - } - } - - return true; - } -} diff --git a/core/cli/SeedRun.php b/core/cli/SeedRun.php deleted file mode 100644 index 353d2bc1..00000000 --- a/core/cli/SeedRun.php +++ /dev/null @@ -1,508 +0,0 @@ -', 'populate database with sample data from profile'], - ]; - - private string $profileDir; - private int $adminUserId; - - public function run(array $args): bool - { - if (count($args) < 1) { - echo "Usage: ob seed run " . PHP_EOL; - return false; - } - - $profile = $args[0]; - $this->profileDir = OB_LOCAL . '/core/data/profiles/' . $profile; - - if (!is_dir($this->profileDir)) { - echo "Profile not found: {$profile}" . PHP_EOL; - echo "Run 'ob seed list' to see available profiles." . PHP_EOL; - return false; - } - - $manifestPath = $this->profileDir . '/manifest.json'; - if (!file_exists($manifestPath)) { - echo "Profile manifest not found: {$manifestPath}" . PHP_EOL; - return false; - } - - $manifest = json_decode(file_get_contents($manifestPath), true); - if (!$manifest) { - echo "Invalid manifest JSON." . PHP_EOL; - return false; - } - - echo PHP_EOL; - echo Helpers::bold("Seeding profile: " . ($manifest['name'] ?? $profile)) . PHP_EOL; - echo str_repeat('-', 60) . PHP_EOL; - - // Get the admin user ID (first user in the system). - $this->db->query('SELECT * FROM `users` ORDER BY `id` ASC LIMIT 1'); - $adminUser = $this->db->assoc_list(); - $this->adminUserId = $adminUser[0]['id'] ?? 1; - - $steps = [ - ['seedCategories', 'categories.json', 'Categories'], - ['seedGenres', 'genres.json', 'Genres'], - ['seedGroups', 'groups.json', 'Permission Groups'], - ['seedUsers', 'users.json', 'Users'], - ['seedSettings', 'settings.json', 'Settings'], - ['seedMetadataFields', 'metadata_fields.json', 'Custom Metadata Fields'], - ['seedPlaylists', 'playlists.json', 'Playlists'], - ['seedSchedule', 'schedule.json', 'Player & Schedule'], - ]; - - foreach ($steps as $step) { - $method = $step[0]; - $file = $step[1]; - $label = $step[2]; - - $filePath = $this->profileDir . '/' . $file; - if (!file_exists($filePath)) { - echo "[SKIP] {$label} — {$file} not found." . PHP_EOL; - continue; - } - - $data = json_decode(file_get_contents($filePath), true); - if ($data === null) { - echo "[ERROR] {$label} — invalid JSON in {$file}." . PHP_EOL; - return false; - } - - echo PHP_EOL . Helpers::bold("[{$label}]") . PHP_EOL; - $this->$method($data); - } - - echo PHP_EOL . str_repeat('-', 60) . PHP_EOL; - echo Helpers::bold("Seed complete.") . PHP_EOL . PHP_EOL; - - return true; - } - - /** - * Seed media categories. Skips categories that already exist by name. - */ - private function seedCategories(array $categories): void - { - $inserted = 0; - $skipped = 0; - - foreach ($categories as $name) { - $this->db->where('name', $name); - $existing = $this->db->get_one('media_categories'); - - if ($existing) { - $skipped++; - continue; - } - - $this->db->insert('media_categories', ['name' => $name]); - $inserted++; - } - - echo " Inserted: {$inserted}, Skipped (already exist): {$skipped}" . PHP_EOL; - } - - /** - * Seed genres grouped by category name. Skips genres that already exist - * for the given category. - */ - private function seedGenres(array $genresByCategory): void - { - $inserted = 0; - $skipped = 0; - $errors = 0; - - foreach ($genresByCategory as $categoryName => $genres) { - // Look up category ID. - $this->db->where('name', $categoryName); - $category = $this->db->get_one('media_categories'); - - if (!$category) { - echo " Warning: Category '{$categoryName}' not found, skipping its genres." . PHP_EOL; - $errors += count($genres); - continue; - } - - $categoryId = $category['id']; - - foreach ($genres as $genre) { - $this->db->where('name', $genre['name']); - $this->db->where('media_category_id', $categoryId); - $existing = $this->db->get_one('media_genres'); - - if ($existing) { - $skipped++; - continue; - } - - $this->db->insert('media_genres', [ - 'name' => $genre['name'], - 'description' => $genre['description'] ?? $genre['name'], - 'media_category_id' => $categoryId, - ]); - $inserted++; - } - } - - echo " Inserted: {$inserted}, Skipped: {$skipped}"; - if ($errors > 0) { - echo ", Errors: {$errors}"; - } - echo PHP_EOL; - } - - /** - * Seed permission groups with their permission assignments. - * The Administrator group (ID 1) already exists and is skipped. - */ - private function seedGroups(array $groups): void - { - $inserted = 0; - $skipped = 0; - - foreach ($groups as $group) { - $this->db->where('name', $group['name']); - $existing = $this->db->get_one('users_groups'); - - if ($existing) { - echo " Group '{$group['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $groupId = $this->db->insert('users_groups', ['name' => $group['name']]); - - if (!$groupId) { - echo " Error inserting group '{$group['name']}'." . PHP_EOL; - continue; - } - - // Assign permissions to this group. - $permCount = 0; - foreach ($group['permissions'] as $permName) { - $this->db->where('name', $permName); - $permission = $this->db->get_one('users_permissions'); - - if (!$permission) { - echo " Warning: Permission '{$permName}' not found." . PHP_EOL; - continue; - } - - $this->db->insert('users_permissions_to_groups', [ - 'permission_id' => $permission['id'], - 'group_id' => $groupId, - ]); - $permCount++; - } - - echo " Created group '{$group['name']}' with {$permCount} permissions." . PHP_EOL; - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } - } - - /** - * Seed users and assign them to groups. Skips users whose username already exists. - */ - private function seedUsers(array $users): void - { - $user = \OBFUser::get_instance(); - $inserted = 0; - $skipped = 0; - - foreach ($users as $userData) { - $this->db->where('username', $userData['username']); - $existing = $this->db->get_one('users'); - - if ($existing) { - echo " User '{$userData['username']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $hashedPassword = $user->password_hash($userData['password']); - - $userId = $this->db->insert('users', [ - 'name' => $userData['name'], - 'username' => $userData['username'], - 'email' => $userData['email'], - 'password' => $hashedPassword, - 'display_name' => $userData['display_name'], - 'enabled' => $userData['enabled'] ? 1 : 0, - 'created' => time(), - 'last_access' => 0, - ]); - - if (!$userId) { - echo " Error inserting user '{$userData['username']}'." . PHP_EOL; - continue; - } - - // Assign user to group. - if (!empty($userData['group'])) { - $this->db->where('name', $userData['group']); - $group = $this->db->get_one('users_groups'); - - if ($group) { - $this->db->insert('users_to_groups', [ - 'user_id' => $userId, - 'group_id' => $group['id'], - ]); - echo " Created user '{$userData['username']}' in group '{$userData['group']}'." . PHP_EOL; - } else { - echo " Created user '{$userData['username']}' (group '{$userData['group']}' not found)." . PHP_EOL; - } - } - - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } - } - - /** - * Seed application settings (formats, core metadata, client messages). - * Uses delete+insert pattern matching the Settings model. - */ - private function seedSettings(array $data): void - { - // Seed settings table entries. - if (!empty($data['settings'])) { - foreach ($data['settings'] as $name => $value) { - $this->db->where('name', $name); - $this->db->delete('settings'); - $this->db->insert('settings', [ - 'name' => $name, - 'value' => $value, - ]); - echo " Set '{$name}'." . PHP_EOL; - } - } - - // Seed login message. - if (!empty($data['client_login_message'])) { - $this->db->where('name', 'client_login_message'); - $this->db->delete('settings'); - $this->db->insert('settings', [ - 'name' => 'client_login_message', - 'value' => $data['client_login_message'], - ]); - echo " Set login message." . PHP_EOL; - } - - // Seed welcome page via client_storage (user_id=0, global setting). - if (!empty($data['client_welcome_page'])) { - $this->db->where('user_id', 0); - $this->db->where('client_name', 'obapp_web_client'); - $existing = $this->db->get_one('client_storage'); - - $storageData = []; - if ($existing) { - $storageData = json_decode($existing['data'], true) ?: []; - } - $storageData['welcome_message'] = $data['client_welcome_page']; - - if ($existing) { - $this->db->where('id', $existing['id']); - $this->db->update('client_storage', [ - 'data' => json_encode($storageData), - ]); - } else { - $this->db->insert('client_storage', [ - 'user_id' => 0, - 'client_name' => 'obapp_web_client', - 'data' => json_encode($storageData), - ]); - } - echo " Set welcome page." . PHP_EOL; - } - } - - /** - * Seed custom metadata fields. Skips fields that already exist by name. - * Uses the MediaMetadata model save method which handles ALTER TABLE. - */ - private function seedMetadataFields(array $fields): void - { - $inserted = 0; - $skipped = 0; - - foreach ($fields as $field) { - $this->db->where('name', $field['name']); - $existing = $this->db->get_one('media_metadata'); - - if ($existing) { - echo " Field '{$field['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - // Use the model save method which handles ALTER TABLE on media. - $result = $this->models->mediametadata('save', $field, null); - - if (!$result) { - echo " Error inserting field '{$field['name']}'." . PHP_EOL; - continue; - } - - echo " Created field '{$field['name']}' ({$field['type']})." . PHP_EOL; - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } - } - - /** - * Seed playlists. Skips playlists that already exist by name. - */ - private function seedPlaylists(array $playlists): void - { - $inserted = 0; - $skipped = 0; - - foreach ($playlists as $playlist) { - $this->db->where('name', $playlist['name']); - $existing = $this->db->get_one('playlists'); - - if ($existing) { - echo " Playlist '{$playlist['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $now = time(); - $playlistId = $this->db->insert('playlists', [ - 'owner_id' => $this->adminUserId, - 'type' => $playlist['type'] ?? 'standard', - 'name' => $playlist['name'], - 'description' => $playlist['description'] ?? '', - 'status' => $playlist['status'] ?? 'public', - 'created' => $now, - 'updated' => $now, - ]); - - if ($playlistId) { - echo " Created playlist '{$playlist['name']}' ({$playlist['type']})." . PHP_EOL; - $inserted++; - } else { - echo " Error creating playlist '{$playlist['name']}'." . PHP_EOL; - } - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } - } - - /** - * Seed a sample player and schedule. Creates playlists for each show - * and schedules them as recurring daily shows. - */ - private function seedSchedule(array $data): void - { - if (empty($data['player']) || empty($data['shows'])) { - echo " No player or shows defined." . PHP_EOL; - return; - } - - $playerData = $data['player']; - - // Check if player already exists. - $this->db->where('name', $playerData['name']); - $existingPlayer = $this->db->get_one('players'); - - if ($existingPlayer) { - echo " Player '{$playerData['name']}' already exists, skipping schedule." . PHP_EOL; - return; - } - - // Create the player directly (CLI has no authenticated user for the model). - $playerId = $this->db->insert('players', [ - 'name' => $playerData['name'], - 'description' => 'Sample player created by seed profile.', - 'timezone' => $playerData['timezone'] ?? 'America/Toronto', - 'support_audio' => $playerData['support_audio'] ? 1 : 0, - 'support_video' => $playerData['support_video'] ? 1 : 0, - 'support_images' => $playerData['support_images'] ? 1 : 0, - 'support_linein' => 0, - 'password' => password_hash('changeme' . OB_HASH_SALT, PASSWORD_DEFAULT), - 'owner_id' => $this->adminUserId, - 'use_parent_schedule' => 0, - 'use_parent_ids' => 0, - 'use_parent_dynamic' => 0, - 'use_parent_playlist' => 0, - 'stream_url' => '', - 'version' => '', - ]); - - if (!$playerId) { - echo " Error creating player." . PHP_EOL; - return; - } - - echo " Created player '{$playerData['name']}'." . PHP_EOL; - - // Create a playlist for each show and schedule it. - $showCount = 0; - foreach ($data['shows'] as $show) { - // Create playlist for this show. - $now = time(); - $playlistId = $this->db->insert('playlists', [ - 'owner_id' => $this->adminUserId, - 'type' => 'standard', - 'name' => $show['title'], - 'description' => $show['description'] ?? '', - 'status' => 'public', - 'created' => $now, - 'updated' => $now, - ]); - - if (!$playlistId) { - echo " Error creating playlist for show '{$show['title']}'." . PHP_EOL; - continue; - } - - // Schedule the show using the Shows model. - $startDate = date('Y-m-d') . ' ' . $show['start_time'] . ':00'; - $recurringDays = $show['recurring_days'] ?? 180; - $stopDate = date('Y-m-d', strtotime("+{$recurringDays} days")); - - $this->models->shows('save_show', [ - 'player_id' => $playerId, - 'user_id' => $this->adminUserId, - 'item_id' => $playlistId, - 'item_type' => 'playlist', - 'mode' => $show['mode'] ?? 'daily', - 'x_data' => 1, - 'start' => $startDate, - 'duration' => intval($show['duration']) * 60, - 'stop' => $stopDate, - ]); - - echo " Scheduled '{$show['title']}' at {$show['start_time']} ({$show['duration']}min, {$show['mode']})." . PHP_EOL; - $showCount++; - } - - echo " Scheduled {$showCount} shows on player '{$playerData['name']}'." . PHP_EOL; - } -} diff --git a/tools/sampledata/README.md b/tools/sampledata/README.md new file mode 100644 index 00000000..dca40f65 --- /dev/null +++ b/tools/sampledata/README.md @@ -0,0 +1,90 @@ +# Sample Data Seeder + +Populates a fresh OpenBroadcaster Observer install with categories, genres, +permission groups, users, settings, custom metadata fields, playlists, a sample +player, and a daily show schedule. + +The seeder talks to the Observer API over HTTP using `curl` — it does not touch +the database directly and does not depend on Observer's CLI or core code. + +## Requirements + +- PHP 8.1+ on the machine running the seeder (uses curl extension) +- Network access to a running Observer instance +- Credentials for an Observer admin user with permissions to manage users, + permissions, players, playlists, schedules, media settings, metadata, and + global client storage + +## Usage + +```sh +# List available profiles +php tools/sampledata/seed.php list + +# Run a profile against a server +php tools/sampledata/seed.php run en_community_radio \ + --base-url=https://observer.example.com \ + --username=admin + +# Same, but read all settings from environment variables +OB_BASE_URL=https://observer.example.com \ +OB_USERNAME=admin \ +OB_PASSWORD=hunter2 \ + php tools/sampledata/seed.php run en_community_radio +``` + +If `--password` is not given and `OB_PASSWORD` is not set, the seeder prompts +interactively (without echoing) when run from a terminal. + +### Configuration precedence + +Each setting can come from a CLI flag, an environment variable, or a default; +the first source listed wins. + +| Setting | CLI flag | Env var | Default | +| ---------- | ------------ | ------------ | ------------------------ | +| Base URL | `--base-url` | `OB_BASE_URL`| `http://127.0.0.1:8080` | +| Username | `--username` | `OB_USERNAME`| `admin` | +| Password | `--password` | `OB_PASSWORD`| (interactive prompt) | + +## Idempotency + +The seeder is safe to re-run. Before creating each item it checks whether one +with the same name already exists and skips it if so. This applies to +categories, genres, permission groups, users, custom metadata fields, +playlists, and the sample player. + +Settings (file format whitelists, core metadata flags, login message, welcome +page) are always overwritten on each run, matching the original seeder +behaviour — the API endpoints for these settings are inherently +overwrite-only. + +## Profile layout + +``` +tools/sampledata/profiles// + manifest.json # name, description, version + categories.json # ["Music", "News", ...] + genres.json # { "": [{name, description}, ...] } + groups.json # [{ name, permissions: ["perm_name", ...] }] + users.json # [{ name, username, email, password, ... , group }] + settings.json # { settings, client_login_message, client_welcome_page } + metadata_fields.json # [{ name, type, mode, visibility, ... }] + playlists.json # [{ name, description, status, type }] + schedule.json # { player: {...}, shows: [{ title, start_time, ... }] } +``` + +Each step is optional — if a JSON file is missing for a step the seeder logs +`[SKIP]` and moves on. + +## Adding a new profile + +Create a new directory under `tools/sampledata/profiles/` named with +lowercase letters, digits, and underscores only (matches `^[a-z0-9_]+$`), then +add the JSON files above. `seed.php list` will pick it up automatically. + +## Admin UI integration + +The Observer admin panel at `/admin/` exposes the seeder via the "Import +sample data" button, which `exec()`s this script with credentials supplied in +the form. The same auth and idempotency rules apply. diff --git a/core/data/profiles/en_community_radio/categories.json b/tools/sampledata/profiles/en_community_radio/categories.json similarity index 100% rename from core/data/profiles/en_community_radio/categories.json rename to tools/sampledata/profiles/en_community_radio/categories.json diff --git a/core/data/profiles/en_community_radio/genres.json b/tools/sampledata/profiles/en_community_radio/genres.json similarity index 100% rename from core/data/profiles/en_community_radio/genres.json rename to tools/sampledata/profiles/en_community_radio/genres.json diff --git a/core/data/profiles/en_community_radio/groups.json b/tools/sampledata/profiles/en_community_radio/groups.json similarity index 100% rename from core/data/profiles/en_community_radio/groups.json rename to tools/sampledata/profiles/en_community_radio/groups.json diff --git a/core/data/profiles/en_community_radio/manifest.json b/tools/sampledata/profiles/en_community_radio/manifest.json similarity index 100% rename from core/data/profiles/en_community_radio/manifest.json rename to tools/sampledata/profiles/en_community_radio/manifest.json diff --git a/core/data/profiles/en_community_radio/metadata_fields.json b/tools/sampledata/profiles/en_community_radio/metadata_fields.json similarity index 100% rename from core/data/profiles/en_community_radio/metadata_fields.json rename to tools/sampledata/profiles/en_community_radio/metadata_fields.json diff --git a/core/data/profiles/en_community_radio/playlists.json b/tools/sampledata/profiles/en_community_radio/playlists.json similarity index 100% rename from core/data/profiles/en_community_radio/playlists.json rename to tools/sampledata/profiles/en_community_radio/playlists.json diff --git a/core/data/profiles/en_community_radio/schedule.json b/tools/sampledata/profiles/en_community_radio/schedule.json similarity index 100% rename from core/data/profiles/en_community_radio/schedule.json rename to tools/sampledata/profiles/en_community_radio/schedule.json diff --git a/core/data/profiles/en_community_radio/settings.json b/tools/sampledata/profiles/en_community_radio/settings.json similarity index 93% rename from core/data/profiles/en_community_radio/settings.json rename to tools/sampledata/profiles/en_community_radio/settings.json index dd866b00..1bb2615e 100644 --- a/core/data/profiles/en_community_radio/settings.json +++ b/tools/sampledata/profiles/en_community_radio/settings.json @@ -1,8 +1,8 @@ { "settings": { "audio_formats": "flac,mp3,ogg,wav", - "video_formats": "avi,mpg,ogg,mp4,webm", - "image_formats": "jpg,png,gif,webp", + "video_formats": "avi,mpg,ogg", + "image_formats": "jpg,png", "document_formats": "pdf", "core_metadata": "{\"artist\":\"required\",\"album\":\"required\",\"year\":\"required\",\"category_id\":\"required\",\"country_id\":\"enabled\",\"language_id\":\"enabled\",\"comments\":\"enabled\"}" }, diff --git a/core/data/profiles/en_community_radio/users.json b/tools/sampledata/profiles/en_community_radio/users.json similarity index 100% rename from core/data/profiles/en_community_radio/users.json rename to tools/sampledata/profiles/en_community_radio/users.json diff --git a/tools/sampledata/seed.php b/tools/sampledata/seed.php new file mode 100644 index 00000000..711ef6db --- /dev/null +++ b/tools/sampledata/seed.php @@ -0,0 +1,726 @@ + +// +// Configuration (CLI flags override environment): +// --base-url= or env OB_BASE_URL (default http://127.0.0.1:8080) +// --username= or env OB_USERNAME (default admin) +// --password= or env OB_PASSWORD (read interactively if missing) + +if (php_sapi_name() !== 'cli') { + exit("This tool may only be run from the command line.\n"); +} + +// Convert errors to exceptions, but ignore deprecation/notice noise so a +// transient warning (e.g. PHP 8.5 deprecating curl_close) doesn't kill the run. +set_error_handler(function ($severity, $message, $file, $line) { + if (!(error_reporting() & $severity)) { + return false; + } + if ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED || $severity === E_NOTICE || $severity === E_USER_NOTICE) { + return false; + } + throw new ErrorException($message, 0, $severity, $file, $line); +}); + +$opts = parseArgs($argv); +$command = $opts['_positional'][0] ?? null; + +if ($command === 'list') { + listProfiles(); + exit(0); +} + +if ($command === 'run') { + $profile = $opts['_positional'][1] ?? null; + if (!$profile) { + fwrite(STDERR, "Profile name required.\n"); + printUsage(); + exit(1); + } + + if (!preg_match('/^[a-z0-9_]+$/', $profile)) { + fwrite(STDERR, "Invalid profile name: must match [a-z0-9_]+\n"); + exit(1); + } + + $config = loadConfig($opts); + $exitCode = runProfile($profile, $config) ? 0 : 1; + exit($exitCode); +} + +printUsage(); +exit($command ? 1 : 0); + +function printUsage(): void +{ + echo "Usage:" . PHP_EOL; + echo " php tools/sampledata/seed.php list" . PHP_EOL; + echo " php tools/sampledata/seed.php run [--base-url=URL] [--username=USER] [--password=PASS]" . PHP_EOL; +} + +function parseArgs(array $argv): array +{ + $opts = ['_positional' => []]; + array_shift($argv); // script name + + foreach ($argv as $arg) { + if (str_starts_with($arg, '--')) { + $body = substr($arg, 2); + if (str_contains($body, '=')) { + [$k, $v] = explode('=', $body, 2); + $opts[$k] = $v; + } else { + $opts[$body] = true; + } + } else { + $opts['_positional'][] = $arg; + } + } + + return $opts; +} + +function profilesRoot(): string +{ + return __DIR__ . '/profiles'; +} + +function listProfiles(): void +{ + $root = profilesRoot(); + if (!is_dir($root)) { + echo "No profiles directory found." . PHP_EOL; + return; + } + + $directories = array_filter( + scandir($root), + fn ($f) => $f[0] !== '.' && is_dir($root . '/' . $f) + ); + + if (empty($directories)) { + echo "No profiles found." . PHP_EOL; + return; + } + + echo "Available sample data profiles:" . PHP_EOL . PHP_EOL; + + foreach ($directories as $dir) { + $manifestPath = $root . '/' . $dir . '/manifest.json'; + $manifest = file_exists($manifestPath) + ? (json_decode(file_get_contents($manifestPath), true) ?: []) + : []; + + echo $dir . PHP_EOL; + echo ' ' . ($manifest['name'] ?? $dir) . PHP_EOL; + if (!empty($manifest['description'])) { + echo ' ' . $manifest['description'] . PHP_EOL; + } + echo PHP_EOL; + } +} + +function loadConfig(array $opts): array +{ + $baseUrl = $opts['base-url'] ?? getenv('OB_BASE_URL') ?: 'http://127.0.0.1:8080'; + $username = $opts['username'] ?? getenv('OB_USERNAME') ?: 'admin'; + $password = $opts['password'] ?? getenv('OB_PASSWORD') ?: null; + + if ($password === null || $password === '') { + if (!stream_isatty(STDIN)) { + fwrite(STDERR, "Password required: pass --password=, set OB_PASSWORD, or run interactively.\n"); + exit(1); + } + $password = readPasswordPrompt("Password for {$username}: "); + } + + return [ + 'base_url' => rtrim($baseUrl, '/'), + 'username' => $username, + 'password' => $password, + ]; +} + +function readPasswordPrompt(string $prompt): string +{ + fwrite(STDOUT, $prompt); + if (DIRECTORY_SEPARATOR === '\\') { + $password = trim(fgets(STDIN)); + } else { + @system('stty -echo'); + $password = trim(fgets(STDIN)); + @system('stty echo'); + fwrite(STDOUT, PHP_EOL); + } + return $password; +} + +function runProfile(string $profileName, array $config): bool +{ + $profileDir = profilesRoot() . '/' . $profileName; + if (!is_dir($profileDir)) { + fwrite(STDERR, "Profile not found: {$profileName}\n"); + return false; + } + + $manifest = readJson($profileDir . '/manifest.json'); + if ($manifest === null) { + fwrite(STDERR, "Profile manifest missing or invalid.\n"); + return false; + } + + echo PHP_EOL; + echo 'Seeding profile: ' . ($manifest['name'] ?? $profileName) . PHP_EOL; + echo 'Server: ' . $config['base_url'] . PHP_EOL; + echo str_repeat('-', 60) . PHP_EOL; + + try { + $client = new OBClient($config['base_url']); + $client->login($config['username'], $config['password']); + echo 'Logged in as ' . $config['username'] . PHP_EOL; + } catch (Throwable $e) { + fwrite(STDERR, 'Login failed: ' . $e->getMessage() . PHP_EOL); + return false; + } + + $steps = [ + ['seedCategories', 'categories.json', 'Categories'], + ['seedGenres', 'genres.json', 'Genres'], + ['seedGroups', 'groups.json', 'Permission Groups'], + ['seedUsers', 'users.json', 'Users'], + ['seedSettings', 'settings.json', 'Settings'], + ['seedMetadataFields', 'metadata_fields.json', 'Custom Metadata Fields'], + ['seedPlaylists', 'playlists.json', 'Playlists'], + ['seedSchedule', 'schedule.json', 'Player & Schedule'], + ]; + + foreach ($steps as [$method, $file, $label]) { + $filePath = $profileDir . '/' . $file; + if (!file_exists($filePath)) { + echo "[SKIP] {$label} — {$file} not found." . PHP_EOL; + continue; + } + + $data = readJson($filePath); + if ($data === null) { + fwrite(STDERR, "Invalid JSON in {$file}.\n"); + return false; + } + + echo PHP_EOL . "[{$label}]" . PHP_EOL; + try { + $method($client, $data); + } catch (Throwable $e) { + fwrite(STDERR, "Error in {$label}: " . $e->getMessage() . PHP_EOL); + return false; + } + } + + echo PHP_EOL . str_repeat('-', 60) . PHP_EOL; + echo 'Seed complete.' . PHP_EOL; + + return true; +} + +function readJson(string $path): ?array +{ + if (!file_exists($path)) { + return null; + } + $decoded = json_decode(file_get_contents($path), true); + return is_array($decoded) ? $decoded : null; +} + +// ---- seed steps --------------------------------------------------------- + +function seedCategories(OBClient $client, array $categories): void +{ + $existing = indexBy($client->call('metadata', 'category_list', []) ?: [], 'name'); + + $inserted = 0; + $skipped = 0; + foreach ($categories as $name) { + if (isset($existing[$name])) { + $skipped++; + continue; + } + $client->call('metadata', 'category_save', ['name' => $name]); + $inserted++; + } + + echo " Inserted: {$inserted}, Skipped (already exist): {$skipped}" . PHP_EOL; +} + +function seedGenres(OBClient $client, array $genresByCategory): void +{ + $categories = indexBy($client->call('metadata', 'category_list', []) ?: [], 'name'); + $existingGenres = $client->call('metadata', 'genre_list', []) ?: []; + + // Index existing genres by "name|category_id" so we can detect dupes per category. + $genreKey = []; + foreach ($existingGenres as $g) { + $genreKey[strtolower($g['name']) . '|' . $g['media_category_id']] = true; + } + + $inserted = 0; + $skipped = 0; + $errors = 0; + + foreach ($genresByCategory as $categoryName => $genres) { + if (!isset($categories[$categoryName])) { + echo " Warning: Category '{$categoryName}' not found, skipping its genres." . PHP_EOL; + $errors += count($genres); + continue; + } + $categoryId = (int) $categories[$categoryName]['id']; + + foreach ($genres as $genre) { + $key = strtolower($genre['name']) . '|' . $categoryId; + if (isset($genreKey[$key])) { + $skipped++; + continue; + } + $client->call('metadata', 'genre_save', [ + 'name' => $genre['name'], + 'description' => $genre['description'] ?? $genre['name'], + 'media_category_id' => $categoryId, + ]); + $genreKey[$key] = true; + $inserted++; + } + } + + $line = " Inserted: {$inserted}, Skipped: {$skipped}"; + if ($errors > 0) { + $line .= ", Errors: {$errors}"; + } + echo $line . PHP_EOL; +} + +function seedGroups(OBClient $client, array $groups): void +{ + $existingGroups = indexBy($client->call('users', 'group_list') ?: [], 'name'); + + $inserted = 0; + $skipped = 0; + foreach ($groups as $group) { + if (isset($existingGroups[$group['name']])) { + echo " Group '{$group['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $client->call('users', 'permissions_manage_addedit', [ + 'name' => $group['name'], + 'permissions' => $group['permissions'], + ]); + echo " Created group '{$group['name']}' with " . count($group['permissions']) . ' permissions.' . PHP_EOL; + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } +} + +function seedUsers(OBClient $client, array $users): void +{ + // user_manage_list returns [users, sort_col, sort_desc]; first element is the user list. + $userListResponse = $client->call('users', 'user_manage_list') ?: []; + $userList = $userListResponse[0] ?? []; + $existingUsers = indexBy($userList, 'username'); + $groups = indexBy($client->call('users', 'group_list') ?: [], 'name'); + + $inserted = 0; + $skipped = 0; + foreach ($users as $userData) { + if (isset($existingUsers[$userData['username']])) { + echo " User '{$userData['username']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $groupIds = []; + if (!empty($userData['group']) && isset($groups[$userData['group']])) { + $groupIds[] = (int) $groups[$userData['group']]['id']; + } + + $client->call('users', 'user_manage_addedit', [ + 'name' => $userData['name'], + 'username' => $userData['username'], + 'email' => $userData['email'], + 'password' => $userData['password'], + 'password_confirm' => $userData['password'], + 'display_name' => $userData['display_name'], + 'enabled' => $userData['enabled'] ? 1 : 0, + 'group_ids' => $groupIds, + 'appkeys' => [], + ]); + + if ($groupIds) { + echo " Created user '{$userData['username']}' in group '{$userData['group']}'." . PHP_EOL; + } else { + echo " Created user '{$userData['username']}'." . PHP_EOL; + } + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } +} + +function seedSettings(OBClient $client, array $data): void +{ + $settings = $data['settings'] ?? []; + + // Media file format whitelist. The API expects arrays; profiles store + // comma-separated strings for readability. + $formatKeys = ['audio_formats', 'video_formats', 'image_formats', 'document_formats']; + $formatPayload = []; + foreach ($formatKeys as $key) { + if (!isset($settings[$key])) { + continue; + } + $value = $settings[$key]; + $formatPayload[$key] = is_array($value) + ? $value + : array_values(array_filter(array_map('trim', explode(',', (string) $value)))); + } + if (!empty($formatPayload)) { + try { + $client->call('media', 'formats_save', $formatPayload); + echo ' Set media file formats.' . PHP_EOL; + } catch (RuntimeException $e) { + // formats_save is all-or-nothing; surface the API message but keep going. + echo ' Skipped media file formats: ' . $e->getMessage() . PHP_EOL; + } + } + + // Core metadata required/enabled flags. + if (isset($settings['core_metadata'])) { + $coreMetadata = is_string($settings['core_metadata']) + ? (json_decode($settings['core_metadata'], true) ?: []) + : $settings['core_metadata']; + + // The API uses "country" / "language" keys; profiles may use "country_id" / "language_id". + // Each value must be one of 'required', 'enabled', or 'disabled' per validate_fields(). + $payload = [ + 'artist' => $coreMetadata['artist'] ?? 'disabled', + 'album' => $coreMetadata['album'] ?? 'disabled', + 'year' => $coreMetadata['year'] ?? 'disabled', + 'category_id' => $coreMetadata['category_id'] ?? 'disabled', + 'country' => $coreMetadata['country'] ?? $coreMetadata['country_id'] ?? 'disabled', + 'language' => $coreMetadata['language'] ?? $coreMetadata['language_id'] ?? 'disabled', + 'comments' => $coreMetadata['comments'] ?? 'disabled', + 'dynamic_content_default' => $coreMetadata['dynamic_content_default'] ?? 'enabled', + 'dynamic_content_hidden' => $coreMetadata['dynamic_content_hidden'] ?? false, + ]; + $client->call('metadata', 'media_required_fields', $payload); + echo ' Set core metadata fields.' . PHP_EOL; + } + + if (!empty($data['client_login_message'])) { + $client->call('clientsettings', 'set_login_message', [ + 'client_login_message' => $data['client_login_message'], + ]); + echo ' Set login message.' . PHP_EOL; + } + + if (!empty($data['client_welcome_page'])) { + $client->call('clientsettings', 'set_welcome_page', [ + 'client_welcome_page' => $data['client_welcome_page'], + ]); + echo ' Set welcome page.' . PHP_EOL; + } +} + +function seedMetadataFields(OBClient $client, array $fields): void +{ + $existing = indexBy($client->call('metadata', 'media_metadata_fields') ?: [], 'name'); + + $inserted = 0; + $skipped = 0; + foreach ($fields as $field) { + if (isset($existing[strtolower($field['name'])])) { + echo " Field '{$field['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $payload = [ + 'name' => $field['name'], + 'description' => $field['description'] ?? $field['name'], + 'type' => $field['type'], + 'mode' => $field['mode'] ?? 'optional', + 'visibility' => $field['visibility'] ?? 'visible', + 'select_options' => $field['select_options'] ?? '', + 'id3_key' => $field['id3_key'] ?? '', + 'default' => $field['default'] ?? '', + 'tag_suggestions' => $field['tag_suggestions'] ?? [], + ]; + + $client->call('metadata', 'metadata_save', $payload); + echo " Created field '{$field['name']}' ({$field['type']})." . PHP_EOL; + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } +} + +function seedPlaylists(OBClient $client, array $playlists): void +{ + $existing = indexBy(searchPlaylists($client), 'name'); + + $inserted = 0; + $skipped = 0; + foreach ($playlists as $playlist) { + if (isset($existing[$playlist['name']])) { + echo " Playlist '{$playlist['name']}' already exists, skipping." . PHP_EOL; + $skipped++; + continue; + } + + $client->call('playlists', 'save', [ + 'name' => $playlist['name'], + 'description' => $playlist['description'] ?? '', + 'status' => $playlist['status'] ?? 'public', + 'type' => $playlist['type'] ?? 'standard', + 'items' => [], + 'liveassist_button_items' => [], + 'properties' => null, + ]); + echo " Created playlist '{$playlist['name']}' (" . ($playlist['type'] ?? 'standard') . ').' . PHP_EOL; + $inserted++; + } + + if ($skipped > 0) { + echo " Skipped: {$skipped}" . PHP_EOL; + } +} + +function seedSchedule(OBClient $client, array $data): void +{ + if (empty($data['player']) || empty($data['shows'])) { + echo ' No player or shows defined.' . PHP_EOL; + return; + } + + $playerData = $data['player']; + + $existingPlayers = indexBy($client->call('players', 'search') ?: [], 'name'); + if (isset($existingPlayers[$playerData['name']])) { + echo " Player '{$playerData['name']}' already exists, skipping schedule." . PHP_EOL; + return; + } + + $playerSaveResult = $client->call('players', 'save', [ + 'name' => $playerData['name'], + 'description' => 'Sample player created by seed profile.', + 'timezone' => $playerData['timezone'] ?? 'America/Toronto', + 'support_audio' => $playerData['support_audio'] ?? true, + 'support_video' => $playerData['support_video'] ?? true, + 'support_images' => $playerData['support_images'] ?? true, + 'support_linein' => false, + 'password' => 'changeme', + 'station_ids' => [], + 'station_id_image_duration' => 15, + 'stream_url' => '', + 'parent_player_id' => null, + ], rawData: true); + + $playerId = is_array($playerSaveResult) ? ($playerSaveResult['data'] ?? null) : null; + if (!$playerId) { + // Fallback: re-list and find by name. + $allPlayers = indexBy($client->call('players', 'search', ['l' => 999999]) ?: [], 'name'); + $playerId = $allPlayers[$playerData['name']]['id'] ?? null; + } + + if (!$playerId) { + echo ' Error creating player.' . PHP_EOL; + return; + } + + echo " Created player '{$playerData['name']}'." . PHP_EOL; + + $existingPlaylists = indexBy(searchPlaylists($client), 'name'); + + $showCount = 0; + foreach ($data['shows'] as $show) { + if (isset($existingPlaylists[$show['title']])) { + $playlistId = $existingPlaylists[$show['title']]['id']; + } else { + $client->call('playlists', 'save', [ + 'name' => $show['title'], + 'description' => $show['description'] ?? '', + 'status' => 'public', + 'type' => 'standard', + 'items' => [], + 'liveassist_button_items' => [], + 'properties' => null, + ]); + $refreshed = indexBy(searchPlaylists($client), 'name'); + $playlistId = $refreshed[$show['title']]['id'] ?? null; + $existingPlaylists[$show['title']] = ['id' => $playlistId]; + } + + if (!$playlistId) { + echo " Error creating playlist for show '{$show['title']}'." . PHP_EOL; + continue; + } + + $startDate = date('Y-m-d') . ' ' . $show['start_time'] . ':00'; + $recurringDays = $show['recurring_days'] ?? 180; + $stopDate = date('Y-m-d', strtotime("+{$recurringDays} days")); + + $client->call('shows', 'save', [ + 'player_id' => $playerId, + 'item_id' => $playlistId, + 'item_type' => 'playlist', + 'mode' => $show['mode'] ?? 'daily', + 'x_data' => 1, + 'start' => $startDate, + 'duration' => intval($show['duration']) * 60, + 'stop' => $stopDate, + ]); + + echo " Scheduled '{$show['title']}' at {$show['start_time']} ({$show['duration']}min, " . ($show['mode'] ?? 'daily') . ').' . PHP_EOL; + $showCount++; + } + + echo " Scheduled {$showCount} shows on player '{$playerData['name']}'." . PHP_EOL; +} + +function searchPlaylists(OBClient $client): array +{ + // playlists.search returns ['num_results' => int, 'playlists' => [...]] + $response = $client->call('playlists', 'search') ?: []; + return $response['playlists'] ?? []; +} + +function indexBy(array $items, string $key): array +{ + $out = []; + foreach ($items as $item) { + if (!is_array($item) || !isset($item[$key])) { + continue; + } + $out[$item[$key]] = $item; + } + return $out; +} + +// ---- API client -------------------------------------------------------- + +class OBClient +{ + private string $baseUrl; + private ?string $authId = null; + private ?string $authKey = null; + + public function __construct(string $baseUrl) + { + $this->baseUrl = $baseUrl; + } + + public function login(string $username, string $password): void + { + $response = $this->request('account', 'login', [ + 'username' => $username, + 'password' => $password, + ], authenticated: false); + + if (empty($response['status'])) { + throw new RuntimeException($response['msg'] ?? 'Login failed.'); + } + + $session = $response['data'] ?? []; + if (empty($session['id']) || empty($session['key'])) { + throw new RuntimeException('Login response missing session credentials.'); + } + + $this->authId = (string) $session['id']; + $this->authKey = (string) $session['key']; + } + + /** + * Issue an API call. By default returns just the `data` payload of a + * successful response and throws on non-success. Pass rawData: true to + * receive the full envelope ['status', 'msg', 'data']. + */ + public function call(string $controller, string $action, array $data = [], bool $rawData = false): mixed + { + $response = $this->request($controller, $action, $data); + + if ($rawData) { + return $response; + } + + if (empty($response['status'])) { + $msg = $response['msg'] ?? 'Unknown error'; + if (is_array($msg)) { + $msg = implode(': ', array_map('strval', $msg)); + } + throw new RuntimeException("API call {$controller}.{$action} failed: {$msg}"); + } + + return $response['data'] ?? null; + } + + private function request(string $controller, string $action, array $data, bool $authenticated = true): array + { + $ch = curl_init($this->baseUrl . '/api.php'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'c' => $controller, + 'a' => $action, + 'd' => json_encode($data), + ]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + ]); + + if ($authenticated) { + if (!$this->authId || !$this->authKey) { + throw new RuntimeException('Not authenticated. Call login() first.'); + } + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'X-Auth-ID: ' . $this->authId, + 'X-Auth-Key: ' . $this->authKey, + ]); + } + + $body = curl_exec($ch); + $err = curl_error($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($body === false) { + throw new RuntimeException("HTTP request to {$controller}.{$action} failed: {$err}"); + } + if ($code < 200 || $code >= 300) { + throw new RuntimeException("API {$controller}.{$action} returned HTTP {$code}: " . substr($body, 0, 500)); + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new RuntimeException("API {$controller}.{$action} returned non-JSON: " . substr($body, 0, 200)); + } + + return $decoded; + } +} From 579decb4a6a999f509d9a7b0e601da25f2359c4e Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Sun, 3 May 2026 11:55:58 +0530 Subject: [PATCH 6/7] update admin UI for sampledata --- public/admin/index.php | 10 +++--- public/admin/run.php | 70 +++++++++++++++++++++++++++++++++++++++--- public/admin/script.js | 24 ++++++++++----- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/public/admin/index.php b/public/admin/index.php index b720088d..5bfaf2d2 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -35,13 +35,15 @@
- - - + + +
- \ No newline at end of file + diff --git a/public/admin/run.php b/public/admin/run.php index 5d1959be..f30fbd4e 100644 --- a/public/admin/run.php +++ b/public/admin/run.php @@ -32,12 +32,74 @@ exit(); } -$validCommands = ['check', 'cron run', 'updates list all', 'updates run all', 'seed list']; +$validCommands = ['check', 'cron run', 'updates list all', 'updates run all']; -// Allow seed run with a validated profile name. -if (preg_match('/^seed run ([a-z0-9_]+)$/', $json->command, $matches)) { - $validCommands[] = $json->command; +// Sample-data tool: lives in tools/sampledata/ and talks to the Observer +// API over HTTP. We pass Observer admin credentials through the environment +// so they don't appear in the process list. +if ($json->command === 'sampledata list') { + $output = []; + $resultCode = 0; + $cmd = 'php ' . escapeshellarg(__DIR__ . '/../../tools/sampledata/seed.php') . ' list 2>&1'; + exec($cmd, $output, $resultCode); + + echo json_encode([ + 'message' => 'Listing sample data profiles.', + 'result' => $converter->convert(implode(PHP_EOL, $output) ?: 'No output.'), + 'theme' => $theme->asCss() + ]); + + exit(); +} + +if (preg_match('/^sampledata run ([a-z0-9_]+)$/', $json->command, $matches)) { + $profile = $matches[1]; + $obUser = $json->sampleDataUsername ?? null; + $obPass = $json->sampleDataPassword ?? null; + + if (!$obUser || !$obPass) { + http_response_code(400); + echo json_encode(['message' => 'Observer admin credentials required to import sample data.']); + exit(); + } + + $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http') + . '://' . ($_SERVER['HTTP_HOST'] ?? '127.0.0.1'); + + $env = [ + 'OB_BASE_URL' => $baseUrl, + 'OB_USERNAME' => $obUser, + 'OB_PASSWORD' => $obPass, + // Preserve PATH so curl etc. resolve. + 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin', + ]; + + $cmd = 'php ' . escapeshellarg(__DIR__ . '/../../tools/sampledata/seed.php') + . ' run ' . escapeshellarg($profile) . ' 2>&1'; + + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $process = proc_open($cmd, $descriptors, $pipes, null, $env); + + if (!is_resource($process)) { + http_response_code(500); + echo json_encode(['message' => 'Failed to start the sample-data tool.']); + exit(); + } + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + $resultCode = proc_close($process); + + echo json_encode([ + 'message' => $resultCode === 0 ? 'Sample data imported.' : 'Sample data import failed.', + 'result' => $converter->convert($stdout ?: 'No output.'), + 'theme' => $theme->asCss() + ]); + + exit(); } + if (in_array($json->command, $validCommands)) { $output = []; $resultCode = 0; diff --git a/public/admin/script.js b/public/admin/script.js index 847a46fa..23f77415 100644 --- a/public/admin/script.js +++ b/public/admin/script.js @@ -12,7 +12,7 @@ async function run(data) }); const output = document.querySelector("#cli-output"); - + response.json().then((data) => { if (! response.ok) { output.innerHTML += '

' + data.message + '

'; @@ -60,25 +60,35 @@ async function cliUpdatesRun() run(data); } -async function cliSeedList() +async function sampleDataList() { const data = { - command: "seed list" + command: "sampledata list" }; run(data); } -async function cliSeedRun() +async function sampleDataRun() { - const profile = document.querySelector("#seed-profile").value; + const profile = document.querySelector("#sample-data-profile").value; + const username = document.querySelector("#sample-data-username").value; + const password = document.querySelector("#sample-data-password").value; + + if (! username || ! password) { + alert("Observer admin username and password are required to import sample data."); + return; + } + if (! confirm("Import sample data from profile: " + profile + "?\n\nThis will add categories, genres, users, playlists, and schedules. Existing data will not be overwritten.")) { return; } const data = { - command: "seed run " + profile + command: "sampledata run " + profile, + sampleDataUsername: username, + sampleDataPassword: password }; run(data); -} \ No newline at end of file +} From df5b4755bb915d5df5f17560159ce90b1d38f161 Mon Sep 17 00:00:00 2001 From: sreeshanth-soma1 Date: Fri, 8 May 2026 19:28:08 +0530 Subject: [PATCH 7/7] replace legacy shell-based sample data tool with integrated SampleData module and add performance test artifacts --- modules/SampleData/SampleData.php | 39 + modules/SampleData/controllers/SampleData.php | 42 + modules/SampleData/html/sampledata.html | 23 + modules/SampleData/js/sampledata.js | 101 +++ .../en_community_radio/categories.json | 0 .../profiles/en_community_radio/genres.json | 0 .../profiles/en_community_radio/groups.json | 0 .../profiles/en_community_radio/manifest.json | 0 .../en_community_radio/metadata_fields.json | 0 .../en_community_radio/playlists.json | 0 .../profiles/en_community_radio/schedule.json | 0 .../profiles/en_community_radio/settings.json | 0 .../profiles/en_community_radio/users.json | 0 public/admin/index.php | 11 +- public/admin/run.php | 69 +- public/admin/script.js | 37 +- tools/sampledata/README.md | 90 --- tools/sampledata/seed.php | 726 ------------------ 18 files changed, 209 insertions(+), 929 deletions(-) create mode 100644 modules/SampleData/SampleData.php create mode 100644 modules/SampleData/controllers/SampleData.php create mode 100644 modules/SampleData/html/sampledata.html create mode 100644 modules/SampleData/js/sampledata.js rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/categories.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/genres.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/groups.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/manifest.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/metadata_fields.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/playlists.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/schedule.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/settings.json (100%) rename {tools/sampledata => modules/SampleData}/profiles/en_community_radio/users.json (100%) delete mode 100644 tools/sampledata/README.md delete mode 100644 tools/sampledata/seed.php diff --git a/modules/SampleData/SampleData.php b/modules/SampleData/SampleData.php new file mode 100644 index 00000000..f546f2b9 --- /dev/null +++ b/modules/SampleData/SampleData.php @@ -0,0 +1,39 @@ +permission_enable('administration', 'import_sample_data', 'import sample data profiles into Observer'); + + return true; + } + + public function uninstall() + { + $this->permission_disable('import_sample_data'); + + return true; + } + + public function purge() + { + $this->permission_delete('import_sample_data'); + + return true; + } +} diff --git a/modules/SampleData/controllers/SampleData.php b/modules/SampleData/controllers/SampleData.php new file mode 100644 index 00000000..4c72c199 --- /dev/null +++ b/modules/SampleData/controllers/SampleData.php @@ -0,0 +1,42 @@ +user->require_permission('import_sample_data'); + $this->SampleDataModel = $this->load->model('SampleData', 'SampleData'); + } + + public function listProfiles() + { + $profiles = $this->SampleDataModel('listProfiles'); + return [true, 'Sample data profiles.', $profiles]; + } + + public function runProfile() + { + $profile = trim((string) $this->data('profile')); + + if ($profile === '' || !preg_match('/^[a-z0-9_]+$/', $profile)) { + return [false, 'Invalid profile name.']; + } + + $result = $this->SampleDataModel('runProfile', $profile); + + if (!$result['success']) { + return [false, $result['error'], ['log' => $result['log']]]; + } + + return [true, 'Sample data imported.', ['log' => $result['log']]]; + } +} diff --git a/modules/SampleData/html/sampledata.html b/modules/SampleData/html/sampledata.html new file mode 100644 index 00000000..93a3a235 --- /dev/null +++ b/modules/SampleData/html/sampledata.html @@ -0,0 +1,23 @@ + + +

Import Sample Data

+ +

Pick a sample data profile and click "Import" to seed your installation. The action is idempotent — items that already exist are skipped, never overwritten.

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

+ + diff --git a/modules/SampleData/js/sampledata.js b/modules/SampleData/js/sampledata.js new file mode 100644 index 00000000..eb227dca --- /dev/null +++ b/modules/SampleData/js/sampledata.js @@ -0,0 +1,101 @@ +// Copyright 2012-2026 OpenBroadcaster, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +OBModules.SampleData = new Object(); + +OBModules.SampleData.init = function () { + OB.Callbacks.add('ready', 0, OBModules.SampleData.initMenu); +}; + +OBModules.SampleData.initMenu = function () { + OB.UI.addSubMenuItem( + 'admin', + 'Import Sample Data', + 'import_sample_data', + OBModules.SampleData.importPage, + 110, + 'import_sample_data' + ); +}; + +OBModules.SampleData.importPage = function () { + OB.UI.replaceMain('modules/sampledata/sampledata.html'); + + OBModules.SampleData.profiles = {}; + $('#sampledata_module-profile').html(''); + $('#sampledata_module-import').prop('disabled', true); + $('#sampledata_module-log').hide().text(''); + + OB.API.post('sampledata', 'listProfiles', {}, function (response) { + if (!response.status) { + $('#sampledata_module-info').text('Error loading sample data profiles.'); + return; + } + + var profiles = response.data || []; + var $select = $('#sampledata_module-profile'); + $select.empty(); + + if (profiles.length === 0) { + $select.append(''); + $('#sampledata_module-import').prop('disabled', true); + return; + } + + $.each(profiles, function (_, profile) { + OBModules.SampleData.profiles[profile.directory] = profile; + $select.append( + $('').val(profile.directory).text(profile.name) + ); + }); + + OBModules.SampleData.updateDescription(); + $('#sampledata_module-import').prop('disabled', false); + }); + + $('#sampledata_module-profile').off('change').on('change', OBModules.SampleData.updateDescription); + $('#sampledata_module-import').off('click').on('click', function () { + OBModules.SampleData.runImport(false); + }); +}; + +OBModules.SampleData.updateDescription = function () { + var dir = $('#sampledata_module-profile').val(); + var profile = OBModules.SampleData.profiles[dir]; + $('#sampledata_module-description').text(profile ? (profile.description || '') : ''); +}; + +OBModules.SampleData.runImport = function (confirmed) { + var dir = $('#sampledata_module-profile').val(); + if (!dir) { + return; + } + + if (!confirmed) { + OB.UI.confirm( + 'Import sample data profile "' + dir + '"?\n\nThis will create permission groups, users, playlists, a sample player and schedule, and apply settings. The action is idempotent — existing items are skipped — but it cannot be undone.', + function () { OBModules.SampleData.runImport(true); }, + 'Yes, Import', + 'No, Cancel', + 'delete' + ); + return; + } + + $('#sampledata_module-info').text('Importing…'); + $('#sampledata_module-import').prop('disabled', true); + $('#sampledata_module-log').show().text(''); + + OB.API.post('sampledata', 'runProfile', { profile: dir }, function (response) { + var log = (response.data && response.data.log) ? response.data.log.join('\n') : ''; + $('#sampledata_module-log').text(log); + + if (response.status) { + $('#sampledata_module-info').text('Sample data imported.'); + } else { + $('#sampledata_module-info').text('Import failed: ' + (response.msg || 'unknown error')); + } + + $('#sampledata_module-import').prop('disabled', false); + }); +}; diff --git a/tools/sampledata/profiles/en_community_radio/categories.json b/modules/SampleData/profiles/en_community_radio/categories.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/categories.json rename to modules/SampleData/profiles/en_community_radio/categories.json diff --git a/tools/sampledata/profiles/en_community_radio/genres.json b/modules/SampleData/profiles/en_community_radio/genres.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/genres.json rename to modules/SampleData/profiles/en_community_radio/genres.json diff --git a/tools/sampledata/profiles/en_community_radio/groups.json b/modules/SampleData/profiles/en_community_radio/groups.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/groups.json rename to modules/SampleData/profiles/en_community_radio/groups.json diff --git a/tools/sampledata/profiles/en_community_radio/manifest.json b/modules/SampleData/profiles/en_community_radio/manifest.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/manifest.json rename to modules/SampleData/profiles/en_community_radio/manifest.json diff --git a/tools/sampledata/profiles/en_community_radio/metadata_fields.json b/modules/SampleData/profiles/en_community_radio/metadata_fields.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/metadata_fields.json rename to modules/SampleData/profiles/en_community_radio/metadata_fields.json diff --git a/tools/sampledata/profiles/en_community_radio/playlists.json b/modules/SampleData/profiles/en_community_radio/playlists.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/playlists.json rename to modules/SampleData/profiles/en_community_radio/playlists.json diff --git a/tools/sampledata/profiles/en_community_radio/schedule.json b/modules/SampleData/profiles/en_community_radio/schedule.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/schedule.json rename to modules/SampleData/profiles/en_community_radio/schedule.json diff --git a/tools/sampledata/profiles/en_community_radio/settings.json b/modules/SampleData/profiles/en_community_radio/settings.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/settings.json rename to modules/SampleData/profiles/en_community_radio/settings.json diff --git a/tools/sampledata/profiles/en_community_radio/users.json b/modules/SampleData/profiles/en_community_radio/users.json similarity index 100% rename from tools/sampledata/profiles/en_community_radio/users.json rename to modules/SampleData/profiles/en_community_radio/users.json diff --git a/public/admin/index.php b/public/admin/index.php index 5bfaf2d2..15bfb8ba 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -34,16 +34,7 @@ -
- - - - - -
- + \ No newline at end of file diff --git a/public/admin/run.php b/public/admin/run.php index f30fbd4e..da8ada8a 100644 --- a/public/admin/run.php +++ b/public/admin/run.php @@ -33,77 +33,10 @@ } $validCommands = ['check', 'cron run', 'updates list all', 'updates run all']; - -// Sample-data tool: lives in tools/sampledata/ and talks to the Observer -// API over HTTP. We pass Observer admin credentials through the environment -// so they don't appear in the process list. -if ($json->command === 'sampledata list') { - $output = []; - $resultCode = 0; - $cmd = 'php ' . escapeshellarg(__DIR__ . '/../../tools/sampledata/seed.php') . ' list 2>&1'; - exec($cmd, $output, $resultCode); - - echo json_encode([ - 'message' => 'Listing sample data profiles.', - 'result' => $converter->convert(implode(PHP_EOL, $output) ?: 'No output.'), - 'theme' => $theme->asCss() - ]); - - exit(); -} - -if (preg_match('/^sampledata run ([a-z0-9_]+)$/', $json->command, $matches)) { - $profile = $matches[1]; - $obUser = $json->sampleDataUsername ?? null; - $obPass = $json->sampleDataPassword ?? null; - - if (!$obUser || !$obPass) { - http_response_code(400); - echo json_encode(['message' => 'Observer admin credentials required to import sample data.']); - exit(); - } - - $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http') - . '://' . ($_SERVER['HTTP_HOST'] ?? '127.0.0.1'); - - $env = [ - 'OB_BASE_URL' => $baseUrl, - 'OB_USERNAME' => $obUser, - 'OB_PASSWORD' => $obPass, - // Preserve PATH so curl etc. resolve. - 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin', - ]; - - $cmd = 'php ' . escapeshellarg(__DIR__ . '/../../tools/sampledata/seed.php') - . ' run ' . escapeshellarg($profile) . ' 2>&1'; - - $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $process = proc_open($cmd, $descriptors, $pipes, null, $env); - - if (!is_resource($process)) { - http_response_code(500); - echo json_encode(['message' => 'Failed to start the sample-data tool.']); - exit(); - } - - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - fclose($pipes[2]); - $resultCode = proc_close($process); - - echo json_encode([ - 'message' => $resultCode === 0 ? 'Sample data imported.' : 'Sample data import failed.', - 'result' => $converter->convert($stdout ?: 'No output.'), - 'theme' => $theme->asCss() - ]); - - exit(); -} - if (in_array($json->command, $validCommands)) { $output = []; $resultCode = 0; - exec(__DIR__ . "/../../cli/ob {$json->command}", $output); + exec(__DIR__ . "/../../tools/cli/ob {$json->command}", $output); $output = $converter->convert(implode(PHP_EOL, $output)); if ($output === "" && $resultCode === 0) { diff --git a/public/admin/script.js b/public/admin/script.js index 23f77415..26688dbf 100644 --- a/public/admin/script.js +++ b/public/admin/script.js @@ -12,7 +12,7 @@ async function run(data) }); const output = document.querySelector("#cli-output"); - + response.json().then((data) => { if (! response.ok) { output.innerHTML += '

' + data.message + '

'; @@ -58,37 +58,4 @@ async function cliUpdatesRun() }; run(data); -} - -async function sampleDataList() -{ - const data = { - command: "sampledata list" - }; - - run(data); -} - -async function sampleDataRun() -{ - const profile = document.querySelector("#sample-data-profile").value; - const username = document.querySelector("#sample-data-username").value; - const password = document.querySelector("#sample-data-password").value; - - if (! username || ! password) { - alert("Observer admin username and password are required to import sample data."); - return; - } - - if (! confirm("Import sample data from profile: " + profile + "?\n\nThis will add categories, genres, users, playlists, and schedules. Existing data will not be overwritten.")) { - return; - } - - const data = { - command: "sampledata run " + profile, - sampleDataUsername: username, - sampleDataPassword: password - }; - - run(data); -} +} \ No newline at end of file diff --git a/tools/sampledata/README.md b/tools/sampledata/README.md deleted file mode 100644 index dca40f65..00000000 --- a/tools/sampledata/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Sample Data Seeder - -Populates a fresh OpenBroadcaster Observer install with categories, genres, -permission groups, users, settings, custom metadata fields, playlists, a sample -player, and a daily show schedule. - -The seeder talks to the Observer API over HTTP using `curl` — it does not touch -the database directly and does not depend on Observer's CLI or core code. - -## Requirements - -- PHP 8.1+ on the machine running the seeder (uses curl extension) -- Network access to a running Observer instance -- Credentials for an Observer admin user with permissions to manage users, - permissions, players, playlists, schedules, media settings, metadata, and - global client storage - -## Usage - -```sh -# List available profiles -php tools/sampledata/seed.php list - -# Run a profile against a server -php tools/sampledata/seed.php run en_community_radio \ - --base-url=https://observer.example.com \ - --username=admin - -# Same, but read all settings from environment variables -OB_BASE_URL=https://observer.example.com \ -OB_USERNAME=admin \ -OB_PASSWORD=hunter2 \ - php tools/sampledata/seed.php run en_community_radio -``` - -If `--password` is not given and `OB_PASSWORD` is not set, the seeder prompts -interactively (without echoing) when run from a terminal. - -### Configuration precedence - -Each setting can come from a CLI flag, an environment variable, or a default; -the first source listed wins. - -| Setting | CLI flag | Env var | Default | -| ---------- | ------------ | ------------ | ------------------------ | -| Base URL | `--base-url` | `OB_BASE_URL`| `http://127.0.0.1:8080` | -| Username | `--username` | `OB_USERNAME`| `admin` | -| Password | `--password` | `OB_PASSWORD`| (interactive prompt) | - -## Idempotency - -The seeder is safe to re-run. Before creating each item it checks whether one -with the same name already exists and skips it if so. This applies to -categories, genres, permission groups, users, custom metadata fields, -playlists, and the sample player. - -Settings (file format whitelists, core metadata flags, login message, welcome -page) are always overwritten on each run, matching the original seeder -behaviour — the API endpoints for these settings are inherently -overwrite-only. - -## Profile layout - -``` -tools/sampledata/profiles// - manifest.json # name, description, version - categories.json # ["Music", "News", ...] - genres.json # { "": [{name, description}, ...] } - groups.json # [{ name, permissions: ["perm_name", ...] }] - users.json # [{ name, username, email, password, ... , group }] - settings.json # { settings, client_login_message, client_welcome_page } - metadata_fields.json # [{ name, type, mode, visibility, ... }] - playlists.json # [{ name, description, status, type }] - schedule.json # { player: {...}, shows: [{ title, start_time, ... }] } -``` - -Each step is optional — if a JSON file is missing for a step the seeder logs -`[SKIP]` and moves on. - -## Adding a new profile - -Create a new directory under `tools/sampledata/profiles/` named with -lowercase letters, digits, and underscores only (matches `^[a-z0-9_]+$`), then -add the JSON files above. `seed.php list` will pick it up automatically. - -## Admin UI integration - -The Observer admin panel at `/admin/` exposes the seeder via the "Import -sample data" button, which `exec()`s this script with credentials supplied in -the form. The same auth and idempotency rules apply. diff --git a/tools/sampledata/seed.php b/tools/sampledata/seed.php deleted file mode 100644 index 711ef6db..00000000 --- a/tools/sampledata/seed.php +++ /dev/null @@ -1,726 +0,0 @@ - -// -// Configuration (CLI flags override environment): -// --base-url= or env OB_BASE_URL (default http://127.0.0.1:8080) -// --username= or env OB_USERNAME (default admin) -// --password= or env OB_PASSWORD (read interactively if missing) - -if (php_sapi_name() !== 'cli') { - exit("This tool may only be run from the command line.\n"); -} - -// Convert errors to exceptions, but ignore deprecation/notice noise so a -// transient warning (e.g. PHP 8.5 deprecating curl_close) doesn't kill the run. -set_error_handler(function ($severity, $message, $file, $line) { - if (!(error_reporting() & $severity)) { - return false; - } - if ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED || $severity === E_NOTICE || $severity === E_USER_NOTICE) { - return false; - } - throw new ErrorException($message, 0, $severity, $file, $line); -}); - -$opts = parseArgs($argv); -$command = $opts['_positional'][0] ?? null; - -if ($command === 'list') { - listProfiles(); - exit(0); -} - -if ($command === 'run') { - $profile = $opts['_positional'][1] ?? null; - if (!$profile) { - fwrite(STDERR, "Profile name required.\n"); - printUsage(); - exit(1); - } - - if (!preg_match('/^[a-z0-9_]+$/', $profile)) { - fwrite(STDERR, "Invalid profile name: must match [a-z0-9_]+\n"); - exit(1); - } - - $config = loadConfig($opts); - $exitCode = runProfile($profile, $config) ? 0 : 1; - exit($exitCode); -} - -printUsage(); -exit($command ? 1 : 0); - -function printUsage(): void -{ - echo "Usage:" . PHP_EOL; - echo " php tools/sampledata/seed.php list" . PHP_EOL; - echo " php tools/sampledata/seed.php run [--base-url=URL] [--username=USER] [--password=PASS]" . PHP_EOL; -} - -function parseArgs(array $argv): array -{ - $opts = ['_positional' => []]; - array_shift($argv); // script name - - foreach ($argv as $arg) { - if (str_starts_with($arg, '--')) { - $body = substr($arg, 2); - if (str_contains($body, '=')) { - [$k, $v] = explode('=', $body, 2); - $opts[$k] = $v; - } else { - $opts[$body] = true; - } - } else { - $opts['_positional'][] = $arg; - } - } - - return $opts; -} - -function profilesRoot(): string -{ - return __DIR__ . '/profiles'; -} - -function listProfiles(): void -{ - $root = profilesRoot(); - if (!is_dir($root)) { - echo "No profiles directory found." . PHP_EOL; - return; - } - - $directories = array_filter( - scandir($root), - fn ($f) => $f[0] !== '.' && is_dir($root . '/' . $f) - ); - - if (empty($directories)) { - echo "No profiles found." . PHP_EOL; - return; - } - - echo "Available sample data profiles:" . PHP_EOL . PHP_EOL; - - foreach ($directories as $dir) { - $manifestPath = $root . '/' . $dir . '/manifest.json'; - $manifest = file_exists($manifestPath) - ? (json_decode(file_get_contents($manifestPath), true) ?: []) - : []; - - echo $dir . PHP_EOL; - echo ' ' . ($manifest['name'] ?? $dir) . PHP_EOL; - if (!empty($manifest['description'])) { - echo ' ' . $manifest['description'] . PHP_EOL; - } - echo PHP_EOL; - } -} - -function loadConfig(array $opts): array -{ - $baseUrl = $opts['base-url'] ?? getenv('OB_BASE_URL') ?: 'http://127.0.0.1:8080'; - $username = $opts['username'] ?? getenv('OB_USERNAME') ?: 'admin'; - $password = $opts['password'] ?? getenv('OB_PASSWORD') ?: null; - - if ($password === null || $password === '') { - if (!stream_isatty(STDIN)) { - fwrite(STDERR, "Password required: pass --password=, set OB_PASSWORD, or run interactively.\n"); - exit(1); - } - $password = readPasswordPrompt("Password for {$username}: "); - } - - return [ - 'base_url' => rtrim($baseUrl, '/'), - 'username' => $username, - 'password' => $password, - ]; -} - -function readPasswordPrompt(string $prompt): string -{ - fwrite(STDOUT, $prompt); - if (DIRECTORY_SEPARATOR === '\\') { - $password = trim(fgets(STDIN)); - } else { - @system('stty -echo'); - $password = trim(fgets(STDIN)); - @system('stty echo'); - fwrite(STDOUT, PHP_EOL); - } - return $password; -} - -function runProfile(string $profileName, array $config): bool -{ - $profileDir = profilesRoot() . '/' . $profileName; - if (!is_dir($profileDir)) { - fwrite(STDERR, "Profile not found: {$profileName}\n"); - return false; - } - - $manifest = readJson($profileDir . '/manifest.json'); - if ($manifest === null) { - fwrite(STDERR, "Profile manifest missing or invalid.\n"); - return false; - } - - echo PHP_EOL; - echo 'Seeding profile: ' . ($manifest['name'] ?? $profileName) . PHP_EOL; - echo 'Server: ' . $config['base_url'] . PHP_EOL; - echo str_repeat('-', 60) . PHP_EOL; - - try { - $client = new OBClient($config['base_url']); - $client->login($config['username'], $config['password']); - echo 'Logged in as ' . $config['username'] . PHP_EOL; - } catch (Throwable $e) { - fwrite(STDERR, 'Login failed: ' . $e->getMessage() . PHP_EOL); - return false; - } - - $steps = [ - ['seedCategories', 'categories.json', 'Categories'], - ['seedGenres', 'genres.json', 'Genres'], - ['seedGroups', 'groups.json', 'Permission Groups'], - ['seedUsers', 'users.json', 'Users'], - ['seedSettings', 'settings.json', 'Settings'], - ['seedMetadataFields', 'metadata_fields.json', 'Custom Metadata Fields'], - ['seedPlaylists', 'playlists.json', 'Playlists'], - ['seedSchedule', 'schedule.json', 'Player & Schedule'], - ]; - - foreach ($steps as [$method, $file, $label]) { - $filePath = $profileDir . '/' . $file; - if (!file_exists($filePath)) { - echo "[SKIP] {$label} — {$file} not found." . PHP_EOL; - continue; - } - - $data = readJson($filePath); - if ($data === null) { - fwrite(STDERR, "Invalid JSON in {$file}.\n"); - return false; - } - - echo PHP_EOL . "[{$label}]" . PHP_EOL; - try { - $method($client, $data); - } catch (Throwable $e) { - fwrite(STDERR, "Error in {$label}: " . $e->getMessage() . PHP_EOL); - return false; - } - } - - echo PHP_EOL . str_repeat('-', 60) . PHP_EOL; - echo 'Seed complete.' . PHP_EOL; - - return true; -} - -function readJson(string $path): ?array -{ - if (!file_exists($path)) { - return null; - } - $decoded = json_decode(file_get_contents($path), true); - return is_array($decoded) ? $decoded : null; -} - -// ---- seed steps --------------------------------------------------------- - -function seedCategories(OBClient $client, array $categories): void -{ - $existing = indexBy($client->call('metadata', 'category_list', []) ?: [], 'name'); - - $inserted = 0; - $skipped = 0; - foreach ($categories as $name) { - if (isset($existing[$name])) { - $skipped++; - continue; - } - $client->call('metadata', 'category_save', ['name' => $name]); - $inserted++; - } - - echo " Inserted: {$inserted}, Skipped (already exist): {$skipped}" . PHP_EOL; -} - -function seedGenres(OBClient $client, array $genresByCategory): void -{ - $categories = indexBy($client->call('metadata', 'category_list', []) ?: [], 'name'); - $existingGenres = $client->call('metadata', 'genre_list', []) ?: []; - - // Index existing genres by "name|category_id" so we can detect dupes per category. - $genreKey = []; - foreach ($existingGenres as $g) { - $genreKey[strtolower($g['name']) . '|' . $g['media_category_id']] = true; - } - - $inserted = 0; - $skipped = 0; - $errors = 0; - - foreach ($genresByCategory as $categoryName => $genres) { - if (!isset($categories[$categoryName])) { - echo " Warning: Category '{$categoryName}' not found, skipping its genres." . PHP_EOL; - $errors += count($genres); - continue; - } - $categoryId = (int) $categories[$categoryName]['id']; - - foreach ($genres as $genre) { - $key = strtolower($genre['name']) . '|' . $categoryId; - if (isset($genreKey[$key])) { - $skipped++; - continue; - } - $client->call('metadata', 'genre_save', [ - 'name' => $genre['name'], - 'description' => $genre['description'] ?? $genre['name'], - 'media_category_id' => $categoryId, - ]); - $genreKey[$key] = true; - $inserted++; - } - } - - $line = " Inserted: {$inserted}, Skipped: {$skipped}"; - if ($errors > 0) { - $line .= ", Errors: {$errors}"; - } - echo $line . PHP_EOL; -} - -function seedGroups(OBClient $client, array $groups): void -{ - $existingGroups = indexBy($client->call('users', 'group_list') ?: [], 'name'); - - $inserted = 0; - $skipped = 0; - foreach ($groups as $group) { - if (isset($existingGroups[$group['name']])) { - echo " Group '{$group['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $client->call('users', 'permissions_manage_addedit', [ - 'name' => $group['name'], - 'permissions' => $group['permissions'], - ]); - echo " Created group '{$group['name']}' with " . count($group['permissions']) . ' permissions.' . PHP_EOL; - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } -} - -function seedUsers(OBClient $client, array $users): void -{ - // user_manage_list returns [users, sort_col, sort_desc]; first element is the user list. - $userListResponse = $client->call('users', 'user_manage_list') ?: []; - $userList = $userListResponse[0] ?? []; - $existingUsers = indexBy($userList, 'username'); - $groups = indexBy($client->call('users', 'group_list') ?: [], 'name'); - - $inserted = 0; - $skipped = 0; - foreach ($users as $userData) { - if (isset($existingUsers[$userData['username']])) { - echo " User '{$userData['username']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $groupIds = []; - if (!empty($userData['group']) && isset($groups[$userData['group']])) { - $groupIds[] = (int) $groups[$userData['group']]['id']; - } - - $client->call('users', 'user_manage_addedit', [ - 'name' => $userData['name'], - 'username' => $userData['username'], - 'email' => $userData['email'], - 'password' => $userData['password'], - 'password_confirm' => $userData['password'], - 'display_name' => $userData['display_name'], - 'enabled' => $userData['enabled'] ? 1 : 0, - 'group_ids' => $groupIds, - 'appkeys' => [], - ]); - - if ($groupIds) { - echo " Created user '{$userData['username']}' in group '{$userData['group']}'." . PHP_EOL; - } else { - echo " Created user '{$userData['username']}'." . PHP_EOL; - } - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } -} - -function seedSettings(OBClient $client, array $data): void -{ - $settings = $data['settings'] ?? []; - - // Media file format whitelist. The API expects arrays; profiles store - // comma-separated strings for readability. - $formatKeys = ['audio_formats', 'video_formats', 'image_formats', 'document_formats']; - $formatPayload = []; - foreach ($formatKeys as $key) { - if (!isset($settings[$key])) { - continue; - } - $value = $settings[$key]; - $formatPayload[$key] = is_array($value) - ? $value - : array_values(array_filter(array_map('trim', explode(',', (string) $value)))); - } - if (!empty($formatPayload)) { - try { - $client->call('media', 'formats_save', $formatPayload); - echo ' Set media file formats.' . PHP_EOL; - } catch (RuntimeException $e) { - // formats_save is all-or-nothing; surface the API message but keep going. - echo ' Skipped media file formats: ' . $e->getMessage() . PHP_EOL; - } - } - - // Core metadata required/enabled flags. - if (isset($settings['core_metadata'])) { - $coreMetadata = is_string($settings['core_metadata']) - ? (json_decode($settings['core_metadata'], true) ?: []) - : $settings['core_metadata']; - - // The API uses "country" / "language" keys; profiles may use "country_id" / "language_id". - // Each value must be one of 'required', 'enabled', or 'disabled' per validate_fields(). - $payload = [ - 'artist' => $coreMetadata['artist'] ?? 'disabled', - 'album' => $coreMetadata['album'] ?? 'disabled', - 'year' => $coreMetadata['year'] ?? 'disabled', - 'category_id' => $coreMetadata['category_id'] ?? 'disabled', - 'country' => $coreMetadata['country'] ?? $coreMetadata['country_id'] ?? 'disabled', - 'language' => $coreMetadata['language'] ?? $coreMetadata['language_id'] ?? 'disabled', - 'comments' => $coreMetadata['comments'] ?? 'disabled', - 'dynamic_content_default' => $coreMetadata['dynamic_content_default'] ?? 'enabled', - 'dynamic_content_hidden' => $coreMetadata['dynamic_content_hidden'] ?? false, - ]; - $client->call('metadata', 'media_required_fields', $payload); - echo ' Set core metadata fields.' . PHP_EOL; - } - - if (!empty($data['client_login_message'])) { - $client->call('clientsettings', 'set_login_message', [ - 'client_login_message' => $data['client_login_message'], - ]); - echo ' Set login message.' . PHP_EOL; - } - - if (!empty($data['client_welcome_page'])) { - $client->call('clientsettings', 'set_welcome_page', [ - 'client_welcome_page' => $data['client_welcome_page'], - ]); - echo ' Set welcome page.' . PHP_EOL; - } -} - -function seedMetadataFields(OBClient $client, array $fields): void -{ - $existing = indexBy($client->call('metadata', 'media_metadata_fields') ?: [], 'name'); - - $inserted = 0; - $skipped = 0; - foreach ($fields as $field) { - if (isset($existing[strtolower($field['name'])])) { - echo " Field '{$field['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $payload = [ - 'name' => $field['name'], - 'description' => $field['description'] ?? $field['name'], - 'type' => $field['type'], - 'mode' => $field['mode'] ?? 'optional', - 'visibility' => $field['visibility'] ?? 'visible', - 'select_options' => $field['select_options'] ?? '', - 'id3_key' => $field['id3_key'] ?? '', - 'default' => $field['default'] ?? '', - 'tag_suggestions' => $field['tag_suggestions'] ?? [], - ]; - - $client->call('metadata', 'metadata_save', $payload); - echo " Created field '{$field['name']}' ({$field['type']})." . PHP_EOL; - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } -} - -function seedPlaylists(OBClient $client, array $playlists): void -{ - $existing = indexBy(searchPlaylists($client), 'name'); - - $inserted = 0; - $skipped = 0; - foreach ($playlists as $playlist) { - if (isset($existing[$playlist['name']])) { - echo " Playlist '{$playlist['name']}' already exists, skipping." . PHP_EOL; - $skipped++; - continue; - } - - $client->call('playlists', 'save', [ - 'name' => $playlist['name'], - 'description' => $playlist['description'] ?? '', - 'status' => $playlist['status'] ?? 'public', - 'type' => $playlist['type'] ?? 'standard', - 'items' => [], - 'liveassist_button_items' => [], - 'properties' => null, - ]); - echo " Created playlist '{$playlist['name']}' (" . ($playlist['type'] ?? 'standard') . ').' . PHP_EOL; - $inserted++; - } - - if ($skipped > 0) { - echo " Skipped: {$skipped}" . PHP_EOL; - } -} - -function seedSchedule(OBClient $client, array $data): void -{ - if (empty($data['player']) || empty($data['shows'])) { - echo ' No player or shows defined.' . PHP_EOL; - return; - } - - $playerData = $data['player']; - - $existingPlayers = indexBy($client->call('players', 'search') ?: [], 'name'); - if (isset($existingPlayers[$playerData['name']])) { - echo " Player '{$playerData['name']}' already exists, skipping schedule." . PHP_EOL; - return; - } - - $playerSaveResult = $client->call('players', 'save', [ - 'name' => $playerData['name'], - 'description' => 'Sample player created by seed profile.', - 'timezone' => $playerData['timezone'] ?? 'America/Toronto', - 'support_audio' => $playerData['support_audio'] ?? true, - 'support_video' => $playerData['support_video'] ?? true, - 'support_images' => $playerData['support_images'] ?? true, - 'support_linein' => false, - 'password' => 'changeme', - 'station_ids' => [], - 'station_id_image_duration' => 15, - 'stream_url' => '', - 'parent_player_id' => null, - ], rawData: true); - - $playerId = is_array($playerSaveResult) ? ($playerSaveResult['data'] ?? null) : null; - if (!$playerId) { - // Fallback: re-list and find by name. - $allPlayers = indexBy($client->call('players', 'search', ['l' => 999999]) ?: [], 'name'); - $playerId = $allPlayers[$playerData['name']]['id'] ?? null; - } - - if (!$playerId) { - echo ' Error creating player.' . PHP_EOL; - return; - } - - echo " Created player '{$playerData['name']}'." . PHP_EOL; - - $existingPlaylists = indexBy(searchPlaylists($client), 'name'); - - $showCount = 0; - foreach ($data['shows'] as $show) { - if (isset($existingPlaylists[$show['title']])) { - $playlistId = $existingPlaylists[$show['title']]['id']; - } else { - $client->call('playlists', 'save', [ - 'name' => $show['title'], - 'description' => $show['description'] ?? '', - 'status' => 'public', - 'type' => 'standard', - 'items' => [], - 'liveassist_button_items' => [], - 'properties' => null, - ]); - $refreshed = indexBy(searchPlaylists($client), 'name'); - $playlistId = $refreshed[$show['title']]['id'] ?? null; - $existingPlaylists[$show['title']] = ['id' => $playlistId]; - } - - if (!$playlistId) { - echo " Error creating playlist for show '{$show['title']}'." . PHP_EOL; - continue; - } - - $startDate = date('Y-m-d') . ' ' . $show['start_time'] . ':00'; - $recurringDays = $show['recurring_days'] ?? 180; - $stopDate = date('Y-m-d', strtotime("+{$recurringDays} days")); - - $client->call('shows', 'save', [ - 'player_id' => $playerId, - 'item_id' => $playlistId, - 'item_type' => 'playlist', - 'mode' => $show['mode'] ?? 'daily', - 'x_data' => 1, - 'start' => $startDate, - 'duration' => intval($show['duration']) * 60, - 'stop' => $stopDate, - ]); - - echo " Scheduled '{$show['title']}' at {$show['start_time']} ({$show['duration']}min, " . ($show['mode'] ?? 'daily') . ').' . PHP_EOL; - $showCount++; - } - - echo " Scheduled {$showCount} shows on player '{$playerData['name']}'." . PHP_EOL; -} - -function searchPlaylists(OBClient $client): array -{ - // playlists.search returns ['num_results' => int, 'playlists' => [...]] - $response = $client->call('playlists', 'search') ?: []; - return $response['playlists'] ?? []; -} - -function indexBy(array $items, string $key): array -{ - $out = []; - foreach ($items as $item) { - if (!is_array($item) || !isset($item[$key])) { - continue; - } - $out[$item[$key]] = $item; - } - return $out; -} - -// ---- API client -------------------------------------------------------- - -class OBClient -{ - private string $baseUrl; - private ?string $authId = null; - private ?string $authKey = null; - - public function __construct(string $baseUrl) - { - $this->baseUrl = $baseUrl; - } - - public function login(string $username, string $password): void - { - $response = $this->request('account', 'login', [ - 'username' => $username, - 'password' => $password, - ], authenticated: false); - - if (empty($response['status'])) { - throw new RuntimeException($response['msg'] ?? 'Login failed.'); - } - - $session = $response['data'] ?? []; - if (empty($session['id']) || empty($session['key'])) { - throw new RuntimeException('Login response missing session credentials.'); - } - - $this->authId = (string) $session['id']; - $this->authKey = (string) $session['key']; - } - - /** - * Issue an API call. By default returns just the `data` payload of a - * successful response and throws on non-success. Pass rawData: true to - * receive the full envelope ['status', 'msg', 'data']. - */ - public function call(string $controller, string $action, array $data = [], bool $rawData = false): mixed - { - $response = $this->request($controller, $action, $data); - - if ($rawData) { - return $response; - } - - if (empty($response['status'])) { - $msg = $response['msg'] ?? 'Unknown error'; - if (is_array($msg)) { - $msg = implode(': ', array_map('strval', $msg)); - } - throw new RuntimeException("API call {$controller}.{$action} failed: {$msg}"); - } - - return $response['data'] ?? null; - } - - private function request(string $controller, string $action, array $data, bool $authenticated = true): array - { - $ch = curl_init($this->baseUrl . '/api.php'); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query([ - 'c' => $controller, - 'a' => $action, - 'd' => json_encode($data), - ]), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - ]); - - if ($authenticated) { - if (!$this->authId || !$this->authKey) { - throw new RuntimeException('Not authenticated. Call login() first.'); - } - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'X-Auth-ID: ' . $this->authId, - 'X-Auth-Key: ' . $this->authKey, - ]); - } - - $body = curl_exec($ch); - $err = curl_error($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($body === false) { - throw new RuntimeException("HTTP request to {$controller}.{$action} failed: {$err}"); - } - if ($code < 200 || $code >= 300) { - throw new RuntimeException("API {$controller}.{$action} returned HTTP {$code}: " . substr($body, 0, 500)); - } - - $decoded = json_decode($body, true); - if (!is_array($decoded)) { - throw new RuntimeException("API {$controller}.{$action} returned non-JSON: " . substr($body, 0, 200)); - } - - return $decoded; - } -}