From 62e7ea55cde76ae65180606a35b4023f81ef0a20 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 17 Sep 2026 07:51:18 +0000 Subject: [PATCH] fix: three crashes from null values and a missing cache file - Admin global search: SearchAjaxController asks CategoryService::getAllByType (null) for every category type, as the method's docblock and body allow, but the cs-fix pass typed the parameter `string`. Any search with results threw and returned nothing. - Movie and episode encoding: startMovie hands movie_subtitles to buildSubtitleImport(string), and titles saved without subtitles store NULL. The TypeError ended the queue daemon, which stopped encoding the whole movie queue. - Line authentication: every category save or reorder deleted the category_map cache, which only the heavy cache pass rebuilds, up to five minutes later. Meanwhile getUserInfo passed igbinary_unserialize(false) to resolveCategoryIds(array) and player_api, the Ministra portal and probe failed for every line. Category saves no longer delete the map (which categories a bouquet reaches does not depend on a category's name, type or order), and a missing map now means no categories instead of an error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA --- src/Domain/Stream/CategoryService.php | 12 ++++++--- src/Domain/Stream/StreamProcess.php | 9 ++++--- src/Domain/User/UserRepository.php | 6 +++-- .../Unit/StreamProcessSubtitleImportTest.php | 25 +++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/StreamProcessSubtitleImportTest.php diff --git a/src/Domain/Stream/CategoryService.php b/src/Domain/Stream/CategoryService.php index 76ef1521..230eeecb 100644 --- a/src/Domain/Stream/CategoryService.php +++ b/src/Domain/Stream/CategoryService.php @@ -45,7 +45,9 @@ class CategoryService { } } FileCache::delCache('categories'); - FileCache::delCache('category_map'); + // category_map (bouquet => category ids) is left alone: reordering does not + // change it, and only the heavy cache pass rebuilds it — up to 5 minutes + // later — while every line authentication reads it. } if (!defined('STATUS_SUCCESS') && class_exists(\XC_Bootstrap::class)) { @@ -88,7 +90,8 @@ class CategoryService { $rInsertID = $db->last_insert_id(); $catId = isset($rData['edit']) ? intval($rData['edit']) : intval($rInsertID); FileCache::delCache('categories'); - FileCache::delCache('category_map'); + // Not category_map: which categories a bouquet reaches depends on its + // streams, not on a category's name, type or order (see reorder()). // Sync any templates and subscribers tied to this category if ($catId > 0 && class_exists(CategoryTemplateService::class)) { @@ -105,10 +108,11 @@ class CategoryService { * Возвращает категории с int-ключами (замена legacy getCategories()). * Всегда читает из БД ($rForce = true). * - * @param string $rType 'live'|'movie'|'series'|'radio'|null + * @param string|null $rType 'live'|'movie'|'series'|'radio', or null for every type + * (the admin global search asks for all of them). * @return array */ - public static function getAllByType(string $rType = 'live') { + public static function getAllByType(?string $rType = 'live') { $rCategories = self::getFromDatabase(($rType ?: null), true); $rReturn = []; foreach ($rCategories as $rID => $rRow) { diff --git a/src/Domain/Stream/StreamProcess.php b/src/Domain/Stream/StreamProcess.php index 106aaa9b..e9e72150 100644 --- a/src/Domain/Stream/StreamProcess.php +++ b/src/Domain/Stream/StreamProcess.php @@ -315,12 +315,13 @@ class StreamProcess { * still emit `-map` for every file — so multi-subtitle movies imported one * track but mapped non-existent inputs. The two loops are now siblings. * - * @param string $rSubtitlesJson `movie_subtitles` JSON from the stream row. - * @param array $rServers Server registry (for remote subtitle fetch). + * @param string|null $rSubtitlesJson `movie_subtitles` JSON from the stream row; + * NULL for a movie or episode saved without subtitles. + * @param array $rServers Server registry (for remote subtitle fetch). * @return array{0:string,1:string} [$rSubtitlesImport, $rSubtitlesMetadata]. */ - private static function buildSubtitleImport(string $rSubtitlesJson, array $rServers) { - $rSubtitles = json_decode($rSubtitlesJson, true); + private static function buildSubtitleImport(?string $rSubtitlesJson, array $rServers) { + $rSubtitles = json_decode((string) $rSubtitlesJson, true); $rSubtitlesImport = ''; $rSubtitlesMetadata = ''; if (!empty($rSubtitles) && !empty($rSubtitles['files']) && is_array($rSubtitles['files'])) { diff --git a/src/Domain/User/UserRepository.php b/src/Domain/User/UserRepository.php index 54e7dc93..fedccd51 100644 --- a/src/Domain/User/UserRepository.php +++ b/src/Domain/User/UserRepository.php @@ -552,8 +552,10 @@ class UserRepository { $rUserInfo = array_merge($rUserInfo, self::aggregateBouquetIds($rUserInfo['bouquet'], $rBouquets)); } - $rCategoryMap = igbinary_unserialize(file_get_contents(CACHE_TMP_PATH . 'category_map')); - $rUserInfo['category_ids'] = self::resolveCategoryIds($rUserInfo['bouquet'], $rCategoryMap); + // Built by the heavy cache pass; until it exists (a fresh install, a cleared + // cache) the line simply has no categories instead of the request failing. + $rCategoryMap = @igbinary_unserialize((string) @file_get_contents(CACHE_TMP_PATH . 'category_map')); + $rUserInfo['category_ids'] = self::resolveCategoryIds($rUserInfo['bouquet'], is_array($rCategoryMap) ? $rCategoryMap : []); return $rUserInfo; } diff --git a/tests/Unit/StreamProcessSubtitleImportTest.php b/tests/Unit/StreamProcessSubtitleImportTest.php new file mode 100644 index 00000000..521878ad --- /dev/null +++ b/tests/Unit/StreamProcessSubtitleImportTest.php @@ -0,0 +1,25 @@ +setAccessible(true); + return $rMethod->invoke(null, $rJson, []); + } + + public function testNoSubtitlesImportsNothing(): void { + $this->assertSame(['', ''], $this->build(null)); + $this->assertSame(['', ''], $this->build('')); + $this->assertSame(['', ''], $this->build('{"files":[]}')); + } +}