> /path/to/logs/cron.log 2>&1 */ $config = require __DIR__ . '/config.php'; foreach (['output_dir','public_playlist_dir','cache_dir','log_dir'] as $k) { if (!is_dir($config[$k])) mkdir($config[$k], 0775, true); } function log_line($message, $data = null) { global $config; $line = '[' . date('c') . '] ' . $message; if ($data !== null) $line .= ' ' . json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $line .= PHP_EOL; file_put_contents($config['log_dir'] . '/generate.log', $line, FILE_APPEND); echo $line; } function http_get($url, $ttl_seconds = 600) { global $config; $cache_file = $config['cache_dir'] . '/' . sha1($url) . '.cache'; if (file_exists($cache_file) && (time() - filemtime($cache_file) < $ttl_seconds)) { return file_get_contents($cache_file); } $context = stream_context_create(['http' => [ 'method' => 'GET', 'header' => "User-Agent: " . $config['user_agent'] . "\r\n", 'timeout' => 20 ]]); $body = @file_get_contents($url, false, $context); if ($body !== false && strlen($body) > 0) { file_put_contents($cache_file, $body); return $body; } return file_exists($cache_file) ? file_get_contents($cache_file) : false; } function normalize_handle($handle) { $handle = trim((string)$handle); $handle = preg_replace('#^https?://(www\.)?youtube\.com/#i', '', $handle); $handle = trim($handle, "/ \t\r\n"); if ($handle === '') return ''; if ($handle[0] !== '@' && !preg_match('#^(channel|c|user)/#i', $handle)) $handle = '@' . $handle; return $handle; } function resolve_channel_id($tile) { global $config; if (!empty($tile['channel_id'])) return $tile['channel_id']; $handle = normalize_handle($tile['channel_handle'] ?? $tile['handle'] ?? ''); if (!$handle) return ''; $id_cache = $config['cache_dir'] . '/channel_' . sha1($handle) . '.id'; if (file_exists($id_cache)) { $id = trim(file_get_contents($id_cache)); if (preg_match('/^UC[0-9A-Za-z_-]+$/', $id)) return $id; } $html = http_get('https://www.youtube.com/' . $handle, 86400); if (!$html) return ''; foreach ([ '/"channelId":"(UC[0-9A-Za-z_-]+)"/', '/"externalId":"(UC[0-9A-Za-z_-]+)"/', '/ $handle]); return ''; } function parse_youtube_rss($xml_string) { $items = []; if (!$xml_string) return $items; libxml_use_internal_errors(true); $xml = simplexml_load_string($xml_string); if (!$xml) return $items; $ns = $xml->getNamespaces(true); foreach ($xml->entry as $entry) { $yt = $entry->children($ns['yt'] ?? 'http://www.youtube.com/xml/schemas/2015'); $media = $entry->children($ns['media'] ?? 'http://search.yahoo.com/mrss/'); $video_id = (string)($yt->videoId ?? ''); if (!$video_id) continue; $desc = ''; if (isset($media->group)) { $mg = $media->group->children($ns['media'] ?? 'http://search.yahoo.com/mrss/'); $desc = (string)($mg->description ?? ''); } $items[] = [ 'video_id' => $video_id, 'title' => (string)($entry->title ?? ''), 'published' => (string)($entry->published ?? ''), 'url' => 'https://www.youtube.com/watch?v=' . $video_id, 'description' => $desc ]; } usort($items, fn($a, $b) => strcmp($b['published'], $a['published'])); return $items; } function fetch_channel_items($tile) { global $config; $channel_id = resolve_channel_id($tile); if (!$channel_id) { log_line('Missing channel for tile', ['label' => $tile['label'] ?? '', 'tile' => $tile]); return []; } $xml = http_get('https://www.youtube.com/feeds/videos.xml?channel_id=' . rawurlencode($channel_id), 600); return array_slice(parse_youtube_rss($xml), 0, $config['max_items_per_feed']); } function fetch_playlist_items($playlist_id) { global $config; $playlist_id = trim((string)$playlist_id); if (!$playlist_id || empty($config['youtube_api_key'])) return []; $url = 'https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=25&playlistId=' . rawurlencode($playlist_id) . '&key=' . rawurlencode($config['youtube_api_key']); $json = http_get($url, 600); $data = json_decode($json, true); $items = []; foreach (($data['items'] ?? []) as $it) { $s = $it['snippet'] ?? []; $vid = $s['resourceId']['videoId'] ?? ''; if ($vid) $items[] = [ 'video_id' => $vid, 'title' => $s['title'] ?? '', 'published' => $s['publishedAt'] ?? '', 'url' => 'https://www.youtube.com/watch?v=' . $vid, 'description' => $s['description'] ?? '' ]; } usort($items, fn($a,$b) => strcmp($b['published'], $a['published'])); return $items; } function contains_terms($text, $terms, $mode = 'any') { $terms = array_values(array_filter(array_map('trim', (array)$terms))); if (!$terms) return true; $text = mb_strtolower((string)$text); $hits = 0; foreach ($terms as $term) { if (mb_strpos($text, mb_strtolower($term)) !== false) $hits++; } return $mode === 'all' ? $hits === count($terms) : $hits > 0; } function is_excluded($text, $terms) { $text = mb_strtolower((string)$text); foreach ((array)$terms as $term) { $term = mb_strtolower(trim((string)$term)); if ($term !== '' && mb_strpos($text, $term) !== false) return true; } return false; } function filter_items($items, $tile) { global $config; $include = $tile['include'] ?? []; $include_mode = $tile['include_mode'] ?? 'any'; $exclude = array_merge($config['default_exclude'] ?? [], $tile['exclude'] ?? []); $out = []; foreach ($items as $it) { $hay = ($it['title'] ?? '') . ' ' . ($it['description'] ?? ''); if (!contains_terms($hay, $include, $include_mode)) continue; if (is_excluded($hay, $exclude)) continue; $out[] = $it; } return $out; } function public_playlist_path($filename) { global $config; return rtrim($config['public_playlist_path_prefix'], '/') . '/' . rawurlencode($filename); } function app_url_for_playlist($playlist_path) { global $config; return $config['myanythinglist_app_url'] . '?MyAnythingList=' . rawurlencode($playlist_path); } function resolve_tile_url($tile) { $mode = $tile['mode'] ?? 'static_url'; if ($mode === 'specific_video') { $id = trim((string)($tile['video_id'] ?? '')); return $id ? 'https://www.youtube.com/watch?v=' . $id : ($tile['fallback'] ?? ''); } if ($mode === 'latest_channel_video' || $mode === 'latest_matching_video') { $items = filter_items(fetch_channel_items($tile), $tile); return $items[0]['url'] ?? ($tile['fallback'] ?? ''); } if ($mode === 'latest_playlist_item') { $items = filter_items(fetch_playlist_items($tile['playlist_id'] ?? ''), $tile); return $items[0]['url'] ?? (!empty($tile['playlist_id']) ? 'https://www.youtube.com/playlist?list=' . rawurlencode($tile['playlist_id']) : ($tile['fallback'] ?? '')); } if ($mode === 'latest_matching_across_channels' || $mode === 'latest_from_channel_list') { $all = []; foreach (($tile['channels'] ?? []) as $ch) { $ch_tile = array_merge($tile, $ch); foreach (filter_items(fetch_channel_items($ch_tile), $ch_tile) as $it) $all[] = $it; } usort($all, fn($a,$b) => strcmp($b['published'], $a['published'])); return $all[0]['url'] ?? ($tile['fallback'] ?? ''); } if ($mode === 'channel_home') { if (!empty($tile['channel_handle'])) return 'https://www.youtube.com/' . normalize_handle($tile['channel_handle']); if (!empty($tile['handle'])) return 'https://www.youtube.com/' . normalize_handle($tile['handle']); if (!empty($tile['channel_id'])) return 'https://www.youtube.com/channel/' . $tile['channel_id']; return $tile['fallback'] ?? ''; } if ($mode === 'playlist_home') { return !empty($tile['playlist_id']) ? 'https://www.youtube.com/playlist?list=' . rawurlencode($tile['playlist_id']) : ($tile['url'] ?? ''); } if ($mode === 'subgrid') { $file = $tile['playlist_file'] ?? ''; return $file ? app_url_for_playlist(public_playlist_path($file)) : ($tile['url'] ?? ''); } return $tile['url'] ?? $tile['fallback'] ?? ''; } function slugify($text) { $slug = preg_replace('/[^a-z0-9]+/i', '-', strtolower((string)$text)); $slug = trim($slug, '-'); return $slug ?: 'playlist'; } function make_channel_subgrid_rule($tile, $parent_title) { $playlist_file = 'subgrid-' . slugify($tile['label'] ?? 'channel') . '.txt'; return [ 'title' => ($tile['subgrid_title'] ?? (($tile['label'] ?? 'Channel') . ' — Latest 10 Videos')), 'output_file' => $playlist_file, 'grid' => intval($tile['subgrid_grid'] ?? 2), 'show_qr' => false, 'show_urls' => false, 'footer' => 'Automatically generated channel sub-grid from ' . $parent_title . '.', 'tiles' => [[ 'label' => $tile['label'] ?? 'Channel', 'mode' => 'latest_channel_many', 'channel_id' => $tile['channel_id'] ?? '', 'channel_handle' => $tile['channel_handle'] ?? ($tile['handle'] ?? ''), 'exclude' => $tile['exclude'] ?? [], 'limit' => intval($tile['limit'] ?? 10) ]] ]; } function write_playlist_from_rules($rules) { global $config; $title = $rules['title'] ?? 'MyAnythingList Playlist'; $output_file = $rules['output_file'] ?? 'playlist.txt'; $lines = [ '#_GRID=' . intval($rules['grid'] ?? 3), '#_ShowQR=' . (!empty($rules['show_qr']) ? 'true' : 'false'), '#_ShowURLs=' . (!empty($rules['show_urls']) ? 'true' : 'false'), '#_HeaderText=' . $title ]; if (!empty($rules['footer'])) $lines[] = '#_FooterText=' . $rules['footer']; if (!empty($rules['resolution'])) $lines[] = '#_Resolution=' . $rules['resolution']; if (!empty($rules['aspect_ratio'])) $lines[] = '#_Aspect_Ratio=' . $rules['aspect_ratio']; $lines[] = ''; $seen = []; $selected = []; foreach (($rules['tiles'] ?? []) as $tile) { if (($tile['mode'] ?? '') === 'latest_channel_many') { $items = filter_items(fetch_channel_items($tile), $tile); $limit = intval($tile['limit'] ?? 10); foreach (array_slice($items, 0, $limit) as $it) { $url = $it['url']; if (!isset($seen[$url])) { $seen[$url] = true; $lines[] = $url; $selected[] = ['label' => $tile['label'] ?? '', 'mode' => 'latest_channel_many', 'title' => $it['title'] ?? '', 'url' => $url]; } } continue; } if (($tile['mode'] ?? '') === 'channel_subgrid') { $sub_rules = make_channel_subgrid_rule($tile, $title); write_playlist_from_rules($sub_rules); $tile = [ 'label' => $tile['label'] ?? 'Channel Subgrid', 'mode' => 'subgrid', 'playlist_file' => $sub_rules['output_file'] ]; } $url = trim((string)resolve_tile_url($tile)); if (!$url || isset($seen[$url])) continue; $seen[$url] = true; $lines[] = $url; $selected[] = ['label' => $tile['label'] ?? '', 'mode' => $tile['mode'] ?? '', 'url' => $url]; } $content = implode(PHP_EOL, $lines) . PHP_EOL; $paths = [ rtrim($config['output_dir'], '/') . '/' . $output_file, rtrim($config['public_playlist_dir'], '/') . '/' . $output_file ]; foreach ($paths as $path) { file_put_contents($path, $content); } log_line('Generated playlist', ['file' => $output_file, 'count' => count($selected), 'selected' => $selected]); } $files = glob(__DIR__ . '/rules/*.json'); sort($files); foreach ($files as $file) { $rules = json_decode(file_get_contents($file), true); if (!$rules) { log_line('Invalid rule file', ['file' => $file]); continue; } write_playlist_from_rules($rules); } log_line('Done');