Files
gooseek/backend/cmd/podcast-svc/main.go
home 06fe57c765 feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
2026-02-27 04:15:32 +03:00

425 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/podcast"
"github.com/gooseek/backend/pkg/config"
)
type PodcastStore struct {
podcasts map[string]*podcast.Podcast
}
func NewPodcastStore() *PodcastStore {
return &PodcastStore{
podcasts: make(map[string]*podcast.Podcast),
}
}
func (s *PodcastStore) Save(p *podcast.Podcast) {
s.podcasts[p.ID] = p
}
func (s *PodcastStore) Get(id string) *podcast.Podcast {
return s.podcasts[id]
}
func (s *PodcastStore) List(limit, offset int) []*podcast.Podcast {
result := make([]*podcast.Podcast, 0)
i := 0
for _, p := range s.podcasts {
if i >= offset && len(result) < limit {
result = append(result, p)
}
i++
}
return result
}
func (s *PodcastStore) GetLatest() *podcast.Podcast {
var latest *podcast.Podcast
for _, p := range s.podcasts {
if latest == nil || p.GeneratedAt.After(latest.GeneratedAt) {
latest = p
}
}
return latest
}
func (s *PodcastStore) GetByDate(date time.Time) *podcast.Podcast {
dateStr := date.Format("2006-01-02")
for _, p := range s.podcasts {
if p.Date.Format("2006-01-02") == dateStr {
return p
}
}
return nil
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var llmClient llm.Client
if cfg.OpenAIAPIKey != "" {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err != nil {
log.Fatal("Failed to create OpenAI client:", err)
}
llmClient = client
} else if cfg.AnthropicAPIKey != "" {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err != nil {
log.Fatal("Failed to create Anthropic client:", err)
}
llmClient = client
}
var ttsClient podcast.TTSClient
elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY")
if elevenLabsKey != "" {
ttsClient = podcast.NewElevenLabsTTS(elevenLabsKey)
} else {
ttsClient = &podcast.DummyTTS{}
}
generator := podcast.NewPodcastGenerator(llmClient, ttsClient, podcast.GeneratorConfig{
DefaultDuration: 300,
MaxDuration: 1800,
OutputDir: "/data/podcasts",
})
store := NewPodcastStore()
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 120 * time.Second,
WriteTimeout: 120 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Post("/api/v1/podcast/generate", func(c *fiber.Ctx) error {
var req struct {
Type string `json:"type"`
Topics []string `json:"topics"`
NewsItems []podcast.NewsItem `json:"newsItems"`
Duration int `json:"duration"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
podcastType := podcast.PodcastDaily
switch req.Type {
case "weekly":
podcastType = podcast.PodcastWeekly
case "topic_deep":
podcastType = podcast.PodcastTopicDeep
case "breaking":
podcastType = podcast.PodcastBreaking
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcastType,
Topics: req.Topics,
NewsItems: req.NewsItems,
Duration: req.Duration,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-daily", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.Locale == "" {
req.Locale = "ru"
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcast.PodcastDaily,
NewsItems: req.NewsItems,
Date: time.Now(),
Duration: 300,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-weekly", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
p, err := generator.GenerateWeeklySummary(ctx, req.NewsItems, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-topic", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Articles []podcast.NewsItem `json:"articles"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
p, err := generator.GenerateTopicDeepDive(ctx, req.Topic, req.Articles, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/:id/audio", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
audioData, err := generator.GenerateAudio(ctx, p)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "audio/mpeg")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.mp3\"", p.ID))
return c.Send(audioData)
})
app.Get("/api/v1/podcasts", func(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
podcasts := store.List(limit, offset)
summaries := make([]map[string]interface{}, 0)
for _, p := range podcasts {
summaries = append(summaries, map[string]interface{}{
"id": p.ID,
"title": p.Title,
"description": p.Description,
"type": p.Type,
"date": p.Date,
"duration": p.Duration,
"status": p.Status,
"topics": p.Topics,
"audioUrl": p.AudioURL,
"generatedAt": p.GeneratedAt,
})
}
return c.JSON(fiber.Map{"podcasts": summaries, "count": len(summaries)})
})
app.Get("/api/v1/podcasts/latest", func(c *fiber.Ctx) error {
p := store.GetLatest()
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcasts found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/today", func(c *fiber.Ctx) error {
p := store.GetByDate(time.Now())
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcast for today"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id/transcript", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"id": p.ID,
"title": p.Title,
"transcript": p.Transcript,
"segments": p.Segments,
})
})
app.Get("/api/v1/podcasts/:id/segments", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"segments": p.Segments,
})
})
app.Post("/api/v1/podcast/:id/publish", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
if p.Status != podcast.StatusReady {
return c.Status(400).JSON(fiber.Map{"error": "Podcast audio not ready"})
}
now := time.Now()
p.Status = podcast.StatusPublished
p.PublishedAt = &now
return c.JSON(fiber.Map{"success": true, "publishedAt": now})
})
app.Get("/api/v1/podcast/rss", func(c *fiber.Ctx) error {
podcasts := store.List(50, 0)
rss := generateRSSFeed(podcasts, c.BaseURL())
c.Set("Content-Type", "application/rss+xml")
return c.SendString(rss)
})
port := getEnvInt("PODCAST_SVC_PORT", 3032)
log.Printf("podcast-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func generateRSSFeed(podcasts []*podcast.Podcast, baseURL string) string {
items := ""
for _, p := range podcasts {
if p.Status == podcast.StatusPublished && p.AudioURL != "" {
pubDate := ""
if p.PublishedAt != nil {
pubDate = p.PublishedAt.Format(time.RFC1123Z)
}
items += fmt.Sprintf(`
<item>
<title>%s</title>
<description><![CDATA[%s]]></description>
<pubDate>%s</pubDate>
<enclosure url="%s" type="audio/mpeg" length="0"/>
<guid>%s</guid>
<itunes:duration>%d</itunes:duration>
</item>`, p.Title, p.Description, pubDate, p.AudioURL, p.ID, p.Duration)
}
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>GooSeek Daily</title>
<link>%s</link>
<description>Ежедневный подкаст с главными новостями от GooSeek</description>
<language>ru</language>
<itunes:author>GooSeek</itunes:author>
<itunes:category text="News"/>
<atom:link href="%s/api/v1/podcast/rss" rel="self" type="application/rss+xml"/>
%s
</channel>
</rss>`, baseURL, baseURL, items)
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}