Release v1.5.1

This commit is contained in:
AudioCore Bot
2026-04-01 14:12:17 +00:00
parent dc53051358
commit 9deabe1ec9
50 changed files with 10775 additions and 5637 deletions

View File

@@ -0,0 +1,760 @@
<?php
declare(strict_types=1);
namespace Modules\Api;
use Core\Http\Response;
use Core\Services\ApiLayer;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Views\View;
use PDO;
use Throwable;
class ApiController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function adminIndex(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$db = Database::get();
$clients = [];
$createdKey = (string)($_SESSION['ac_api_created_key'] ?? '');
unset($_SESSION['ac_api_created_key']);
if ($db instanceof PDO) {
ApiLayer::ensureSchema($db);
try {
$stmt = $db->query("
SELECT id, name, api_key_prefix, webhook_url, is_active, last_used_at, last_used_ip, created_at
FROM ac_api_clients
ORDER BY created_at DESC, id DESC
");
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
} catch (Throwable $e) {
$clients = [];
}
}
return new Response($this->view->render('admin/index.php', [
'title' => 'API',
'clients' => $clients,
'created_key' => $createdKey,
'status' => trim((string)($_GET['status'] ?? '')),
'message' => trim((string)($_GET['message'] ?? '')),
'base_url' => $this->baseUrl(),
]));
}
public function adminCreateClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$name = trim((string)($_POST['name'] ?? ''));
$webhookUrl = trim((string)($_POST['webhook_url'] ?? ''));
if ($name === '') {
return $this->adminRedirect('error', 'Client name is required.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$plainKey = ApiLayer::issueClient($db, $name, $webhookUrl);
$_SESSION['ac_api_created_key'] = $plainKey;
return $this->adminRedirect('ok', 'API client created. Copy the key now.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to create API client.');
}
}
public function adminToggleClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("
UPDATE ac_api_clients
SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END
WHERE id = :id
");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client updated.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to update API client.');
}
}
public function adminDeleteClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("DELETE FROM ac_api_clients WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client deleted.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to delete API client.');
}
}
public function authVerify(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
return $this->json([
'ok' => true,
'client' => [
'id' => (int)($client['id'] ?? 0),
'name' => (string)($client['name'] ?? ''),
'prefix' => (string)($client['api_key_prefix'] ?? ''),
],
]);
}
public function artistSales(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at,
o.email AS customer_email,
o.payment_provider,
o.payment_ref
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE a.artist_id = :artist_id
AND o.status = 'paid'
ORDER BY a.id DESC
LIMIT 500
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist sales.'], 500);
}
}
public function salesSince(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
if ($afterId <= 0 && $since === '') {
return $this->json(['ok' => false, 'error' => 'after_id or since is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$where = [
'a.artist_id = :artist_id',
"o.status = 'paid'",
];
$params = [':artist_id' => $artistId];
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id ASC
LIMIT 500
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load incremental sales.'], 500);
}
}
public function artistTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
t.id,
t.release_id,
r.title AS release_title,
r.slug AS release_slug,
r.catalog_no,
r.release_date,
t.track_no,
t.title,
t.mix_name,
t.duration,
t.bpm,
t.key_signature,
t.sample_url,
sp.is_enabled AS store_enabled,
sp.track_price,
sp.currency
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
WHERE r.artist_id = :artist_id
ORDER BY r.release_date DESC, r.id DESC, t.track_no ASC, t.id ASC
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'tracks' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist tracks.'], 500);
}
}
public function submitRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$title = trim((string)($data['title'] ?? ''));
if ($title === '') {
return $this->json(['ok' => false, 'error' => 'title is required.'], 422);
}
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$slug = $this->slugify((string)($data['slug'] ?? $title));
try {
$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' => $this->nullableText($data['description'] ?? null),
':credits' => $this->nullableText($data['credits'] ?? null),
':catalog_no' => $this->nullableText($data['catalog_no'] ?? null),
':release_date' => $this->nullableText($data['release_date'] ?? null),
':cover_url' => $this->nullableText($data['cover_url'] ?? null),
':sample_url' => $this->nullableText($data['sample_url'] ?? null),
':is_published' => !empty($data['is_published']) ? 1 : 0,
]);
$releaseId = (int)$db->lastInsertId();
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'slug' => $slug,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to create release.'], 500);
}
}
public function submitTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
$tracks = is_array($data['tracks'] ?? null) ? $data['tracks'] : [];
if ($releaseId <= 0 || !$tracks) {
return $this->json(['ok' => false, 'error' => 'release_id and tracks are required.'], 422);
}
$created = [];
try {
foreach ($tracks as $track) {
if (!is_array($track)) {
continue;
}
$trackId = (int)($track['id'] ?? 0);
$title = trim((string)($track['title'] ?? ''));
if ($title === '') {
continue;
}
if ($trackId > 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 AND release_id = :release_id
");
$stmt->execute([
':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
':id' => $trackId,
':release_id' => $releaseId,
]);
$created[] = ['id' => $trackId, 'updated' => true];
} 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' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
]);
$created[] = ['id' => (int)$db->lastInsertId(), 'created' => true];
}
}
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'tracks' => $created,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to submit tracks.'], 500);
}
}
public function updateRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
if ($releaseId <= 0) {
return $this->json(['ok' => false, 'error' => 'release_id is required.'], 422);
}
$fields = [];
$params = [':id' => $releaseId];
$map = [
'title' => 'title',
'description' => 'description',
'credits' => 'credits',
'catalog_no' => 'catalog_no',
'release_date' => 'release_date',
'cover_url' => 'cover_url',
'sample_url' => 'sample_url',
'is_published' => 'is_published',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
$params[":{$input}"] = $input === 'is_published'
? (!empty($data[$input]) ? 1 : 0)
: $this->nullableText($data[$input]);
}
if (array_key_exists('slug', $data)) {
$fields[] = "slug = :slug";
$params[':slug'] = $this->slugify((string)$data['slug']);
}
if (array_key_exists('artist_id', $data) || array_key_exists('artist_name', $data)) {
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$fields[] = "artist_id = :artist_id";
$fields[] = "artist_name = :artist_name";
$params[':artist_id'] = $artistId > 0 ? $artistId : null;
$params[':artist_name'] = $artistName !== '' ? $artistName : null;
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_releases SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'release_id' => $releaseId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update release.'], 500);
}
}
public function updateTrack(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$data = $this->requestData();
$trackId = (int)($data['track_id'] ?? 0);
if ($trackId <= 0) {
return $this->json(['ok' => false, 'error' => 'track_id is required.'], 422);
}
$fields = [];
$params = [':id' => $trackId];
$map = [
'track_no' => 'track_no',
'title' => 'title',
'mix_name' => 'mix_name',
'duration' => 'duration',
'bpm' => 'bpm',
'key_signature' => 'key_signature',
'sample_url' => 'sample_url',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
if ($input === 'bpm' || $input === 'track_no') {
$params[":{$input}"] = $data[$input] !== '' ? (int)$data[$input] : null;
} else {
$params[":{$input}"] = $this->nullableText($data[$input]);
}
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_release_tracks SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'track_id' => $trackId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update track.'], 500);
}
}
public function orderItemData(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$artistId = (int)($_GET['artist_id'] ?? 0);
$orderId = (int)($_GET['order_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
try {
$where = ['1=1'];
$params = [];
if ($artistId > 0) {
$where[] = 'a.artist_id = :artist_id';
$params[':artist_id'] = $artistId;
}
if ($orderId > 0) {
$where[] = 'a.order_id = :order_id';
$params[':order_id'] = $orderId;
}
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
o.status AS order_status,
o.email AS customer_email,
a.order_item_id,
a.artist_id,
a.release_id,
a.track_id,
a.source_item_type,
a.source_item_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id DESC
LIMIT 1000
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load order item data.'], 500);
}
}
private function requestData(): array
{
$contentType = strtolower(trim((string)($_SERVER['CONTENT_TYPE'] ?? '')));
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$decoded = json_decode(is_string($raw) ? $raw : '', true);
return is_array($decoded) ? $decoded : [];
}
return is_array($_POST) ? $_POST : [];
}
private function requireClient(): array|Response
{
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$client = ApiLayer::verifyRequest($db);
if (!$client) {
return $this->json(['ok' => false, 'error' => 'Invalid API key.'], 401);
}
return $client;
}
private function json(array $data, int $status = 200): Response
{
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
return new Response(
is_string($json) ? $json : '{"ok":false,"error":"json_encode_failed"}',
$status,
['Content-Type' => 'application/json; charset=utf-8']
);
}
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 nullableText(mixed $value): ?string
{
$text = trim((string)$value);
return $text !== '' ? $text : null;
}
private function artistNameById(PDO $db, int $artistId): string
{
if ($artistId <= 0) {
return '';
}
try {
$stmt = $db->prepare("SELECT name FROM ac_artists WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $artistId]);
return trim((string)($stmt->fetchColumn() ?: ''));
} catch (Throwable $e) {
return '';
}
}
private function adminRedirect(string $status, string $message): Response
{
return new Response('', 302, [
'Location' => '/admin/api?status=' . rawurlencode($status) . '&message=' . rawurlencode($message),
]);
}
private function baseUrl(): string
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
$scheme = $https ? 'https' : 'http';
$host = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
return $host !== '' ? ($scheme . '://' . $host) : '';
}
private function guardAdmin(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

26
modules/api/module.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Api\ApiController;
require_once __DIR__ . '/ApiController.php';
return function (Router $router): void {
$controller = new ApiController();
$router->get('/admin/api', [$controller, 'adminIndex']);
$router->post('/admin/api/clients/create', [$controller, 'adminCreateClient']);
$router->post('/admin/api/clients/toggle', [$controller, 'adminToggleClient']);
$router->post('/admin/api/clients/delete', [$controller, 'adminDeleteClient']);
$router->get('/api/v1/auth/verify', [$controller, 'authVerify']);
$router->get('/api/v1/sales', [$controller, 'artistSales']);
$router->get('/api/v1/sales/since', [$controller, 'salesSince']);
$router->get('/api/v1/tracks', [$controller, 'artistTracks']);
$router->get('/api/v1/order-items', [$controller, 'orderItemData']);
$router->post('/api/v1/releases', [$controller, 'submitRelease']);
$router->post('/api/v1/tracks', [$controller, 'submitTracks']);
$router->post('/api/v1/releases/update', [$controller, 'updateRelease']);
$router->post('/api/v1/tracks/update', [$controller, 'updateTrack']);
};

View File

@@ -0,0 +1,189 @@
<?php
$pageTitle = 'API';
$clients = is_array($clients ?? null) ? $clients : [];
$createdKey = (string)($created_key ?? '');
$status = trim((string)($status ?? ''));
$message = trim((string)($message ?? ''));
$baseUrl = rtrim((string)($base_url ?? ''), '/');
$endpointRows = [
[
'title' => 'Verify key',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/auth/verify',
'note' => 'Quick auth check for AMS bootstrapping.',
],
[
'title' => 'Artist sales',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales?artist_id=123',
'note' => 'Detailed paid sales rows for one artist.',
],
[
'title' => 'Sales since sync',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales/since?artist_id=123&after_id=500',
'note' => 'Incremental sync using after_id or timestamp.',
],
[
'title' => 'Artist tracks',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/tracks?artist_id=123',
'note' => 'Track list tied to one artist account.',
],
[
'title' => 'Order item detail',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/order-items?artist_id=123',
'note' => 'Granular order transparency for AMS reporting.',
],
[
'title' => 'Submit release / tracks',
'method' => 'POST',
'path' => $baseUrl . '/api/v1/releases + /api/v1/tracks',
'note' => 'AMS pushes approved releases and tracks into AudioCore.',
],
];
ob_start();
?>
<section class="admin-card">
<div class="badge">Integration</div>
<div style="margin-top:14px; max-width:860px;">
<h1 style="margin:0; font-size:30px;">API</h1>
<p style="margin:8px 0 0; color:var(--muted);">AMS integration endpoints, API keys, and sales sync access live here. Keep this page operational rather than decorative: create clients, issue keys, and hand exact endpoints to the AMS.</p>
</div>
<?php if ($message !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:14px; border-color:<?= $status === 'ok' ? 'rgba(34,242,165,.25)' : 'rgba(255,120,120,.22)' ?>; color:<?= $status === 'ok' ? '#9ff8d8' : '#ffb0b0' ?>;">
<?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<?php if ($createdKey !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">New API Key</div>
<div style="margin-top:8px; font-size:13px; color:#ffdfad;">Copy this now. It is only shown once.</div>
</div>
<button type="button" class="btn outline small" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?>')">Copy key</button>
</div>
<code style="display:block; margin-top:12px; padding:14px 16px; border-radius:14px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.03); font-family:'IBM Plex Mono', monospace; font-size:13px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endif; ?>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-top:18px;">
<article class="admin-card" style="padding:16px;">
<div class="label">Clients</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;"><?= count($clients) ?></div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Configured external systems</div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Auth</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">Bearer</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Also accepts <code>X-API-Key</code></div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Webhook</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">sale.paid</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Outbound sale notifications for AMS sync</div>
</article>
</div>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Create API Client</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px; max-width:680px;">Use one client per AMS install or per integration target. That keeps revocation clean and usage attribution obvious.</div>
</div>
</div>
<form method="post" action="/admin/api/clients/create" style="display:grid; gap:14px; margin-top:16px;">
<div style="display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end;">
<label style="display:grid; gap:6px;">
<span class="label">Client Name</span>
<input class="input" type="text" name="name" placeholder="AudioCore AMS">
</label>
<label style="display:grid; gap:6px;">
<span class="label">Webhook URL (optional)</span>
<input class="input" type="url" name="webhook_url" placeholder="https://ams.example.com/webhooks/audiocore">
</label>
<button class="btn" type="submit">Create Key</button>
</div>
</form>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Endpoint Reference</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Keep this as an operator reference. The layout is stacked because this panel needs readability more than compression.</div>
</div>
<div style="color:var(--muted); font-size:12px;">Use <strong>Authorization: Bearer &lt;api-key&gt;</strong> or <strong>X-API-Key</strong>.</div>
</div>
<div style="display:grid; gap:10px; margin-top:16px;">
<?php foreach ($endpointRows as $row): ?>
<div class="admin-card" style="padding:14px 16px; display:grid; grid-template-columns:140px 96px minmax(0,1fr); gap:14px; align-items:start;">
<div>
<div style="font-weight:700;"><?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="margin-top:5px; color:var(--muted); font-size:12px; line-height:1.45;"><?= htmlspecialchars($row['note'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<span class="pill" style="padding:6px 10px; font-size:10px; letter-spacing:0.14em; border-color:rgba(115,255,198,0.25); color:#9ff8d8;"><?= htmlspecialchars($row['method'], ENT_QUOTES, 'UTF-8') ?></span>
</div>
<code style="display:block; padding:10px 12px; border-radius:10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.1); font-size:12px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($row['path'], ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endforeach; ?>
</div>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Active Clients</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Disable a client to cut access immediately. Delete only when you do not need audit visibility anymore.</div>
</div>
</div>
<?php if (!$clients): ?>
<div style="margin-top:14px; color:var(--muted); font-size:13px;">No API clients created yet.</div>
<?php else: ?>
<div style="display:grid; gap:10px; margin-top:14px;">
<?php foreach ($clients as $client): ?>
<div class="admin-card" style="padding:16px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center;">
<div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<strong><?= htmlspecialchars((string)($client['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></strong>
<span class="pill" style="padding:5px 10px; font-size:10px; letter-spacing:0.14em; border-color:<?= (int)($client['is_active'] ?? 0) === 1 ? 'rgba(115,255,198,0.35)' : 'rgba(255,255,255,0.16)' ?>; color:<?= (int)($client['is_active'] ?? 0) === 1 ? '#9ff8d8' : '#a7adba' ?>;">
<?= (int)($client['is_active'] ?? 0) === 1 ? 'Active' : 'Disabled' ?>
</span>
</div>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,auto)); gap:18px; margin-top:10px; color:var(--muted); font-size:12px;">
<div><span class="label" style="display:block; margin-bottom:4px;">Key prefix</span><?= htmlspecialchars((string)($client['api_key_prefix'] ?? ''), ENT_QUOTES, 'UTF-8') ?>...</div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last used</span><?= htmlspecialchars((string)($client['last_used_at'] ?? 'Never'), ENT_QUOTES, 'UTF-8') ?></div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last IP</span><?= htmlspecialchars((string)($client['last_used_ip'] ?? '—'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php if (!empty($client['webhook_url'])): ?>
<div style="margin-top:8px; color:var(--muted); font-size:12px;">
Webhook: <?= htmlspecialchars((string)$client['webhook_url'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
</div>
<div style="display:flex; gap:8px; align-items:center; justify-content:flex-end;">
<form method="post" action="/admin/api/clients/toggle">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small"><?= (int)($client['is_active'] ?? 0) === 1 ? 'Disable' : 'Enable' ?></button>
</form>
<form method="post" action="/admin/api/clients/delete" onsubmit="return confirm('Delete this API client?');">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small" style="border-color:rgba(255,120,120,.35); color:#ffb0b0;">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';