fix(download): skip deleted channel during playlist refresh

When a channel is deleted between being added to the channel queue and
post-processing running, _add_channel_playlists called channel.get_overwrites()
on a None json_data, raising AttributeError and aborting the whole
post-processing task. The same problem affected playlists/comments for every
other channel in the queue.

Skip the missing channel cleanly with a log line, mirroring how
refresh_playlist already handles failed playlist imports (#1032).

Closes #1103
This commit is contained in:
Ian Schwartz 2026-06-21 02:06:45 -05:00
parent 5477ca97f1
commit 389d344ed9
2 changed files with 148 additions and 0 deletions

View File

@ -428,6 +428,10 @@ class DownloadPostProcess(DownloaderBase):
channel = YoutubeChannel(channel_id)
channel.get_from_es()
if not channel.json_data:
print(f"{channel_id}: skip deleted channel for playlist refresh")
continue
overwrites = channel.get_overwrites()
if overwrites.get("index_playlists"):
channel.get_all_playlists()

View File

@ -0,0 +1,144 @@
"""tests for _add_channel_playlists handling of deleted channels"""
from unittest.mock import patch
import pytest
from download.src.yt_dlp_handler import DownloadPostProcess
class FakeChannelQueue:
"""minimal queue stand-in returning a fixed sequence of (channel_id, idx) pairs"""
def __init__(self, items):
self._items = list(items)
def get_next(self):
if not self._items:
return None, None
return self._items.pop(0)
class FakePlaylistQueue:
"""records add_list calls, returns nothing on get_next"""
def __init__(self):
self.added_lists: list[list] = []
def get_next(self):
return None, None
def add_list(self, to_add):
if to_add:
self.added_lists.append(list(to_add))
@pytest.fixture
def handler():
"""DownloadPostProcess with __init__ bypassed — _add_channel_playlists doesn't touch config or task"""
instance = DownloadPostProcess.__new__(DownloadPostProcess)
instance.task = None
return instance
def test_skips_channel_when_json_data_missing(handler, capsys):
"""regression for #1103: channel deleted from ES must not raise AttributeError"""
channel_id = "UCdeleted-channel-1"
fake_channel_q = FakeChannelQueue([(channel_id, 0)])
class FakeDeletedChannel:
youtube_id = channel_id
json_data = None
def get_from_es(self):
pass
def get_overwrites(self):
raise AssertionError(
"get_overwrites must not be called when json_data is None"
)
def get_all_playlists(self):
raise AssertionError(
"get_all_playlists must not be called when json_data is None"
)
with patch(
"download.src.yt_dlp_handler.RedisQueue",
side_effect=[fake_channel_q, FakePlaylistQueue()],
), patch(
"download.src.yt_dlp_handler.YoutubeChannel",
return_value=FakeDeletedChannel(),
):
handler._add_channel_playlists()
captured = capsys.readouterr()
assert "skip deleted channel" in captured.out
assert channel_id in captured.out
def test_processes_channel_with_playlists_enabled(handler):
"""a live channel with index_playlists=true should add its playlists"""
channel_id = "UClive-channel-2"
fake_channel_q = FakeChannelQueue([(channel_id, 0)])
fake_playlist_q = FakePlaylistQueue()
class FakeLiveChannel:
youtube_id = channel_id
json_data = {"channel_overwrites": {"index_playlists": True}}
all_playlists = [("PLplaylistA", "Playlist A"), ("PLplaylistB", "Playlist B")]
def get_from_es(self):
pass
def get_overwrites(self):
return self.json_data["channel_overwrites"]
def get_all_playlists(self):
pass
with patch(
"download.src.yt_dlp_handler.RedisQueue",
side_effect=[fake_channel_q, fake_playlist_q],
), patch(
"download.src.yt_dlp_handler.YoutubeChannel",
return_value=FakeLiveChannel(),
):
handler._add_channel_playlists()
assert fake_playlist_q.added_lists == [["PLplaylistA", "PLplaylistB"]]
def test_skips_channel_with_playlists_disabled(handler):
"""a live channel with index_playlists missing/false must add nothing"""
channel_id = "UCquiet-channel-3"
fake_channel_q = FakeChannelQueue([(channel_id, 0)])
fake_playlist_q = FakePlaylistQueue()
class FakeQuietChannel:
youtube_id = channel_id
json_data = {"channel_overwrites": {}}
all_playlists = []
def get_from_es(self):
pass
def get_overwrites(self):
return self.json_data["channel_overwrites"]
def get_all_playlists(self):
pass
with patch(
"download.src.yt_dlp_handler.RedisQueue",
side_effect=[fake_channel_q, fake_playlist_q],
), patch(
"download.src.yt_dlp_handler.YoutubeChannel",
return_value=FakeQuietChannel(),
):
handler._add_channel_playlists()
assert fake_playlist_q.added_lists == []