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
This commit is contained in:
424
backend/cmd/podcast-svc/main.go
Normal file
424
backend/cmd/podcast-svc/main.go
Normal file
@@ -0,0 +1,424 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user