package main import ( "context" "encoding/json" "io" "log" "net/http" "os" "google.golang.org/genai" ) var geminiClient *genai.Client func main() { loadDotEnv(".env") ctx := context.Background() client, err := genai.NewClient(ctx, &genai.ClientConfig{ APIKey: os.Getenv("GEMINI_API_KEY"), Backend: genai.BackendGeminiAPI, }) if err != nil { log.Fatalf("failed to create genai client: %v", err) } geminiClient = client port := os.Getenv("PORT") if port == "" { port = "8080" } http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) http.HandleFunc("/api/video-meta", handleVideoMeta) http.HandleFunc("/api/search", handleSearch) http.HandleFunc("/api/chat", handleChat) http.HandleFunc("/api/unlock", handleUnlock) http.HandleFunc("/api/watch", handleWatch) http.HandleFunc("/api/voice-chat", handleVoiceChat) http.Handle("/", http.FileServer(http.Dir("web"))) log.Printf("listening on :%s", port) if err := http.ListenAndServe(":"+port, nil); err != nil { log.Fatal(err) } } func handleVideoMeta(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { http.Error(w, "missing id", http.StatusBadRequest) return } meta, err := fetchVideoMeta(id, os.Getenv("YOUTUBE_API_KEY")) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(meta) } func handleSearch(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { http.Error(w, "missing q", http.StatusBadRequest) return } results, err := searchVideos(query, os.Getenv("YOUTUBE_API_KEY")) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(results) } type chatRequest struct { VideoTitle string `json:"videoTitle"` VideoDescription string `json:"videoDescription"` History []chatTurn `json:"history"` Message string `json:"message"` UnlockCode string `json:"unlockCode"` Persona string `json:"persona"` } type chatStreamEvent struct { Type string `json:"type"` // "status" | "final" | "error" Text string `json:"text,omitempty"` Reply string `json:"reply,omitempty"` Model string `json:"model,omitempty"` Videos []searchResult `json:"videos,omitempty"` AutoPlayVideoID string `json:"autoPlayVideoId,omitempty"` } func handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req chatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/x-ndjson") flusher, _ := w.(http.Flusher) encoder := json.NewEncoder(w) writeEvent := func(ev chatStreamEvent) { encoder.Encode(ev) if flusher != nil { flusher.Flush() } } model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE")) outcome, err := chatReply(r.Context(), geminiClient, model, os.Getenv("YOUTUBE_API_KEY"), req.VideoTitle, req.VideoDescription, req.Persona, req.History, req.Message, func(status string) { writeEvent(chatStreamEvent{Type: "status", Text: status}) }) if err != nil { writeEvent(chatStreamEvent{Type: "error", Text: err.Error()}) return } writeEvent(chatStreamEvent{ Type: "final", Reply: outcome.Reply, Model: outcome.Model, Videos: outcome.Videos, AutoPlayVideoID: outcome.AutoPlayVideoID, }) } type watchResponse struct { Reaction string `json:"reaction"` } func handleWatch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, "bad multipart form", http.StatusBadRequest) return } file, header, err := r.FormFile("clip") if err != nil { http.Error(w, "missing clip", http.StatusBadRequest) return } defer file.Close() clipBytes, err := io.ReadAll(file) if err != nil { http.Error(w, "failed to read clip", http.StatusBadRequest) return } mimeType := header.Header.Get("Content-Type") if mimeType == "" { mimeType = "video/webm" } var recentReactions []chatTurn if raw := r.FormValue("history"); raw != "" { json.Unmarshal([]byte(raw), &recentReactions) } model := selectModel(r.FormValue("unlockCode"), os.Getenv("UNLOCK_CODE")) reaction, err := watchReaction(r.Context(), geminiClient, model, clipBytes, mimeType, r.FormValue("videoTitle"), r.FormValue("videoDescription"), r.FormValue("persona"), recentReactions) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(watchResponse{Reaction: reaction}) } type voiceChatResponse struct { Transcript string `json:"transcript"` Reply string `json:"reply"` Model string `json:"model"` } func handleVoiceChat(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(16 << 20); err != nil { http.Error(w, "bad multipart form", http.StatusBadRequest) return } file, header, err := r.FormFile("audio") if err != nil { http.Error(w, "missing audio", http.StatusBadRequest) return } defer file.Close() audioBytes, err := io.ReadAll(file) if err != nil { http.Error(w, "failed to read audio", http.StatusBadRequest) return } mimeType := header.Header.Get("Content-Type") if mimeType == "" { mimeType = "audio/webm" } var history []chatTurn if raw := r.FormValue("history"); raw != "" { json.Unmarshal([]byte(raw), &history) } model := selectModel(r.FormValue("unlockCode"), os.Getenv("UNLOCK_CODE")) outcome, err := voiceChat(r.Context(), geminiClient, model, audioBytes, mimeType, r.FormValue("videoTitle"), r.FormValue("videoDescription"), r.FormValue("persona"), history) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(voiceChatResponse{Transcript: outcome.Transcript, Reply: outcome.Reply, Model: model}) } type unlockRequest struct { Code string `json:"code"` } type unlockResponse struct { Ok bool `json:"ok"` Model string `json:"model"` } func handleUnlock(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req unlockRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } model := selectModel(req.Code, os.Getenv("UNLOCK_CODE")) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(unlockResponse{Ok: model == modelGemini, Model: model}) }