diff --git a/.gitignore b/.gitignore
index a0740a00..6b538a11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ modules/*
!modules/NowPlaying
!modules/Logger
!modules/Tutorial
+!modules/SampleData
.*.swp
.*.swo
.well-known
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..35257672
--- /dev/null
+++ b/modules/SampleData/controllers/SampleData.php
@@ -0,0 +1,57 @@
+user->require_permission('import_sample_data');
+ $this->SampleDataModel = $this->load->model('SampleData', 'SampleData');
+ }
+
+ /**
+ * @route GET /profiles
+ */
+ public function listProfiles()
+ {
+ return [true, 'Sample data profiles.', $this->SampleDataModel('listProfiles')];
+ }
+
+ /**
+ * The envelope is always success=true at the API level; the real outcome
+ * (success/log/error) is in the data payload so OB.API.request (which
+ * returns only data) can read it.
+ *
+ * @route POST /run
+ */
+ public function runProfile()
+ {
+ $profile = trim((string) $this->data('profile'));
+
+ // Fallback while MODULES_5.5.md "v2 API integration for JS with modules"
+ // TODO is open: under X-Auth session auth, api.php skips the JSON body
+ // parse, so $this->data() returns nothing. Read php://input ourselves.
+ if ($profile === '') {
+ $body = json_decode(file_get_contents('php://input'), true);
+ $profile = is_array($body) ? trim((string) ($body['profile'] ?? '')) : '';
+ }
+
+ if ($profile === '' || !preg_match('/^[a-z0-9_]+$/', $profile)) {
+ return [true, 'Invalid profile name.', ['success' => false, 'log' => [], 'error' => 'Invalid profile name.']];
+ }
+
+ return [true, 'Sample data import attempted.', $this->SampleDataModel('runProfile', $profile)];
+ }
+}
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.
+
+
+
+ Profile
+
+
+
+
+
+
+ Import Selected Profile
+
+
+
+
diff --git a/modules/SampleData/js/sampledata.js b/modules/SampleData/js/sampledata.js
new file mode 100644
index 00000000..0bc74043
--- /dev/null
+++ b/modules/SampleData/js/sampledata.js
@@ -0,0 +1,76 @@
+// 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 = async function () {
+ // Cache key preserves the module dir's actual case (core/controllers/UI.php
+ // find_module_html_files) — must be 'SampleData', not 'sampledata'.
+ OB.UI.replaceMain('modules/SampleData/sampledata.html');
+
+ OBModules.SampleData.profiles = {};
+ $('#sampledata_module-profile').html('Loading… ');
+ $('#sampledata_module-import').prop('disabled', true);
+ $('#sampledata_module-log').hide().text('');
+
+ const profiles = await OB.API.request({ endpoint: 'module/SampleData/profiles', method: 'GET' });
+ var $select = $('#sampledata_module-profile').empty();
+
+ if (!profiles || profiles.length === 0) {
+ $select.append('No profiles found ');
+ $('#sampledata_module-info').text('No sample data profiles found.');
+ 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 profile = OBModules.SampleData.profiles[$('#sampledata_module-profile').val()];
+ $('#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… (first run downloads ~73MB of media; this can take a minute)');
+ $('#sampledata_module-import').prop('disabled', true);
+ $('#sampledata_module-log').show().text('');
+
+ OB.API.request({ endpoint: 'module/SampleData/run', method: 'POST', data: { profile: dir } }).then(function (result) {
+ $('#sampledata_module-import').prop('disabled', false);
+ if (!result) {
+ $('#sampledata_module-info').text('Import failed: no response from server.');
+ return;
+ }
+ $('#sampledata_module-log').text((result.log || []).join('\n'));
+ $('#sampledata_module-info').text(result.success ? 'Sample data imported.' : 'Import failed: ' + (result.error || 'unknown error'));
+ });
+};
diff --git a/modules/SampleData/models/SampleData.php b/modules/SampleData/models/SampleData.php
new file mode 100644
index 00000000..1a21fc6f
--- /dev/null
+++ b/modules/SampleData/models/SampleData.php
@@ -0,0 +1,624 @@
+profilesRoot();
+ if (!is_dir($root)) {
+ return [];
+ }
+ $out = [];
+ foreach (scandir($root) as $dir) {
+ if ($dir[0] === '.' || !is_dir($root . '/' . $dir)) {
+ continue;
+ }
+ $manifest = $this->readJson($root . '/' . $dir . '/manifest.json') ?: [];
+ $out[] = [
+ 'directory' => $dir,
+ 'name' => $manifest['name'] ?? $dir,
+ 'description' => $manifest['description'] ?? '',
+ ];
+ }
+ return $out;
+ }
+
+ /**
+ * Run a profile. Returns ['success' => bool, 'log' => string[], 'error' => string|null].
+ */
+ public function runProfile(string $profile): array
+ {
+ $this->log = [];
+
+ if (!preg_match('/^[a-z0-9_]+$/', $profile)) {
+ return $this->fail('Invalid profile name.');
+ }
+ $this->profileDir = $this->profilesRoot() . '/' . $profile;
+ if (!is_dir($this->profileDir)) {
+ return $this->fail("Profile not found: {$profile}");
+ }
+ $manifest = $this->readJson($this->profileDir . '/manifest.json');
+ if (!$manifest) {
+ return $this->fail('Profile manifest missing or invalid.');
+ }
+
+ $this->log('Seeding profile: ' . ($manifest['name'] ?? $profile));
+ $this->log(str_repeat('-', 60));
+
+ $this->db->query('SELECT * FROM `users` ORDER BY `id` ASC LIMIT 1');
+ $admin = $this->db->assoc_list();
+ $this->adminUserId = $admin[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'],
+ ['seedMedia', 'media.json', 'Media Library'],
+ ['seedPlaylists', 'playlists.json', 'Playlists'],
+ ['seedSchedule', 'schedule.json', 'Player & Schedule'],
+ ];
+
+ foreach ($steps as [$method, $file, $label]) {
+ $data = $this->readJson($this->profileDir . '/' . $file);
+ if ($data === null) {
+ $this->log("[SKIP] {$label} — {$file} not found or invalid.");
+ continue;
+ }
+ $this->log('');
+ $this->log("[{$label}]");
+ try {
+ $this->$method($data);
+ } catch (\Throwable $e) {
+ return $this->fail("Error in {$label}: " . $e->getMessage());
+ }
+ }
+
+ $this->log('');
+ $this->log(str_repeat('-', 60));
+ $this->log('Seed complete.');
+ return ['success' => true, 'log' => $this->log, 'error' => null];
+ }
+
+ // -------- helpers --------
+
+ private function profilesRoot(): string
+ {
+ return OB_LOCAL . '/modules/SampleData/profiles';
+ }
+
+ private function log(string $line): void
+ {
+ $this->log[] = $line;
+ }
+
+ private function fail(string $msg): array
+ {
+ return ['success' => false, 'log' => $this->log, 'error' => $msg];
+ }
+
+ private 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;
+ }
+
+ private function findByName(string $table, string $name)
+ {
+ $this->db->where('name', $name);
+ return $this->db->get_one($table);
+ }
+
+ // -------- seed steps --------
+
+ private function seedCategories(array $names): void
+ {
+ $ins = $skip = 0;
+ foreach ($names as $name) {
+ if ($this->findByName('media_categories', $name)) {
+ $skip++;
+ continue;
+ }
+ $this->db->insert('media_categories', ['name' => $name]);
+ $ins++;
+ }
+ $this->log(" Inserted: {$ins}, Skipped (already exist): {$skip}");
+ }
+
+ private function seedGenres(array $genresByCategory): void
+ {
+ $ins = $skip = $err = 0;
+ foreach ($genresByCategory as $catName => $genres) {
+ $cat = $this->findByName('media_categories', $catName);
+ if (!$cat) {
+ $this->log(" Warning: Category '{$catName}' not found, skipping its genres.");
+ $err += count($genres);
+ continue;
+ }
+ foreach ($genres as $g) {
+ $this->db->where('name', $g['name']);
+ $this->db->where('media_category_id', $cat['id']);
+ if ($this->db->get_one('media_genres')) {
+ $skip++;
+ continue;
+ }
+ $this->db->insert('media_genres', [
+ 'name' => $g['name'],
+ 'description' => $g['description'] ?? $g['name'],
+ 'media_category_id' => (int) $cat['id'],
+ ]);
+ $ins++;
+ }
+ }
+ $line = " Inserted: {$ins}, Skipped: {$skip}";
+ if ($err) {
+ $line .= ", Errors: {$err}";
+ }
+ $this->log($line);
+ }
+
+ private function seedGroups(array $groups): void
+ {
+ $ins = $skip = 0;
+ foreach ($groups as $group) {
+ if ($this->findByName('users_groups', $group['name'])) {
+ $skip++;
+ continue;
+ }
+ $gid = $this->db->insert('users_groups', ['name' => $group['name']]);
+ if (!$gid) {
+ continue;
+ }
+ $count = 0;
+ foreach ($group['permissions'] as $perm) {
+ $row = $this->findByName('users_permissions', $perm);
+ if (!$row) {
+ $this->log(" Warning: permission '{$perm}' not found.");
+ continue;
+ }
+ $this->db->insert('users_permissions_to_groups', [
+ 'permission_id' => $row['id'],
+ 'group_id' => $gid,
+ ]);
+ $count++;
+ }
+ $this->log(" Created '{$group['name']}' with {$count} permissions.");
+ $ins++;
+ }
+ $this->log(" Inserted: {$ins}, Skipped: {$skip}");
+ }
+
+ private function seedUsers(array $users): void
+ {
+ $ins = $skip = 0;
+ foreach ($users as $u) {
+ $this->db->where('username', $u['username']);
+ if ($this->db->get_one('users')) {
+ $skip++;
+ continue;
+ }
+ $uid = $this->db->insert('users', [
+ 'name' => $u['name'], 'username' => $u['username'], 'email' => $u['email'],
+ 'password' => $this->user->password_hash($u['password']),
+ 'display_name' => $u['display_name'],
+ 'enabled' => $u['enabled'] ? 1 : 0,
+ 'created' => time(), 'last_access' => 0,
+ ]);
+ if (!$uid) {
+ continue;
+ }
+ if (!empty($u['group'])) {
+ $g = $this->findByName('users_groups', $u['group']);
+ if ($g) {
+ $this->db->insert('users_to_groups', ['user_id' => $uid, 'group_id' => $g['id']]);
+ }
+ }
+ $ins++;
+ }
+ $this->log(" Inserted: {$ins}, Skipped: {$skip}");
+ }
+
+ /**
+ * Settings are overwrite-only by design: format whitelists, core metadata
+ * required-field flags, login message, welcome page.
+ */
+ private function seedSettings(array $data): void
+ {
+ $settings = $data['settings'] ?? [];
+
+ // Format whitelists.
+ $formatKeys = ['audio_formats', 'video_formats', 'image_formats', 'document_formats'];
+ foreach ($formatKeys as $k) {
+ if (isset($settings[$k])) {
+ $v = is_array($settings[$k]) ? implode(',', $settings[$k]) : $settings[$k];
+ $this->writeSetting($k, $v);
+ $this->log(" Set '{$k}'.");
+ }
+ }
+
+ // Core metadata flags. Profiles may use country_id/language_id; canonical is country/language.
+ if (isset($settings['core_metadata'])) {
+ $cm = is_string($settings['core_metadata'])
+ ? (json_decode($settings['core_metadata'], true) ?: [])
+ : $settings['core_metadata'];
+ $this->writeSetting('core_metadata', json_encode([
+ 'artist' => $cm['artist'] ?? 'disabled',
+ 'album' => $cm['album'] ?? 'disabled',
+ 'year' => $cm['year'] ?? 'disabled',
+ 'category_id' => $cm['category_id'] ?? 'disabled',
+ 'country' => $cm['country'] ?? $cm['country_id'] ?? 'disabled',
+ 'language' => $cm['language'] ?? $cm['language_id'] ?? 'disabled',
+ 'comments' => $cm['comments'] ?? 'disabled',
+ ]));
+ $this->log(' Set core metadata fields.');
+ }
+
+ // Free-form settings (everything not handled above).
+ foreach ($settings as $name => $value) {
+ if (in_array($name, $formatKeys, true) || $name === 'core_metadata') {
+ continue;
+ }
+ $this->writeSetting($name, is_array($value) ? json_encode($value) : (string) $value);
+ $this->log(" Set '{$name}'.");
+ }
+
+ if (!empty($data['client_login_message'])) {
+ $this->writeSetting('client_login_message', $data['client_login_message']);
+ $this->log(' Set login message.');
+ }
+
+ if (!empty($data['client_welcome_page'])) {
+ $this->setClientWelcome($data['client_welcome_page']);
+ $this->log(' Set welcome page.');
+ }
+ }
+
+ private function writeSetting(string $name, string $value): void
+ {
+ $this->db->where('name', $name);
+ $this->db->delete('settings');
+ $this->db->insert('settings', ['name' => $name, 'value' => $value]);
+ }
+
+ private function setClientWelcome(string $html): void
+ {
+ // Welcome page lives in client_storage as a global (user_id=0) entry
+ // for obapp_web_client. Same shape ClientSettings uses.
+ $this->db->where('user_id', 0);
+ $this->db->where('client_name', 'obapp_web_client');
+ $existing = $this->db->get_one('client_storage');
+ $data = $existing ? (json_decode($existing['data'], true) ?: []) : [];
+ $data['welcome_message'] = $html;
+ $encoded = json_encode($data);
+ if ($existing) {
+ $this->db->where('id', $existing['id']);
+ $this->db->update('client_storage', ['data' => $encoded]);
+ } else {
+ $this->db->insert('client_storage', ['user_id' => 0, 'client_name' => 'obapp_web_client', 'data' => $encoded]);
+ }
+ }
+
+ /**
+ * Custom metadata fields. Uses the MediaMetadata model so the ALTER TABLE
+ * on the media table happens correctly.
+ */
+ private function seedMetadataFields(array $fields): void
+ {
+ $ins = $skip = 0;
+ foreach ($fields as $field) {
+ if ($this->findByName('media_metadata', $field['name'])) {
+ $skip++;
+ continue;
+ }
+ if ($this->models->mediametadata('save', $field, null)) {
+ $ins++;
+ }
+ }
+ $this->log(" Inserted: {$ins}, Skipped: {$skip}");
+ }
+
+ private function seedPlaylists(array $playlists): void
+ {
+ $ins = $skip = 0;
+ foreach ($playlists as $p) {
+ if ($this->findByName('playlists', $p['name'])) {
+ $skip++;
+ continue;
+ }
+ $this->insertPlaylist($p['name'], $p['description'] ?? '', $p['type'] ?? 'standard', $p['status'] ?? 'public');
+ $ins++;
+ }
+ $this->log(" Inserted: {$ins}, Skipped: {$skip}");
+ }
+
+ private function insertPlaylist(string $name, string $description, string $type, string $status = 'public'): ?int
+ {
+ $now = time();
+ $id = $this->db->insert('playlists', [
+ 'owner_id' => $this->adminUserId,
+ 'type' => $type,
+ 'name' => $name,
+ 'description' => $description,
+ 'status' => $status,
+ 'created' => $now,
+ 'updated' => $now,
+ ]);
+ return $id ?: null;
+ }
+
+ private function seedSchedule(array $data): void
+ {
+ if (empty($data['player']) || empty($data['shows'])) {
+ $this->log(' No player or shows defined.');
+ return;
+ }
+ $pd = $data['player'];
+ if ($this->findByName('players', $pd['name'])) {
+ $this->log(" Player '{$pd['name']}' already exists, skipping schedule.");
+ return;
+ }
+
+ $playerId = $this->db->insert('players', [
+ 'name' => $pd['name'], 'description' => 'Sample player created by seed profile.',
+ 'timezone' => $pd['timezone'] ?? 'America/Toronto',
+ 'support_audio' => $pd['support_audio'] ? 1 : 0,
+ 'support_video' => $pd['support_video'] ? 1 : 0,
+ 'support_images' => $pd['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) {
+ $this->log(' Error creating player.');
+ return;
+ }
+ $this->log(" Created player '{$pd['name']}'.");
+
+ $count = 0;
+ foreach ($data['shows'] as $show) {
+ $existing = $this->findByName('playlists', $show['title']);
+ $playlistId = $existing
+ ? $existing['id']
+ : $this->insertPlaylist($show['title'], $show['description'] ?? '', 'standard');
+ if (!$playlistId) {
+ continue;
+ }
+ $start = date('Y-m-d') . ' ' . $show['start_time'] . ':00';
+ $stop = date('Y-m-d', strtotime('+' . ($show['recurring_days'] ?? 180) . ' 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' => $start,
+ 'duration' => intval($show['duration']) * 60,
+ 'stop' => $stop,
+ ]);
+ $count++;
+ }
+ $this->log(" Scheduled {$count} shows on '{$pd['name']}'.");
+ }
+
+ /**
+ * Download + import the official OB sample media bundle. Files land in
+ * OB_MEDIA via the standard 2-char file_location convention; rows go into
+ * the media table directly. Idempotent via SHA1 hash check.
+ */
+ private function seedMedia(array $data): void
+ {
+ $url = $data['url'] ?? null;
+ if (!$url) {
+ $this->log(' No media bundle URL provided.');
+ return;
+ }
+ $cacheDir = OB_LOCAL . '/media_data/cache/sampledata';
+ if (!is_dir($cacheDir) && !mkdir($cacheDir, 0755, true)) {
+ $this->log(' Failed to create cache directory.');
+ return;
+ }
+ $zipPath = $cacheDir . '/' . basename(parse_url($url, PHP_URL_PATH));
+ if (!$this->downloadBundle($url, $zipPath)) {
+ return;
+ }
+ $extractDir = $cacheDir . '/extracted';
+ if (!$this->extractBundle($zipPath, $extractDir)) {
+ return;
+ }
+ $catIds = $this->resolveDefaultCategories($data['default_categories_by_type'] ?? []);
+
+ $ins = $skip = $err = 0;
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($extractDir, \RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+ foreach ($iter as $f) {
+ if (!$f->isFile()) {
+ continue;
+ }
+ $result = $this->importMediaFile($f, $catIds);
+ if ($result === null) {
+ continue; // unsupported extension, ignore silently
+ }
+ if ($result === true) {
+ $ins++;
+ } elseif ($result === 'skip') {
+ $skip++;
+ } else {
+ $err++;
+ }
+ }
+ $line = " Imported: {$ins}, Skipped (already exist by hash): {$skip}";
+ if ($err) {
+ $line .= ", Errors: {$err}";
+ }
+ $this->log($line);
+ }
+
+ private function downloadBundle(string $url, string $zipPath): bool
+ {
+ if (file_exists($zipPath)) {
+ $this->log(' Using cached bundle (' . round(filesize($zipPath) / 1048576, 1) . ' MB).');
+ return true;
+ }
+ $this->log(" Downloading bundle from {$url}...");
+ $fp = fopen($zipPath, 'w');
+ if (!$fp) {
+ $this->log(' Could not open cache file for writing.');
+ return false;
+ }
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_FILE => $fp,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => 600,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => false,
+ ]);
+ $ok = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $err = curl_error($ch);
+ fclose($fp);
+ if (!$ok || $code >= 400) {
+ $this->log(" Download failed (HTTP {$code}): {$err}");
+ @unlink($zipPath);
+ return false;
+ }
+ $this->log(' Downloaded ' . round(filesize($zipPath) / 1048576, 1) . ' MB.');
+ return true;
+ }
+
+ private function extractBundle(string $zipPath, string $extractDir): bool
+ {
+ if (is_dir($extractDir)) {
+ return true;
+ }
+ $zip = new \ZipArchive();
+ if ($zip->open($zipPath) !== true) {
+ $this->log(' Failed to open zip archive.');
+ return false;
+ }
+ if (!mkdir($extractDir, 0755, true)) {
+ $zip->close();
+ $this->log(' Failed to create extract directory.');
+ return false;
+ }
+ $zip->extractTo($extractDir);
+ $zip->close();
+ $this->log(' Extracted bundle.');
+ return true;
+ }
+
+ private function resolveDefaultCategories(array $map): array
+ {
+ $out = [];
+ foreach ($map as $type => $catName) {
+ $row = $this->findByName('media_categories', $catName);
+ if ($row) {
+ $out[$type] = (int) $row['id'];
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Import one media file. Returns true on import, 'skip' on dedup hit, false on error,
+ * null when extension is unsupported (caller should ignore).
+ */
+ private function importMediaFile(\SplFileInfo $f, array $categoryIds)
+ {
+ static $types = [
+ 'mp3' => 'audio', 'ogg' => 'audio', 'wav' => 'audio', 'flac' => 'audio',
+ 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'gif' => 'image',
+ 'mp4' => 'video', 'mov' => 'video', 'avi' => 'video', 'mpg' => 'video', 'ogv' => 'video', 'webm' => 'video',
+ 'pdf' => 'document',
+ ];
+ $ext = strtolower($f->getExtension());
+ if (!isset($types[$ext])) {
+ return null;
+ }
+ $src = $f->getPathname();
+ $hash = sha1_file($src);
+ if (!$hash) {
+ return false;
+ }
+
+ $this->db->where('file_hash', $hash);
+ if ($this->db->get_one('media')) {
+ return 'skip';
+ }
+
+ $type = $types[$ext];
+ $location = $this->models->media('rand_file_location');
+ // Insert placeholder, then store file as "." and update filename
+ // to match -- Observer reads filename to locate the source for previews.
+ $now = time();
+ $id = $this->db->insert('media', [
+ 'title' => $f->getBasename('.' . $ext),
+ 'type' => $type,
+ 'category_id' => $categoryIds[$type] ?? null,
+ 'is_approved' => 1, 'comments' => '', 'filename' => '',
+ 'file_hash' => $hash, 'file_location' => $location, 'format' => $ext,
+ 'is_copyright_owner' => 1, 'owner_id' => $this->adminUserId,
+ 'created' => $now, 'updated' => $now,
+ 'is_archived' => 0, 'status' => 'public', 'dynamic_select' => 0,
+ ]);
+ if (!$id) {
+ return false;
+ }
+ $disk = $id . '.' . $ext;
+ $dest = OB_MEDIA . '/' . $location[0] . '/' . $location[1] . '/' . $disk;
+ if (!@copy($src, $dest)) {
+ $this->db->where('id', $id);
+ $this->db->delete('media');
+ return false;
+ }
+ $this->db->where('id', $id);
+ $this->db->update('media', ['filename' => $disk]);
+
+ if ($type === 'image') {
+ $this->stageImageThumbnail($src, $id, $location, $ext);
+ }
+ return true;
+ }
+
+ /**
+ * Workaround for an OB core bug at core/support/Helpers.php:192 where
+ * `new Imagick()` is unqualified inside the OpenBroadcaster\Support
+ * namespace, so first-time thumbnail generation fatals. We sidestep it by
+ * pre-staging a "cached thumbnail" using the source image. Media's
+ * thumbnail_file() returns early on glob match (see
+ * core/models/Media.php:343-347) so the buggy path never fires. When the
+ * core is fixed this method can be removed.
+ */
+ private function stageImageThumbnail(string $src, int $id, string $location, string $ext): void
+ {
+ $dir = OB_CACHE . '/thumbnails/media/' . $location[0] . '/' . $location[1];
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+ @copy($src, $dir . '/' . $id . '.' . $ext);
+ }
+}
diff --git a/modules/SampleData/profiles/en_community_radio/categories.json b/modules/SampleData/profiles/en_community_radio/categories.json
new file mode 100644
index 00000000..f535ebdc
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/genres.json b/modules/SampleData/profiles/en_community_radio/genres.json
new file mode 100644
index 00000000..d9774f9f
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/groups.json b/modules/SampleData/profiles/en_community_radio/groups.json
new file mode 100644
index 00000000..8e47f8c9
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/manifest.json b/modules/SampleData/profiles/en_community_radio/manifest.json
new file mode 100644
index 00000000..d0f0e1f0
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/media.json b/modules/SampleData/profiles/en_community_radio/media.json
new file mode 100644
index 00000000..27607546
--- /dev/null
+++ b/modules/SampleData/profiles/en_community_radio/media.json
@@ -0,0 +1,10 @@
+{
+ "url": "https://www.openbroadcaster.com/wp-content/uploads/2026/05/EN_Sample_Media_Ver_1.0.zip",
+ "description": "Official copyright-free sample media bundle from openbroadcaster.com — mix of audio (mp3/ogg), images (jpg/png), and video (mov/ogv).",
+ "default_categories_by_type": {
+ "audio": "Music",
+ "image": "Images",
+ "video": "Video",
+ "document": "Documents"
+ }
+}
diff --git a/modules/SampleData/profiles/en_community_radio/metadata_fields.json b/modules/SampleData/profiles/en_community_radio/metadata_fields.json
new file mode 100644
index 00000000..1111a450
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/playlists.json b/modules/SampleData/profiles/en_community_radio/playlists.json
new file mode 100644
index 00000000..2d3be17f
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/schedule.json b/modules/SampleData/profiles/en_community_radio/schedule.json
new file mode 100644
index 00000000..8057b15d
--- /dev/null
+++ b/modules/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/modules/SampleData/profiles/en_community_radio/settings.json b/modules/SampleData/profiles/en_community_radio/settings.json
new file mode 100644
index 00000000..1bb2615e
--- /dev/null
+++ b/modules/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:
Upload Media — Add music, audio, images, and video to your libraryCreate Playlists — Organize your media into playlists for broadcastSchedule Shows — Set up your broadcast schedule on the Player ManagerManage Users — Add team members and assign permissionsThis 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/modules/SampleData/profiles/en_community_radio/users.json b/modules/SampleData/profiles/en_community_radio/users.json
new file mode 100644
index 00000000..0df12eb0
--- /dev/null
+++ b/modules/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/modules/SampleData/updates/Update20260505.php b/modules/SampleData/updates/Update20260505.php
new file mode 100644
index 00000000..60346ac5
--- /dev/null
+++ b/modules/SampleData/updates/Update20260505.php
@@ -0,0 +1,18 @@
+