view = new View(__DIR__ . '/views'); } public function index(): Response { $this->ensureReleaseArtistColumn(); $db = Database::get(); $page = null; $releases = []; $topTracks = []; $featuredArtists = []; $labels = []; $artistOptions = []; $labelOptions = []; $keyOptions = []; $artistFilter = trim((string)($_GET['artist'] ?? '')); $labelFilter = trim((string)($_GET['label'] ?? '')); $keyFilter = trim((string)($_GET['musical_key'] ?? '')); $bpmFilter = trim((string)($_GET['bpm'] ?? '')); $dateFilter = trim((string)($_GET['date_range'] ?? 'all')); $formatFilter = trim((string)($_GET['format'] ?? 'all')); $search = trim((string)($_GET['q'] ?? '')); $sort = trim((string)($_GET['sort'] ?? 'newest')); $currentPage = max(1, (int)($_GET['p'] ?? 1)); $perPage = 20; $totalReleases = 0; $totalPages = 1; $allowedSorts = [ 'newest' => 'r.release_date DESC, r.created_at DESC', 'oldest' => 'r.release_date ASC, r.created_at ASC', 'title_asc' => 'r.title ASC', 'title_desc' => 'r.title DESC', ]; if (!isset($allowedSorts[$sort])) { $sort = 'newest'; } $allowedFormats = ['all', 'digital', 'mp3', 'wav', 'flac']; if (!in_array($formatFilter, $allowedFormats, true)) { $formatFilter = 'all'; } $allowedDateFilters = ['all', '7d', '30d', '90d', 'year']; if (!in_array($dateFilter, $allowedDateFilters, true)) { $dateFilter = 'all'; } if ($db instanceof PDO) { try { $today = date('Y-m-d'); $releaseColumns = $this->tableColumns($db, 'ac_releases'); $trackColumns = $this->tableColumns($db, 'ac_release_tracks'); $artistJoinReady = in_array('artist_id', $releaseColumns, true); $labelColumn = in_array('label_name', $releaseColumns, true) ? 'label_name' : (in_array('label', $releaseColumns, true) ? 'label' : ''); $hasTracks = $this->tableExists($db, 'ac_release_tracks'); $hasStoreRelease = $this->tableExists($db, 'ac_store_release_products'); $hasStoreTracks = $this->tableExists($db, 'ac_store_track_products'); $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = 'releases' AND is_published = 1 LIMIT 1"); $stmt->execute(); $page = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; $params = []; $where = ["r.is_published = 1", "(r.release_date IS NULL OR r.release_date <= :today)"]; $params[':today'] = $today; if ($artistFilter !== '') { if ($artistJoinReady) { $where[] = "(r.artist_name = :artist OR a.name = :artist)"; } else { $where[] = "r.artist_name = :artist"; } $params[':artist'] = $artistFilter; } if ($labelFilter !== '' && $labelColumn !== '') { $where[] = "r.{$labelColumn} = :label"; $params[':label'] = $labelFilter; } if ($search !== '') { $searchParts = [ "r.title LIKE :search", "r.slug LIKE :search", "r.artist_name LIKE :search", "r.catalog_no LIKE :search", ]; if ($labelColumn !== '') { $searchParts[] = "r.{$labelColumn} LIKE :search"; } if ($artistJoinReady) { $searchParts[] = "a.name LIKE :search"; } $where[] = '(' . implode(' OR ', $searchParts) . ')'; $params[':search'] = '%' . $search . '%'; } if ($dateFilter !== 'all') { $days = 0; if ($dateFilter === '7d') { $days = 7; } elseif ($dateFilter === '30d') { $days = 30; } elseif ($dateFilter === '90d') { $days = 90; } elseif ($dateFilter === 'year') { $days = 365; } if ($days > 0) { $where[] = "r.release_date >= :date_from"; $params[':date_from'] = date('Y-m-d', strtotime('-' . $days . ' days')); } } if ($hasTracks && $keyFilter !== '' && in_array('key_signature', $trackColumns, true)) { $where[] = "EXISTS ( SELECT 1 FROM ac_release_tracks tkey WHERE tkey.release_id = r.id AND tkey.key_signature = :key_filter )"; $params[':key_filter'] = $keyFilter; } if ($hasTracks && $bpmFilter !== '' && in_array('bpm', $trackColumns, true)) { if ($bpmFilter === '150+') { $where[] = "EXISTS ( SELECT 1 FROM ac_release_tracks tbpm WHERE tbpm.release_id = r.id AND tbpm.bpm >= :bpm_min )"; $params[':bpm_min'] = 150; } elseif (preg_match('/^(\d{2,3})-(\d{2,3})$/', $bpmFilter, $m)) { $where[] = "EXISTS ( SELECT 1 FROM ac_release_tracks tbpm WHERE tbpm.release_id = r.id AND tbpm.bpm BETWEEN :bpm_min AND :bpm_max )"; $params[':bpm_min'] = (int)$m[1]; $params[':bpm_max'] = (int)$m[2]; } } if ($artistJoinReady) { $countSql = " SELECT COUNT(*) AS total_rows FROM ac_releases r LEFT JOIN ac_artists a ON a.id = r.artist_id WHERE " . implode(' AND ', $where); $countStmt = $db->prepare($countSql); $countStmt->execute($params); $totalReleases = (int)($countStmt->fetchColumn() ?: 0); $totalPages = max(1, (int)ceil($totalReleases / $perPage)); if ($currentPage > $totalPages) { $currentPage = $totalPages; } $offset = ($currentPage - 1) * $perPage; $listSql = " SELECT r.id, r.title, r.slug, r.release_date, r.cover_url, " . ($labelColumn !== '' ? "r.{$labelColumn} AS label_name," : "'' AS label_name,") . " " . (in_array('sample_url', $releaseColumns, true) ? "r.sample_url," : "'' AS sample_url,") . " COALESCE(r.artist_name, a.name) AS artist_name FROM ac_releases r LEFT JOIN ac_artists a ON a.id = r.artist_id WHERE " . implode(' AND ', $where) . " ORDER BY {$allowedSorts[$sort]} LIMIT :limit OFFSET :offset "; $listStmt = $db->prepare($listSql); foreach ($params as $k => $v) { $listStmt->bindValue($k, $v); } $listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT); $listStmt->bindValue(':offset', $offset, PDO::PARAM_INT); $listStmt->execute(); $labelSelect = $labelColumn !== '' ? "TRIM(r.{$labelColumn}) AS label_name," : "'' AS label_name,"; $artistStmt = $db->query(" SELECT DISTINCT TRIM(COALESCE(NULLIF(r.artist_name, ''), a.name)) AS artist_name FROM ac_releases r LEFT JOIN ac_artists a ON a.id = r.artist_id WHERE r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= CURDATE()) ORDER BY artist_name ASC "); $labelStmt = $db->query(" SELECT DISTINCT {$labelSelect} 1 FROM ac_releases r WHERE r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= CURDATE()) ORDER BY label_name ASC "); } else { $countSql = " SELECT COUNT(*) AS total_rows FROM ac_releases r WHERE " . implode(' AND ', $where); $countStmt = $db->prepare($countSql); $countStmt->execute($params); $totalReleases = (int)($countStmt->fetchColumn() ?: 0); $totalPages = max(1, (int)ceil($totalReleases / $perPage)); if ($currentPage > $totalPages) { $currentPage = $totalPages; } $offset = ($currentPage - 1) * $perPage; $listSql = " SELECT r.id, r.title, r.slug, r.release_date, r.cover_url, " . ($labelColumn !== '' ? "r.{$labelColumn} AS label_name," : "'' AS label_name,") . " " . (in_array('sample_url', $releaseColumns, true) ? "r.sample_url," : "'' AS sample_url,") . " r.artist_name FROM ac_releases r WHERE " . implode(' AND ', $where) . " ORDER BY {$allowedSorts[$sort]} LIMIT :limit OFFSET :offset "; $listStmt = $db->prepare($listSql); foreach ($params as $k => $v) { $listStmt->bindValue($k, $v); } $listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT); $listStmt->bindValue(':offset', $offset, PDO::PARAM_INT); $listStmt->execute(); $artistStmt = $db->query(" SELECT DISTINCT TRIM(artist_name) AS artist_name FROM ac_releases WHERE is_published = 1 AND (release_date IS NULL OR release_date <= CURDATE()) ORDER BY artist_name ASC "); $labelStmt = false; if ($labelColumn !== '') { $labelStmt = $db->query(" SELECT DISTINCT TRIM({$labelColumn}) AS label_name FROM ac_releases WHERE is_published = 1 AND (release_date IS NULL OR release_date <= CURDATE()) ORDER BY label_name ASC "); } } $releases = $listStmt->fetchAll(PDO::FETCH_ASSOC); $rawArtistRows = $artistStmt ? $artistStmt->fetchAll(PDO::FETCH_ASSOC) : []; foreach ($rawArtistRows as $row) { $name = trim((string)($row['artist_name'] ?? '')); if ($name !== '') { $artistOptions[] = $name; } } $artistOptions = array_values(array_unique($artistOptions)); if ($labelStmt) { $rawLabelRows = $labelStmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rawLabelRows as $row) { $name = trim((string)($row['label_name'] ?? '')); if ($name !== '') { $labelOptions[] = $name; } } $labelOptions = array_values(array_unique($labelOptions)); } if ($hasTracks && in_array('key_signature', $trackColumns, true)) { $keyStmt = $db->query(" SELECT DISTINCT TRIM(key_signature) AS k FROM ac_release_tracks WHERE key_signature IS NOT NULL AND key_signature != '' ORDER BY k ASC "); $keyRows = $keyStmt ? $keyStmt->fetchAll(PDO::FETCH_ASSOC) : []; foreach ($keyRows as $row) { $k = trim((string)($row['k'] ?? '')); if ($k !== '') { $keyOptions[] = $k; } } $keyOptions = array_values(array_unique($keyOptions)); } if ($hasStoreRelease || $hasStoreTracks || $hasTracks) { $bundleStmt = null; $trackPriceStmt = null; $sampleStmt = null; if ($hasStoreRelease) { $bundleStmt = $db->prepare(" SELECT is_enabled, bundle_price, currency FROM ac_store_release_products WHERE release_id = :rid LIMIT 1 "); } if ($hasStoreTracks && $hasTracks) { $trackPriceStmt = $db->prepare(" SELECT MIN(sp.track_price) AS min_price, MIN(sp.currency) AS currency FROM ac_release_tracks t JOIN ac_store_track_products sp ON sp.release_track_id = t.id WHERE t.release_id = :rid AND sp.is_enabled = 1 "); } if ($hasTracks && in_array('sample_url', $trackColumns, true)) { $sampleStmt = $db->prepare(" SELECT sample_url FROM ac_release_tracks WHERE release_id = :rid AND sample_url IS NOT NULL AND sample_url != '' ORDER BY track_no ASC, id ASC LIMIT 1 "); } foreach ($releases as &$releaseRow) { $releaseRow['store_enabled'] = 0; $releaseRow['display_price'] = null; $releaseRow['display_currency'] = 'GBP'; $releaseRow['sample_url'] = (string)($releaseRow['sample_url'] ?? ''); $rid = (int)($releaseRow['id'] ?? 0); if ($rid <= 0) { continue; } if ($bundleStmt) { $bundleStmt->execute([':rid' => $rid]); $bundle = $bundleStmt->fetch(PDO::FETCH_ASSOC); if ($bundle && (int)($bundle['is_enabled'] ?? 0) === 1 && (float)($bundle['bundle_price'] ?? 0) > 0) { $releaseRow['store_enabled'] = 1; $releaseRow['display_price'] = (float)$bundle['bundle_price']; $releaseRow['display_currency'] = (string)($bundle['currency'] ?? 'GBP'); } } if ($releaseRow['display_price'] === null && $trackPriceStmt) { $trackPriceStmt->execute([':rid' => $rid]); $minRow = $trackPriceStmt->fetch(PDO::FETCH_ASSOC); if ($minRow && (float)($minRow['min_price'] ?? 0) > 0) { $releaseRow['display_price'] = (float)$minRow['min_price']; $releaseRow['display_currency'] = (string)($minRow['currency'] ?? 'GBP'); } } if ($releaseRow['sample_url'] === '' && $sampleStmt) { $sampleStmt->execute([':rid' => $rid]); $sample = $sampleStmt->fetch(PDO::FETCH_ASSOC); if ($sample) { $releaseRow['sample_url'] = (string)($sample['sample_url'] ?? ''); } } } unset($releaseRow); } if ($hasTracks) { $topTrackSelectLabel = $labelColumn !== '' ? "COALESCE(NULLIF(r.{$labelColumn}, ''), '') AS label_name," : "'' AS label_name,"; $topTrackSql = " SELECT t.id, t.track_no, t.title, t.mix_name, t.duration, t.bpm, t.key_signature, t.sample_url, r.slug AS release_slug, r.title AS release_title, COALESCE(NULLIF(r.artist_name, ''), a.name, '') AS artist_name, {$topTrackSelectLabel} COALESCE(sp.track_price, 0) AS track_price, COALESCE(sp.currency, 'GBP') AS store_currency, COALESCE(sp.is_enabled, 0) AS store_enabled, r.cover_url FROM ac_release_tracks t JOIN ac_releases r ON r.id = t.release_id LEFT JOIN ac_artists a ON a.id = r.artist_id LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id WHERE r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today_tracks) ORDER BY r.release_date DESC, r.created_at DESC, t.track_no ASC, t.id ASC LIMIT 18 "; $topStmt = $db->prepare($topTrackSql); $topStmt->execute([':today_tracks' => $today]); $topTracks = $topStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } if ($this->tableExists($db, 'ac_artists')) { $artistSql = " SELECT name, slug, avatar_url, country FROM ac_artists WHERE is_active = 1 ORDER BY updated_at DESC, created_at DESC LIMIT 10 "; $artistStmt2 = $db->query($artistSql); $featuredArtists = $artistStmt2 ? $artistStmt2->fetchAll(PDO::FETCH_ASSOC) : []; } if ($this->tableExists($db, 'ac_labels')) { $labelSql = " SELECT name, slug, logo_url FROM ac_labels WHERE is_active = 1 ORDER BY is_primary DESC, name ASC LIMIT 24 "; $labelStmt2 = $db->query($labelSql); $labels = $labelStmt2 ? $labelStmt2->fetchAll(PDO::FETCH_ASSOC) : []; } } catch (Throwable $e) { } } return new Response($this->view->render('site/index.php', [ 'title' => (string)($page['title'] ?? 'Releases'), 'content_html' => (string)($page['content_html'] ?? ''), 'releases' => $releases, 'total_releases' => $totalReleases, 'per_page' => $perPage, 'current_page' => $currentPage, 'total_pages' => $totalPages, 'artist_filter' => $artistFilter, 'artist_options' => $artistOptions, 'label_filter' => $labelFilter, 'label_options' => $labelOptions, 'key_filter' => $keyFilter, 'key_options' => $keyOptions, 'bpm_filter' => $bpmFilter, 'date_filter' => $dateFilter, 'format_filter' => $formatFilter, 'search' => $search, 'sort' => $sort, 'top_tracks' => $topTracks, 'featured_artists' => $featuredArtists, 'labels' => $labels, ])); } public function show(): Response { $this->ensureReleaseArtistColumn(); $slug = trim((string)($_GET['slug'] ?? '')); $release = null; $tracks = []; $bundles = []; Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); if ($slug !== '') { $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare(" SELECT * FROM ac_releases WHERE slug = :slug AND is_published = 1 AND (release_date IS NULL OR release_date <= :today) LIMIT 1 "); $stmt->execute([ ':slug' => $slug, ':today' => date('Y-m-d'), ]); $release = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; if ($release) { if ($storePluginEnabled) { try { $bundleStmt = $db->prepare(" SELECT is_enabled, bundle_price, currency, purchase_label FROM ac_store_release_products WHERE release_id = :release_id LIMIT 1 "); $bundleStmt->execute([':release_id' => (int)$release['id']]); $bundle = $bundleStmt->fetch(PDO::FETCH_ASSOC); if ($bundle) { $release['store_enabled'] = (int)($bundle['is_enabled'] ?? 0); $release['bundle_price'] = (float)($bundle['bundle_price'] ?? 0); $release['store_currency'] = (string)($bundle['currency'] ?? 'GBP'); $release['purchase_label'] = (string)($bundle['purchase_label'] ?? ''); } } catch (Throwable $e) { } } if ($storePluginEnabled) { $trackStmt = $db->prepare(" SELECT t.id, t.track_no, t.title, t.mix_name, t.duration, t.bpm, t.key_signature, t.sample_url, COALESCE(sp.is_enabled, 0) AS store_enabled, COALESCE(sp.track_price, 0.00) AS track_price, COALESCE(sp.currency, 'GBP') AS store_currency FROM ac_release_tracks t LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id WHERE t.release_id = :rid ORDER BY t.track_no ASC, t.id ASC "); } else { $trackStmt = $db->prepare(" SELECT id, track_no, title, mix_name, duration, bpm, key_signature, sample_url FROM ac_release_tracks WHERE release_id = :rid ORDER BY track_no ASC, id ASC "); } $trackStmt->execute([':rid' => (int)$release['id']]); $tracks = $trackStmt->fetchAll(PDO::FETCH_ASSOC); if ($storePluginEnabled) { try { $bundleStmt = $db->prepare(" SELECT b.id, b.name, b.slug, b.bundle_price, b.currency, b.purchase_label FROM ac_store_bundles b JOIN ac_store_bundle_items bi ON bi.bundle_id = b.id WHERE bi.release_id = :release_id AND b.is_enabled = 1 ORDER BY b.created_at DESC LIMIT 6 "); $bundleStmt->execute([':release_id' => (int)$release['id']]); $bundles = $bundleStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; if ($bundles) { $coverStmt = $db->prepare(" SELECT r.cover_url FROM ac_store_bundle_items bi JOIN ac_releases r ON r.id = bi.release_id WHERE bi.bundle_id = :bundle_id AND r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today) ORDER BY bi.sort_order ASC, bi.id ASC LIMIT 6 "); $sumStmt = $db->prepare(" SELECT COALESCE(SUM(CASE WHEN srp.is_enabled = 1 THEN srp.bundle_price ELSE 0 END), 0) AS regular_total FROM ac_store_bundle_items bi LEFT JOIN ac_store_release_products srp ON srp.release_id = bi.release_id WHERE bi.bundle_id = :bundle_id "); $countStmt = $db->prepare(" SELECT COUNT(*) AS release_count FROM ac_store_bundle_items WHERE bundle_id = :bundle_id "); foreach ($bundles as &$bundleRow) { $bundleId = (int)($bundleRow['id'] ?? 0); if ($bundleId <= 0) { $bundleRow['covers'] = []; $bundleRow['release_count'] = 0; $bundleRow['regular_total'] = 0.0; continue; } $coverStmt->execute([ ':bundle_id' => $bundleId, ':today' => date('Y-m-d'), ]); $coverRows = $coverStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; $covers = []; foreach ($coverRows as $c) { $url = trim((string)($c['cover_url'] ?? '')); if ($url !== '') { $covers[] = $url; } } $bundleRow['covers'] = $covers; $sumStmt->execute([':bundle_id' => $bundleId]); $bundleRow['regular_total'] = (float)($sumStmt->fetchColumn() ?: 0); $countStmt->execute([':bundle_id' => $bundleId]); $bundleRow['release_count'] = (int)($countStmt->fetchColumn() ?: 0); } unset($bundleRow); } } catch (Throwable $e) { $bundles = []; } } } } catch (Throwable $e) { $tracks = []; } } } return new Response($this->view->render('site/show.php', [ 'title' => $release ? (string)$release['title'] : 'Release', 'release' => $release, 'tracks' => $tracks, 'bundles' => $bundles, 'store_plugin_enabled' => $storePluginEnabled, ])); } public function adminIndex(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $this->ensureReleaseArtistColumn(); $tableReady = $this->releasesTableReady(); $releases = []; $pageId = 0; $pagePublished = 0; if ($tableReady) { $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->query("SELECT id, title, slug, artist_name, release_date, cover_url, is_published FROM ac_releases ORDER BY created_at DESC"); $releases = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; $pageStmt = $db->prepare("SELECT id, is_published FROM ac_pages WHERE slug = 'releases' LIMIT 1"); $pageStmt->execute(); $pageRow = $pageStmt->fetch(PDO::FETCH_ASSOC); if ($pageRow) { $pageId = (int)($pageRow['id'] ?? 0); $pagePublished = (int)($pageRow['is_published'] ?? 0); } } } return new Response($this->view->render('admin/index.php', [ 'title' => 'Releases', 'table_ready' => $tableReady, 'releases' => $releases, 'page_id' => $pageId, 'page_published' => $pagePublished, ])); } public function adminNew(): Response { return $this->adminEdit(0); } public function adminEdit(int $id = 0): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); $release = [ 'id' => 0, 'title' => '', 'slug' => '', 'artist_name' => '', 'description' => '', 'credits' => '', 'catalog_no' => '', 'release_date' => '', 'cover_url' => '', 'sample_url' => '', 'is_published' => 1, 'store_enabled' => 0, 'bundle_price' => '', 'store_currency' => 'GBP', 'purchase_label' => '', ]; if ($id > 0) { $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->prepare("SELECT * FROM ac_releases WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $release = array_merge($release, $row); } if ($storePluginEnabled) { try { $storeStmt = $db->prepare(" SELECT is_enabled, bundle_price, currency, purchase_label FROM ac_store_release_products WHERE release_id = :release_id LIMIT 1 "); $storeStmt->execute([':release_id' => $id]); $storeRow = $storeStmt->fetch(PDO::FETCH_ASSOC); if ($storeRow) { $release['store_enabled'] = (int)($storeRow['is_enabled'] ?? 0); $release['bundle_price'] = (string)($storeRow['bundle_price'] ?? ''); $release['store_currency'] = (string)($storeRow['currency'] ?? 'GBP'); $release['purchase_label'] = (string)($storeRow['purchase_label'] ?? ''); } } catch (Throwable $e) { } } } } if (!empty($_GET['cover_url']) && $release['cover_url'] === '') { $release['cover_url'] = (string)$_GET['cover_url']; } if (!empty($_GET['sample_url']) && $release['sample_url'] === '') { $release['sample_url'] = (string)$_GET['sample_url']; } return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Release' : 'New Release', 'release' => $release, 'store_plugin_enabled' => $storePluginEnabled, 'error' => '', ])); } public function adminTracks(int $releaseId = 0): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $release = null; $tracks = []; $tableReady = $this->tracksTableReady(); if ($tableReady && $releaseId > 0) { $db = Database::get(); if ($db instanceof PDO) { $relStmt = $db->prepare("SELECT id, title, slug FROM ac_releases WHERE id = :id LIMIT 1"); $relStmt->execute([':id' => $releaseId]); $release = $relStmt->fetch(PDO::FETCH_ASSOC) ?: null; if ($release) { $trackStmt = $db->prepare(" SELECT id, track_no, title, mix_name, duration, bpm, key_signature, sample_url FROM ac_release_tracks WHERE release_id = :rid ORDER BY track_no ASC, id ASC "); $trackStmt->execute([':rid' => $releaseId]); $tracks = $trackStmt->fetchAll(PDO::FETCH_ASSOC); } } } return new Response($this->view->render('admin/tracks_index.php', [ 'title' => 'Release Tracks', 'release' => $release, 'tracks' => $tracks, 'table_ready' => $tableReady, 'release_id' => $releaseId, ])); } public function adminTrackEdit(int $id = 0, int $releaseId = 0): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); $track = [ 'id' => 0, 'release_id' => $releaseId, 'track_no' => '', 'title' => '', 'mix_name' => '', 'duration' => '', 'bpm' => '', 'key_signature' => '', 'sample_url' => '', 'store_enabled' => 0, 'track_price' => '', 'store_currency' => 'GBP', 'full_file_url' => '', ]; $release = null; $db = Database::get(); if ($db instanceof PDO && $releaseId > 0) { $relStmt = $db->prepare("SELECT id, title FROM ac_releases WHERE id = :id LIMIT 1"); $relStmt->execute([':id' => $releaseId]); $release = $relStmt->fetch(PDO::FETCH_ASSOC) ?: null; } if ($id > 0 && $db instanceof PDO) { $stmt = $db->prepare("SELECT * FROM ac_release_tracks WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $track = array_merge($track, $row); $releaseId = (int)($track['release_id'] ?? $releaseId); if ($storePluginEnabled) { try { $storeStmt = $db->prepare(" SELECT is_enabled, track_price, currency FROM ac_store_track_products WHERE release_track_id = :track_id LIMIT 1 "); $storeStmt->execute([':track_id' => $id]); $storeRow = $storeStmt->fetch(PDO::FETCH_ASSOC); if ($storeRow) { $track['store_enabled'] = (int)($storeRow['is_enabled'] ?? 0); $track['track_price'] = (string)($storeRow['track_price'] ?? ''); $track['store_currency'] = (string)($storeRow['currency'] ?? 'GBP'); } } catch (Throwable $e) { } try { $fileStmt = $db->prepare(" SELECT file_url FROM ac_store_files WHERE scope_type = 'track' AND scope_id = :track_id AND is_active = 1 ORDER BY id DESC LIMIT 1 "); $fileStmt->execute([':track_id' => $id]); $fileRow = $fileStmt->fetch(PDO::FETCH_ASSOC); if ($fileRow) { $track['full_file_url'] = (string)($fileRow['file_url'] ?? ''); } } catch (Throwable $e) { } } } } if (!empty($_GET['sample_url']) && $track['sample_url'] === '') { $track['sample_url'] = (string)$_GET['sample_url']; } if (!empty($_GET['full_file_url']) && $track['full_file_url'] === '') { $track['full_file_url'] = (string)$_GET['full_file_url']; } return new Response($this->view->render('admin/track_edit.php', [ 'title' => $id > 0 ? 'Edit Track' : 'New Track', 'track' => $track, 'release' => $release, 'store_plugin_enabled' => $storePluginEnabled, 'error' => '', ])); } public function adminTrackSave(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); $id = (int)($_POST['id'] ?? 0); $releaseId = (int)($_POST['release_id'] ?? 0); $trackNo = (int)($_POST['track_no'] ?? 0); $title = trim((string)($_POST['title'] ?? '')); $mixName = trim((string)($_POST['mix_name'] ?? '')); $duration = trim((string)($_POST['duration'] ?? '')); $bpm = trim((string)($_POST['bpm'] ?? '')); $keySig = trim((string)($_POST['key_signature'] ?? '')); $sampleUrl = isset($_POST['sample_url']) ? trim((string)$_POST['sample_url']) : null; $storeEnabled = isset($_POST['store_enabled']) ? 1 : 0; $trackPrice = trim((string)($_POST['track_price'] ?? '')); $storeCurrency = strtoupper(trim((string)($_POST['store_currency'] ?? 'GBP'))); $fullFileUrl = trim((string)($_POST['full_file_url'] ?? '')); if (!preg_match('/^[A-Z]{3}$/', $storeCurrency)) { $storeCurrency = 'GBP'; } if ($releaseId <= 0) { return $this->trackSaveError($id, $releaseId, 'Release is required.'); } if ($title === '') { return $this->trackSaveError($id, $releaseId, 'Track title is required.'); } $db = Database::get(); if (!$db instanceof PDO) { return $this->trackSaveError($id, $releaseId, 'Database unavailable.'); } try { if ($id > 0) { $stmt = $db->prepare(" UPDATE ac_release_tracks SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration, bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url WHERE id = :id "); $stmt->execute([ ':track_no' => $trackNo > 0 ? $trackNo : null, ':title' => $title, ':mix_name' => $mixName !== '' ? $mixName : null, ':duration' => $duration !== '' ? $duration : null, ':bpm' => $bpm !== '' ? (int)$bpm : null, ':key_signature' => $keySig !== '' ? $keySig : null, ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, ':id' => $id, ]); $trackId = $id; } else { $stmt = $db->prepare(" INSERT INTO ac_release_tracks (release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url) VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url) "); $stmt->execute([ ':release_id' => $releaseId, ':track_no' => $trackNo > 0 ? $trackNo : null, ':title' => $title, ':mix_name' => $mixName !== '' ? $mixName : null, ':duration' => $duration !== '' ? $duration : null, ':bpm' => $bpm !== '' ? (int)$bpm : null, ':key_signature' => $keySig !== '' ? $keySig : null, ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, ]); $trackId = (int)$db->lastInsertId(); } if ($storePluginEnabled) { try { $storeStmt = $db->prepare(" INSERT INTO ac_store_track_products (release_track_id, is_enabled, track_price, currency, created_at, updated_at) VALUES (:track_id, :is_enabled, :track_price, :currency, NOW(), NOW()) ON DUPLICATE KEY UPDATE is_enabled = VALUES(is_enabled), track_price = VALUES(track_price), currency = VALUES(currency), updated_at = NOW() "); $storeStmt->execute([ ':track_id' => $trackId, ':is_enabled' => $storeEnabled, ':track_price' => $trackPrice !== '' ? (float)$trackPrice : 0.00, ':currency' => $storeCurrency, ]); } catch (Throwable $e) { } if ($fullFileUrl !== '') { try { $db->prepare(" UPDATE ac_store_files SET is_active = 0 WHERE scope_type = 'track' AND scope_id = :track_id ")->execute([':track_id' => $trackId]); $fileName = basename(parse_url($fullFileUrl, PHP_URL_PATH) ?: $fullFileUrl); $insFile = $db->prepare(" INSERT INTO ac_store_files (scope_type, scope_id, file_url, file_name, file_size, mime_type, is_active, created_at) VALUES ('track', :scope_id, :file_url, :file_name, NULL, NULL, 1, NOW()) "); $insFile->execute([ ':scope_id' => $trackId, ':file_url' => $fullFileUrl, ':file_name' => $fileName !== '' ? $fileName : 'track-file', ]); } catch (Throwable $e) { } } } } catch (Throwable $e) { error_log('AC release tracks save error: ' . $e->getMessage()); return $this->trackSaveError($id, $releaseId, 'Unable to save track.'); } return new Response('', 302, ['Location' => '/admin/releases/tracks?release_id=' . $releaseId]); } public function adminTrackDelete(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $id = (int)($_POST['id'] ?? 0); $releaseId = (int)($_POST['release_id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $id > 0) { $stmt = $db->prepare("DELETE FROM ac_release_tracks WHERE id = :id"); $stmt->execute([':id' => $id]); } return new Response('', 302, ['Location' => '/admin/releases/tracks?release_id=' . $releaseId]); } public function adminTrackUpload(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); $file = $_FILES['track_sample'] ?? null; $trackId = (int)($_POST['track_id'] ?? ($_POST['id'] ?? 0)); $releaseId = (int)($_POST['release_id'] ?? 0); $uploadKind = (string)($_POST['upload_kind'] ?? 'sample'); if ($trackId <= 0) { return $this->trackUploadRedirect($releaseId, $trackId, 'Save the track first, then upload audio.'); } if ($uploadKind === 'full' && !$storePluginEnabled) { return $this->trackUploadRedirect($releaseId, $trackId, 'Enable Store plugin to upload full track files.'); } if (!$file || !is_array($file)) { return $this->trackUploadRedirect( $releaseId, $trackId, $this->trackUploadDebugMessage('No file payload received.', null) ); } if (!array_key_exists('tmp_name', $file)) { return $this->trackUploadRedirect( $releaseId, $trackId, $this->trackUploadDebugMessage('Upload payload missing tmp_name.', $file) ); } if ($file['error'] !== UPLOAD_ERR_OK) { return $this->trackUploadRedirect($releaseId, $trackId, $this->uploadErrorMessage((int)$file['error'])); } $tmp = (string)$file['tmp_name']; if ($tmp === '' || !is_uploaded_file($tmp)) { return $this->trackUploadRedirect( $releaseId, $trackId, $this->trackUploadDebugMessage('Uploaded temp file not detected by PHP.', $file) ); } $uploadDir = __DIR__ . '/../../uploads/media'; $trackFolder = 'tracks'; if ($uploadKind === 'full') { $privateRoot = rtrim(Settings::get('store_private_root', '/home/audiocore.site/private_downloads'), '/'); if ($privateRoot === '') { return $this->trackUploadRedirect($releaseId, $trackId, 'Private download root is not configured.'); } $trackName = 'track-' . ($trackId > 0 ? (string)$trackId : date('YmdHis')); $db = Database::get(); if ($db instanceof PDO && $trackId > 0) { try { $trackStmt = $db->prepare("SELECT title FROM ac_release_tracks WHERE id = :id LIMIT 1"); $trackStmt->execute([':id' => $trackId]); $trackRow = $trackStmt->fetch(PDO::FETCH_ASSOC); $candidate = trim((string)($trackRow['title'] ?? '')); if ($candidate !== '') { $trackName = $candidate; } } catch (Throwable $e) { } } $trackSlug = $this->slugify($trackName); $trackFolder = 'tracks/' . $trackSlug; $uploadDir = $privateRoot . '/' . $trackFolder; } if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { return $this->trackUploadRedirect($releaseId, $trackId, 'Upload directory is not writable.'); } if (!is_writable($uploadDir)) { return $this->trackUploadRedirect($releaseId, $trackId, 'Upload directory is not writable.'); } $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); $mime = strtolower((string)($file['type'] ?? '')); $mp3ByExt = $ext === 'mp3'; $mp3ByMime = in_array($mime, ['audio/mpeg', 'audio/mp3', 'audio/x-mp3', 'audio/mpg', 'audio/x-mpeg'], true); if (!$mp3ByExt && !$mp3ByMime) { $msg = $uploadKind === 'full' ? 'Full track must be an MP3.' : 'Sample must be an MP3.'; return $this->trackUploadRedirect($releaseId, $trackId, $msg); } if ($ext === '') { $ext = 'mp3'; } $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'sample'; $baseName = trim($baseName, '-'); $fileName = ($baseName !== '' ? $baseName : 'sample') . '-' . date('YmdHis') . '.' . $ext; $dest = $uploadDir . '/' . $fileName; if (!move_uploaded_file($tmp, $dest)) { return $this->trackUploadRedirect($releaseId, $trackId, 'Upload failed.'); } $fileUrl = '/uploads/media/' . $fileName; if ($uploadKind === 'full') { $fileUrl = $trackFolder . '/' . $fileName; } $fileType = (string)($file['type'] ?? 'audio/mpeg'); $fileSize = (int)($file['size'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $uploadKind !== 'full') { try { $stmt = $db->prepare(" INSERT INTO ac_media (file_name, file_url, file_type, file_size, folder_id) VALUES (:name, :url, :type, :size, NULL) "); $stmt->execute([ ':name' => (string)$file['name'], ':url' => $fileUrl, ':type' => $fileType, ':size' => $fileSize, ]); } catch (Throwable $e) { } } if ($trackId > 0 && $db instanceof PDO) { if ($uploadKind === 'full') { try { $db->prepare(" UPDATE ac_store_files SET is_active = 0 WHERE scope_type = 'track' AND scope_id = :track_id ")->execute([':track_id' => $trackId]); $insFile = $db->prepare(" INSERT INTO ac_store_files (scope_type, scope_id, file_url, file_name, file_size, mime_type, is_active, created_at) VALUES ('track', :scope_id, :file_url, :file_name, :file_size, :mime_type, 1, NOW()) "); $insFile->execute([ ':scope_id' => $trackId, ':file_url' => $fileUrl, ':file_name' => (string)$file['name'], ':file_size' => $fileSize > 0 ? $fileSize : null, ':mime_type' => $fileType !== '' ? $fileType : null, ]); } catch (Throwable $e) { } $generatedSampleUrl = $this->generateTrackSampleFromFullUpload($dest, $trackId); if ($generatedSampleUrl !== '') { try { $stmt = $db->prepare("UPDATE ac_release_tracks SET sample_url = :url WHERE id = :id"); $stmt->execute([':url' => $generatedSampleUrl, ':id' => $trackId]); } catch (Throwable $e) { } } } else { $stmt = $db->prepare("UPDATE ac_release_tracks SET sample_url = :url WHERE id = :id"); $stmt->execute([':url' => $fileUrl, ':id' => $trackId]); } } return $this->trackUploadRedirect($releaseId, $trackId, '', $fileUrl, $uploadKind); } public function adminTrackGenerateSample(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $trackId = (int)($_POST['track_id'] ?? 0); $releaseId = (int)($_POST['release_id'] ?? 0); $start = (int)($_POST['sample_start'] ?? 0); $duration = (int)($_POST['sample_duration'] ?? 90); $duration = max(10, min(90, $duration)); $start = max(0, $start); if ($trackId <= 0) { return $this->trackUploadRedirect($releaseId, $trackId, 'Save the track first, then generate sample.'); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->trackUploadRedirect($releaseId, $trackId, 'Database unavailable.'); } $fullPath = $this->resolveTrackFullFilePath($db, $trackId); if ($fullPath === '' || !is_file($fullPath)) { return $this->trackUploadRedirect($releaseId, $trackId, 'No full track file found. Upload full track first.'); } $sampleUrl = $this->generateTrackSampleFromFullUpload($fullPath, $trackId, $start, $duration); if ($sampleUrl === '') { return $this->trackUploadRedirect($releaseId, $trackId, 'Sample generation failed. Check ffmpeg on server.'); } try { $stmt = $db->prepare("UPDATE ac_release_tracks SET sample_url = :url WHERE id = :id"); $stmt->execute([':url' => $sampleUrl, ':id' => $trackId]); } catch (Throwable $e) { return $this->trackUploadRedirect($releaseId, $trackId, 'Sample generated but DB update failed.'); } return new Response('', 302, [ 'Location' => '/admin/releases/tracks/edit?release_id=' . $releaseId . '&id=' . $trackId . '&sample_url=' . rawurlencode($sampleUrl) . '&upload_error=' . rawurlencode('Sample generated successfully.'), ]); } public function adminTrackSource(): Response { if (!Auth::check() || !Auth::hasRole(['admin', 'manager'])) { return new Response('', 403); } $trackId = (int)($_GET['track_id'] ?? 0); if ($trackId <= 0) { return new Response('', 404); } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 500); } $fullPath = $this->resolveTrackFullFilePath($db, $trackId); if ($fullPath === '' || !is_file($fullPath)) { return new Response('', 404); } $size = filesize($fullPath); if ($size === false) { return new Response('', 500); } header('Content-Type: audio/mpeg'); header('Accept-Ranges: bytes'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); $range = (string)($_SERVER['HTTP_RANGE'] ?? ''); if ($range !== '' && preg_match('/bytes=(\d*)-(\d*)/', $range, $m)) { $start = $m[1] === '' ? 0 : (int)$m[1]; $end = $m[2] === '' ? ($size - 1) : (int)$m[2]; $start = max(0, min($start, $size - 1)); $end = max($start, min($end, $size - 1)); $length = $end - $start + 1; header('HTTP/1.1 206 Partial Content'); header('Content-Length: ' . $length); header("Content-Range: bytes {$start}-{$end}/{$size}"); $fp = fopen($fullPath, 'rb'); if ($fp === false) { return new Response('', 500); } fseek($fp, $start); $buffer = 8192; $remaining = $length; while ($remaining > 0 && !feof($fp)) { $read = ($remaining > $buffer) ? $buffer : $remaining; $chunk = fread($fp, $read); if ($chunk === false) { break; } echo $chunk; $remaining -= strlen($chunk); if (connection_aborted()) { break; } } fclose($fp); exit; } header('Content-Length: ' . $size); readfile($fullPath); exit; } public function adminSave(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); $id = (int)($_POST['id'] ?? 0); $this->ensureReleaseArtistColumn(); $title = trim((string)($_POST['title'] ?? '')); $slug = trim((string)($_POST['slug'] ?? '')); $artistName = trim((string)($_POST['artist_name'] ?? '')); $description = trim((string)($_POST['description'] ?? '')); $credits = trim((string)($_POST['credits'] ?? '')); $catalogNo = trim((string)($_POST['catalog_no'] ?? '')); $releaseDate = trim((string)($_POST['release_date'] ?? '')); $coverUrl = trim((string)($_POST['cover_url'] ?? '')); $sampleUrl = trim((string)($_POST['sample_url'] ?? '')); $isPublished = isset($_POST['is_published']) ? 1 : 0; $storeEnabled = isset($_POST['store_enabled']) ? 1 : 0; $bundlePrice = trim((string)($_POST['bundle_price'] ?? '')); $storeCurrency = strtoupper(trim((string)($_POST['store_currency'] ?? 'GBP'))); $purchaseLabel = trim((string)($_POST['purchase_label'] ?? '')); if (!preg_match('/^[A-Z]{3}$/', $storeCurrency)) { $storeCurrency = 'GBP'; } if ($title === '') { return $this->saveError($id, 'Title is required.'); } $slug = $slug !== '' ? $this->slugify($slug) : $this->slugify($title); $db = Database::get(); if (!$db instanceof PDO) { return $this->saveError($id, 'Database unavailable.'); } $existingSampleUrl = null; if ($sampleUrl === null && $id > 0) { try { $sampleStmt = $db->prepare("SELECT sample_url FROM ac_releases WHERE id = :id LIMIT 1"); $sampleStmt->execute([':id' => $id]); $sampleRow = $sampleStmt->fetch(PDO::FETCH_ASSOC); if ($sampleRow) { $existingSampleUrl = (string)($sampleRow['sample_url'] ?? ''); } } catch (Throwable $e) { } } $artistId = $this->resolveArtistId($db, $artistName); $dupStmt = $id > 0 ? $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug AND id != :id LIMIT 1") : $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug LIMIT 1"); $params = $id > 0 ? [':slug' => $slug, ':id' => $id] : [':slug' => $slug]; $dupStmt->execute($params); if ($dupStmt->fetch()) { return $this->saveError($id, 'Slug already exists.'); } try { if ($id > 0) { $stmt = $db->prepare(" UPDATE ac_releases SET title = :title, slug = :slug, artist_id = :artist_id, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date, cover_url = :cover_url, sample_url = :sample_url, is_published = :is_published WHERE id = :id "); $stmt->execute([ ':title' => $title, ':slug' => $slug, ':artist_id' => $artistId > 0 ? $artistId : null, ':artist_name' => $artistName !== '' ? $artistName : null, ':description' => $description !== '' ? $description : null, ':credits' => $credits !== '' ? $credits : null, ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, ':release_date' => $releaseDate !== '' ? $releaseDate : null, ':cover_url' => $coverUrl !== '' ? $coverUrl : null, ':sample_url' => $sampleUrl !== null ? ($sampleUrl !== '' ? $sampleUrl : null) : (($existingSampleUrl ?? '') !== '' ? $existingSampleUrl : null), ':is_published' => $isPublished, ':id' => $id, ]); $releaseId = $id; } else { $stmt = $db->prepare(" INSERT INTO ac_releases (title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) "); $stmt->execute([ ':title' => $title, ':slug' => $slug, ':artist_id' => $artistId > 0 ? $artistId : null, ':artist_name' => $artistName !== '' ? $artistName : null, ':description' => $description !== '' ? $description : null, ':credits' => $credits !== '' ? $credits : null, ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, ':release_date' => $releaseDate !== '' ? $releaseDate : null, ':cover_url' => $coverUrl !== '' ? $coverUrl : null, ':sample_url' => $sampleUrl !== null && $sampleUrl !== '' ? $sampleUrl : null, ':is_published' => $isPublished, ]); $releaseId = (int)$db->lastInsertId(); } if ($storePluginEnabled) { try { $storeStmt = $db->prepare(" INSERT INTO ac_store_release_products (release_id, is_enabled, bundle_price, currency, purchase_label, created_at, updated_at) VALUES (:release_id, :is_enabled, :bundle_price, :currency, :purchase_label, NOW(), NOW()) ON DUPLICATE KEY UPDATE is_enabled = VALUES(is_enabled), bundle_price = VALUES(bundle_price), currency = VALUES(currency), purchase_label = VALUES(purchase_label), updated_at = NOW() "); $storeStmt->execute([ ':release_id' => $releaseId, ':is_enabled' => $storeEnabled, ':bundle_price' => $bundlePrice !== '' ? (float)$bundlePrice : 0.00, ':currency' => $storeCurrency, ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null, ]); } catch (Throwable $e) { } } } catch (Throwable $e) { error_log('AC releases save error: ' . $e->getMessage()); return $this->saveError($id, 'Unable to save release. Check table columns and input.'); } return new Response('', 302, ['Location' => '/admin/releases']); } public function adminDelete(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $id = (int)($_POST['id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $id > 0) { $stmt = $db->prepare("DELETE FROM ac_releases WHERE id = :id"); $stmt->execute([':id' => $id]); } return new Response('', 302, ['Location' => '/admin/releases']); } public function adminUpload(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $type = (string)($_POST['upload_type'] ?? 'cover'); $file = $type === 'sample' ? ($_FILES['release_sample'] ?? null) : ($_FILES['release_cover'] ?? null); $releaseId = (int)($_POST['release_id'] ?? 0); if (!$file || !isset($file['tmp_name'])) { return $this->uploadRedirect($releaseId); } if ($file['error'] !== UPLOAD_ERR_OK) { return $this->uploadRedirect($releaseId, $this->uploadErrorMessage((int)$file['error'])); } $tmp = (string)$file['tmp_name']; if ($tmp === '' || !is_uploaded_file($tmp)) { return $this->uploadRedirect($releaseId); } $uploadDir = __DIR__ . '/../../uploads/media'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { return $this->uploadRedirect($releaseId, 'Upload directory is not writable.'); } if (!is_writable($uploadDir)) { return $this->uploadRedirect($releaseId, 'Upload directory is not writable.'); } if ($type === 'sample') { $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); if ($ext !== 'mp3') { return $this->uploadRedirect($releaseId, 'Sample must be an MP3.'); } } else { $info = getimagesize($tmp); if ($info === false) { return $this->uploadRedirect($releaseId, 'Cover must be an image.'); } $ext = image_type_to_extension($info[2], false); $allowed = ['jpg', 'jpeg', 'png', 'webp']; if (!in_array($ext, $allowed, true)) { return $this->uploadRedirect($releaseId, 'Cover must be JPG, PNG, or WEBP.'); } } $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'file'; $baseName = trim($baseName, '-'); $fileName = ($baseName !== '' ? $baseName : 'file') . '-' . date('YmdHis') . '.' . $ext; $dest = $uploadDir . '/' . $fileName; if (!move_uploaded_file($tmp, $dest)) { return $this->uploadRedirect($releaseId, 'Upload failed.'); } $fileUrl = '/uploads/media/' . $fileName; $fileType = (string)($file['type'] ?? ''); $fileSize = (int)($file['size'] ?? 0); $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare(" INSERT INTO ac_media (file_name, file_url, file_type, file_size, folder_id) VALUES (:name, :url, :type, :size, NULL) "); $stmt->execute([ ':name' => (string)$file['name'], ':url' => $fileUrl, ':type' => $fileType, ':size' => $fileSize, ]); } catch (Throwable $e) { } } if ($releaseId > 0 && $db instanceof PDO) { if ($type === 'sample') { $stmt = $db->prepare("UPDATE ac_releases SET sample_url = :url WHERE id = :id"); } else { $stmt = $db->prepare("UPDATE ac_releases SET cover_url = :url WHERE id = :id"); } $stmt->execute([':url' => $fileUrl, ':id' => $releaseId]); } return $this->uploadRedirect($releaseId, '', $fileUrl, $type); } public function adminInstall(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $db = Database::get(); if ($db instanceof PDO) { try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_releases ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, artist_id INT UNSIGNED NULL, artist_name VARCHAR(200) NULL, description MEDIUMTEXT NULL, credits MEDIUMTEXT NULL, catalog_no VARCHAR(120) NULL, release_date DATE NULL, cover_url VARCHAR(255) NULL, sample_url VARCHAR(255) NULL, is_published TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug"); $db->exec("ALTER TABLE ac_releases ADD COLUMN sample_url VARCHAR(255) NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN cover_url VARCHAR(255) NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN release_date DATE NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN description MEDIUMTEXT NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN credits MEDIUMTEXT NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN catalog_no VARCHAR(120) NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_name VARCHAR(200) NULL"); $db->exec(" CREATE TABLE IF NOT EXISTS ac_release_tracks ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, release_id INT UNSIGNED NOT NULL, track_no INT NULL, title VARCHAR(200) NOT NULL, mix_name VARCHAR(200) NULL, duration VARCHAR(20) NULL, bpm INT NULL, key_signature VARCHAR(50) NULL, sample_url VARCHAR(255) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_release_tracks_release (release_id), CONSTRAINT fk_release_tracks_release FOREIGN KEY (release_id) REFERENCES ac_releases(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN track_no INT NULL"); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN mix_name VARCHAR(200) NULL"); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN duration VARCHAR(20) NULL"); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN bpm INT NULL"); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN key_signature VARCHAR(50) NULL"); $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN sample_url VARCHAR(255) NULL"); $db->exec(" CREATE TABLE IF NOT EXISTS ac_store_release_products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, release_id INT UNSIGNED NOT NULL UNIQUE, is_enabled TINYINT(1) NOT NULL DEFAULT 0, bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT 'GBP', purchase_label VARCHAR(120) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_store_track_products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, release_track_id INT UNSIGNED NOT NULL UNIQUE, is_enabled TINYINT(1) NOT NULL DEFAULT 0, track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT 'GBP', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_store_files ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, scope_type ENUM('release','track') NOT NULL, scope_id INT UNSIGNED NOT NULL, file_url VARCHAR(1024) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT UNSIGNED NULL, mime_type VARCHAR(128) NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { } } return new Response('', 302, ['Location' => '/admin/releases']); } private function ensureReleaseArtistColumn(): void { $db = Database::get(); if (!($db instanceof PDO)) { return; } try { $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_name'"); $exists = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); if (!$exists) { $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_name VARCHAR(200) NULL AFTER slug"); } } catch (Throwable $e) { } try { $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); $exists = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); if (!$exists) { $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug"); } } catch (Throwable $e) { } try { $db->exec(" UPDATE ac_releases r JOIN ac_artists a ON a.name = r.artist_name SET r.artist_id = a.id WHERE r.artist_id IS NULL AND r.artist_name IS NOT NULL AND r.artist_name <> '' "); } catch (Throwable $e) { } } private function resolveArtistId(PDO $db, string $artistName): int { $artistName = trim($artistName); if ($artistName === '') { return 0; } try { $stmt = $db->prepare("SELECT id FROM ac_artists WHERE name = :name LIMIT 1"); $stmt->execute([':name' => $artistName]); return (int)($stmt->fetchColumn() ?: 0); } catch (Throwable $e) { return 0; } } /** * @return string[] */ private function tableColumns(PDO $db, string $table): array { try { $stmt = $db->query("SHOW COLUMNS FROM {$table}"); $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; $columns = []; foreach ($rows as $row) { $field = strtolower(trim((string)($row['Field'] ?? ''))); if ($field !== '') { $columns[] = $field; } } return $columns; } catch (Throwable $e) { return []; } } private function tableExists(PDO $db, string $table): bool { try { $stmt = $db->query("SHOW TABLES LIKE " . $db->quote($table)); return (bool)($stmt && $stmt->fetch(PDO::FETCH_NUM)); } catch (Throwable $e) { return false; } } private function releasesTableReady(): bool { $db = Database::get(); if (!$db instanceof PDO) { return false; } try { $stmt = $db->query("SELECT 1 FROM ac_releases LIMIT 1"); return $stmt !== false; } catch (Throwable $e) { return false; } } private function saveError(int $id, string $message): Response { Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Release' : 'New Release', 'release' => [ 'id' => $id, 'title' => (string)($_POST['title'] ?? ''), 'slug' => (string)($_POST['slug'] ?? ''), 'artist_name' => (string)($_POST['artist_name'] ?? ''), 'description' => (string)($_POST['description'] ?? ''), 'credits' => (string)($_POST['credits'] ?? ''), 'catalog_no' => (string)($_POST['catalog_no'] ?? ''), 'release_date' => (string)($_POST['release_date'] ?? ''), 'cover_url' => (string)($_POST['cover_url'] ?? ''), 'sample_url' => (string)($_POST['sample_url'] ?? ''), 'is_published' => isset($_POST['is_published']) ? 1 : 0, 'store_enabled' => isset($_POST['store_enabled']) ? 1 : 0, 'bundle_price' => (string)($_POST['bundle_price'] ?? ''), 'store_currency' => (string)($_POST['store_currency'] ?? 'GBP'), 'purchase_label' => (string)($_POST['purchase_label'] ?? ''), ], 'store_plugin_enabled' => $storePluginEnabled, 'error' => $message, ])); } private function tracksTableReady(): bool { $db = Database::get(); if (!$db instanceof PDO) { return false; } try { $stmt = $db->query("SELECT 1 FROM ac_release_tracks LIMIT 1"); return $stmt !== false; } catch (Throwable $e) { return false; } } private function trackSaveError(int $id, int $releaseId, string $message): Response { Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); return new Response($this->view->render('admin/track_edit.php', [ 'title' => $id > 0 ? 'Edit Track' : 'New Track', 'track' => [ 'id' => $id, 'release_id' => $releaseId, 'track_no' => (string)($_POST['track_no'] ?? ''), 'title' => (string)($_POST['title'] ?? ''), 'mix_name' => (string)($_POST['mix_name'] ?? ''), 'duration' => (string)($_POST['duration'] ?? ''), 'bpm' => (string)($_POST['bpm'] ?? ''), 'key_signature' => (string)($_POST['key_signature'] ?? ''), 'sample_url' => (string)($_POST['sample_url'] ?? ''), 'store_enabled' => isset($_POST['store_enabled']) ? 1 : 0, 'track_price' => (string)($_POST['track_price'] ?? ''), 'store_currency' => (string)($_POST['store_currency'] ?? 'GBP'), 'full_file_url' => (string)($_POST['full_file_url'] ?? ''), ], 'release' => null, 'store_plugin_enabled' => $storePluginEnabled, 'error' => $message, ])); } private function trackUploadRedirect(int $releaseId, int $trackId, string $error = '', string $url = '', string $uploadKind = 'sample'): Response { $target = '/admin/releases/tracks/edit?release_id=' . $releaseId; if ($trackId > 0) { $target .= '&id=' . $trackId; } if ($url !== '') { $param = $uploadKind === 'full' ? 'full_file_url' : 'sample_url'; $target .= '&' . $param . '=' . rawurlencode($url); } if ($error !== '') { $target .= '&upload_error=' . rawurlencode($error); } return new Response('', 302, ['Location' => $target]); } private function trackUploadDebugMessage(string $reason, ?array $file): string { $code = $file !== null && isset($file['error']) ? (int)$file['error'] : -1; $name = $file !== null ? (string)($file['name'] ?? '') : ''; $size = $file !== null ? (int)($file['size'] ?? 0) : 0; $maxUpload = (string)ini_get('upload_max_filesize'); $maxPost = (string)ini_get('post_max_size'); $contentLength = (string)($_SERVER['CONTENT_LENGTH'] ?? '0'); return $reason . ' (file="' . $name . '", size=' . $size . ', code=' . $code . ', content_length=' . $contentLength . ', upload_max_filesize=' . $maxUpload . ', post_max_size=' . $maxPost . ')'; } private function slugify(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; $value = trim($value, '-'); return $value !== '' ? $value : 'release'; } private function uploadRedirect(int $releaseId, string $error = '', string $url = '', string $type = ''): Response { $target = $releaseId > 0 ? '/admin/releases/edit?id=' . $releaseId : '/admin/releases/new'; $queryPrefix = strpos($target, '?') === false ? '?' : '&'; if ($url !== '') { $param = $type === 'sample' ? 'sample_url' : 'cover_url'; $target .= $queryPrefix . $param . '=' . rawurlencode($url); $queryPrefix = '&'; } if ($error !== '') { $target .= $queryPrefix . 'upload_error=' . rawurlencode($error); } return new Response('', 302, ['Location' => $target]); } private function uploadErrorMessage(int $code): string { $max = (string)ini_get('upload_max_filesize'); $map = [ UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).", UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit.', UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'Missing temp upload directory.', UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', UPLOAD_ERR_EXTENSION => 'Upload stopped by a PHP extension.', UPLOAD_ERR_NO_FILE => 'No file uploaded.', ]; return $map[$code] ?? 'Upload failed.'; } private function generateTrackSampleFromFullUpload(string $fullPath, int $trackId, int $startSeconds = 45, int $durationSeconds = 90): string { if ($trackId <= 0 || $fullPath === '' || !is_file($fullPath)) { return ''; } if (!function_exists('shell_exec')) { return ''; } $ffmpeg = $this->resolveFfmpegBinary(); if ($ffmpeg === '') { return ''; } $sampleDir = __DIR__ . '/../../uploads/media/samples'; if (!is_dir($sampleDir) && !@mkdir($sampleDir, 0755, true)) { return ''; } if (!is_writable($sampleDir)) { return ''; } $sampleBase = 'track-' . $trackId . '-sample'; $db = Database::get(); if ($db instanceof PDO) { try { $releaseCols = $this->tableColumns($db, 'ac_releases'); $trackCols = $this->tableColumns($db, 'ac_release_tracks'); $artistExpr = in_array('artist_name', $releaseCols, true) ? "COALESCE(NULLIF(r.artist_name, ''), '')" : "''"; $catalogExpr = in_array('catalog_no', $releaseCols, true) ? "COALESCE(NULLIF(r.catalog_no, ''), '')" : "''"; $titleExpr = in_array('title', $trackCols, true) ? "COALESCE(NULLIF(t.title, ''), '')" : "''"; $metaStmt = $db->prepare(" SELECT {$artistExpr} AS artist_name, {$catalogExpr} AS catalog_no, {$titleExpr} AS track_title FROM ac_release_tracks t JOIN ac_releases r ON r.id = t.release_id WHERE t.id = :id LIMIT 1 "); $metaStmt->execute([':id' => $trackId]); $meta = $metaStmt->fetch(PDO::FETCH_ASSOC) ?: []; $parts = []; $artist = trim((string)($meta['artist_name'] ?? '')); $catalog = trim((string)($meta['catalog_no'] ?? '')); $title = trim((string)($meta['track_title'] ?? '')); if ($artist !== '') { $parts[] = $this->slugify($artist); } if ($catalog !== '') { $parts[] = $this->slugify($catalog); } if ($title !== '') { $parts[] = $this->slugify($title); } if ($parts) { $sampleBase = implode('-', $parts) . '-sample'; } } catch (Throwable $e) { } } $sampleName = $sampleBase . '-' . date('YmdHis') . '.mp3'; $samplePath = $sampleDir . '/' . $sampleName; $startSeconds = max(0, $startSeconds); $durationSeconds = max(10, min(90, $durationSeconds)); $cmd = escapeshellarg($ffmpeg) . ' -hide_banner -loglevel error -y' . ' -ss ' . (int)$startSeconds . ' -t ' . (int)$durationSeconds . ' -i ' . escapeshellarg($fullPath) . ' -codec:a libmp3lame -b:a 192k ' . escapeshellarg($samplePath) . ' 2>&1'; $out = shell_exec($cmd); if (!is_file($samplePath) || filesize($samplePath) <= 0) { if (is_file($samplePath)) { @unlink($samplePath); } if ($out) { error_log('AC sample generation failed: ' . trim((string)$out)); } return ''; } return '/uploads/media/samples/' . $sampleName; } private function resolveTrackFullFilePath(PDO $db, int $trackId): string { if ($trackId <= 0) { return ''; } try { $stmt = $db->prepare(" SELECT file_url FROM ac_store_files WHERE scope_type = 'track' AND scope_id = :track_id AND is_active = 1 ORDER BY id DESC LIMIT 1 "); $stmt->execute([':track_id' => $trackId]); $fileUrl = trim((string)($stmt->fetchColumn() ?: '')); if ($fileUrl === '') { return ''; } if (str_starts_with($fileUrl, '/uploads/')) { return __DIR__ . '/../../' . ltrim($fileUrl, '/'); } $privateRoot = rtrim((string)Settings::get('store_private_root', '/home/audiocore.site/private_downloads'), '/'); if ($privateRoot === '') { return ''; } return $privateRoot . '/' . ltrim($fileUrl, '/'); } catch (Throwable $e) { return ''; } } private function resolveFfmpegBinary(): string { $candidates = ['ffmpeg', '/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg']; foreach ($candidates as $bin) { $cmd = 'command -v ' . escapeshellarg($bin) . ' 2>/dev/null'; $found = trim((string)shell_exec($cmd)); if ($found !== '') { return $found; } if ($bin !== '' && $bin[0] === '/' && is_executable($bin)) { return $bin; } } return ''; } }