Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ TVheadend is optional. If any of the three `TVHEADEND_*` variables are missing,
| `!play <channel name>` | Friend | Play a channel by name (case-insensitive substring match; searches TVheadend and IPTV) |
| `!play <URL>` | Friend | Play any URL — YouTube VODs, YouTube Live streams, direct HLS/HTTP/RTSP streams, `.cgi` MJPEG feeds, etc. Live streams are detected automatically and streamed without downloading. |
| `!channels` | Viewer | List all available channels with live now-playing info (paginated) |
| `!channels <keyword>` | Viewer | Filter the channel list by name (case-insensitive substring match) |
| `!search <show title>` | Friend | Search EPG for a show — plays immediately if airing now, or schedules for upcoming airtime. Showtimes are shown in the `TIMEZONE` configured in `.env`. |

### Jellyfin
Expand Down
4 changes: 2 additions & 2 deletions cogs/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ async def help_command(self, ctx: commands.Context):
lines += [
"",
"**Streaming**",
" `!channels` — list all channels (TVheadend + IPTV)"
" with now-playing info (paginated)",
" `!channels [keyword]` — list all channels (TVheadend + IPTV)"
" with now-playing info; optionally filter by name",
" `!play <number, name, or url>` — stream a TVheadend/IPTV"
" channel or a yt-dlp URL into voice",
" `!search <show title>` — find a show in the EPG;"
Expand Down
17 changes: 14 additions & 3 deletions cogs/tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,13 @@ async def _start_iptv_stream(

@require_role(Role.VIEWER)
@commands.command()
async def channels(self, ctx: commands.Context):
"""List all enabled channels (TVheadend + IPTV) with what's currently airing."""
async def channels(self, ctx: commands.Context, *, query: str = ""):
"""List all enabled channels (TVheadend + IPTV) with what's currently airing.

Optional keyword filter: !channels <name>
"""
log.info("fetching channel list for %s in guild '%s'", ctx.author, ctx.guild)
query = query.strip().lower()

sm = getattr(self.bot, "source_manager", None)

Expand Down Expand Up @@ -239,6 +243,8 @@ async def channels(self, ctx: commands.Context):
for c in chs:
num = c.get("number")
name = c.get("name", "(unnamed)")
if query and query not in name.lower():
continue
title = now_playing.get(c.get("uuid", ""), "")
if num is not None:
prefix = f"{num:>4} "
Expand Down Expand Up @@ -277,6 +283,8 @@ async def channels(self, ctx: commands.Context):

by_source: dict[str, list[dict]] = {}
for ch in iptv_channels:
if query and query not in ch["name"].lower():
continue
by_source.setdefault(ch["source"], []).append(ch)
for source_name, source_chs in by_source.items():
lines.append(f"--- {source_name} ---")
Expand All @@ -291,7 +299,10 @@ async def channels(self, ctx: commands.Context):
lines.append(f" {ch['name']}{suffix}")

if not lines:
await ctx.send("no channels found")
if query:
await ctx.send(f"no channels found matching **{query}**")
else:
await ctx.send("no channels found")
return

# Split lines into 1800-char pages.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@
from services.ytdlp import _DEFAULT_YT_FORMAT, _yt_format


# ---------------------------------------------------------------------------
# !channels name filter logic
# ---------------------------------------------------------------------------

IPTV_CHANNELS = [
{"name": "Zorbax One", "source": "src-a", "tvg_id": "zorb1"},
{"name": "Zorbax Two", "source": "src-a", "tvg_id": "zorb2"},
{"name": "Flumbo Network", "source": "src-b", "tvg_id": "flmb"},
{"name": "Quixel Sports 1", "source": "src-b", "tvg_id": "qxs1"},
]


def _apply_filter(channels: list[dict], query: str) -> list[dict]:
"""Mirrors the inline filter in the channels command."""
return [ch for ch in channels if not query or query.lower() in ch["name"].lower()]


def test_channels_filter_matches_multiple():
result = _apply_filter(IPTV_CHANNELS, "zorbax")
assert [ch["name"] for ch in result] == ["Zorbax One", "Zorbax Two"]


def test_channels_filter_case_insensitive():
assert _apply_filter(IPTV_CHANNELS, "ZORBAX") == _apply_filter(IPTV_CHANNELS, "zorbax")


def test_channels_filter_partial_match():
result = _apply_filter(IPTV_CHANNELS, "sports")
assert len(result) == 1
assert result[0]["name"] == "Quixel Sports 1"


def test_channels_filter_empty_query_passes_all():
assert _apply_filter(IPTV_CHANNELS, "") == IPTV_CHANNELS


def test_channels_filter_no_match_returns_empty():
assert _apply_filter(IPTV_CHANNELS, "gronkvision") == []


def test_channels_filter_empty_channel_list():
assert _apply_filter([], "zorbax") == []


# ---------------------------------------------------------------------------
# _get_display_tz
# ---------------------------------------------------------------------------
Expand Down
Loading