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
425 lines
11 KiB
Go
425 lines
11 KiB
Go
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
|
||
}
|