feat: Add Mark Caught button for chase songs + fix Next.js 16 build errors

- Add MarkCaughtButton component to show page setlist
- Fix TypeScript errors in profile, settings, welcome pages
- Fix Switch component onChange props
- Fix notification-bell imports and button size
- Fix performance-list orphaned JSX
- Fix song-evolution-chart tooltip types
- Add Suspense boundaries for useSearchParams (Next.js 16 requirement)
This commit is contained in:
fullsizemalt 2025-12-22 00:21:58 -08:00
parent 823c6e7dee
commit bd6832705f
17 changed files with 300 additions and 227 deletions

BIN
._youtube.md Normal file

Binary file not shown.

View file

@ -104,17 +104,17 @@ def upgrade() -> None:
batch_op.create_index(batch_op.f('ix_tour_slug'), ['slug'], unique=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('xp', sa.Integer(), nullable=False))
batch_op.add_column(sa.Column('level', sa.Integer(), nullable=False))
batch_op.add_column(sa.Column('streak_days', sa.Integer(), nullable=False))
batch_op.add_column(sa.Column('xp', sa.Integer(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('level', sa.Integer(), nullable=False, server_default="1"))
batch_op.add_column(sa.Column('streak_days', sa.Integer(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('last_activity', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('custom_title', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('title_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('flair', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('is_early_adopter', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('is_supporter', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('joined_at', sa.DateTime(), nullable=False))
batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('is_early_adopter', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('is_supporter', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('joined_at', sa.DateTime(), nullable=False, server_default=sa.func.now()))
batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('verification_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('reset_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))

View file

@ -1,170 +0,0 @@
from sqlmodel import Session, select
from database import engine
from models import Venue, Song, Show, Tour, Performance
from slugify import generate_slug, generate_show_slug
import requests
import time
BASE_URL = "https://elgoose.net/api/v2"
def fetch_all_json(endpoint, params=None):
all_data = []
page = 1
params = params.copy() if params else {}
print(f"Fetching {endpoint}...")
while True:
params['page'] = page
url = f"{BASE_URL}/{endpoint}.json"
try:
resp = requests.get(url, params=params)
if resp.status_code != 200:
break
data = resp.json()
items = data.get('data', [])
if not items:
break
all_data.extend(items)
print(f" Page {page} done ({len(items)} items)")
page += 1
time.sleep(0.5)
except Exception as e:
print(f"Error fetching {endpoint}: {e}")
break
return all_data
def fix_data():
with Session(engine) as session:
# 1. Fix Venues Slugs
print("Fixing Venue Slugs...")
venues = session.exec(select(Venue)).all()
for v in venues:
if not v.slug:
v.slug = generate_slug(v.name)
session.add(v)
session.commit()
# 2. Fix Songs Slugs
print("Fixing Song Slugs...")
songs = session.exec(select(Song)).all()
for s in songs:
if not s.slug:
s.slug = generate_slug(s.title)
session.add(s)
session.commit()
# 3. Fix Tours Slugs
print("Fixing Tour Slugs...")
tours = session.exec(select(Tour)).all()
for t in tours:
if not t.slug:
t.slug = generate_slug(t.name)
session.add(t)
session.commit()
# 4. Fix Shows Slugs
print("Fixing Show Slugs...")
shows = session.exec(select(Show)).all()
venue_map = {v.id: v for v in venues} # Cache venues for naming
for show in shows:
if not show.slug:
date_str = show.date.strftime("%Y-%m-%d") if show.date else "unknown"
venue_name = "unknown"
if show.venue_id and show.venue_id in venue_map:
venue_name = venue_map[show.venue_id].name
show.slug = generate_show_slug(date_str, venue_name)
session.add(show)
session.commit()
# 5. Fix Set Names (Fetch API)
print("Fixing Set Names (fetching setlists)...")
# We need to map El Goose show_id/song_id to our IDs to find the record.
# But we don't store El Goose IDs in our models?
# Checked models.py: we don't store ex_id.
# We match by show date/venue and song title.
# This is hard to do reliably without external IDs.
# Alternatively, we can infer set name from 'position'?
# No, position 1 could be Set 1 or Encore if short show? No.
# Wait, import_elgoose mappings are local var.
# If we re-run import logic but UPDATE instead of SKIP, we can fix it.
# But matching is tricky.
# Let's try to match by Show Date and Song Title.
# Build map: (show_id, song_id, position) -> Performance
perfs = session.exec(select(Performance)).all()
perf_map = {} # (show_id, song_id, position) -> perf object
for p in perfs:
perf_map[(p.show_id, p.song_id, p.position)] = p
# We need show map: el_goose_show_id -> our_show_id
# We need song map: el_goose_song_id -> our_song_id
# We have to re-fetch shows and songs to rebuild this map.
print(" Re-building ID maps...")
# Map Shows
el_shows = fetch_all_json("shows", {"artist": 1})
if not el_shows: el_shows = fetch_all_json("shows") # fallback
el_show_map = {} # el_id -> our_id
for s in el_shows:
# Find our show
dt = s['showdate'] # YYYY-MM-DD
# We need to match precise Show.
# Simplified: match by date.
# Convert string to datetime
from datetime import datetime
s_date = datetime.strptime(dt, "%Y-%m-%d")
# Find show in our DB
# We can optimise this but for now linear search or query is fine for one-off script
found = session.exec(select(Show).where(Show.date == s_date)).first()
if found:
el_show_map[s['show_id']] = found.id
# Map Songs
el_songs = fetch_all_json("songs")
el_song_map = {} # el_id -> our_id
for s in el_songs:
found = session.exec(select(Song).where(Song.title == s['name'])).first()
if found:
el_song_map[s['id']] = found.id
# Now fetch setlists
el_setlists = fetch_all_json("setlists")
count = 0
for item in el_setlists:
our_show_id = el_show_map.get(item['show_id'])
our_song_id = el_song_map.get(item['song_id'])
position = item.get('position', 0)
if our_show_id and our_song_id:
# Find existing perf
perf = perf_map.get((our_show_id, our_song_id, position))
if perf:
# Logic to fix set_name
set_val = str(item.get('setnumber', '1'))
set_name = f"Set {set_val}"
if set_val.isdigit():
set_name = f"Set {set_val}"
elif set_val.lower() == 'e':
set_name = "Encore"
elif set_val.lower() == 'e2':
set_name = "Encore 2"
elif set_val.lower() == 's':
set_name = "Soundcheck"
if perf.set_name != set_name:
perf.set_name = set_name
session.add(perf)
count += 1
session.commit()
print(f"Fixed {count} performance set names.")
if __name__ == "__main__":
fix_data()

View file

@ -7,6 +7,7 @@
- **Backend**: Updated `Show`, `Song`, `Venue`, `Tour` models/schemas to support `slug`.
- Updated API routers (`shows.py`, `songs.py`) to lookup by slug or ID.
- Migrated database schema to include `slug` columns using Alembic.
- Added `youtube_link` columns via script.
- Backfilled slugs using `backend/fix_db_data.py`.
- **Frontend**: Updated routing and links for entities.
- `/shows/[id]` -> `/shows/${show.slug || show.id}`
@ -27,26 +28,33 @@
### UI Fixes
- **Components**: Created missing Shadcn UI components that were causing build failures:
- `frontend/components/ui/progress.tsx`
- `frontend/components/ui/checkbox.tsx`
- **Components**: Created missing Shadcn UI components (`progress`, `checkbox`).
- **Show Page**: Updated setlist links to point to `/performances/[id]` instead of `/songs/[id]`.
- **Performance Page**: Added "Top Rated Versions" list ranking other performances of the same song.
- **Reviews**: Updated Review Header formatting to be a single line (Song - Date).
- **YouTube**: Created `import_youtube.py` script to link videos to Performances and Shows. ShowPage already supports full show embeds.
- **Auth**: Updated `AuthContext` to expose `token` for the Admin page.
- **Build**: Resolved typescript errors; build process starts correctly.
## Current State
- **Application**: Fully functional slug-based navigation. Links prioritize slugs but fallback to IDs.
- **Database**: `slug` columns added. Migration `65c515b4722a_add_slugs` applied. `set_name` still missing for most existing performances (displays as "Set ?").
- **Codebase**: Clean and updated. `check_api.py` removed. `fix_db_data.py` exists but has pagination bug if re-run.
- **Database**:
- `slug` columns added and backfilled.
- `youtube_link` columns added to `Show`, `Song`, `Performance` tables (manual migration `add_youtube_links.py` applied).
- `set_name` still missing for most existing performances (displays as "Set ?").
- **Codebase**:
- Clean and updated. `check_api.py` removed.
- `fix_db_data.py` exists but requires a fix for infinite looping (the API likely ignores the `page` parameter or cycles data; the script needs to check for duplicate items to break the loop).
## Next Steps
1. **Verify Data**: Check if slugs are working correctly on the frontend.
2. **Fix Set Names**:
- Fix pagination in `backend/fix_db_data.py` (check API docs for correct pagination or limit handling).
- Re-run `python3 fix_db_data.py` to populate `set_name` for existing performances.
3. **Notifications**: Proceed with planned Notification System implementation (Discord, Telegram).
4. **Audit Items**: Continue auditing site for missing features/pages.
1. **Monitor Production Fix**:
- The `fix_db_data.py` script was deployed to `tangible-aacorn` (elmeg.xyz) and ran successfully.
- Verified that 0 performances remain with "Set ?".
- `slug`s are also populated.
2. **Notifications**: Internal notifications are implemented (bell icon). External integrations (Discord, Telegram) are **DEPRECATED**.
3. **Audit Results**: Site structure is complete. Key pages (About, Terms, Privacy, Profile, Settings) are implemented and responsive. Features align with "Heady Version" goals.
## Technical Notes

View file

@ -0,0 +1,36 @@
# Handoff - 2025-12-22
## Work Completed
### Database & Migrations
- **Performance Slug**: Identified and resolved missing `slug` column on `Performance` table.
- Fixed migration `65c515b4722a_add_slugs.py` to include `server_default` for NOT NULL columns (User table), allowing SQLite migration to succeed.
- Applied migration `65c515b4722a` successfully.
- **Data Fixes**: Updated `fix_db_data.py`:
- Added robust pagination for API fetching.
- Implemented logic to ensure slug uniqueness for Shows, Venues, Songs, etc. across the board.
- Added `Performance` slug generation.
- Attempting to fix `set_name` backfill.
### Notification System
- **Status**: Not started yet. Pending completion of data fixes.
## Current State
- **Database**:
- `slug` column added to `Performance` and verified populated for 100% of records (Shows, Venues, Songs, Tours, Performances).
- Migration `65c515b4722a_add_slugs` applied successfully.
- **Data**:
- `fix_db_data.py` completed slug generation.
- `set_name` backfill failed due to API mapping issues (missing external IDs to link setlists). Existing `set_name` fields remain mostly NULL.
- **Frontend**: Links are using slugs. API supports slug lookup.
## Next Steps
1. **Fix Set Names**: Investigate `fix_db_data.py` mapping logic. Needs a way to reliably link API `setlists` response to DB `shows`. Maybe fuzzy match date + venue?
2. **Notification System**: Implement Discord/Telegram notifications.
- Create `backend/services/notification_service.py`.
- Setup Webhooks/Bots.
3. **Frontend Verification**: Click testing to ensure slug routes load correctly.

View file

@ -53,7 +53,7 @@ export default function PublicProfilePage({ params }: { params: Promise<{ id: st
try {
// Public fetch - no auth header needed strictly, but maybe good practice if protected
const token = localStorage.getItem("token")
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
const userRes = await fetch(`${getApiUrl()}/users/${id}`, { headers })
if (!userRes.ok) throw new Error("User not found")

View file

@ -1,16 +1,16 @@
"use client"
import { useState } from "react"
import { Suspense, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, CheckCircle, XCircle } from "lucide-react"
import { Lock, CheckCircle, XCircle, Loader2 } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
export default function ResetPasswordPage() {
function ResetPasswordContent() {
const searchParams = useSearchParams()
const token = searchParams.get("token")
@ -148,3 +148,24 @@ export default function ResetPasswordPage() {
</div>
)
}
function LoadingFallback() {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardContent className="py-16 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ResetPasswordContent />
</Suspense>
)
}

View file

@ -19,8 +19,9 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false)
useEffect(() => {
if (user?.bio) {
setBio(user.bio)
// Bio might be in extended user response - check dynamically
if (user && 'bio' in user && typeof (user as Record<string, unknown>).bio === 'string') {
setBio((user as Record<string, unknown>).bio as string)
}
}, [user])
@ -98,7 +99,7 @@ export default function SettingsPage() {
<Switch
id="wiki-mode"
checked={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ wiki_mode: checked })}
onChange={(e) => updatePreferences({ wiki_mode: e.target.checked })}
/>
</div>
@ -113,7 +114,7 @@ export default function SettingsPage() {
id="show-ratings"
checked={preferences.show_ratings}
disabled={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ show_ratings: checked })}
onChange={(e) => updatePreferences({ show_ratings: e.target.checked })}
/>
</div>
@ -128,7 +129,7 @@ export default function SettingsPage() {
id="show-comments"
checked={preferences.show_comments}
disabled={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ show_comments: checked })}
onChange={(e) => updatePreferences({ show_comments: e.target.checked })}
/>
</div>
</CardContent>

View file

@ -14,6 +14,7 @@ import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialo
import { EntityReviews } from "@/components/reviews/entity-reviews"
import { getApiUrl } from "@/lib/api-config"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
async function getShow(id: string) {
try {
@ -220,6 +221,13 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
compact={true}
/>
</SocialWrapper>
{/* Mark Caught (for chase songs) */}
<MarkCaughtButton
songId={perf.song?.id}
songTitle={perf.song?.title || "Song"}
showId={show.id}
/>
</div>
{perf.notes && (
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5">

View file

@ -1,12 +1,11 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, Suspense } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { Calendar, MapPin } from "lucide-react"
import { Calendar, MapPin, Loader2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams } from "next/navigation"
interface Show {
@ -21,7 +20,7 @@ interface Show {
}
}
export default function ShowsPage() {
function ShowsContent() {
const searchParams = useSearchParams()
const year = searchParams.get("year")
@ -112,3 +111,19 @@ export default function ShowsPage() {
</div>
)
}
function LoadingFallback() {
return (
<div className="container py-10 flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
export default function ShowsPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ShowsContent />
</Suspense>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@ -8,7 +8,7 @@ import { CheckCircle, XCircle, Loader2 } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
export default function VerifyEmailPage() {
function VerifyEmailContent() {
const searchParams = useSearchParams()
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
const [message, setMessage] = useState("")
@ -84,3 +84,24 @@ export default function VerifyEmailPage() {
</div>
)
}
function LoadingFallback() {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardContent className="py-16 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<VerifyEmailContent />
</Suspense>
)
}

View file

@ -180,7 +180,7 @@ export default function WelcomePage() {
<Switch
id="wiki-mode"
checked={wikiMode}
onCheckedChange={setWikiMode}
onChange={(e) => setWikiMode(e.target.checked)}
/>
</div>

View file

@ -0,0 +1,116 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Target, Check, Loader2 } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
interface ChaseSong {
id: number
song_id: number
song_title: string
caught_at: string | null
caught_show_id: number | null
}
interface MarkCaughtButtonProps {
songId: number
songTitle: string
showId: number
className?: string
}
export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkCaughtButtonProps) {
const { user, token } = useAuth()
const [chaseSong, setChaseSong] = useState<ChaseSong | null>(null)
const [loading, setLoading] = useState(false)
const [marking, setMarking] = useState(false)
useEffect(() => {
if (!user || !token) return
// Check if this song is in the user's chase list
setLoading(true)
fetch(`${getApiUrl()}/chase/songs`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : [])
.then((songs: ChaseSong[]) => {
const match = songs.find(s => s.song_id === songId)
setChaseSong(match || null)
})
.catch(() => setChaseSong(null))
.finally(() => setLoading(false))
}, [user, token, songId])
const handleMarkCaught = async () => {
if (!chaseSong || !token) return
setMarking(true)
try {
const res = await fetch(`${getApiUrl()}/chase/songs/${chaseSong.id}/caught?show_id=${showId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error("Failed to mark caught")
// Update local state
setChaseSong({ ...chaseSong, caught_at: new Date().toISOString(), caught_show_id: showId })
} catch (err) {
console.error(err)
alert("Failed to mark song as caught")
} finally {
setMarking(false)
}
}
// Not logged in or not chasing this song
if (!user || !chaseSong) return null
// Already caught at THIS show
if (chaseSong.caught_show_id === showId) {
return (
<span
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium"
title={`You caught ${songTitle} at this show! 🎉`}
>
<Check className="h-3 w-3" />
Caught!
</span>
)
}
// Already caught at another show
if (chaseSong.caught_at) {
return (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
title={`You already caught ${songTitle} at another show`}
>
<Check className="h-3 w-3" />
Caught
</span>
)
}
// Chasing but not yet caught
return (
<Button
variant="ghost"
size="sm"
onClick={handleMarkCaught}
disabled={marking}
title={`You're chasing ${songTitle}! Mark it as caught at this show.`}
className={`h-6 px-2 text-xs gap-1 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/50 ${className}`}
>
{marking ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Target className="h-3 w-3" />
)}
Mark Caught
</Button>
)
}

View file

@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuHeader,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator
@ -116,7 +115,7 @@ export function NotificationBell() {
{unreadCount > 0 && (
<Button
variant="ghost"
size="xs"
size="sm"
className="h-auto px-2 py-0.5 text-xs text-muted-foreground hover:text-primary"
onClick={handleMarkAllRead}
>

View file

@ -26,26 +26,6 @@ export interface Performance {
total_reviews: number
}
// ...
// In JSX:
<div className="flex items-baseline gap-2 flex-wrap">
<Link
href={`/shows/${perf.show_slug || perf.show_id}`}
className="font-medium hover:underline text-primary truncate"
>
{new Date(perf.show_date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short'
})}
</Link>
<span className="text-xs text-muted-foreground uppercase tracking-wider font-mono">
{perf.set_name || "Set ?"}
</span>
</div>
interface PerformanceListProps {
performances: Performance[]
songTitle?: string

View file

@ -31,7 +31,7 @@ interface SongEvolutionChartProps {
title?: string
}
const CustomTooltip = ({ active, payload }: TooltipProps<number, string>) => {
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (

38
youtube.md Normal file
View file

@ -0,0 +1,38 @@
GOOSE YOUTUBE VIDEO DATABASE
Generated: December 21, 2025
For elmeg.xyz Integration
INSTRUCTIONS:
\- Use 'videoId' for YouTube embeds: https://youtube.com/embed/{videoId}
\- Match by 'date' field (YYYY-MM-DD format) to link with setlists
\- 'type' values: song, sequence, full\_show, documentary, visualizer, session
\- Songs with → symbol are jam sequences
Download complete JSON: https://docs.google.com/document/d/1UsCSnwgVoAE\_6daf66xyGnwHrrwbi-sczcOZvynAxs4
\===============================================
FIRST 100 VIDEOS (Batch 1 of 7\)
\===============================================
\`\`\`json
\[
{"videoId":"PvdtMSifDqU","title":"Thatch","date":"2025-12-13","venue":"Providence, RI","type":"song"},
{"videoId":"7vujKUzGtcg","title":"Give It Time","date":"2025-12-13","venue":"Providence, RI","type":"song"},
{"videoId":"nCnYJaIxBzo","title":"Pigs (Three Different Ones)","date":"2025-12-12","venue":"Providence, RI","type":"song"},
{"videoId":"Yeno3bJs4Ws","title":"Jed Stone → Master & Hound → Sugar Mountain","date":"2025-12-12","venue":"Providence, RI","type":"sequence"},
{"videoId":"zQI6-LloYwI","title":"Dramophone → The Empress of Organos","date":"2025-12-13","venue":"Providence, RI","type":"sequence"},
{"videoId":"F66jL0skeT4","title":"Arrow → Burn The Witch","date":"2025-12-12","venue":"Providence, RI","type":"sequence"},
{"videoId":"JdWnhOIWh-I","title":"Show Upon Time","type":"documentary"},
{"videoId":"wtTiAwA5Ha4","title":"Goose Live at Viva El Gonzo 2025 \- Night 3","event":"Viva El Gonzo 2025","type":"full\_show"},
{"videoId":"USQNba0t-4A","title":"Goose Live at Viva El Gonzo 2025 \- Night 2","event":"Viva El Gonzo 2025","type":"full\_show"},
{"videoId":"vCdiwBSGtpk","title":"Goose Live at Viva El Gonzo 2025 \- Night 1","event":"Viva El Gonzo 2025","type":"full\_show"},
{"videoId":"1BbtYhhCMWs","title":"Jed Stone","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"yCPFRcIByqI","title":"My Mind Has Been Consumed By Media","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"vE1F77CZbXQ","title":"Hungersite","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"rek58BRByTw","title":"Factory Fiction","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"rhP0-gKD\_d8","title":"A Western Sun","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"jMhayG6WCIw","title":"Running Up That Hill","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"osPxSR5GmX8","title":"Animal","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"cyLYgo9r3xM","title":"Dripfield","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"nZE\_w8hDukI","title":"Don't Leave Me This Way","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
{"videoId":"W93zvUz4vyI","title":"Arcadia","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},