1405 lines
51 KiB
PHP
1405 lines
51 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../db/config.php';
|
|
|
|
const CHANNEL_EDITABLE_FIELDS = [
|
|
'upper_ch_ref',
|
|
'country_iso',
|
|
'sat_ref',
|
|
'genre',
|
|
'type',
|
|
'groupe',
|
|
'region',
|
|
'langue',
|
|
'resolution',
|
|
'active',
|
|
'sat_in',
|
|
'sat_out',
|
|
'sat_update',
|
|
];
|
|
|
|
const CHANNEL_LOCKED_FIELDS = [
|
|
'country_client',
|
|
'sat_tpinfo',
|
|
'idtype',
|
|
'six_id',
|
|
'sid',
|
|
'onid',
|
|
'tid',
|
|
'sat_ch_client_upper',
|
|
'sat_client',
|
|
'counting',
|
|
'lastview',
|
|
'manually_edited',
|
|
'manually_edited_at',
|
|
];
|
|
|
|
const CHANNEL_LIST_COLUMNS = [
|
|
'id', 'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
|
|
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
|
|
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at'
|
|
];
|
|
|
|
const CHANNEL_RECORD_COLUMNS = [
|
|
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
|
|
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
|
|
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at',
|
|
'date_in', 'date_out'
|
|
];
|
|
|
|
const CHANNEL_FILTER_OPTION_FIELDS = [
|
|
'sat_ref', 'country_iso', 'country_client', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe',
|
|
'sat_tpinfo', 'sat_ch_client_upper', 'sat_client'
|
|
];
|
|
|
|
const CHANNEL_ADVANCED_FILTER_FIELDS = [
|
|
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution',
|
|
'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out',
|
|
'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited', 'manually_edited_at'
|
|
];
|
|
|
|
|
|
function channel_workspace_csv_candidates(): array
|
|
{
|
|
$root = dirname(__DIR__);
|
|
$preferred = glob($root . '/data-*.csv') ?: [];
|
|
$all = glob($root . '/*.csv') ?: [];
|
|
$files = array_values(array_unique(array_merge($preferred, $all)));
|
|
|
|
usort($files, static function (string $a, string $b): int {
|
|
$aPriority = basename($a) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($a), 'data-') ? 1 : 2);
|
|
$bPriority = basename($b) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($b), 'data-') ? 1 : 2);
|
|
if ($aPriority !== $bPriority) {
|
|
return $aPriority <=> $bPriority;
|
|
}
|
|
|
|
$mtimeCompare = @filemtime($b) <=> @filemtime($a);
|
|
if ($mtimeCompare !== 0) {
|
|
return $mtimeCompare;
|
|
}
|
|
|
|
return strcmp(basename($a), basename($b));
|
|
});
|
|
|
|
return $files;
|
|
}
|
|
|
|
function channel_detect_workspace_csv(): ?array
|
|
{
|
|
$files = channel_workspace_csv_candidates();
|
|
if (!$files) {
|
|
return null;
|
|
}
|
|
|
|
$path = $files[0];
|
|
return [
|
|
'path' => $path,
|
|
'basename' => basename($path),
|
|
'sha1' => is_file($path) ? sha1_file($path) : null,
|
|
'modified_at' => is_file($path) ? gmdate('Y-m-d H:i:s', (int) filemtime($path)) : null,
|
|
];
|
|
}
|
|
|
|
function channel_normalize_csv_header(string $header): string
|
|
{
|
|
$header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header;
|
|
$header = strtolower(trim($header));
|
|
$header = preg_replace('/[^a-z0-9]+/', '_', $header) ?? $header;
|
|
return trim($header, '_');
|
|
}
|
|
|
|
function channel_csv_header_map(array $headers): array
|
|
{
|
|
$aliases = [
|
|
'upper_ch_ref' => ['upper_ch_ref', 'upper ch ref', 'upper channel ref', 'channel', 'channel_name', 'channel name', 'service_name', 'service name', 'name'],
|
|
'country_iso' => ['country_iso', 'country iso', 'country_code', 'country code', 'iso', 'iso2', 'country'],
|
|
'country_client' => ['country_client', 'country client', 'client_country', 'client country'],
|
|
'sat_ref' => ['sat_ref', 'sat ref', 'satellite', 'satellite_ref', 'satellite ref', 'satellite_reference'],
|
|
'sat_tpinfo' => ['sat_tpinfo', 'sat tpinfo', 'tpinfo', 'tp_info', 'transponder', 'frequency', 'freq'],
|
|
'genre' => ['genre', 'category'],
|
|
'type' => ['type', 'channel_type', 'channel type', 'free_pay', 'free/pay', 'fta_type'],
|
|
'resolution' => ['resolution', 'quality', 'video_quality', 'video quality', 'format'],
|
|
'langue' => ['langue', 'language', 'lang'],
|
|
'region' => ['region', 'market'],
|
|
'groupe' => ['groupe', 'group', 'bouquet'],
|
|
'active' => ['active', 'is_active', 'is active', 'enabled', 'status'],
|
|
'idtype' => ['idtype', 'id_type', 'id type', 'identifier_type', 'identifier type'],
|
|
'six_id' => ['six_id', 'six id', 'sixid'],
|
|
'sid' => ['sid', 'service_id', 'service id'],
|
|
'onid' => ['onid', 'original_network_id', 'original network id'],
|
|
'tid' => ['tid', 'transport_stream_id', 'transport stream id'],
|
|
'sat_in' => ['sat_in', 'sat in', 'start_date', 'start date'],
|
|
'sat_out' => ['sat_out', 'sat out', 'end_date', 'end date'],
|
|
'sat_update' => ['sat_update', 'sat update', 'update_date', 'update date', 'updated_at_source', 'updated at source', 'last_update', 'last update'],
|
|
'sat_ch_client_upper' => ['sat_ch_client_upper', 'sat ch client upper', 'client_channel_name', 'client channel name'],
|
|
'sat_client' => ['sat_client', 'sat client', 'client', 'platform', 'operator'],
|
|
'counting' => ['counting', 'hits', 'views', 'count'],
|
|
'lastview' => ['lastview', 'last view', 'last_seen', 'last seen', 'last_viewed_at', 'last viewed at'],
|
|
'manually_edited' => ['manually_edited', 'manually edited', 'manual_edit', 'manual edit'],
|
|
'manually_edited_at' => ['manually_edited_at', 'manually edited at', 'manual_edit_date', 'manual edit date'],
|
|
'date_in' => ['date_in', 'date in', 'record_date_in', 'record date in'],
|
|
'date_out' => ['date_out', 'date out', 'record_date_out', 'record date out'],
|
|
];
|
|
|
|
$normalizedAliases = [];
|
|
foreach ($aliases as $canonical => $variants) {
|
|
$normalizedAliases[channel_normalize_csv_header($canonical)] = $canonical;
|
|
foreach ($variants as $variant) {
|
|
$normalizedAliases[channel_normalize_csv_header($variant)] = $canonical;
|
|
}
|
|
}
|
|
|
|
$mapped = [];
|
|
foreach ($headers as $index => $header) {
|
|
$normalized = channel_normalize_csv_header((string) $header);
|
|
if ($normalized === '') {
|
|
continue;
|
|
}
|
|
|
|
$canonical = $normalizedAliases[$normalized] ?? null;
|
|
if ($canonical === null && in_array($normalized, CHANNEL_RECORD_COLUMNS, true)) {
|
|
$canonical = $normalized;
|
|
}
|
|
if ($canonical !== null && !isset($mapped[$canonical])) {
|
|
$mapped[$canonical] = (int) $index;
|
|
}
|
|
}
|
|
|
|
return $mapped;
|
|
}
|
|
|
|
function channel_csv_nullish(?string $value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
$lower = strtolower($value);
|
|
if (in_array($lower, ['null', 'n/a', 'na', 'none', '-'], true)) {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
function channel_csv_boolish(?string $value, int $default = 1): int
|
|
{
|
|
$value = channel_csv_nullish($value);
|
|
if ($value === null) {
|
|
return $default;
|
|
}
|
|
|
|
$lower = strtolower($value);
|
|
if (in_array($lower, ['1', 'true', 'yes', 'y', 'active', 'enabled'], true)) {
|
|
return 1;
|
|
}
|
|
if (in_array($lower, ['0', 'false', 'no', 'n', 'inactive', 'disabled'], true)) {
|
|
return 0;
|
|
}
|
|
|
|
return is_numeric($value) ? ((int) $value > 0 ? 1 : 0) : $default;
|
|
}
|
|
|
|
function channel_csv_intish(?string $value, int $default = 0): int
|
|
{
|
|
$value = channel_csv_nullish($value);
|
|
if ($value === null) {
|
|
return $default;
|
|
}
|
|
|
|
return is_numeric($value) ? (int) $value : $default;
|
|
}
|
|
|
|
function channel_csv_date(?string $value): ?string
|
|
{
|
|
$value = channel_csv_nullish($value);
|
|
if ($value === null || $value === '0000-00-00') {
|
|
return null;
|
|
}
|
|
|
|
$formats = ['Y-m-d', 'd/m/Y', 'm/d/Y', 'd-m-Y', 'm-d-Y', 'd.m.Y', 'Y/m/d'];
|
|
foreach ($formats as $format) {
|
|
$date = DateTimeImmutable::createFromFormat($format, $value);
|
|
if ($date instanceof DateTimeImmutable) {
|
|
return $date->format('Y-m-d');
|
|
}
|
|
}
|
|
|
|
$timestamp = strtotime($value);
|
|
if ($timestamp === false) {
|
|
return null;
|
|
}
|
|
|
|
return gmdate('Y-m-d', $timestamp);
|
|
}
|
|
|
|
function channel_csv_datetime(?string $value): ?string
|
|
{
|
|
$value = channel_csv_nullish($value);
|
|
if ($value === null || $value === '0000-00-00 00:00:00') {
|
|
return null;
|
|
}
|
|
|
|
$formats = ['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'd/m/Y H:i:s', 'm/d/Y H:i:s', 'd-m-Y H:i:s', 'Y-m-d'];
|
|
foreach ($formats as $format) {
|
|
$date = DateTimeImmutable::createFromFormat($format, $value);
|
|
if ($date instanceof DateTimeImmutable) {
|
|
return $date->format('Y-m-d H:i:s');
|
|
}
|
|
}
|
|
|
|
$timestamp = strtotime($value);
|
|
if ($timestamp === false) {
|
|
return null;
|
|
}
|
|
|
|
return gmdate('Y-m-d H:i:s', $timestamp);
|
|
}
|
|
|
|
function channel_csv_type(?string $value): ?string
|
|
{
|
|
$value = channel_csv_nullish($value);
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$lower = strtolower($value);
|
|
if (str_contains($lower, 'free') || str_contains($lower, 'fta') || str_contains($lower, 'open')) {
|
|
return 'free';
|
|
}
|
|
if (str_contains($lower, 'pay')) {
|
|
return 'payed';
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
function channel_import_workspace_csv(PDO $pdo, string $path): int
|
|
{
|
|
if (!is_file($path) || !is_readable($path)) {
|
|
return 0;
|
|
}
|
|
|
|
$csv = new SplFileObject($path, 'r');
|
|
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
|
|
|
|
$headers = null;
|
|
$headerMap = [];
|
|
$imported = 0;
|
|
|
|
foreach ($csv as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$hasContent = false;
|
|
foreach ($row as $cell) {
|
|
if (trim((string) $cell) !== '') {
|
|
$hasContent = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$hasContent) {
|
|
continue;
|
|
}
|
|
|
|
if ($headers === null) {
|
|
$headers = array_map(static fn($value): string => (string) $value, $row);
|
|
$headerMap = channel_csv_header_map($headers);
|
|
if (!isset($headerMap['upper_ch_ref'])) {
|
|
throw new RuntimeException('CSV import needs a channel name column (for example: upper_ch_ref or channel_name).');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$record = array_fill_keys(CHANNEL_RECORD_COLUMNS, null);
|
|
$record['active'] = 1;
|
|
$record['idtype'] = 1;
|
|
$record['counting'] = 0;
|
|
$record['manually_edited'] = 0;
|
|
|
|
foreach ($headerMap as $column => $index) {
|
|
$raw = isset($row[$index]) ? (string) $row[$index] : null;
|
|
switch ($column) {
|
|
case 'active':
|
|
$record[$column] = channel_csv_boolish($raw, 1);
|
|
break;
|
|
case 'idtype':
|
|
$record[$column] = channel_csv_intish($raw, 1);
|
|
break;
|
|
case 'counting':
|
|
$record[$column] = channel_csv_intish($raw, 0);
|
|
break;
|
|
case 'manually_edited':
|
|
$record[$column] = channel_csv_boolish($raw, 0);
|
|
break;
|
|
case 'sat_in':
|
|
case 'sat_out':
|
|
case 'sat_update':
|
|
case 'manually_edited_at':
|
|
case 'date_in':
|
|
case 'date_out':
|
|
$record[$column] = channel_csv_date($raw);
|
|
break;
|
|
case 'lastview':
|
|
$record[$column] = channel_csv_datetime($raw);
|
|
break;
|
|
case 'type':
|
|
$record[$column] = channel_csv_type($raw);
|
|
break;
|
|
default:
|
|
$record[$column] = channel_csv_nullish($raw);
|
|
break;
|
|
}
|
|
}
|
|
|
|
$record['upper_ch_ref'] = trim((string) ($record['upper_ch_ref'] ?? ''));
|
|
if ($record['upper_ch_ref'] === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($record['country_iso'] !== null) {
|
|
$record['country_iso'] = strtoupper(substr((string) $record['country_iso'], 0, 3));
|
|
}
|
|
if ($record['date_in'] === null) {
|
|
$record['date_in'] = $record['sat_in'] ?? gmdate('Y-m-d');
|
|
}
|
|
|
|
channel_insert($record, $pdo);
|
|
$imported++;
|
|
}
|
|
|
|
return $imported;
|
|
}
|
|
|
|
function channel_app_bootstrap(): void
|
|
{
|
|
static $booted = false;
|
|
if ($booted) {
|
|
return;
|
|
}
|
|
|
|
$pdo = db();
|
|
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS channels (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
upper_ch_ref VARCHAR(255) NOT NULL,
|
|
country_iso VARCHAR(10) DEFAULT NULL,
|
|
country_client VARCHAR(100) DEFAULT NULL,
|
|
sat_ref VARCHAR(100) DEFAULT NULL,
|
|
sat_tpinfo VARCHAR(100) DEFAULT NULL,
|
|
genre VARCHAR(100) DEFAULT NULL,
|
|
type VARCHAR(20) DEFAULT NULL,
|
|
resolution VARCHAR(50) DEFAULT NULL,
|
|
langue VARCHAR(50) DEFAULT NULL,
|
|
region VARCHAR(100) DEFAULT NULL,
|
|
groupe VARCHAR(100) DEFAULT NULL,
|
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
|
idtype TINYINT(1) NOT NULL DEFAULT 1,
|
|
six_id VARCHAR(50) DEFAULT NULL,
|
|
sid VARCHAR(50) DEFAULT NULL,
|
|
onid VARCHAR(50) DEFAULT NULL,
|
|
tid VARCHAR(50) DEFAULT NULL,
|
|
sat_in DATE DEFAULT NULL,
|
|
sat_out DATE DEFAULT NULL,
|
|
sat_update DATE DEFAULT NULL,
|
|
sat_ch_client_upper VARCHAR(255) DEFAULT NULL,
|
|
sat_client VARCHAR(255) DEFAULT NULL,
|
|
counting INT NOT NULL DEFAULT 0,
|
|
lastview DATETIME DEFAULT NULL,
|
|
manually_edited TINYINT(1) NOT NULL DEFAULT 0,
|
|
manually_edited_at DATE DEFAULT NULL,
|
|
date_in DATE DEFAULT NULL,
|
|
date_out DATE DEFAULT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_active (active),
|
|
INDEX idx_sat_ref (sat_ref),
|
|
INDEX idx_country_iso (country_iso),
|
|
INDEX idx_genre (genre),
|
|
INDEX idx_idtype (idtype),
|
|
INDEX idx_manually_edited (manually_edited)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id VARCHAR(100) NOT NULL,
|
|
channel_id INT NOT NULL,
|
|
field_name VARCHAR(100) NOT NULL,
|
|
old_value TEXT,
|
|
new_value TEXT,
|
|
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_channel_id (channel_id),
|
|
INDEX idx_user_id (user_id),
|
|
INDEX idx_field_name (field_name),
|
|
INDEX idx_changed_at (changed_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
|
|
$count = (int) $pdo->query("SELECT COUNT(*) FROM channels")->fetchColumn();
|
|
if ($count === 0) {
|
|
$csvSource = channel_detect_workspace_csv();
|
|
$imported = 0;
|
|
|
|
if ($csvSource !== null) {
|
|
try {
|
|
$imported = channel_import_workspace_csv($pdo, $csvSource['path']);
|
|
} catch (Throwable $e) {
|
|
error_log('CSV import failed for ' . ($csvSource['basename'] ?? 'workspace csv') . ': ' . $e->getMessage());
|
|
$imported = 0;
|
|
}
|
|
}
|
|
|
|
if ($imported === 0) {
|
|
$rows = [
|
|
['DISCOVERY SPORT', 'FR', 'France', 'EUTELSAT-5W', '11554/V/29950', 'Sport', 'free', 'HD', 'FR', 'West Europe', 'Warner Sports', 1, 1, '101001', '1201', '1', '301', '2024-01-04', null, '2026-03-15', 'DISCOVERY SPORT FR', 'Canal France', 1842, '2026-04-04 14:20:00', 0, null, '2024-01-04', null],
|
|
['EURO NEWS WORLD', 'GB', 'United Kingdom', 'ASTRA-28E', '11778/H/27500', 'News', 'free', 'HD', 'EN', 'UK & Ireland', 'Global News', 1, 1, '101002', '1202', '1', '302', '2024-02-12', null, '2026-03-20', 'EURONEWS UK', 'Sky UK', 2215, '2026-04-04 13:08:00', 1, '2026-04-03', '2024-02-12', null],
|
|
['CINEMA MAX', 'DE', 'Germany', 'HOTBIRD-13E', '11034/H/29900', 'Movie', 'payed', 'UHD', 'DE', 'DACH', 'Max Studios', 1, 1, '101003', '1203', '1', '303', '2023-11-01', null, '2026-03-29', 'CINEMA MAX DE', 'HD+ Germany', 1560, '2026-04-04 12:11:00', 0, null, '2023-11-01', null],
|
|
['KIDS ONE', 'IT', 'Italy', 'HOTBIRD-13E', '12149/V/27500', 'Kids', 'free', 'HD', 'IT', 'Southern Europe', 'Junior Media', 1, 1, '101004', '1204', '1', '304', '2024-04-18', null, '2026-03-30', 'KIDS ONE IT', 'Sky Italia', 980, '2026-04-03 11:42:00', 0, null, '2024-04-18', null],
|
|
['MUSIC HITS', 'ES', 'Spain', 'ASTRA-19E', '10847/V/22000', 'Music', 'free', 'SD', 'ES', 'Iberia', 'Nova Music', 1, 1, '101005', '1205', '1', '305', '2023-08-10', null, '2026-03-14', 'MUSIC HITS ES', 'Movistar+', 3104, '2026-04-04 08:15:00', 1, '2026-04-01', '2023-08-10', null],
|
|
['WORLD DOCS', 'US', 'United States', 'INTELSAT-34', '11720/H/45000', 'Documentary', 'payed', 'HD', 'EN', 'Americas', 'Discovery Group', 1, 1, '101006', '1206', '2', '306', '2022-06-20', null, '2026-03-25', 'WORLD DOCS US', 'Dish Americas', 884, '2026-04-04 16:20:00', 0, null, '2022-06-20', null],
|
|
['NEWS 24 AFRICA', 'ZA', 'South Africa', 'EUTELSAT-36E', '12360/R/27500', 'News', 'free', 'HD', 'EN', 'Africa', 'Africa Media', 1, 1, '101007', '1207', '7', '307', '2024-01-16', null, '2026-03-18', 'NEWS 24 AFRICA', 'MultiChoice', 1733, '2026-04-04 15:00:00', 0, null, '2024-01-16', null],
|
|
['SPORT PLUS 4K', 'PL', 'Poland', 'HOTBIRD-13E', '10758/V/30000', 'Sport', 'payed', 'UHD', 'PL', 'Central Europe', 'Polsat Sport', 1, 1, '101008', '1208', '3', '308', '2024-03-05', null, '2026-03-27', 'SPORT PLUS 4K', 'Cyfrowy Polsat', 1270, '2026-04-04 12:55:00', 1, '2026-04-02', '2024-03-05', null],
|
|
['ARTE CULTURE', 'FR', 'France', 'ASTRA-19E', '11376/V/22000', 'Culture', 'free', 'HD', 'FR', 'West Europe', 'Arte Group', 1, 1, '101009', '1209', '1', '309', '2023-02-14', null, '2026-03-21', 'ARTE CULTURE FR', 'Canal France', 2011, '2026-04-04 10:30:00', 0, null, '2023-02-14', null],
|
|
['MOVIES ACTION', 'TR', 'Turkey', 'TURKSAT-42E', '11844/V/2222', 'Movie', 'payed', 'HD', 'TR', 'Middle East', 'Action Studios', 0, 1, '101010', '1210', '4', '310', '2024-05-12', '2026-03-01', '2026-03-01', 'MOVIES ACTION TR', 'D-Smart', 542, '2026-03-01 09:00:00', 1, '2026-03-01', '2024-05-12', null],
|
|
['UNKNOWN HIT 1198', null, 'Unmatched', 'EUTELSAT-7B', '11200/H/30000', null, null, null, null, null, null, 1, 2, '101011', '1211', '9', '311', '2026-03-22', null, '2026-03-22', 'UNMATCHED FEED', 'Pushimatic', 14, '2026-04-04 05:55:00', 0, null, '2026-03-22', null],
|
|
['LATAM SPORT EXTRA', 'BR', 'Brazil', 'INTELSAT-34', '12050/V/45000', 'Sport', 'payed', 'HD', 'PT', 'LATAM', 'Globo Sports', 1, 1, '101012', '1212', '6', '312', '2024-07-01', null, '2026-03-31', 'LATAM SPORT EXTRA', 'Claro TV', 698, '2026-04-04 18:05:00', 0, null, '2024-07-01', null],
|
|
['ASIA BUSINESS TV', 'SG', 'Singapore', 'ASIASAT-7', '12210/H/43200', 'Business', 'free', 'HD', 'EN', 'Asia Pacific', 'Asia Finance', 1, 1, '101013', '1213', '5', '313', '2023-09-09', null, '2026-03-26', 'ASIA BUSINESS TV', 'StarHub', 431, '2026-04-02 06:12:00', 0, null, '2023-09-09', null],
|
|
['FAMILY LIFE', 'US', 'United States', 'EUTELSAT-5W', '12648/H/29500', 'Lifestyle', 'free', 'HD', 'EN', 'North America', 'Family Media', 1, 1, '101014', '1214', '2', '314', '2024-01-28', null, '2026-03-17', 'FAMILY LIFE US', 'Dish Americas', 799, '2026-04-03 19:44:00', 1, '2026-03-28', '2024-01-28', null],
|
|
['SCIENCE HUB', 'CA', 'Canada', 'INTELSAT-34', '12190/V/45000', 'Education', 'payed', 'HD', 'EN', 'North America', 'Knowledge Networks', 1, 1, '101015', '1215', '2', '315', '2024-06-11', null, '2026-03-19', 'SCIENCE HUB CA', 'Bell TV', 611, '2026-04-04 07:12:00', 0, null, '2024-06-11', null],
|
|
['RETRO CINEMA', 'MX', 'Mexico', 'SATMEX-8', '11180/H/30000', 'Movie', 'free', 'SD', 'ES', 'LATAM', 'Retro Media', 1, 1, '101016', '1216', '8', '316', '2022-10-03', null, '2026-03-11', 'RETRO CINEMA MX', 'Sky Mexico', 1221, '2026-04-04 09:40:00', 0, null, '2022-10-03', null],
|
|
];
|
|
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO channels (
|
|
upper_ch_ref, country_iso, country_client, sat_ref, sat_tpinfo, genre, type, resolution, langue,
|
|
region, groupe, active, idtype, six_id, sid, onid, tid, sat_in, sat_out, sat_update,
|
|
sat_ch_client_upper, sat_client, counting, lastview, manually_edited, manually_edited_at, date_in, date_out
|
|
) VALUES (
|
|
:upper_ch_ref, :country_iso, :country_client, :sat_ref, :sat_tpinfo, :genre, :type, :resolution, :langue,
|
|
:region, :groupe, :active, :idtype, :six_id, :sid, :onid, :tid, :sat_in, :sat_out, :sat_update,
|
|
:sat_ch_client_upper, :sat_client, :counting, :lastview, :manually_edited, :manually_edited_at, :date_in, :date_out
|
|
)"
|
|
);
|
|
|
|
foreach ($rows as $row) {
|
|
$stmt->execute([
|
|
':upper_ch_ref' => $row[0],
|
|
':country_iso' => $row[1],
|
|
':country_client' => $row[2],
|
|
':sat_ref' => $row[3],
|
|
':sat_tpinfo' => $row[4],
|
|
':genre' => $row[5],
|
|
':type' => $row[6],
|
|
':resolution' => $row[7],
|
|
':langue' => $row[8],
|
|
':region' => $row[9],
|
|
':groupe' => $row[10],
|
|
':active' => $row[11],
|
|
':idtype' => $row[12],
|
|
':six_id' => $row[13],
|
|
':sid' => $row[14],
|
|
':onid' => $row[15],
|
|
':tid' => $row[16],
|
|
':sat_in' => $row[17],
|
|
':sat_out' => $row[18],
|
|
':sat_update' => $row[19],
|
|
':sat_ch_client_upper' => $row[20],
|
|
':sat_client' => $row[21],
|
|
':counting' => $row[22],
|
|
':lastview' => $row[23],
|
|
':manually_edited' => $row[24],
|
|
':manually_edited_at' => $row[25],
|
|
':date_in' => $row[26],
|
|
':date_out' => $row[27],
|
|
]);
|
|
}
|
|
|
|
$auditStmt = $pdo->prepare(
|
|
"INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value, changed_at)
|
|
VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value, :changed_at)"
|
|
);
|
|
$auditStmt->execute([
|
|
':user_id' => 'system.seed',
|
|
':channel_id' => 2,
|
|
':field_name' => 'genre',
|
|
':old_value' => 'International',
|
|
':new_value' => 'News',
|
|
':changed_at' => '2026-04-03 09:14:00',
|
|
]);
|
|
$auditStmt->execute([
|
|
':user_id' => 'ops.demo',
|
|
':channel_id' => 8,
|
|
':field_name' => 'resolution',
|
|
':old_value' => 'HD',
|
|
':new_value' => 'UHD',
|
|
':changed_at' => '2026-04-02 17:20:00',
|
|
]);
|
|
$auditStmt->execute([
|
|
':user_id' => 'ops.demo',
|
|
':channel_id' => 14,
|
|
':field_name' => 'active',
|
|
':old_value' => '0',
|
|
':new_value' => '1',
|
|
':changed_at' => '2026-03-28 12:03:00',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$booted = true;
|
|
}
|
|
|
|
function channel_current_user_id(): string
|
|
{
|
|
return $_SERVER['REMOTE_USER'] ?? $_SERVER['PHP_AUTH_USER'] ?? 'ops.demo';
|
|
}
|
|
|
|
function channel_allowed_sort_columns(): array
|
|
{
|
|
return [
|
|
'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution', 'langue',
|
|
'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out', 'sat_update',
|
|
'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited_at'
|
|
];
|
|
}
|
|
|
|
function channel_parse_csv_param(string $key): array
|
|
{
|
|
if (!isset($_GET[$key])) {
|
|
return [];
|
|
}
|
|
|
|
$value = $_GET[$key];
|
|
if (is_array($value)) {
|
|
$items = $value;
|
|
} else {
|
|
$items = explode(',', (string) $value);
|
|
}
|
|
|
|
$clean = [];
|
|
foreach ($items as $item) {
|
|
$item = trim((string) $item);
|
|
if ($item !== '') {
|
|
$clean[] = $item;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($clean));
|
|
}
|
|
|
|
function channel_parse_advanced_filters(mixed $rawFilters): array
|
|
{
|
|
if (is_string($rawFilters)) {
|
|
$decoded = json_decode($rawFilters, true);
|
|
$rawFilters = is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
if (!is_array($rawFilters)) {
|
|
return [];
|
|
}
|
|
|
|
$allowedFields = CHANNEL_ADVANCED_FILTER_FIELDS;
|
|
$allowedOperators = ['contains', 'equals', 'not_contains', 'not_equals', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'];
|
|
$normalized = [];
|
|
|
|
foreach ($rawFilters as $rule) {
|
|
if (!is_array($rule)) {
|
|
continue;
|
|
}
|
|
|
|
$field = trim((string) ($rule['field'] ?? ''));
|
|
$operator = trim((string) ($rule['operator'] ?? 'contains'));
|
|
$value = trim((string) ($rule['value'] ?? ''));
|
|
|
|
if (!in_array($field, $allowedFields, true) || !in_array($operator, $allowedOperators, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (!in_array($operator, ['is_empty', 'is_not_empty'], true) && $value === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'field' => $field,
|
|
'operator' => $operator,
|
|
'value' => $value,
|
|
];
|
|
|
|
if (count($normalized) >= 10) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
function channel_apply_advanced_filters(array $rules, array &$where, array &$params): bool
|
|
{
|
|
$applied = false;
|
|
|
|
foreach ($rules as $index => $rule) {
|
|
$field = $rule['field'];
|
|
$operator = $rule['operator'];
|
|
$value = $rule['value'] ?? '';
|
|
$placeholder = ':adv_' . $index;
|
|
|
|
switch ($operator) {
|
|
case 'contains':
|
|
$where[] = "{$field} LIKE {$placeholder}";
|
|
$params[$placeholder] = '%' . $value . '%';
|
|
$applied = true;
|
|
break;
|
|
case 'equals':
|
|
$where[] = "{$field} = {$placeholder}";
|
|
$params[$placeholder] = $value;
|
|
$applied = true;
|
|
break;
|
|
case 'not_contains':
|
|
$where[] = "({$field} IS NULL OR {$field} NOT LIKE {$placeholder})";
|
|
$params[$placeholder] = '%' . $value . '%';
|
|
$applied = true;
|
|
break;
|
|
case 'not_equals':
|
|
$where[] = "({$field} IS NULL OR {$field} <> {$placeholder})";
|
|
$params[$placeholder] = $value;
|
|
$applied = true;
|
|
break;
|
|
case 'starts_with':
|
|
$where[] = "{$field} LIKE {$placeholder}";
|
|
$params[$placeholder] = $value . '%';
|
|
$applied = true;
|
|
break;
|
|
case 'ends_with':
|
|
$where[] = "{$field} LIKE {$placeholder}";
|
|
$params[$placeholder] = '%' . $value;
|
|
$applied = true;
|
|
break;
|
|
case 'is_empty':
|
|
$where[] = "({$field} IS NULL OR {$field} = '')";
|
|
$applied = true;
|
|
break;
|
|
case 'is_not_empty':
|
|
$where[] = "({$field} IS NOT NULL AND {$field} <> '')";
|
|
$applied = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $applied;
|
|
}
|
|
|
|
function channel_distinct_options(array $fields): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$pdo = db();
|
|
$allowed = array_values(array_intersect($fields, CHANNEL_FILTER_OPTION_FIELDS));
|
|
$out = [];
|
|
foreach ($allowed as $field) {
|
|
$stmt = $pdo->query("SELECT DISTINCT {$field} AS value FROM channels WHERE date_out IS NULL AND {$field} IS NOT NULL AND {$field} <> '' ORDER BY {$field} ASC");
|
|
$out[$field] = array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll()));
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
function channel_last_changed_field_map(array $channelIds): array
|
|
{
|
|
$channelIds = array_values(array_unique(array_map('intval', array_filter($channelIds, static fn($id): bool => (int) $id > 0))));
|
|
if (!$channelIds) {
|
|
return [];
|
|
}
|
|
|
|
$pdo = db();
|
|
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
|
|
$sql = "SELECT a.channel_id, a.field_name
|
|
FROM audit_log a
|
|
INNER JOIN (
|
|
SELECT channel_id, MAX(id) AS max_id
|
|
FROM audit_log
|
|
WHERE field_name <> 'versioned_from' AND channel_id IN ({$placeholders})
|
|
GROUP BY channel_id
|
|
) latest ON latest.max_id = a.id";
|
|
$stmt = $pdo->prepare($sql);
|
|
foreach ($channelIds as $index => $channelId) {
|
|
$stmt->bindValue($index + 1, $channelId, PDO::PARAM_INT);
|
|
}
|
|
$stmt->execute();
|
|
|
|
$map = [];
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
$map[(int) $row['channel_id']] = (string) $row['field_name'];
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
function channel_list(array $query): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$pdo = db();
|
|
|
|
$limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 50;
|
|
$page = isset($query['page']) ? max(1, (int) $query['page']) : 1;
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
$sortable = channel_allowed_sort_columns();
|
|
$sort = in_array(($query['sort'] ?? ''), $sortable, true) ? (string) $query['sort'] : 'upper_ch_ref';
|
|
$direction = strtoupper((string) ($query['direction'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
|
|
|
|
$filterMap = [
|
|
'sat_ref' => 'sat_ref',
|
|
'country_iso' => 'country_iso',
|
|
'country_client' => 'country_client',
|
|
'genre' => 'genre',
|
|
'type' => 'type',
|
|
'resolution' => 'resolution',
|
|
'langue' => 'langue',
|
|
'region' => 'region',
|
|
'groupe' => 'groupe',
|
|
'active' => 'active',
|
|
'idtype' => 'idtype',
|
|
'manually_edited' => 'manually_edited',
|
|
'sat_tpinfo' => 'sat_tpinfo',
|
|
'sat_ch_client_upper' => 'sat_ch_client_upper',
|
|
'sat_client' => 'sat_client',
|
|
'counting' => 'counting',
|
|
'lastview' => 'lastview',
|
|
'sat_in' => 'sat_in',
|
|
'sat_out' => 'sat_out',
|
|
'sat_update' => 'sat_update',
|
|
'manually_edited_at' => 'manually_edited_at',
|
|
];
|
|
|
|
$where = [];
|
|
$params = [];
|
|
$hasExplicitFilter = false;
|
|
|
|
if (!empty($query['search'])) {
|
|
$hasExplicitFilter = true;
|
|
$where[] = "(
|
|
upper_ch_ref LIKE :search OR country_iso LIKE :search OR country_client LIKE :search OR sat_ref LIKE :search OR
|
|
sat_tpinfo LIKE :search OR genre LIKE :search OR type LIKE :search OR resolution LIKE :search OR langue LIKE :search OR
|
|
region LIKE :search OR groupe LIKE :search OR six_id LIKE :search OR sid LIKE :search OR onid LIKE :search OR tid LIKE :search OR
|
|
sat_client LIKE :search OR sat_ch_client_upper LIKE :search OR counting LIKE :search OR lastview LIKE :search OR
|
|
sat_in LIKE :search OR sat_out LIKE :search OR sat_update LIKE :search OR manually_edited_at LIKE :search
|
|
)";
|
|
$params[':search'] = '%' . $query['search'] . '%';
|
|
}
|
|
|
|
$advancedFilters = channel_parse_advanced_filters($query['advanced_filters'] ?? []);
|
|
if ($advancedFilters) {
|
|
$hasExplicitFilter = channel_apply_advanced_filters($advancedFilters, $where, $params) || $hasExplicitFilter;
|
|
}
|
|
|
|
foreach ($filterMap as $param => $column) {
|
|
$values = [];
|
|
if (isset($query[$param])) {
|
|
if (is_array($query[$param])) {
|
|
$values = $query[$param];
|
|
} else {
|
|
$values = explode(',', (string) $query[$param]);
|
|
}
|
|
}
|
|
$values = array_values(array_filter(array_map(static fn($v) => trim((string) $v), $values), static fn($v) => $v !== ''));
|
|
if ($values) {
|
|
$hasExplicitFilter = true;
|
|
$placeholders = [];
|
|
foreach ($values as $index => $value) {
|
|
$name = ':' . $param . '_' . $index;
|
|
$placeholders[] = $name;
|
|
$params[$name] = $value;
|
|
}
|
|
$where[] = sprintf('%s IN (%s)', $column, implode(',', $placeholders));
|
|
}
|
|
}
|
|
|
|
foreach (['six_id', 'sid', 'onid', 'tid'] as $exactField) {
|
|
if (!empty($query[$exactField])) {
|
|
$hasExplicitFilter = true;
|
|
$where[] = "{$exactField} = :{$exactField}";
|
|
$params[':' . $exactField] = (string) $query[$exactField];
|
|
}
|
|
}
|
|
|
|
$where[] = 'date_out IS NULL';
|
|
|
|
if (!$hasExplicitFilter) {
|
|
$where[] = 'active = 1';
|
|
$where[] = 'sat_out IS NULL';
|
|
}
|
|
|
|
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
|
|
|
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM channels {$whereSql}");
|
|
foreach ($params as $key => $value) {
|
|
$countStmt->bindValue($key, $value);
|
|
}
|
|
$countStmt->execute();
|
|
$total = (int) $countStmt->fetchColumn();
|
|
|
|
$sql = sprintf(
|
|
'SELECT %s FROM channels %s ORDER BY %s %s LIMIT :limit OFFSET :offset',
|
|
implode(', ', CHANNEL_LIST_COLUMNS),
|
|
$whereSql,
|
|
$sort,
|
|
$direction
|
|
);
|
|
$stmt = $pdo->prepare($sql);
|
|
foreach ($params as $key => $value) {
|
|
$stmt->bindValue($key, $value);
|
|
}
|
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
|
|
$rows = $stmt->fetchAll();
|
|
$lastChangedFields = channel_last_changed_field_map(array_map(static fn(array $row): int => (int) $row['id'], $rows));
|
|
foreach ($rows as &$row) {
|
|
$row['last_changed_field'] = $lastChangedFields[(int) ($row['id'] ?? 0)] ?? null;
|
|
}
|
|
unset($row);
|
|
|
|
return [
|
|
'data' => $rows,
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
'total' => $total,
|
|
'pages' => (int) max(1, ceil($total / $limit)),
|
|
],
|
|
'meta' => [
|
|
'sort' => $sort,
|
|
'direction' => $direction,
|
|
'filters_applied' => $hasExplicitFilter,
|
|
],
|
|
'options' => channel_distinct_options(CHANNEL_FILTER_OPTION_FIELDS),
|
|
];
|
|
}
|
|
|
|
function channel_get(int $id): ?array
|
|
{
|
|
channel_app_bootstrap();
|
|
$stmt = db()->prepare('SELECT * FROM channels WHERE id = :id');
|
|
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
return $row ?: null;
|
|
}
|
|
|
|
function channel_insert(array $record, ?PDO $pdo = null): int
|
|
{
|
|
$pdo = $pdo ?: db();
|
|
$columns = CHANNEL_RECORD_COLUMNS;
|
|
$placeholders = array_map(static fn(string $column): string => ':' . $column, $columns);
|
|
|
|
$sql = sprintf(
|
|
'INSERT INTO channels (%s) VALUES (%s)',
|
|
implode(', ', $columns),
|
|
implode(', ', $placeholders)
|
|
);
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
foreach ($columns as $column) {
|
|
$stmt->bindValue(':' . $column, $record[$column] ?? null);
|
|
}
|
|
$stmt->execute();
|
|
|
|
return (int) $pdo->lastInsertId();
|
|
}
|
|
|
|
function channel_close_and_version(int $id, array $changes, string $userId): array
|
|
{
|
|
$pdo = db();
|
|
$channel = channel_get($id);
|
|
if (!$channel) {
|
|
throw new InvalidArgumentException('Channel not found.');
|
|
}
|
|
if ($channel['date_out'] !== null) {
|
|
throw new InvalidArgumentException('Only the current channel version can be edited.');
|
|
}
|
|
|
|
$versionDate = gmdate('Y-m-d');
|
|
$nextRecord = [];
|
|
foreach (CHANNEL_RECORD_COLUMNS as $column) {
|
|
$nextRecord[$column] = $channel[$column] ?? null;
|
|
}
|
|
|
|
foreach ($changes as $field => $change) {
|
|
$nextRecord[$field] = $change['new'];
|
|
}
|
|
|
|
$nextRecord['manually_edited'] = 1;
|
|
$nextRecord['manually_edited_at'] = $versionDate;
|
|
$nextRecord['date_in'] = $versionDate;
|
|
$nextRecord['date_out'] = null;
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$closeStmt = $pdo->prepare('UPDATE channels SET date_out = :date_out WHERE id = :id AND date_out IS NULL');
|
|
$closeStmt->bindValue(':date_out', $versionDate);
|
|
$closeStmt->bindValue(':id', $id, PDO::PARAM_INT);
|
|
$closeStmt->execute();
|
|
|
|
if ($closeStmt->rowCount() !== 1) {
|
|
throw new RuntimeException('Unable to close the previous channel version.');
|
|
}
|
|
|
|
$newId = channel_insert($nextRecord, $pdo);
|
|
|
|
$auditStmt = $pdo->prepare(
|
|
'INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value) VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value)'
|
|
);
|
|
|
|
foreach ($changes as $field => $change) {
|
|
$auditStmt->execute([
|
|
':user_id' => $userId,
|
|
':channel_id' => $newId,
|
|
':field_name' => $field,
|
|
':old_value' => $change['old'] === null ? null : (string) $change['old'],
|
|
':new_value' => $change['new'] === null ? null : (string) $change['new'],
|
|
]);
|
|
}
|
|
|
|
$auditStmt->execute([
|
|
':user_id' => $userId,
|
|
':channel_id' => $newId,
|
|
':field_name' => 'versioned_from',
|
|
':old_value' => (string) $id,
|
|
':new_value' => (string) $newId,
|
|
]);
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $e;
|
|
}
|
|
|
|
return [
|
|
'previous_channel_id' => $id,
|
|
'channel' => channel_get($newId),
|
|
'changed_fields' => array_keys($changes),
|
|
];
|
|
}
|
|
|
|
function channel_validate_patch_fields(array $fields): array
|
|
{
|
|
if (!$fields) {
|
|
throw new InvalidArgumentException('No editable fields were provided.');
|
|
}
|
|
|
|
$clean = [];
|
|
$editable = CHANNEL_EDITABLE_FIELDS;
|
|
$existingSatRefs = channel_distinct_options(['sat_ref'])['sat_ref'];
|
|
|
|
foreach ($fields as $field => $value) {
|
|
if (!in_array($field, $editable, true)) {
|
|
throw new RuntimeException('FORBIDDEN_FIELD:' . $field);
|
|
}
|
|
|
|
if (in_array($field, ['sat_in', 'sat_out', 'sat_update'], true)) {
|
|
if ($value === '' || $value === null) {
|
|
$clean[$field] = null;
|
|
continue;
|
|
}
|
|
$date = date_create((string) $value);
|
|
if (!$date) {
|
|
throw new InvalidArgumentException("Invalid date for {$field}.");
|
|
}
|
|
$clean[$field] = $date->format('Y-m-d');
|
|
continue;
|
|
}
|
|
|
|
if ($field === 'active') {
|
|
if (!in_array((string) $value, ['0', '1'], true)) {
|
|
throw new InvalidArgumentException('Active must be 0 or 1.');
|
|
}
|
|
$clean[$field] = (int) $value;
|
|
continue;
|
|
}
|
|
|
|
if ($field === 'type') {
|
|
if (!in_array((string) $value, ['free', 'payed'], true)) {
|
|
throw new InvalidArgumentException('Type must be free or payed.');
|
|
}
|
|
$clean[$field] = (string) $value;
|
|
continue;
|
|
}
|
|
|
|
if ($field === 'country_iso' && $value !== null && $value !== '') {
|
|
$value = strtoupper(substr((string) $value, 0, 3));
|
|
}
|
|
|
|
if ($field === 'sat_ref' && $value !== null && $value !== '' && !in_array((string) $value, $existingSatRefs, true)) {
|
|
throw new InvalidArgumentException('sat_ref must match an existing satellite reference.');
|
|
}
|
|
|
|
$clean[$field] = $value === '' ? null : trim((string) $value);
|
|
}
|
|
|
|
return $clean;
|
|
}
|
|
|
|
function channel_patch(int $id, array $fields, string $userId): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$channel = channel_get($id);
|
|
if (!$channel) {
|
|
throw new InvalidArgumentException('Channel not found.');
|
|
}
|
|
|
|
$clean = channel_validate_patch_fields($fields);
|
|
$changes = [];
|
|
foreach ($clean as $field => $newValue) {
|
|
$oldValue = $channel[$field];
|
|
if ((string) ($oldValue ?? '') !== (string) ($newValue ?? '')) {
|
|
$changes[$field] = ['old' => $oldValue, 'new' => $newValue];
|
|
}
|
|
}
|
|
|
|
if (!$changes) {
|
|
return ['previous_channel_id' => $id, 'channel' => $channel, 'changed_fields' => []];
|
|
}
|
|
|
|
return channel_close_and_version($id, $changes, $userId);
|
|
}
|
|
|
|
function channel_bulk_patch(array $ids, array $fields, string $userId): array
|
|
{
|
|
$ids = array_values(array_unique(array_map('intval', $ids)));
|
|
$ids = array_values(array_filter($ids, static fn(int $id): bool => $id > 0));
|
|
if (!$ids) {
|
|
throw new InvalidArgumentException('No valid ids were provided for bulk update.');
|
|
}
|
|
|
|
$cleanFields = channel_validate_patch_fields($fields);
|
|
$updated = [];
|
|
foreach ($ids as $id) {
|
|
$result = channel_patch($id, $cleanFields, $userId);
|
|
if (!empty($result['changed_fields'])) {
|
|
$updated[] = [
|
|
'previous_id' => $id,
|
|
'new_id' => (int) ($result['channel']['id'] ?? 0),
|
|
'changed_fields' => $result['changed_fields'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'updated_count' => count($updated),
|
|
'items' => $updated,
|
|
];
|
|
}
|
|
|
|
function channel_build_latest_transition(array $versions): array
|
|
{
|
|
if (!$versions) {
|
|
return [
|
|
'current_id' => null,
|
|
'previous_id' => null,
|
|
'changed_fields' => [],
|
|
'changes' => [],
|
|
'last_changed_field' => null,
|
|
'last_change' => null,
|
|
];
|
|
}
|
|
|
|
$currentIndex = null;
|
|
foreach ($versions as $index => $version) {
|
|
if (!empty($version['is_current'])) {
|
|
$currentIndex = $index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($currentIndex === null) {
|
|
$currentIndex = count($versions) - 1;
|
|
}
|
|
|
|
$currentVersion = $versions[$currentIndex] ?? null;
|
|
$previousVersion = $currentIndex > 0 ? ($versions[$currentIndex - 1] ?? null) : null;
|
|
|
|
if (!$currentVersion || !$previousVersion) {
|
|
return [
|
|
'current_id' => $currentVersion['id'] ?? null,
|
|
'previous_id' => $previousVersion['id'] ?? null,
|
|
'changed_fields' => [],
|
|
'changes' => [],
|
|
'last_changed_field' => null,
|
|
'last_change' => null,
|
|
];
|
|
}
|
|
|
|
$changes = [];
|
|
foreach (CHANNEL_EDITABLE_FIELDS as $field) {
|
|
$oldValue = $previousVersion[$field] ?? null;
|
|
$newValue = $currentVersion[$field] ?? null;
|
|
if ((string) ($oldValue ?? '') === (string) ($newValue ?? '')) {
|
|
continue;
|
|
}
|
|
|
|
$changes[] = [
|
|
'field_name' => $field,
|
|
'old_value' => $oldValue,
|
|
'new_value' => $newValue,
|
|
];
|
|
}
|
|
|
|
$lastChange = null;
|
|
$currentChanges = $currentVersion['changes'] ?? [];
|
|
if ($currentChanges) {
|
|
$lastAuditChange = $currentChanges[count($currentChanges) - 1];
|
|
$lastField = (string) ($lastAuditChange['field_name'] ?? '');
|
|
foreach ($changes as $change) {
|
|
if (($change['field_name'] ?? '') === $lastField) {
|
|
$lastChange = $change;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($lastChange === null && $changes) {
|
|
$lastChange = $changes[count($changes) - 1];
|
|
}
|
|
|
|
return [
|
|
'current_id' => $currentVersion['id'] ?? null,
|
|
'previous_id' => $previousVersion['id'] ?? null,
|
|
'changed_fields' => array_values(array_map(static fn(array $change): string => $change['field_name'], $changes)),
|
|
'changes' => $changes,
|
|
'last_changed_field' => $lastChange['field_name'] ?? null,
|
|
'last_change' => $lastChange,
|
|
];
|
|
}
|
|
|
|
function channel_history(int $id): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$pdo = db();
|
|
|
|
$selected = channel_get($id);
|
|
if (!$selected) {
|
|
throw new InvalidArgumentException('Channel not found.');
|
|
}
|
|
|
|
$backwardIds = [];
|
|
$cursor = $id;
|
|
while (true) {
|
|
$stmt = $pdo->prepare("SELECT old_value FROM audit_log WHERE channel_id = :channel_id AND field_name = 'versioned_from' ORDER BY id DESC LIMIT 1");
|
|
$stmt->bindValue(':channel_id', $cursor, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
$previousId = (int) $stmt->fetchColumn();
|
|
if ($previousId <= 0 || in_array($previousId, $backwardIds, true)) {
|
|
break;
|
|
}
|
|
$backwardIds[] = $previousId;
|
|
$cursor = $previousId;
|
|
}
|
|
|
|
$forwardIds = [];
|
|
$cursor = $id;
|
|
while (true) {
|
|
$stmt = $pdo->prepare("SELECT new_value FROM audit_log WHERE field_name = 'versioned_from' AND old_value = :old_value ORDER BY id ASC LIMIT 1");
|
|
$stmt->bindValue(':old_value', (string) $cursor);
|
|
$stmt->execute();
|
|
$nextId = (int) $stmt->fetchColumn();
|
|
if ($nextId <= 0 || in_array($nextId, $forwardIds, true)) {
|
|
break;
|
|
}
|
|
$forwardIds[] = $nextId;
|
|
$cursor = $nextId;
|
|
}
|
|
|
|
$orderedIds = array_values(array_unique(array_merge(array_reverse($backwardIds), [$id], $forwardIds)));
|
|
$placeholders = implode(', ', array_fill(0, count($orderedIds), '?'));
|
|
|
|
$rowStmt = $pdo->prepare("SELECT * FROM channels WHERE id IN ({$placeholders})");
|
|
foreach ($orderedIds as $index => $versionId) {
|
|
$rowStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT);
|
|
}
|
|
$rowStmt->execute();
|
|
$rows = $rowStmt->fetchAll();
|
|
|
|
$rowsById = [];
|
|
foreach ($rows as $row) {
|
|
$rowsById[(int) $row['id']] = $row;
|
|
}
|
|
|
|
$auditStmt = $pdo->prepare("SELECT channel_id, user_id, field_name, old_value, new_value, changed_at FROM audit_log WHERE channel_id IN ({$placeholders}) ORDER BY changed_at ASC, id ASC");
|
|
foreach ($orderedIds as $index => $versionId) {
|
|
$auditStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT);
|
|
}
|
|
$auditStmt->execute();
|
|
|
|
$changesByChannel = [];
|
|
$previousMap = [];
|
|
foreach ($auditStmt->fetchAll() as $entry) {
|
|
$channelId = (int) $entry['channel_id'];
|
|
if (($entry['field_name'] ?? '') === 'versioned_from') {
|
|
$previousMap[$channelId] = (int) ($entry['old_value'] ?? 0);
|
|
continue;
|
|
}
|
|
|
|
$changesByChannel[$channelId][] = [
|
|
'field_name' => (string) $entry['field_name'],
|
|
'old_value' => $entry['old_value'],
|
|
'new_value' => $entry['new_value'],
|
|
'changed_at' => $entry['changed_at'],
|
|
'user_id' => $entry['user_id'],
|
|
];
|
|
}
|
|
|
|
$versions = [];
|
|
foreach ($orderedIds as $position => $versionId) {
|
|
if (!isset($rowsById[$versionId])) {
|
|
continue;
|
|
}
|
|
|
|
$row = $rowsById[$versionId];
|
|
$changes = $changesByChannel[$versionId] ?? [];
|
|
$versions[] = [
|
|
'id' => (int) $row['id'],
|
|
'version_number' => $position + 1,
|
|
'is_current' => $row['date_out'] === null,
|
|
'previous_id' => $previousMap[$versionId] ?? null,
|
|
'upper_ch_ref' => $row['upper_ch_ref'],
|
|
'country_iso' => $row['country_iso'],
|
|
'sat_ref' => $row['sat_ref'],
|
|
'genre' => $row['genre'],
|
|
'type' => $row['type'],
|
|
'groupe' => $row['groupe'],
|
|
'region' => $row['region'],
|
|
'langue' => $row['langue'],
|
|
'resolution' => $row['resolution'],
|
|
'active' => $row['active'],
|
|
'sat_in' => $row['sat_in'],
|
|
'sat_out' => $row['sat_out'],
|
|
'sat_update' => $row['sat_update'],
|
|
'manually_edited' => $row['manually_edited'],
|
|
'manually_edited_at' => $row['manually_edited_at'],
|
|
'date_in' => $row['date_in'],
|
|
'date_out' => $row['date_out'],
|
|
'changes' => $changes,
|
|
'changed_fields' => array_values(array_unique(array_map(static fn(array $change): string => $change['field_name'], $changes))),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'selected_id' => $id,
|
|
'root_id' => $orderedIds[0] ?? $id,
|
|
'latest_id' => $orderedIds ? $orderedIds[count($orderedIds) - 1] : $id,
|
|
'total_versions' => count($versions),
|
|
'latest_transition' => channel_build_latest_transition($versions),
|
|
'versions' => $versions,
|
|
];
|
|
}
|
|
|
|
function channel_stats(): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$pdo = db();
|
|
|
|
$overview = $pdo->query(
|
|
'SELECT COUNT(*) AS total_channels, SUM(active = 1 AND sat_out IS NULL) AS active_channels, SUM(manually_edited = 1) AS manually_edited_channels, COALESCE(SUM(counting), 0) AS total_hits FROM channels WHERE date_out IS NULL'
|
|
)->fetch();
|
|
|
|
$dimensions = ['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'region'];
|
|
$breakdowns = [];
|
|
foreach ($dimensions as $dimension) {
|
|
$stmt = $pdo->query("SELECT COALESCE({$dimension}, 'Unknown') AS label, COUNT(*) AS total FROM channels WHERE date_out IS NULL GROUP BY {$dimension} ORDER BY total DESC, label ASC LIMIT 8");
|
|
$breakdowns[$dimension] = $stmt->fetchAll();
|
|
}
|
|
|
|
return [
|
|
'overview' => $overview,
|
|
'breakdowns' => $breakdowns,
|
|
];
|
|
}
|
|
|
|
function audit_log_list(array $query): array
|
|
{
|
|
channel_app_bootstrap();
|
|
$pdo = db();
|
|
|
|
$page = isset($query['page']) ? max(1, (int) $query['page']) : 1;
|
|
$limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 20;
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
$where = [];
|
|
$params = [];
|
|
|
|
if (!empty($query['user_id'])) {
|
|
$where[] = 'a.user_id = :user_id';
|
|
$params[':user_id'] = (string) $query['user_id'];
|
|
}
|
|
if (!empty($query['field_name'])) {
|
|
$where[] = 'a.field_name = :field_name';
|
|
$params[':field_name'] = (string) $query['field_name'];
|
|
}
|
|
if (!empty($query['date_from'])) {
|
|
$where[] = 'DATE(a.changed_at) >= :date_from';
|
|
$params[':date_from'] = (string) $query['date_from'];
|
|
}
|
|
if (!empty($query['date_to'])) {
|
|
$where[] = 'DATE(a.changed_at) <= :date_to';
|
|
$params[':date_to'] = (string) $query['date_to'];
|
|
}
|
|
|
|
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
|
|
|
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM audit_log a {$whereSql}");
|
|
foreach ($params as $key => $value) {
|
|
$countStmt->bindValue($key, $value);
|
|
}
|
|
$countStmt->execute();
|
|
$total = (int) $countStmt->fetchColumn();
|
|
|
|
$sql = "SELECT a.*, c.upper_ch_ref, c.sat_ref
|
|
FROM audit_log a
|
|
LEFT JOIN channels c ON c.id = a.channel_id
|
|
{$whereSql}
|
|
ORDER BY a.changed_at DESC, a.id DESC
|
|
LIMIT :limit OFFSET :offset";
|
|
$stmt = $pdo->prepare($sql);
|
|
foreach ($params as $key => $value) {
|
|
$stmt->bindValue($key, $value);
|
|
}
|
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
|
|
return [
|
|
'data' => $stmt->fetchAll(),
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
'total' => $total,
|
|
'pages' => (int) max(1, ceil($total / $limit)),
|
|
],
|
|
'filters' => [
|
|
'users' => channel_distinct_audit_values('user_id'),
|
|
'fields' => channel_distinct_audit_values('field_name'),
|
|
],
|
|
];
|
|
}
|
|
|
|
function channel_distinct_audit_values(string $field): array
|
|
{
|
|
$allowed = ['user_id', 'field_name'];
|
|
if (!in_array($field, $allowed, true)) {
|
|
return [];
|
|
}
|
|
|
|
$stmt = db()->query("SELECT DISTINCT {$field} AS value FROM audit_log ORDER BY {$field} ASC");
|
|
return array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll()));
|
|
}
|