diff --git a/public/admin/index.php b/public/admin/index.php index 15bfb8ba..5bfaf2d2 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -34,7 +34,16 @@ +
+ + + + + +
- \ No newline at end of file + diff --git a/public/admin/run.php b/public/admin/run.php index da8ada8a..f30fbd4e 100644 --- a/public/admin/run.php +++ b/public/admin/run.php @@ -33,10 +33,77 @@ } $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__ . "/../../tools/cli/ob {$json->command}", $output); + exec(__DIR__ . "/../../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 26688dbf..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 + '

'; @@ -58,4 +58,37 @@ async function cliUpdatesRun() }; run(data); -} \ No newline at end of file +} + +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); +} 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/tools/sampledata/profiles/en_community_radio/categories.json b/tools/sampledata/profiles/en_community_radio/categories.json new file mode 100644 index 00000000..f535ebdc --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/genres.json b/tools/sampledata/profiles/en_community_radio/genres.json new file mode 100644 index 00000000..d9774f9f --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/groups.json b/tools/sampledata/profiles/en_community_radio/groups.json new file mode 100644 index 00000000..8e47f8c9 --- /dev/null +++ b/tools/sampledata/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_timeslots", + "view_player_monitor", + "download_media" + ] + } +] diff --git a/tools/sampledata/profiles/en_community_radio/manifest.json b/tools/sampledata/profiles/en_community_radio/manifest.json new file mode 100644 index 00000000..d0f0e1f0 --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/metadata_fields.json b/tools/sampledata/profiles/en_community_radio/metadata_fields.json new file mode 100644 index 00000000..1111a450 --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/playlists.json b/tools/sampledata/profiles/en_community_radio/playlists.json new file mode 100644 index 00000000..2d3be17f --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/schedule.json b/tools/sampledata/profiles/en_community_radio/schedule.json new file mode 100644 index 00000000..8057b15d --- /dev/null +++ b/tools/sampledata/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/tools/sampledata/profiles/en_community_radio/settings.json b/tools/sampledata/profiles/en_community_radio/settings.json new file mode 100644 index 00000000..1bb2615e --- /dev/null +++ b/tools/sampledata/profiles/en_community_radio/settings.json @@ -0,0 +1,13 @@ +{ + "settings": { + "audio_formats": "flac,mp3,ogg,wav", + "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\"}" + }, + + "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/tools/sampledata/profiles/en_community_radio/users.json b/tools/sampledata/profiles/en_community_radio/users.json new file mode 100644 index 00000000..0df12eb0 --- /dev/null +++ b/tools/sampledata/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" + } +] 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; + } +}