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.

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

+ + 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(''); + $('#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(''); + $('#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:

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/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 @@ +