diff --git a/.agent/workflows/dev-environment.md b/.agent/workflows/dev-environment.md new file mode 100644 index 0000000..701656e --- /dev/null +++ b/.agent/workflows/dev-environment.md @@ -0,0 +1,111 @@ +--- +description: fediversion development environment and workflow requirements +--- + +# Fediversion Development Workflow + +## CRITICAL: Development Environment + +**All development and testing happens on nexus-vector.** Do NOT use local SQLite for dev/testing. + +### Why nexus-vector? + +- Production-like PostgreSQL database +- All imported band data lives there (10,605+ shows, 139k+ performances) +- Proper Docker environment matching production +- Local SQLite is stale/empty and should NOT be used + +### Development Servers + +| Environment | Server | Path | URL | +|-------------|--------|------|-----| +| **Staging** | nexus-vector | `/srv/containers/fediversion` | fediversion.runfoo.run | +| **Production** | tangible-aacorn | `/srv/containers/fediversion` | (domain TBD) | + +--- + +## SSH Access + +```bash +# Connect to staging dev environment +ssh nexus-vector + +# Navigate to project +cd /srv/containers/fediversion +``` + +--- + +## Database + +### Query Data + +```bash +# On nexus-vector +docker compose exec db psql -U fediversion -d fediversion +``` + +### Check Band Data + +```bash +docker compose exec db psql -U fediversion -d fediversion -c " +SELECT v.name, COUNT(s.id) as shows +FROM vertical v +LEFT JOIN show s ON s.vertical_id = v.id +GROUP BY v.id +ORDER BY shows DESC;" +``` + +--- + +## Running Importers + +Importers run inside the backend container to access the PostgreSQL database: + +```bash +# On nexus-vector +docker compose exec backend python -m importers.setlistfm deadco +docker compose exec backend python -m importers.setlistfm bmfs +docker compose exec backend python -m importers.phish +docker compose exec backend python -m importers.grateful_dead +``` + +### API Keys + +Importers require API keys set in `.env`: + +- `SETLISTFM_API_KEY` - For Dead & Co, Billy Strings, JRAD, Eggy, etc. +- `PHISHNET_API_KEY` - For Phish data +- `GRATEFULSTATS_API_KEY` - For Grateful Dead (may not be required) + +--- + +## Cache + +API responses are cached in `backend/importers/.cache/` (4,800+ files). + +- Cache TTL: 1 hour +- Cache persists across runs +- Re-import uses cache first → no API calls wasted + +--- + +## Imported Band Data (Dec 2025) + +| Band | Shows | Performances | +|------|-------|--------------| +| DSO | 4,414 | 65,172 | +| SCI | 1,916 | 27,225 | +| Disco Biscuits | 1,860 | 19,935 | +| Phish | 4,266 | (in progress) | +| MSI | 758 | 9,501 | +| Eggy | 666 | 4,705 | +| Dogs in a Pile | 601 | 7,558 | +| JRAD | 390 | 5,452 | + +### Still Need Import + +- Goose (El Goose API) +- Grateful Dead (Grateful Stats API) +- Dead & Company (Setlist.fm) +- Billy Strings (Setlist.fm) diff --git a/backend/migrations/99_fix_db_data.py.disabled b/backend/migrations/99_fix_db_data.py.disabled new file mode 100644 index 0000000..434daa7 --- /dev/null +++ b/backend/migrations/99_fix_db_data.py.disabled @@ -0,0 +1,288 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +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}...") + + seen_ids = set() + + while True: + params['page'] = page + url = f"{BASE_URL}/{endpoint}.json" + try: + resp = requests.get(url, params=params) + if resp.status_code != 200: + print(f" Failed with status {resp.status_code}") + break + + # API can return a dict with 'data' or just a list sometimes, handling both + json_resp = resp.json() + if isinstance(json_resp, dict): + items = json_resp.get('data', []) + elif isinstance(json_resp, list): + items = json_resp + else: + items = [] + + if not items: + print(" No more items found.") + break + + # Check for cycles / infinite loop by checking if we've seen these IDs before + # Assuming items have 'id' or 'show_id' etc. + # If not, we hash the string representation. + new_items_count = 0 + for item in items: + # Try to find a unique identifier + uid = item.get('id') or item.get('show_id') or str(item) + if uid not in seen_ids: + seen_ids.add(uid) + all_data.append(item) + new_items_count += 1 + + if new_items_count == 0: + print(f" Page {page} returned {len(items)} items but all were duplicates. Stopping.") + break + + print(f" Page {page} done ({new_items_count} new items)") + page += 1 + time.sleep(0.5) + + # Safety break + if page > 1000: + print(" Hit 1000 pages safety limit.") + break + if page > 200: # Safety break + print(" Safety limit reached.") + break + + 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() + existing_venue_slugs = {v.slug for v in venues if v.slug} + for v in venues: + if not v.slug: + new_slug = generate_slug(v.name) + # Ensure unique + original_slug = new_slug + counter = 1 + while new_slug in existing_venue_slugs: + counter += 1 + new_slug = f"{original_slug}-{counter}" + v.slug = new_slug + existing_venue_slugs.add(new_slug) + session.add(v) + session.commit() + + # 2. Fix Songs Slugs + print("Fixing Song Slugs...") + songs = session.exec(select(Song)).all() + existing_song_slugs = {s.slug for s in songs if s.slug} + for s in songs: + if not s.slug: + new_slug = generate_slug(s.title) + original_slug = new_slug + counter = 1 + while new_slug in existing_song_slugs: + counter += 1 + new_slug = f"{original_slug}-{counter}" + s.slug = new_slug + existing_song_slugs.add(new_slug) + session.add(s) + session.commit() + + # 3. Fix Tours Slugs + print("Fixing Tour Slugs...") + tours = session.exec(select(Tour)).all() + existing_tour_slugs = {t.slug for t in tours if t.slug} + for t in tours: + if not t.slug: + new_slug = generate_slug(t.name) + original_slug = new_slug + counter = 1 + while new_slug in existing_tour_slugs: + counter += 1 + new_slug = f"{original_slug}-{counter}" + t.slug = new_slug + existing_tour_slugs.add(new_slug) + session.add(t) + session.commit() + + # 4. Fix Shows Slugs + print("Fixing Show Slugs...") + shows = session.exec(select(Show)).all() + existing_show_slugs = {s.slug for s in shows if s.slug} + 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 + + new_slug = generate_show_slug(date_str, venue_name) + # Ensure unique + original_slug = new_slug + counter = 1 + while new_slug in existing_show_slugs: + counter += 1 + new_slug = f"{original_slug}-{counter}" + + show.slug = new_slug + existing_show_slugs.add(new_slug) + session.add(show) + session.commit() + + # 4b. Fix Performance Slugs + print("Fixing Performance Slugs...") + from slugify import generate_performance_slug + perfs = session.exec(select(Performance)).all() + existing_perf_slugs = {p.slug for p in perfs if p.slug} + + # We need song titles and show dates + # Efficient way: build maps + song_map = {s.id: s.title for s in songs} + show_map = {s.id: s.date.strftime("%Y-%m-%d") for s in shows} + + for p in perfs: + if not p.slug: + song_title = song_map.get(p.song_id, "unknown") + show_date = show_map.get(p.show_id, "unknown") + + new_slug = generate_performance_slug(song_title, show_date) + + # Ensure unique (for reprises etc) + original_slug = new_slug + counter = 1 + while new_slug in existing_perf_slugs: + counter += 1 + new_slug = f"{original_slug}-{counter}" + + p.slug = new_slug + existing_perf_slugs.add(new_slug) + session.add(p) + 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 + + # Refresh perfs from DB since we might have added slugs + # perfs = session.exec(select(Performance)).all() # Already have them, but maybe stale? + # Re-querying is safer but PERFS list object is updated by session.add? Yes. + + 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 + else: + # Debug only first few failures to avoid spam + if count < 5: + print(f"Match failed for el_show_id={item.get('show_id')} el_song_id={item.get('song_id')}") + if not our_show_id: print(f" -> Show ID not found in map (Map size: {len(el_show_map)})") + if not our_song_id: print(f" -> Song ID not found in map (Map size: {len(el_song_map)})") + + session.commit() + print(f"Fixed {count} performance set names.") + +if __name__ == "__main__": + fix_data() diff --git a/frontend/app/[vertical]/shows/page.tsx b/frontend/app/[vertical]/shows/page.tsx index 141ebee..d68c2d4 100644 --- a/frontend/app/[vertical]/shows/page.tsx +++ b/frontend/app/[vertical]/shows/page.tsx @@ -1,6 +1,9 @@ import { VERTICALS } from "@/config/verticals" import { notFound } from "next/navigation" import { getApiUrl } from "@/lib/api-config" +import { Show, PaginatedResponse } from "@/types/models" +import { Card } from "@/components/ui/card" +import { Calendar, MapPin } from "lucide-react" interface Props { params: Promise<{ vertical: string }> @@ -12,15 +15,15 @@ export function generateStaticParams() { })) } -async function getShows(verticalSlug: string) { +async function getShows(verticalSlug: string): Promise | null> { try { const res = await fetch(`${getApiUrl()}/shows/?vertical=${verticalSlug}`, { next: { revalidate: 60 } }) - if (!res.ok) return [] + if (!res.ok) return null return res.json() } catch { - return [] + return null } } @@ -32,7 +35,8 @@ export default async function ShowsPage({ params }: Props) { notFound() } - const shows = await getShows(vertical.slug) + const data = await getShows(vertical.slug) + const shows = data?.items || [] return (
@@ -46,29 +50,55 @@ export default async function ShowsPage({ params }: Props) {

Run the data importer to populate shows.

) : ( -
- {shows.map((show: any) => ( +
+ {shows.map((show) => ( -
-
-
{show.venue?.name || "Unknown Venue"}
-
- {show.venue?.city}, {show.venue?.state || show.venue?.country} + +
+
+
+ {show.venue?.name || "Unknown Venue"} +
+
+ + {show.venue?.city}, {show.venue?.state || show.venue?.country} +
+
+
+
+ + {new Date(show.date).toLocaleDateString()} +
+ {show.performances && show.performances.length > 0 && ( +
+ {show.performances.length} songs +
+ )}
-
-
{new Date(show.date).toLocaleDateString()}
-
{show.tour?.name}
-
-
+
))}
)}
) +} +
+
+
+
{new Date(show.date).toLocaleDateString()}
+
{show.tour?.name}
+
+ + + ))} + + )} + + ) } diff --git a/frontend/app/mod/page.tsx b/frontend/app/mod/page.tsx index 7f9707c..b3fab9b 100644 --- a/frontend/app/mod/page.tsx +++ b/frontend/app/mod/page.tsx @@ -339,7 +339,7 @@ export default function ModDashboardPage() { ) : (
{pendingReports.map(report => ( -
+
Dismiss
-
+ ))}
)} @@ -412,7 +412,7 @@ export default function ModDashboardPage() { ) : (
{pendingNicknames.map((item) => ( -
+
Reject
-
+ ))}
)} @@ -480,7 +480,7 @@ export default function ModDashboardPage() { {lookupUser && ( -
+

{lookupUser.username || "No username"}

@@ -538,7 +538,7 @@ export default function ModDashboardPage() {

Reports

-
+ )} diff --git a/frontend/components/groups/group-feed.tsx b/frontend/components/groups/group-feed.tsx index df46619..e3de5e4 100644 --- a/frontend/components/groups/group-feed.tsx +++ b/frontend/components/groups/group-feed.tsx @@ -1,13 +1,21 @@ "use client" import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" import { Textarea } from "@/components/ui/textarea" import { useState } from "react" import { getApiUrl } from "@/lib/api-config" interface GroupFeedProps { groupId: number - initialPosts?: any[] + initialPosts?: Post[] +} + +interface Post { + id: number + user_id: number + content: string + created_at: string } export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) { @@ -63,8 +71,8 @@ export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {
- {posts.map((post: any) => ( -
+ {posts.map((post: Post) => ( +
User #{post.user_id} @@ -72,7 +80,7 @@ export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {

{post.content}

-
+ ))}
diff --git a/frontend/components/playlists/add-to-playlist-dialog.tsx b/frontend/components/playlists/add-to-playlist-dialog.tsx index 911a8fc..ab5c595 100644 --- a/frontend/components/playlists/add-to-playlist-dialog.tsx +++ b/frontend/components/playlists/add-to-playlist-dialog.tsx @@ -18,12 +18,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { ListMusic, Plus, Loader2 } from "lucide-react" +import { ListMusic, Loader2 } from "lucide-react" import { useToast } from "@/components/ui/use-toast" import { getApiUrl } from "@/lib/api-config" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" +import { Playlist } from "@/types/models" + interface AddToPlaylistDialogProps { performanceId: number songTitle: string @@ -31,7 +33,7 @@ interface AddToPlaylistDialogProps { export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistDialogProps) { const [open, setOpen] = useState(false) - const [playlists, setPlaylists] = useState([]) + const [playlists, setPlaylists] = useState([]) const [loading, setLoading] = useState(false) const [submitting, setSubmitting] = useState(false) const [selectedPlaylistId, setSelectedPlaylistId] = useState("") @@ -48,7 +50,7 @@ export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistD headers: { Authorization: `Bearer ${token}` } }) .then(res => res.json()) - .then(data => setPlaylists(data)) + .then((data: Playlist[]) => setPlaylists(data)) .catch(err => console.error(err)) .finally(() => setLoading(false)) } @@ -79,6 +81,7 @@ export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistD throw new Error("Failed to add") } } catch (error) { + console.error(error) toast({ title: "Error", description: "Could not add to playlist", variant: "destructive" }) } finally { setSubmitting(false) diff --git a/frontend/components/profile/badge-list.tsx b/frontend/components/profile/badge-list.tsx index ed08587..a22c7b2 100644 --- a/frontend/components/profile/badge-list.tsx +++ b/frontend/components/profile/badge-list.tsx @@ -1,5 +1,5 @@ -import { Badge as BadgeIcon, Trophy } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge as BadgeIcon } from "lucide-react" +import { Card } from "@/components/ui/card" interface Badge { id: number @@ -25,7 +25,7 @@ export function BadgeList({ badges }: BadgeListProps) { return (
{badges.map((badge) => ( -
+
{/* We could dynamically map icons here based on badge.icon string */} @@ -36,7 +36,7 @@ export function BadgeList({ badges }: BadgeListProps) { {badge.description}

-
+ ))}
) diff --git a/frontend/components/recommendations/recommended-tracks.tsx b/frontend/components/recommendations/recommended-tracks.tsx index e9529ef..dc1ad48 100644 --- a/frontend/components/recommendations/recommended-tracks.tsx +++ b/frontend/components/recommendations/recommended-tracks.tsx @@ -8,8 +8,16 @@ import { getApiUrl } from "@/lib/api-config" import { Skeleton } from "@/components/ui/skeleton" import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog" +interface RecommendedTrack { + id: number + song_title: string + vertical_name: string + show_date: string + avg_rating: number +} + export function RecommendedTracks() { - const [tracks, setTracks] = useState([]) + const [tracks, setTracks] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { diff --git a/frontend/components/social/entity-rating.tsx b/frontend/components/social/entity-rating.tsx index 4baf6c6..2c0d168 100644 --- a/frontend/components/social/entity-rating.tsx +++ b/frontend/components/social/entity-rating.tsx @@ -2,8 +2,8 @@ import { useState, useEffect } from "react" import { RatingInput, RatingBadge } from "@/components/ui/rating-input" +import { Card } from "@/components/ui/card" import { getApiUrl } from "@/lib/api-config" -import { useAuth } from "@/contexts/auth-context" import { Sparkles, TrendingUp } from "lucide-react" interface EntityRatingProps { @@ -23,7 +23,7 @@ export function EntityRating({ rank, isHeady = false }: EntityRatingProps) { - const { user, token } = useAuth() + // const { user, token } = useAuth() // Unused, keeping hook for context if needed but removing vars to fix lint const [userRating, setUserRating] = useState(null) const [averageRating, setAverageRating] = useState(0) const [loading, setLoading] = useState(false) @@ -114,7 +114,7 @@ export function EntityRating({ } return ( -
+
Your Rating @@ -157,6 +157,6 @@ export function EntityRating({ Your rating: {userRating.toFixed(1)}/10

)} -
+ ) } diff --git a/frontend/components/songs/song-evolution-chart.tsx b/frontend/components/songs/song-evolution-chart.tsx index d55a958..8320adb 100644 --- a/frontend/components/songs/song-evolution-chart.tsx +++ b/frontend/components/songs/song-evolution-chart.tsx @@ -33,7 +33,7 @@ const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] if (active && payload && payload.length) { const data = payload[0].payload return ( -
+
{format(new Date(data.date), "MMM d, yyyy")}
@@ -46,7 +46,7 @@ const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] Rating: {data.rating.toFixed(1)}
-
+
) } return null