diff --git a/config/settings.py b/config/settings.py
index 1894f24..da3fb63 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -141,6 +141,7 @@ INSTALLED_APPS: list[str] = [
"django.contrib.postgres",
"twitch.apps.TwitchConfig",
"kick.apps.KickConfig",
+ "youtube.apps.YoutubeConfig",
"core.apps.CoreConfig",
]
diff --git a/config/tests/test_urls.py b/config/tests/test_urls.py
index 04fd386..5e6a482 100644
--- a/config/tests/test_urls.py
+++ b/config/tests/test_urls.py
@@ -39,6 +39,9 @@ def test_top_level_named_routes_available() -> None:
msg: str = f"Expected 'twitch:dashboard' to reverse to '/twitch/', got {reverse('twitch:dashboard')}"
assert reverse("twitch:dashboard") == "/twitch/", msg
+ youtube_msg: str = f"Expected 'youtube:index' to reverse to '/youtube/', got {reverse('youtube:index')}"
+ assert reverse("youtube:index") == "/youtube/", youtube_msg
+
def test_debug_tools_not_present_while_testing() -> None:
"""`silk` and Django Debug Toolbar URL patterns are not present while running tests."""
diff --git a/config/urls.py b/config/urls.py
index bf0c81c..22186a7 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -19,6 +19,8 @@ urlpatterns: list[URLPattern | URLResolver] = [
path(route="twitch/", view=include("twitch.urls", namespace="twitch")),
# Kick app
path(route="kick/", view=include("kick.urls", namespace="kick")),
+ # YouTube app
+ path(route="youtube/", view=include("youtube.urls", namespace="youtube")),
]
# Serve media in development
diff --git a/templates/base.html b/templates/base.html
index dcafd79..ae128cb 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -276,7 +276,7 @@
Other sites
Steam |
- YouTube |
+ YouTube |
TikTok |
Discord
diff --git a/templates/youtube/index.html b/templates/youtube/index.html
new file mode 100644
index 0000000..f5dca90
--- /dev/null
+++ b/templates/youtube/index.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}
+ YouTube Drops Channels
+{% endblock title %}
+{% block content %}
+
+ YouTube Drops Channels
+ Official channels from YouTube partner accounts where drops/rewards may be available.
+ {% for group in partner_groups %}
+ {{ group.partner }}
+
+ {% endfor %}
+
+{% endblock content %}
diff --git a/youtube/__init__.py b/youtube/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/youtube/apps.py b/youtube/apps.py
new file mode 100644
index 0000000..b3b48ca
--- /dev/null
+++ b/youtube/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class YoutubeConfig(AppConfig):
+ """Django app configuration for the YouTube app."""
+
+ name = "youtube"
diff --git a/youtube/migrations/__init__.py b/youtube/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/youtube/tests/__init__.py b/youtube/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/youtube/tests/test_youtube.py b/youtube/tests/test_youtube.py
new file mode 100644
index 0000000..6b207ce
--- /dev/null
+++ b/youtube/tests/test_youtube.py
@@ -0,0 +1,49 @@
+from typing import TYPE_CHECKING
+
+from django.test import TestCase
+from django.urls import reverse
+
+if TYPE_CHECKING:
+ from django.test.client import _MonkeyPatchedWSGIResponse
+
+
+class YouTubeIndexViewTest(TestCase):
+ """Tests for the YouTube drops channels index page."""
+
+ def test_index_returns_200(self) -> None:
+ """The YouTube index page should return HTTP 200."""
+ response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
+ assert response.status_code == 200
+
+ def test_index_displays_known_channels(self) -> None:
+ """The page should include key known channels from the partner list."""
+ response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
+ content: str = response.content.decode()
+
+ assert "YouTube Drops Channels" in content
+ assert "Call of Duty" in content
+ assert "PlayOverwatch" in content
+ assert "Hearthstone" in content
+ assert "Fortnite" in content
+ assert "Riot Games" in content
+ assert "Ubisoft" in content
+
+ def test_index_includes_partner_urls(self) -> None:
+ """The page should render partner channel links from the source list."""
+ response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
+ content: str = response.content.decode()
+
+ assert "https://www.youtube.com/channel/UCbLIqv9Puhyp9_ZjVtfOy7w" in content
+ assert "https://www.youtube.com/user/epicfortnite" in content
+ assert "https://www.youtube.com/lolesports" in content
+
+ def test_index_groups_partners_alphabetically(self) -> None:
+ """Partner sections should render grouped and in alphabetical order."""
+ response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
+ content: str = response.content.decode()
+
+ assert "
Activision (Call of Duty)
" in content
+ assert "Battle.net / Blizzard
" in content
+ assert content.index("Activision (Call of Duty)
") < content.index(
+ "Battle.net / Blizzard
",
+ )
diff --git a/youtube/urls.py b/youtube/urls.py
new file mode 100644
index 0000000..046b2c8
--- /dev/null
+++ b/youtube/urls.py
@@ -0,0 +1,16 @@
+from typing import TYPE_CHECKING
+
+from django.urls import path
+
+from youtube import views
+
+if TYPE_CHECKING:
+ from django.urls.resolvers import URLPattern
+ from django.urls.resolvers import URLResolver
+
+app_name = "youtube"
+
+
+urlpatterns: list[URLPattern | URLResolver] = [
+ path(route="", view=views.index, name="index"),
+]
diff --git a/youtube/views.py b/youtube/views.py
new file mode 100644
index 0000000..28b2e27
--- /dev/null
+++ b/youtube/views.py
@@ -0,0 +1,115 @@
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+from django.shortcuts import render
+
+if TYPE_CHECKING:
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+
+
+def index(request: HttpRequest) -> HttpResponse:
+ """Render a minimal list of YouTube channels with known drops-enabled partners.
+
+ Returns:
+ HttpResponse: Rendered index page for YouTube drops channels.
+ """
+ channels: list[dict[str, str]] = [
+ {
+ "partner": "Activision (Call of Duty)",
+ "channel": "Call of Duty",
+ "url": "https://www.youtube.com/channel/UCbLIqv9Puhyp9_ZjVtfOy7w",
+ },
+ {
+ "partner": "Battle.net / Blizzard",
+ "channel": "PlayOverwatch",
+ "url": "https://www.youtube.com/c/playoverwatch/featured",
+ },
+ {
+ "partner": "Battle.net / Blizzard",
+ "channel": "Hearthstone",
+ "url": "https://www.youtube.com/c/Hearthstone/featured",
+ },
+ {
+ "partner": "Electronic Arts",
+ "channel": "FIFA",
+ "url": "https://www.youtube.com/channel/UCFA6YGp5lvgayO20lk7_Ung",
+ },
+ {
+ "partner": "Electronic Arts",
+ "channel": "EA Madden NFL",
+ "url": "https://www.youtube.com/@EAMaddenNFL",
+ },
+ {
+ "partner": "Epic Games",
+ "channel": "Fortnite",
+ "url": "https://www.youtube.com/user/epicfortnite",
+ },
+ {
+ "partner": "Garena",
+ "channel": "Free Fire",
+ "url": "https://www.youtube.com/channel/UC_vVy4OI86F0amXqFN_zTMg",
+ },
+ {
+ "partner": "Krafton (PUBG)",
+ "channel": "PUBG: BATTLEGROUNDS",
+ "url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g",
+ },
+ {
+ "partner": "MLBB",
+ "channel": "Mobile Legends: Bang Bang",
+ "url": "https://www.youtube.com/channel/UCqmld-BIYME2i_ooRTo1EOg",
+ },
+ {
+ "partner": "NBA",
+ "channel": "NBA",
+ "url": "https://www.youtube.com/user/NBA",
+ },
+ {
+ "partner": "NFL",
+ "channel": "NFL",
+ "url": "https://www.youtube.com/@NFL",
+ },
+ {
+ "partner": "PUBG Mobile",
+ "channel": "PUBG MOBILE",
+ "url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g",
+ },
+ {
+ "partner": "Riot Games",
+ "channel": "Riot Games",
+ "url": "https://www.youtube.com/user/RiotGamesInc",
+ },
+ {
+ "partner": "Riot Games",
+ "channel": "LoL Esports",
+ "url": "https://www.youtube.com/lolesports",
+ },
+ {
+ "partner": "Supercell",
+ "channel": "Clash Royale",
+ "url": "https://www.youtube.com/channel/UC_F8DoJf9MZogEOU51TpTbQ",
+ },
+ {
+ "partner": "Ubisoft",
+ "channel": "Ubisoft",
+ "url": "https://www.youtube.com/user/ubisoft",
+ },
+ ]
+
+ grouped_channels: dict[str, list[dict[str, str]]] = defaultdict(list)
+ for channel in channels:
+ grouped_channels[channel["partner"]].append(channel)
+
+ partner_groups: list[dict[str, str | list[dict[str, str]]]] = []
+ for partner in sorted(grouped_channels.keys(), key=str.lower):
+ sorted_items: list[dict[str, str]] = sorted(
+ grouped_channels[partner],
+ key=lambda item: item["channel"].lower(),
+ )
+ partner_groups.append({"partner": partner, "channels": sorted_items})
+
+ context: dict[str, list[dict[str, str | list[dict[str, str]]]]] = {
+ "partner_groups": partner_groups,
+ }
+ return render(request=request, template_name="youtube/index.html", context=context)