diff --git a/plugins/releases/ReleasesController.php b/plugins/releases/ReleasesController.php index 6e79f95..b31df61 100644 --- a/plugins/releases/ReleasesController.php +++ b/plugins/releases/ReleasesController.php @@ -27,8 +27,18 @@ class ReleasesController $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)); @@ -44,19 +54,29 @@ class ReleasesController 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; - $artistJoinReady = false; - try { - $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); - $artistJoinReady = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); - } catch (Throwable $e) { - $artistJoinReady = false; - } $params = []; $where = ["r.is_published = 1", "(r.release_date IS NULL OR r.release_date <= :today)"]; @@ -69,10 +89,65 @@ class ReleasesController } $params[':artist'] = $artistFilter; } + if ($labelFilter !== '' && $labelColumn !== '') { + $where[] = "r.{$labelColumn} = :label"; + $params[':label'] = $labelFilter; + } if ($search !== '') { - $where[] = "(r.title LIKE :search OR r.catalog_no LIKE :search OR r.slug LIKE :search OR r.artist_name LIKE :search" . ($artistJoinReady ? " OR a.name LIKE :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 = " @@ -91,6 +166,8 @@ class ReleasesController $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 @@ -102,10 +179,11 @@ class ReleasesController 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(); + $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 @@ -114,6 +192,14 @@ class ReleasesController 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 @@ -129,7 +215,10 @@ class ReleasesController $offset = ($currentPage - 1) * $perPage; $listSql = " - SELECT r.id, r.title, r.slug, r.release_date, r.cover_url, r.artist_name + 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]} @@ -150,6 +239,16 @@ class ReleasesController 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); @@ -161,6 +260,158 @@ class ReleasesController } } $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) { } } @@ -175,8 +426,18 @@ class ReleasesController '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, ])); } @@ -186,6 +447,7 @@ class ReleasesController $slug = trim((string)($_GET['slug'] ?? '')); $release = null; $tracks = []; + $bundles = []; Plugins::sync(); $storePluginEnabled = Plugins::isEnabled('store'); if ($slug !== '') { @@ -246,6 +508,78 @@ class ReleasesController } $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 = []; @@ -256,6 +590,7 @@ class ReleasesController 'title' => $release ? (string)$release['title'] : 'Release', 'release' => $release, 'tracks' => $tracks, + 'bundles' => $bundles, 'store_plugin_enabled' => $storePluginEnabled, ])); } @@ -528,7 +863,7 @@ class ReleasesController $duration = trim((string)($_POST['duration'] ?? '')); $bpm = trim((string)($_POST['bpm'] ?? '')); $keySig = trim((string)($_POST['key_signature'] ?? '')); - $sampleUrl = trim((string)($_POST['sample_url'] ?? '')); + $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'))); @@ -670,18 +1005,36 @@ class ReleasesController $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 || !isset($file['tmp_name'])) { - return $this->trackUploadRedirect($releaseId, $trackId); + 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); + return $this->trackUploadRedirect( + $releaseId, + $trackId, + $this->trackUploadDebugMessage('Uploaded temp file not detected by PHP.', $file) + ); } $uploadDir = __DIR__ . '/../../uploads/media'; @@ -717,10 +1070,16 @@ class ReleasesController } $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); - if ($ext !== 'mp3') { + $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, '-'); @@ -734,7 +1093,7 @@ class ReleasesController if ($uploadKind === 'full') { $fileUrl = $trackFolder . '/' . $fileName; } - $fileType = (string)($file['type'] ?? ''); + $fileType = (string)($file['type'] ?? 'audio/mpeg'); $fileSize = (int)($file['size'] ?? 0); $db = Database::get(); @@ -777,6 +1136,15 @@ class ReleasesController ]); } 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]); @@ -786,6 +1154,121 @@ class ReleasesController 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()) { @@ -825,6 +1308,18 @@ class ReleasesController 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) { + } + } $dupStmt = $id > 0 ? $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug AND id != :id LIMIT 1") @@ -852,7 +1347,9 @@ class ReleasesController ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, ':release_date' => $releaseDate !== '' ? $releaseDate : null, ':cover_url' => $coverUrl !== '' ? $coverUrl : null, - ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ':sample_url' => $sampleUrl !== null + ? ($sampleUrl !== '' ? $sampleUrl : null) + : (($existingSampleUrl ?? '') !== '' ? $existingSampleUrl : null), ':is_published' => $isPublished, ':id' => $id, ]); @@ -871,7 +1368,7 @@ class ReleasesController ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, ':release_date' => $releaseDate !== '' ? $releaseDate : null, ':cover_url' => $coverUrl !== '' ? $coverUrl : null, - ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ':sample_url' => $sampleUrl !== null && $sampleUrl !== '' ? $sampleUrl : null, ':is_published' => $isPublished, ]); $releaseId = (int)$db->lastInsertId(); @@ -1133,6 +1630,37 @@ class ReleasesController } } + /** + * @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(); @@ -1232,6 +1760,24 @@ class ReleasesController 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)); @@ -1272,4 +1818,149 @@ class ReleasesController ]; 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 ''; + } } diff --git a/plugins/releases/views/admin/track_edit.php b/plugins/releases/views/admin/track_edit.php index 6c229bb..3330282 100644 --- a/plugins/releases/views/admin/track_edit.php +++ b/plugins/releases/views/admin/track_edit.php @@ -5,6 +5,8 @@ $release = $release ?? null; $error = $error ?? ''; $uploadError = (string)($_GET['upload_error'] ?? ''); $storePluginEnabled = (bool)($store_plugin_enabled ?? false); +$trackId = (int)($track['id'] ?? 0); +$fullSourceUrl = $trackId > 0 ? '/admin/releases/tracks/source?track_id=' . $trackId : ''; ob_start(); ?> @@ -53,7 +55,7 @@ ob_start(); or click to upload No file selected - + Upload @@ -70,12 +72,35 @@ ob_start(); or click to upload No file selected - + Upload Full + 0 && (string)($track['full_file_url'] ?? '') !== ''): ?> + + Generate sample from full track + + + + + + Preview + + Range + 0s -> 90s + 90s + + + Reset range + Generate sample + + + + + + Full File URL (Store Download) @@ -105,6 +130,8 @@ ob_start(); + +