From 2571e75eda1d4c7410e61baf25437adfa81e6b39 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 4 May 2026 17:43:47 +0200 Subject: [PATCH 1/2] Add Gen TV livestream index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a public Gen TV surface that lists scheduled and recorded livestreams from the GenLayer team and the broader community. The page renders one section per category (GenLayer Team, Community) and within each splits streams into Live / Upcoming / Past sub-sections. Sub-sections only render when they have content, and a category that ends up empty across all three statuses is dropped entirely so the page never shows an unused heading. Backend ships a small `gen_tv` Django app: a `Stream` model carrying title, slug, source url, image, starts_at, ends_at, category, and is_active. There is no stored status column — `status` is a Python @property derived from `starts_at` / `ends_at` so the lifecycle stays consistent with the schedule without a maintenance burden. The API is read-only with a category filter, pagination disabled (low volume). Frontend wires the new `Gen TV` entry into the sidebar `Discover` group (desktop + mobile), adds the route at `/gen-tv`, and ships a `StreamCard` component under `components/portal/gen-tv/` that renders the cover image with a dark overlay, computed-status pill, duration badge, host metadata and title. ## Claude Implementation Notes - backend/gen_tv/: New Django app with Stream model (status as @property), light/full serializers, public ReadOnlyModelViewSet (slug lookup, category filter, pagination disabled), admin config (status surfaces as read-only computed_status column, date_hierarchy on starts_at), and tests/test_streams.py. - backend/gen_tv/migrations/0001_initial.py: Single initial migration with the final schema (no status column, ends_at required). - backend/api/urls.py: Registers StreamViewSet on the router under `gen-tv/streams`. - backend/tally/settings.py: Adds `gen_tv` to INSTALLED_APPS. - backend/CLAUDE.md: Adds Gen TV section, gen-tv endpoints to the summary, and the new app to the project tree. - frontend/src/lib/api.js: Adds `genTvAPI` with `list` and `get(slug)`. - frontend/src/routes/GenTV.svelte: New page that fetches `genTvAPI.list()`, groups by category, and renders Live / Upcoming / Past sub-sections per category with conditional rendering. - frontend/src/components/portal/gen-tv/StreamCard.svelte: Reusable card variant (`live`, `upcoming`, `past`) with image background, dark gradient, status pill, duration badge, category + host metadata, and title. - frontend/src/components/Sidebar.svelte: Adds a `Gen TV` entry alongside `Ecosystem Partners` in the desktop and mobile sidebars (TV / play icon, `#6D5DD3` accent when active). - frontend/src/stores/category.js: `detectCategoryFromRoute` now also recognises `/gen-tv` (returns `global`). - frontend/src/App.svelte: Imports `GenTV` and mounts it at `/gen-tv`. - frontend/CLAUDE.md: Documents the new route, the `genTvAPI` entry, and the gen-tv components directory. --- backend/CLAUDE.md | 16 ++ backend/api/urls.py | 2 + backend/gen_tv/__init__.py | 0 backend/gen_tv/admin.py | 39 +++++ backend/gen_tv/apps.py | 7 + backend/gen_tv/migrations/0001_initial.py | 32 ++++ backend/gen_tv/migrations/__init__.py | 0 backend/gen_tv/models.py | 53 +++++++ backend/gen_tv/serializers.py | 49 ++++++ backend/gen_tv/tests/__init__.py | 0 backend/gen_tv/tests/test_streams.py | 64 ++++++++ backend/gen_tv/views.py | 23 +++ backend/tally/settings.py | 1 + frontend/CLAUDE.md | 8 + frontend/src/App.svelte | 2 + frontend/src/components/Sidebar.svelte | 32 ++++ .../portal/gen-tv/StreamCard.svelte | 110 +++++++++++++ frontend/src/lib/api.js | 6 + frontend/src/routes/GenTV.svelte | 146 ++++++++++++++++++ frontend/src/stores/category.js | 2 +- 20 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 backend/gen_tv/__init__.py create mode 100644 backend/gen_tv/admin.py create mode 100644 backend/gen_tv/apps.py create mode 100644 backend/gen_tv/migrations/0001_initial.py create mode 100644 backend/gen_tv/migrations/__init__.py create mode 100644 backend/gen_tv/models.py create mode 100644 backend/gen_tv/serializers.py create mode 100644 backend/gen_tv/tests/__init__.py create mode 100644 backend/gen_tv/tests/test_streams.py create mode 100644 backend/gen_tv/views.py create mode 100644 frontend/src/components/portal/gen-tv/StreamCard.svelte create mode 100644 frontend/src/routes/GenTV.svelte diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index f1d90f57..431fbb00 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -29,6 +29,7 @@ backend/ ├── leaderboard/ # Leaderboard and rankings ├── users/ # User management and auth ├── partners/ # Ecosystem partners directory +├── gen_tv/ # Gen TV livestream index ├── utils/ # Shared utilities └── backend/ # Django project settings ``` @@ -129,6 +130,17 @@ backend/ - **Migrations**: `partners/migrations/0001_initial.py` creates the model and seeds the 22 founding partners from a `RunPython` step. - **Admin**: `partners/admin.py` - list_editable on `display_order`, `is_active`; slug prepopulated from name. +### Gen TV +- **Models**: `gen_tv/models.py` + - Stream - Livestream entry with `title`, `slug`, `description`, `url`, `image_url`, `starts_at` (required), `ends_at` (required), `category` (`internal` / `community`), `is_active`. `status` is a derived `@property` computed from `starts_at`/`ends_at` (no DB column). +- **Serializers**: `gen_tv/serializers.py` + - LightStreamSerializer - Minimal fields for list views (status comes through as a read-only string) + - StreamSerializer - Full fields for detail +- **Views**: `gen_tv/views.py` + - `/api/v1/gen-tv/streams/` - Public read-only list with `category` filter; pagination disabled (small dataset) + - `/api/v1/gen-tv/streams/{slug}/` - Public read-only detail by slug +- **Admin**: `gen_tv/admin.py` - status surfaces as a read-only `computed_status` column; date_hierarchy on `starts_at`; slug prepopulated from title. + ### Database & Migrations - **Migrations**: `{app}/migrations/` - **Database**: SQLite by default, configured in settings.py @@ -243,6 +255,10 @@ GET /api/v1/ai-review/templates/ # Partners (Ecosystem Partners) GET /api/v1/partners/ (public, list active partners) GET /api/v1/partners/{slug}/ (public, partner detail) + +# Gen TV +GET /api/v1/gen-tv/streams/ (public, supports ?category= filter) +GET /api/v1/gen-tv/streams/{slug}/ (public, stream detail) ``` ## Environment Variables diff --git a/backend/api/urls.py b/backend/api/urls.py index 9eaa35bc..6cbe4d6b 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,6 +4,7 @@ from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from partners.views import PartnerViewSet +from gen_tv.views import StreamViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView, ParticipantsGrowthView, TestnetMetricsView @@ -22,6 +23,7 @@ router.register(r'featured', FeaturedContentViewSet, basename='featured') router.register(r'alerts', AlertViewSet, basename='alert') router.register(r'partners', PartnerViewSet, basename='partner') +router.register(r'gen-tv/streams', StreamViewSet, basename='stream') # The API URLs are now determined automatically by the router urlpatterns = [ diff --git a/backend/gen_tv/__init__.py b/backend/gen_tv/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/admin.py b/backend/gen_tv/admin.py new file mode 100644 index 00000000..3a330bfe --- /dev/null +++ b/backend/gen_tv/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin + +from .models import Stream + + +@admin.register(Stream) +class StreamAdmin(admin.ModelAdmin): + list_display = ( + 'title', + 'category', + 'computed_status', + 'starts_at', + 'ends_at', + 'is_active', + ) + list_editable = ('is_active',) + list_filter = ('category', 'is_active') + search_fields = ('title', 'description') + prepopulated_fields = {'slug': ('title',)} + date_hierarchy = 'starts_at' + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'description', 'is_active'), + }), + ('Channel & Schedule', { + 'fields': ('category', 'starts_at', 'ends_at'), + }), + ('Media', { + 'fields': ('url', 'image_url'), + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + }), + ) + + @admin.display(description='Status') + def computed_status(self, obj): + return obj.status diff --git a/backend/gen_tv/apps.py b/backend/gen_tv/apps.py new file mode 100644 index 00000000..ece8bb89 --- /dev/null +++ b/backend/gen_tv/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GenTvConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gen_tv' + verbose_name = 'Gen TV' diff --git a/backend/gen_tv/migrations/0001_initial.py b/backend/gen_tv/migrations/0001_initial.py new file mode 100644 index 00000000..67adf5bd --- /dev/null +++ b/backend/gen_tv/migrations/0001_initial.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Stream', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=300)), + ('slug', models.SlugField(max_length=300, unique=True)), + ('description', models.TextField(blank=True)), + ('url', models.URLField(help_text='Source URL (X / Twitter, YouTube, etc.).', max_length=500)), + ('image_url', models.URLField(blank=True, help_text='Thumbnail / cover image URL.', max_length=500)), + ('starts_at', models.DateTimeField(help_text='Scheduled start time (used for sorting and status).')), + ('ends_at', models.DateTimeField(help_text='Scheduled end time (used to compute status and the duration badge).')), + ('category', models.CharField(choices=[('internal', 'GenLayer Team'), ('community', 'Community')], max_length=20)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['-starts_at'], + }, + ), + ] diff --git a/backend/gen_tv/migrations/__init__.py b/backend/gen_tv/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/models.py b/backend/gen_tv/models.py new file mode 100644 index 00000000..f7c18812 --- /dev/null +++ b/backend/gen_tv/models.py @@ -0,0 +1,53 @@ +from django.db import models +from django.utils import timezone + +from utils.models import BaseModel + + +class Stream(BaseModel): + """A livestream entry shown on Gen TV (mostly X / Twitter, for now).""" + + class Category(models.TextChoices): + INTERNAL = 'internal', 'GenLayer Team' + COMMUNITY = 'community', 'Community' + + # Status is derived, not stored — see the `status` property below. + UPCOMING = 'upcoming' + LIVE = 'live' + PAST = 'past' + + title = models.CharField(max_length=300) + slug = models.SlugField(max_length=300, unique=True) + description = models.TextField(blank=True) + url = models.URLField( + max_length=500, + help_text="Source URL (X / Twitter, YouTube, etc.).", + ) + image_url = models.URLField( + max_length=500, + blank=True, + help_text="Thumbnail / cover image URL.", + ) + starts_at = models.DateTimeField( + help_text="Scheduled start time (used for sorting and status).", + ) + ends_at = models.DateTimeField( + help_text="Scheduled end time (used to compute status and the duration badge).", + ) + category = models.CharField(max_length=20, choices=Category.choices) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['-starts_at'] + + def __str__(self): + return f"[{self.get_category_display()}] {self.title}" + + @property + def status(self): + now = timezone.now() + if now < self.starts_at: + return self.UPCOMING + if now < self.ends_at: + return self.LIVE + return self.PAST diff --git a/backend/gen_tv/serializers.py b/backend/gen_tv/serializers.py new file mode 100644 index 00000000..9644ba8e --- /dev/null +++ b/backend/gen_tv/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from .models import Stream + + +class LightStreamSerializer(serializers.ModelSerializer): + """Minimal stream payload for list views.""" + + status = serializers.CharField(read_only=True) + + class Meta: + model = Stream + fields = [ + 'id', + 'title', + 'slug', + 'image_url', + 'url', + 'starts_at', + 'ends_at', + 'category', + 'status', + ] + read_only_fields = fields + + +class StreamSerializer(serializers.ModelSerializer): + """Full stream payload for detail views.""" + + status = serializers.CharField(read_only=True) + + class Meta: + model = Stream + fields = [ + 'id', + 'title', + 'slug', + 'description', + 'url', + 'image_url', + 'starts_at', + 'ends_at', + 'category', + 'status', + 'is_active', + 'created_at', + 'updated_at', + ] + read_only_fields = fields diff --git a/backend/gen_tv/tests/__init__.py b/backend/gen_tv/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/tests/test_streams.py b/backend/gen_tv/tests/test_streams.py new file mode 100644 index 00000000..1b8efdcc --- /dev/null +++ b/backend/gen_tv/tests/test_streams.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from django.utils import timezone + +from gen_tv.models import Stream + + +class StreamAPITest(TestCase): + def setUp(self): + now = timezone.now() + Stream.objects.create( + title='Internal Live', + slug='internal-live', + url='https://x.com/genlayer/status/1', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.LIVE, + ) + Stream.objects.create( + title='Internal Past', + slug='internal-past', + url='https://x.com/genlayer/status/2', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.PAST, + ) + Stream.objects.create( + title='Community Upcoming', + slug='community-upcoming', + url='https://x.com/community/status/3', + scheduled_at=now, + category=Stream.Category.COMMUNITY, + status=Stream.Status.UPCOMING, + ) + Stream.objects.create( + title='Inactive', + slug='inactive', + url='https://x.com/genlayer/status/9', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.LIVE, + is_active=False, + ) + + def test_list_excludes_inactive(self): + res = self.client.get('/api/v1/gen-tv/streams/') + self.assertEqual(res.status_code, 200) + results = res.json() + slugs = {s['slug'] for s in results} + self.assertSetEqual( + slugs, + {'internal-live', 'internal-past', 'community-upcoming'}, + ) + + def test_filter_by_category_and_status(self): + res = self.client.get('/api/v1/gen-tv/streams/?category=internal&status=live') + self.assertEqual(res.status_code, 200) + results = res.json() + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['slug'], 'internal-live') + + def test_detail_uses_slug_lookup(self): + res = self.client.get('/api/v1/gen-tv/streams/community-upcoming/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()['slug'], 'community-upcoming') diff --git a/backend/gen_tv/views.py b/backend/gen_tv/views.py new file mode 100644 index 00000000..c8ec463a --- /dev/null +++ b/backend/gen_tv/views.py @@ -0,0 +1,23 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + +from .models import Stream +from .serializers import LightStreamSerializer, StreamSerializer + + +class StreamViewSet(viewsets.ReadOnlyModelViewSet): + """Public read-only API for Gen TV streams.""" + + queryset = Stream.objects.filter(is_active=True) + permission_classes = [permissions.AllowAny] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['category'] + search_fields = ['title', 'description'] + ordering_fields = ['starts_at', 'created_at'] + lookup_field = 'slug' + pagination_class = None + + def get_serializer_class(self): + if self.action == 'list': + return LightStreamSerializer + return StreamSerializer diff --git a/backend/tally/settings.py b/backend/tally/settings.py index ab495a16..4dfc9913 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -78,6 +78,7 @@ def get_required_env(key): 'stewards', 'creators', 'partners', + 'gen_tv', 'social_connections', ] diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index d7332b6d..2dfbd7ec 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -375,6 +375,7 @@ const routes = { // Discover '/ecosystem-partners': EcosystemPartners, // Public directory of partners + validators + projects + '/gen-tv': GenTV, // Livestream index split by category section '*': NotFound } @@ -402,6 +403,7 @@ const routes = { - `journeyAPI` - Onboarding journeys (startBuilderJourney, startValidatorJourney, completeBuilderJourney, linkXAccount, linkDiscordAccount) - `creatorAPI` - Community/creator membership (joinAsCreator) - `partnersAPI` - Ecosystem partners directory (`list`, `get(slug)`) + - `genTvAPI` - Gen TV streams (`list`, `get(slug)`) ### Authentication (`src/lib/auth.js`) - **Auth Store**: Svelte store `authState` @@ -470,6 +472,12 @@ Reusable, data-driven display components that accept data via props. Used on Das - Partner cards put the logo on a black circle; validator/project cards use a soft-gradient initials fallback when no image is available - Click opens `item.href` (external opens in a new tab; validator profile links navigate in-app) +#### Gen TV Components (`src/components/portal/gen-tv/`) +- **`StreamCard.svelte`** - Card for a livestream with image, dark overlay, status badge, and title + - Props: `stream`, `variant='past'|'live'|'upcoming'` + - Computed `status` and `duration` come from `starts_at` / `ends_at` on the API payload + - Click opens `stream.url` in a new tab + #### How It Works / Landing Page Components (`src/components/portal/landing-page/`) - Used by the `/how-it-works` route (`HowItWorks.svelte`) - First-time users are redirected here after completing their profile via `ProfileCompletionGuard` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index bd4b4a53..8382d9e5 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -82,6 +82,7 @@ import TermsOfUse from './routes/TermsOfUse.svelte'; import PrivacyPolicy from './routes/PrivacyPolicy.svelte'; import EcosystemPartners from './routes/EcosystemPartners.svelte'; + import GenTV from './routes/GenTV.svelte'; import Referrals from './routes/Referrals.svelte'; import Community from './routes/Community.svelte'; import Hackathon from './routes/Hackathon.svelte'; @@ -168,6 +169,7 @@ // Ecosystem '/ecosystem-partners': EcosystemPartners, + '/gen-tv': GenTV, '*': NotFound }; diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index b7a0bbb8..c714d209 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -70,6 +70,7 @@ if (path.startsWith('/community')) return 'community'; if (path.startsWith('/stewards')) return 'steward'; if (path.startsWith('/ecosystem-partners')) return 'partners'; + if (path.startsWith('/gen-tv')) return 'gentv'; return null; } @@ -404,6 +405,24 @@ + +
+ +
+ @@ -760,6 +779,19 @@ Ecosystem Partners + + + diff --git a/frontend/src/components/portal/gen-tv/StreamCard.svelte b/frontend/src/components/portal/gen-tv/StreamCard.svelte new file mode 100644 index 00000000..647fe104 --- /dev/null +++ b/frontend/src/components/portal/gen-tv/StreamCard.svelte @@ -0,0 +1,110 @@ + + + +
+ {#if stream.image_url} + + {:else} +
+ {/if} + +
+ +
+
+ {#if isLive} + + + Live + + {:else if isUpcoming} + + Upcoming + + {:else} + + Ended + + {/if} + + {#if duration} + + {duration} + + {/if} +
+ +
+ +
+
+ +
+
+ {categoryLabel} + {#if host} + · + {host} + {/if} +
+

+ {stream.title} +

+ {#if stream.starts_at} +

+ {formatDateTime(stream.starts_at)} +

+ {/if} +
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index f3aac565..aa38371c 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -271,4 +271,10 @@ export const partnersAPI = { get: (slug) => api.get(`/partners/${slug}/`), }; +// Gen TV API +export const genTvAPI = { + list: (params) => api.get('/gen-tv/streams/', { params }), + get: (slug) => api.get(`/gen-tv/streams/${slug}/`), +}; + export default api; \ No newline at end of file diff --git a/frontend/src/routes/GenTV.svelte b/frontend/src/routes/GenTV.svelte new file mode 100644 index 00000000..af3571bb --- /dev/null +++ b/frontend/src/routes/GenTV.svelte @@ -0,0 +1,146 @@ + + +
+
+

+ Gen TV +

+

+ Live and recorded streams from the GenLayer team and community. +

+
+ + {#if loading} +
+
+ {#each [1, 2, 3] as _} +
+ {/each} +
+
+ {#each [1, 2, 3, 4] as _} +
+ {/each} +
+
+ {:else if error} +
+ {error} +
+ {:else if groups.length === 0} +
+

No streams yet

+

+ Streams will appear here once they're scheduled. +

+
+ {:else} + {#each groups as group (group.id)} +
+

+ {group.label} +

+ + {#if group.live.length > 0} +
+

+ + Live now +

+
+ {#each group.live as stream (stream.id)} + + {/each} +
+
+ {/if} + + {#if group.upcoming.length > 0} +
+

+ Upcoming +

+
+ {#each group.upcoming as stream (stream.id)} + + {/each} +
+
+ {/if} + + {#if group.past.length > 0} +
+

+ Past streams +

+
+ {#each group.past as stream (stream.id)} + + {/each} +
+
+ {/if} +
+ {/each} + {/if} +
diff --git a/frontend/src/stores/category.js b/frontend/src/stores/category.js index 5cf408a2..d893b3f8 100644 --- a/frontend/src/stores/category.js +++ b/frontend/src/stores/category.js @@ -133,7 +133,7 @@ export function detectCategoryFromRoute(path) { return 'steward'; } else if (path.startsWith('/community')) { return 'community'; - } else if (path.startsWith('/ecosystem-partners')) { + } else if (path.startsWith('/ecosystem-partners') || path.startsWith('/gen-tv')) { return 'global'; } return 'global'; From f43984d39947404431bd164e9ce27848448cf161 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 14 May 2026 15:48:01 +0200 Subject: [PATCH 2/2] Seed Gen TV with the 25 official livestreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `gen_tv/0002_seed_streams.py` data migration that populates Stream rows for every official GenLayer livestream we've held to date — the Vibecoding series, the Bradbury hackathon block, the November 2025 hackathon submissions review, and GenTalks 1 through 13 — using update_or_create keyed by slug so the migration is safe to re-run. Each stream points at a Cloudinary banner under the deterministic `gen_tv/` public_id; the one stream without its own banner (Barcelona Openclaw Meetup) falls back to `gen_tv/default-banner`. Banners must be uploaded to Cloudinary out of band before the migration runs in production, otherwise `image_url` will resolve to 404 — see the upload section in the PR description. `ends_at` defaults to `starts_at + 30 minutes` for every stream since the source spreadsheet only carried start times. Descriptions carry the recap text from the source, with a trailing line listing the speakers/hosts so we don't lose that context. ## Claude Implementation Notes - backend/gen_tv/migrations/0002_seed_streams.py: New data migration with a STREAMS list of 25 entries (slug, title, starts_at, url, description, has_own_banner). seed_streams() update_or_creates each by slug; unseed_streams() deletes them. image_url is computed from `gen_tv/.png` (or the shared default-banner) so URLs stay deterministic across upload/re-upload cycles. --- .../gen_tv/migrations/0002_seed_streams.py | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 backend/gen_tv/migrations/0002_seed_streams.py diff --git a/backend/gen_tv/migrations/0002_seed_streams.py b/backend/gen_tv/migrations/0002_seed_streams.py new file mode 100644 index 00000000..2ad1d9f9 --- /dev/null +++ b/backend/gen_tv/migrations/0002_seed_streams.py @@ -0,0 +1,318 @@ +from datetime import datetime, timedelta, timezone + +from django.db import migrations +from django.utils.text import slugify + + +CLOUDINARY_BASE = "https://res.cloudinary.com/dfqmoeawa/image/upload/gen_tv" +DEFAULT_BANNER = f"{CLOUDINARY_BASE}/default-banner.png" +DURATION = timedelta(minutes=30) + + +def _at(year: int, month: int, day: int, hour: int, minute: int = 0) -> datetime: + return datetime(year, month, day, hour, minute, tzinfo=timezone.utc) + + +# Each tuple: (slug, title, starts_at, source_url, description, has_own_banner) +# `has_own_banner=True` -> uses gen_tv/.png; False -> default banner. +STREAMS: list[tuple[str, str, datetime, str, str, bool]] = [ + ( + "barcelona-openclaw-meetup", + "Barcelona Openclaw Meetup Livestream", + _at(2026, 2, 10, 18, 0), + "https://x.com/i/broadcasts/1mnxeNgXbbqKX", + "Discussion of the emerging OpenClaw technology.\n\n" + "Speakers: @MorpheusAIs's speaker, @driudor, @akka_io_'s speaker, " + "@joaquinbressan, @cognocracy, @kstellana", + False, + ), + ( + "presenting-hackathon", + "Presenting Hackathon", + _at(2026, 3, 17, 16, 0), + "https://x.com/i/broadcasts/1NGaraDelYdJj", + "Ivan introduced the Hackathon, outlining its timeline, main features, " + "award categories, and focus areas.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "from-zero-to-genlayer", + "From Zero to GenLayer", + _at(2026, 3, 19, 11, 0), + "https://x.com/i/broadcasts/1jxXgeaLLgRJZ", + "A vibecoding session organized specifically for the Hackathon. The session " + "covered the main aspects of development on GenLayer, including consensus " + "specifics and other technical details. Ivan shared a developer presentation " + "with documentation and useful links. The host also discussed project ideas " + "that could be built on GenLayer during the Hackathon.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "bradbury-builders-hackathon-launch", + "Bradbury Builders Hackathon Launch", + _at(2026, 3, 20, 13, 30), + "https://x.com/i/broadcasts/1nxnRYADmgoxO", + "The sponsors of the Hackathon, concurrently our validators, were introduced. " + "Ivan also once again outlined the Hackathon conditions and emphasized the " + "advantages of developing applications on GenLayer, including lifetime fees. " + "To provide more context, existing solutions such as argue.fun, mergeproof.com, " + "and others were showcased.\n\n" + "Speakers: @raskovsky, Dasha from @stakeme_pro, Anton from @CroutonDigital, " + "Patrick from @pathrock2, Albury from @chutes_ai", + True, + ), + ( + "vibecoding-bradbury-gym", + "GenLayer Vibecoding Series: Bradbury Gym", + _at(2026, 4, 1, 16, 30), + "https://x.com/i/broadcasts/1aKbdbyZdjqJX", + "The session covered the main features and advantages of the Bradbury testnet, " + "followed by a vibecoding demonstration of a benchmark running on GenLayer.\n\n" + "Speakers: @raskovsky, @cognocracy", + True, + ), + ( + "bradbury-hackathon-winners", + "Bradbury Hackathon Winners Announcement", + _at(2026, 4, 14, 14, 0), + "https://x.com/i/broadcasts/1PKqrElvYVmGb", + "Bradbury Hackathon Winners Review: Ivan presented a summary of the Hackathon " + "results and showcased each winning project.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "bradbury-hackathon-demo-day", + "Bradbury Hackathon Demo Day", + _at(2026, 4, 17, 15, 0), + "https://x.com/i/broadcasts/1AJEmOqRBoZJL", + "Discussion with the teams behind BuildersClaw, AutoBounty, and TreasuryPilot — " + "winners of the Bradbury Hackathon — including their backgrounds and demos of " + "their applications.\n\n" + "Speakers: @raskovsky, team @buildersclaw, @ArtuGrande (AutoBounty), " + "@sandraupgrade (TreasuryPilot)", + True, + ), + ( + "nov-2025-hackathon-submissions", + "GenLayer November 2025 Hackathon — Live Submissions Review", + _at(2025, 11, 14, 15, 15), + "https://x.com/i/broadcasts/1ZkJzZygWndJv", + "Live overview of GenLayer Hackathon applications.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep1", + "GenLayer Vibecoding Series Episode 1: GenLayer Introduction & Basics", + _at(2025, 12, 23, 14, 0), + "https://x.com/i/broadcasts/1OdKrOvXjrYGX", + "Vibecoding Series Episode 1. Ivan showcased GenLayer Studio and explained " + "the structure of an intelligent contract.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep2", + "GenLayer Vibecoding Series Episode 2: Our First Intelligent Contract", + _at(2025, 12, 30, 14, 0), + "https://x.com/i/broadcasts/1eaKbjQdYgrKX", + "Vibecoding Series Episode 2. Setting up the Claude terminal, creating the " + "first Intelligent Contract, testing it in Studio, and fixing bugs.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep3", + "GenLayer Vibecoding Series Episode 3: From an Intelligent Contract to a DApp", + _at(2026, 1, 7, 14, 0), + "https://x.com/i/broadcasts/1ypJdqwnwPaxW", + "Vibecoding Series Episode 3. Building a DApp by combining a contract and a " + "frontend using the Claude terminal.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep4", + "GenLayer Vibecoding Series Episode 4: Iterating, Polishing and Deploying", + _at(2026, 1, 13, 14, 0), + "https://x.com/i/broadcasts/1ypKdqgAWkpGW", + "Vibecoding Series Episode 4. Continuing DApp moderation: editing and fixing " + "bugs with the Claude terminal and GenLayer Studio.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "gentalks-ep1", + "GenTalks Episode 1", + _at(2026, 1, 21, 13, 30), + "https://x.com/GenLayer/status/2013747638517047635", + "", + True, + ), + ( + "gentalks-ep2", + "GenTalks Episode 2", + _at(2026, 1, 28, 13, 30), + "https://x.com/GenLayer/status/2016195968031396258", + "", + True, + ), + ( + "gentalks-ep3", + "GenTalks Episode 3", + _at(2026, 2, 5, 15, 30), + "https://x.com/i/broadcasts/1ypKdqpXYLrGW", + "Discussion of the current market landscape, emerging technologies such as " + "OpenClaw, ClawBot, Moltbook, RentAHuman.ai, ClawTasks, Argue.fun, and ClawHub, " + "and GenLayer's role. An IRL meetup in Ukraine was also discussed.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep4", + "GenTalks Episode 4", + _at(2026, 2, 11, 14, 30), + "https://x.com/i/broadcasts/1MYxNlwjdyLGw", + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep5", + "GenTalks Episode 5", + _at(2026, 2, 25, 14, 30), + "https://x.com/i/broadcasts/1yxBeMebmnYJN", + "Introduction to Internet Court and Rally updates, as well as the Community " + "section on the Portal, and the automatic submission review system. The " + "discussion also included Argue.fun features for autonomous agents and " + "introductions to Mergeproof.com and Molly.fun.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep6", + "GenTalks Episode 6", + _at(2026, 3, 4, 14, 30), + "https://x.com/i/broadcasts/1DxleEVZyndKL", + "Discussion of Rally.fun and Argue.fun, and the future role of autonomous " + "agents in shaping the internet. Presentation of Botcha.xyz as a CAPTCHA " + "solution designed for agents.\n\n" + "Speakers: @raskovsky, @joaquinbressan", + True, + ), + ( + "gentalks-ep7", + "GenTalks Episode 7", + _at(2026, 3, 11, 14, 30), + "https://x.com/i/broadcasts/1rxmqoVaVWDxy", + "Edgars spoke about what makes Bradbury stand out today, shared insights on " + "validators, and discussed Argue.fun as a modern approach to dispute " + "resolution.\n\n" + "Speakers: @raskovsky, @driudor, @EdgarsNemse", + True, + ), + ( + "gentalks-ep8", + "GenTalks Episode 8", + _at(2026, 3, 18, 14, 30), + "https://x.com/i/broadcasts/1wxWjaZPpYnJQ", + "First impressions from the Bradbury launch. Albert talked about his " + "experience building on GenLayer and broke down the Developer Fee tokenomics. " + "Review of skills.genlayer.com.\n\n" + "Speakers: @raskovsky, @driudor, @kstellana", + True, + ), + ( + "gentalks-ep9", + "GenTalks Episode 9", + _at(2026, 3, 25, 14, 30), + "https://x.com/i/broadcasts/1qxvvkzNrbRxB", + "A look at the latest stats and some Hackathon projects, followed by a " + "discussion about the GenLayer Portal.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep10", + "GenTalks Episode 10", + _at(2026, 4, 1, 14, 30), + "https://x.com/i/broadcasts/1RKZzjyvzAXKB", + "David and Ivan discuss the current Hackathon results and the support " + "provided to participating teams. They also review internetcourt.org, share " + "thoughts on GenTalks, cover the latest Bradbury testnet news, and go over " + "GenLayer Portal submission statistics.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep11", + "GenTalks Episode 11", + _at(2026, 4, 8, 14, 30), + "https://x.com/i/broadcasts/1aJbdbBQLqoKX", + "Overview of the GenLayer Hackathon submissions.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep12", + "GenTalks Episode 12", + _at(2026, 4, 15, 14, 30), + "https://x.com/i/broadcasts/1rGmqolwYdqGy", + "GenLayer Hackathon results and reviews of several projects. Answers to " + "community questions. Introducing pmkit.courtofinternet.com, a tool for " + "creating prediction markets.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep13", + "GenTalks Episode 13", + _at(2026, 4, 22, 14, 30), + "https://x.com/i/broadcasts/1vJpPrpRaEQJE", + "Announcement of an IRL event in Argentina hosted by Ivan. Discussions about " + "builder meetups and plans from the GenLayer Foundation, including validator " + "onboarding and more.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), +] + + +def _image_url(slug: str, has_own_banner: bool) -> str: + return f"{CLOUDINARY_BASE}/{slug}.png" if has_own_banner else DEFAULT_BANNER + + +def seed_streams(apps, schema_editor): + Stream = apps.get_model("gen_tv", "Stream") + for slug, title, starts_at, url, description, has_own_banner in STREAMS: + Stream.objects.update_or_create( + slug=slug, + defaults={ + "title": title, + "description": description, + "url": url, + "image_url": _image_url(slug, has_own_banner), + "starts_at": starts_at, + "ends_at": starts_at + DURATION, + "category": "internal", + "is_active": True, + }, + ) + + +def unseed_streams(apps, schema_editor): + Stream = apps.get_model("gen_tv", "Stream") + Stream.objects.filter(slug__in=[s[0] for s in STREAMS]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("gen_tv", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_streams, unseed_streams), + ]