From e3969197de63822ff3d3c9196bc6cc3310bce065 Mon Sep 17 00:00:00 2001 From: Boyd Thomson Date: Sun, 7 Jun 2026 12:49:45 -0700 Subject: [PATCH] fix: migrate channel_tags boolean to empty list on startup Channels without tags had channel_tags stored as boolean false rather than an empty list. The strict ListField serializer introduced in later versions raises TypeError: 'bool' object is not iterable when rendering these records, causing HTTP 500 on /api/channel/ and /api/video/ and the perpetual loading spinner in the UI. The existing term-query approach used for artwork fields does not work here because Elasticsearch does not index boolean false as a keyword, making the affected documents invisible to term queries. Instead, use match_all with a Painless instanceof check and ctx.op = 'noop' so only genuinely broken documents are rewritten. Fixes the root cause reported in #1042, #1063, #1067, #1097. --- .../config/management/commands/ta_startup.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index e65a701a..f1b33504 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -64,6 +64,7 @@ class Command(BaseCommand): self._mig_fix_playlist_description() self._mig_fix_missing_stats() self._mig_fix_channel_art_types() + self._mig_fix_channel_tags() self._mig_fix_channel_description() self._mig_fix_video_description() @@ -426,6 +427,36 @@ class Command(BaseCommand): script={"source": source, "lang": "painless"}, ) + def _mig_fix_channel_tags(self) -> None: + """migrate: fix channel_tags stored as boolean instead of list""" + # Boolean false is not indexable as keyword, so term queries don't + # match it; use match_all + Painless instanceof check instead. + channel_script = ( + "if (ctx._source.containsKey('channel_tags')" + " && !(ctx._source.channel_tags instanceof List))" + " { ctx._source.channel_tags = []; }" + " else { ctx.op = 'noop'; }" + ) + self._run_migration( + index_name="ta_channel", + desc="fix channel_tags data type in channel index", + query={"match_all": {}}, + script={"source": channel_script, "lang": "painless"}, + ) + video_script = ( + "if (ctx._source.containsKey('channel')" + " && ctx._source.channel.containsKey('channel_tags')" + " && !(ctx._source.channel.channel_tags instanceof List))" + " { ctx._source.channel.channel_tags = []; }" + " else { ctx.op = 'noop'; }" + ) + self._run_migration( + index_name="ta_video", + desc="fix channel_tags data type in video index", + query={"match_all": {}}, + script={"source": video_script, "lang": "painless"}, + ) + def _mig_fix_channel_description(self) -> None: """migrate from 0.5.8 to 0.5.9, fix channel desc null value""" desc = "fix channel description null value"