Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,30 @@ FEED_LINK=http://localhost:8080
FEED_AUTHOR=Your Name

# TTS (Text-to-Speech) Configuration
# Provider: "openai" or "elevenlabs" (default: openai)
TTS_PROVIDER=openai
# Directory for storing generated audio files
AUDIO_DIR=/data/audio

# OpenAI TTS (when TTS_PROVIDER=openai)
# Set your OpenAI API key to enable TTS. Without it, TTS features are disabled.
OPENAI_API_KEY=your_openai_api_key
# Available models: tts-1 (faster), tts-1-hd (higher quality)
TTS_MODEL=tts-1
# Available voices: alloy, echo, fable, onyx, nova, shimmer
TTS_VOICE=alloy
# Directory for storing generated audio files
AUDIO_DIR=/data/audio

# ElevenLabs TTS (when TTS_PROVIDER=elevenlabs)
# For custom voice cloning - clone your voice at https://elevenlabs.io
# ELEVENLABS_API_KEY=your_elevenlabs_api_key
# ELEVENLABS_VOICE_ID=your_cloned_voice_id
# Model: eleven_multilingual_v2 recommended for Swedish
# ELEVENLABS_MODEL=eleven_multilingual_v2
# Display name for your custom voice
# ELEVENLABS_VOICE_NAME=My Voice

# Podcast Configuration
# Language code for podcast feed (default: sv for Swedish)
PODCAST_LANGUAGE=sv
# Cover image URL for podcast apps (optional)
# PODCAST_IMAGE_URL=https://example.com/podcast-cover.jpg
34 changes: 28 additions & 6 deletions cmd/kiln/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,38 @@ func run() error {
defer scraper.Close()
log.Println("Initialized scraper")

// Initialize TTS service (optional - only if API key is configured)
// Initialize TTS service (optional - requires provider API key)
var ttsSvc *tts.Service
if cfg.OpenAIAPIKey != "" {
ttsSvc, err = tts.New(cfg.OpenAIAPIKey, cfg.TTSModel, cfg.TTSVoice, cfg.AudioDir)
var ttsProvider tts.Provider

switch cfg.TTSProvider {
case "elevenlabs":
if cfg.ElevenLabsAPIKey != "" && cfg.ElevenLabsVoiceID != "" {
ttsProvider = tts.NewElevenLabsProvider(
cfg.ElevenLabsAPIKey,
cfg.ElevenLabsModel,
cfg.ElevenLabsVoiceID,
cfg.ElevenLabsVoiceName,
)
log.Printf("Using ElevenLabs TTS provider (model=%s, voice=%s)", cfg.ElevenLabsModel, cfg.ElevenLabsVoiceName)
} else {
log.Println("TTS disabled (ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID required for elevenlabs provider)")
}
default: // "openai"
if cfg.OpenAIAPIKey != "" {
ttsProvider = tts.NewOpenAIProvider(cfg.OpenAIAPIKey, cfg.TTSModel, cfg.TTSVoice)
log.Printf("Using OpenAI TTS provider (model=%s, voice=%s)", cfg.TTSModel, cfg.TTSVoice)
} else {
log.Println("TTS disabled (OPENAI_API_KEY not set)")
}
}

if ttsProvider != nil {
ttsSvc, err = tts.New(ttsProvider, cfg.AudioDir)
if err != nil {
return fmt.Errorf("failed to initialize TTS: %w", err)
}
log.Printf("Initialized TTS service (model=%s, voice=%s, dir=%s)", cfg.TTSModel, cfg.TTSVoice, cfg.AudioDir)
} else {
log.Println("TTS disabled (OPENAI_API_KEY not set)")
log.Printf("Initialized TTS service (provider=%s, dir=%s)", ttsProvider.Name(), cfg.AudioDir)
}

// Create server
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ services:
- FEED_DESCRIPTION=${FEED_DESCRIPTION:-Articles from Gasetten}
- FEED_LINK=${FEED_LINK:-http://localhost:8080}
- FEED_AUTHOR=${FEED_AUTHOR:-Kiln User}
- TTS_PROVIDER=${TTS_PROVIDER:-openai}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- TTS_MODEL=${TTS_MODEL:-tts-1}
- TTS_VOICE=${TTS_VOICE:-alloy}
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
- ELEVENLABS_VOICE_ID=${ELEVENLABS_VOICE_ID}
- ELEVENLABS_MODEL=${ELEVENLABS_MODEL:-eleven_multilingual_v2}
- ELEVENLABS_VOICE_NAME=${ELEVENLABS_VOICE_NAME:-Custom Voice}
- AUDIO_DIR=/data/audio
- PODCAST_LANGUAGE=${PODCAST_LANGUAGE:-sv}
- PODCAST_IMAGE_URL=${PODCAST_IMAGE_URL}
depends_on:
db:
condition: service_healthy
Expand Down
30 changes: 25 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,23 @@ type Config struct {
ScraperHeadless bool

// TTS (Text-to-Speech)
TTSProvider string // "openai" or "elevenlabs"
AudioDir string

// OpenAI TTS
OpenAIAPIKey string
TTSModel string
TTSVoice string
AudioDir string

// ElevenLabs TTS (for custom voice cloning)
ElevenLabsAPIKey string
ElevenLabsVoiceID string
ElevenLabsModel string
ElevenLabsVoiceName string

// Podcast
PodcastLanguage string
PodcastImageURL string
}

// Load reads configuration from environment variables
Expand All @@ -46,10 +59,17 @@ func Load() (*Config, error) {
FeedLink: getEnv("FEED_LINK", "http://localhost:8080"),
FeedAuthor: getEnv("FEED_AUTHOR", "Kiln User"),
ScraperHeadless: getEnvAsBool("SCRAPER_HEADLESS", true),
OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""),
TTSModel: getEnv("TTS_MODEL", "tts-1"),
TTSVoice: getEnv("TTS_VOICE", "alloy"),
AudioDir: getEnv("AUDIO_DIR", "/data/audio"),
TTSProvider: getEnv("TTS_PROVIDER", "openai"),
AudioDir: getEnv("AUDIO_DIR", "/data/audio"),
OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""),
TTSModel: getEnv("TTS_MODEL", "tts-1"),
TTSVoice: getEnv("TTS_VOICE", "alloy"),
ElevenLabsAPIKey: getEnv("ELEVENLABS_API_KEY", ""),
ElevenLabsVoiceID: getEnv("ELEVENLABS_VOICE_ID", ""),
ElevenLabsModel: getEnv("ELEVENLABS_MODEL", "eleven_multilingual_v2"),
ElevenLabsVoiceName: getEnv("ELEVENLABS_VOICE_NAME", "Custom Voice"),
PodcastLanguage: getEnv("PODCAST_LANGUAGE", "sv"),
PodcastImageURL: getEnv("PODCAST_IMAGE_URL", ""),
}

// Validate required fields
Expand Down
134 changes: 106 additions & 28 deletions internal/server/rss.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"encoding/xml"
"fmt"
"time"

Expand Down Expand Up @@ -66,64 +67,141 @@ func GenerateRSSFeed(articles []*database.Article, cfg *config.Config) (string,
return rss, nil
}

// GeneratePodcastFeed creates a podcast-compatible RSS feed with audio enclosures
// Podcast RSS XML structures with iTunes namespace for Pocket Casts compatibility

type podcastRSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
ITunes string `xml:"xmlns:itunes,attr"`
Channel podcastChannel `xml:"channel"`
}

type podcastChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
LastBuildDate string `xml:"lastBuildDate"`
ITunesAuthor string `xml:"itunes:author"`
ITunesSummary string `xml:"itunes:summary"`
ITunesExplicit string `xml:"itunes:explicit"`
ITunesType string `xml:"itunes:type"`
ITunesImage *podcastITunesImage `xml:"itunes:image,omitempty"`
ITunesCategory podcastCategory `xml:"itunes:category"`
Items []podcastItem `xml:"item"`
}

type podcastITunesImage struct {
Href string `xml:"href,attr"`
}

type podcastCategory struct {
Text string `xml:"text,attr"`
}

type podcastItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID podcastGUID `xml:"guid"`
Description string `xml:"description"`
Author string `xml:"itunes:author,omitempty"`
PubDate string `xml:"pubDate"`
Enclosure podcastEnclosure `xml:"enclosure"`
ITunesDuration string `xml:"itunes:duration,omitempty"`
ITunesExplicit string `xml:"itunes:explicit"`
}

type podcastGUID struct {
IsPermaLink string `xml:"isPermaLink,attr"`
Value string `xml:",chardata"`
}

type podcastEnclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
Type string `xml:"type,attr"`
}

// GeneratePodcastFeed creates a podcast-compatible RSS feed with iTunes namespace
// tags for Pocket Casts and other podcast apps.
func GeneratePodcastFeed(articles []*database.Article, audioMap map[int]*database.AudioFile, cfg *config.Config) (string, error) {
now := time.Now()

feed := &feeds.Feed{
Title: cfg.FeedTitle + " (Podcast)",
Link: &feeds.Link{Href: cfg.FeedLink + "/podcast.xml"},
Description: cfg.FeedDescription + " - Audio versions of articles",
Author: &feeds.Author{Name: cfg.FeedAuthor},
Created: now,
channel := podcastChannel{
Title: cfg.FeedTitle + " (Podcast)",
Link: cfg.FeedLink + "/podcast.xml",
Description: cfg.FeedDescription + " - Audio versions of articles",
Language: cfg.PodcastLanguage,
LastBuildDate: now.Format(time.RFC1123Z),
ITunesAuthor: cfg.FeedAuthor,
ITunesSummary: cfg.FeedDescription + " - Audio versions of articles",
ITunesExplicit: "false",
ITunesType: "episodic",
ITunesCategory: podcastCategory{Text: "News"},
}

if cfg.PodcastImageURL != "" {
channel.ITunesImage = &podcastITunesImage{Href: cfg.PodcastImageURL}
}

// Only include articles that have completed audio
feed.Items = make([]*feeds.Item, 0)
for _, article := range articles {
audio, hasAudio := audioMap[article.ID]
if !hasAudio || audio.Status != "completed" {
continue
}

item := &feeds.Item{
Title: getArticleTitle(article),
Link: &feeds.Link{Href: fmt.Sprintf("%s/articles/%d", cfg.FeedLink, article.ID)},
Id: fmt.Sprintf("%s/articles/%d/audio", cfg.FeedLink, article.ID),
Enclosure: &feeds.Enclosure{
Url: fmt.Sprintf("%s/articles/%d/audio?voice=%s", cfg.FeedLink, article.ID, audio.Voice),
Length: fmt.Sprintf("%d", audio.FileSize),
Type: "audio/mpeg",
},
}

description := ""
if article.ContentText != nil {
description := *article.ContentText
description = *article.ContentText
if len(description) > 500 {
description = description[:500] + "..."
}
item.Description = description
}

author := cfg.FeedAuthor
if article.Author != nil {
item.Author = &feeds.Author{Name: *article.Author}
author = *article.Author
}

pubDate := article.CreatedAt
if article.PublishedAt != nil {
item.Created = *article.PublishedAt
} else {
item.Created = article.CreatedAt
pubDate = *article.PublishedAt
}

feed.Items = append(feed.Items, item)
item := podcastItem{
Title: getArticleTitle(article),
Link: fmt.Sprintf("%s/articles/%d", cfg.FeedLink, article.ID),
Description: description,
Author: author,
PubDate: pubDate.Format(time.RFC1123Z),
GUID: podcastGUID{
IsPermaLink: "false",
Value: fmt.Sprintf("%s/articles/%d/audio", cfg.FeedLink, article.ID),
},
Enclosure: podcastEnclosure{
URL: fmt.Sprintf("%s/articles/%d/audio?voice=%s", cfg.FeedLink, article.ID, audio.Voice),
Length: fmt.Sprintf("%d", audio.FileSize),
Type: "audio/mpeg",
},
ITunesExplicit: "false",
}

channel.Items = append(channel.Items, item)
}

rss, err := feed.ToRss()
rss := podcastRSS{
Version: "2.0",
ITunes: "http://www.itunes.com/dtds/podcast-1.0.dtd",
Channel: channel,
}

output, err := xml.MarshalIndent(rss, "", " ")
if err != nil {
return "", fmt.Errorf("failed to generate podcast RSS: %w", err)
}

return rss, nil
return xml.Header + string(output), nil
}

func getArticleTitle(article *database.Article) string {
Expand Down
33 changes: 26 additions & 7 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (s *Server) handleArticleList(w http.ResponseWriter, r *http.Request) {
audioMap, _ := s.db.GetCompletedAudioForArticles(ctx, articleIDs)

// Render template
ArticleListPage(articles, audioMap, s.ttsEnabled()).Render(ctx, w)
ArticleListPage(articles, audioMap, s.ttsInfo()).Render(ctx, w)
}

// handleArticleDetail renders a single article
Expand All @@ -134,7 +134,7 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
audioFiles, _ := s.db.GetAudioFilesByArticle(ctx, id)

// Render template
ArticleDetailPage(article, audioFiles, s.ttsEnabled()).Render(ctx, w)
ArticleDetailPage(article, audioFiles, s.ttsInfo()).Render(ctx, w)
}

// handleScrape triggers a manual scrape operation
Expand Down Expand Up @@ -380,6 +380,17 @@ func (s *Server) ttsEnabled() bool {
return s.tts != nil
}

// ttsInfo returns TTS information for templates
func (s *Server) ttsInfo() TTSInfo {
if s.tts == nil {
return TTSInfo{Enabled: false}
}
return TTSInfo{
Enabled: true,
Voices: s.tts.AvailableVoices(),
}
}

// handleGenerateTTS triggers TTS generation for an article
func (s *Server) handleGenerateTTS(w http.ResponseWriter, r *http.Request) {
if !s.ttsEnabled() {
Expand All @@ -399,7 +410,7 @@ func (s *Server) handleGenerateTTS(w http.ResponseWriter, r *http.Request) {
// Get the voice from form data or query param
voice := r.FormValue("voice")
if voice == "" {
voice = s.config.TTSVoice
voice = s.tts.DefaultVoice()
}

// Check if article exists
Expand Down Expand Up @@ -508,8 +519,11 @@ func (s *Server) handleServeAudio(w http.ResponseWriter, r *http.Request) {
}

voice := r.URL.Query().Get("voice")
if voice == "" && s.tts != nil {
voice = s.tts.DefaultVoice()
}
if voice == "" {
voice = s.config.TTSVoice
voice = "alloy" // fallback
}

audio, err := s.db.GetAudioFileByArticle(ctx, id, voice)
Expand Down Expand Up @@ -555,12 +569,17 @@ func (s *Server) handleServeAudio(w http.ResponseWriter, r *http.Request) {
// handleTTSVoices returns the list of available TTS voices
func (s *Server) handleTTSVoices(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
voices := tts.AvailableVoices()
if s.tts == nil {
fmt.Fprint(w, `{"voices":[],"default":""}`)
return
}
voices := s.tts.AvailableVoices()
var items []string
for _, v := range voices {
items = append(items, fmt.Sprintf(`"%s"`, v))
items = append(items, fmt.Sprintf(`{"id":"%s","name":"%s"}`, v.ID, v.Name))
}
fmt.Fprintf(w, `{"voices":[%s],"default":"%s"}`, strings.Join(items, ","), s.config.TTSVoice)
fmt.Fprintf(w, `{"voices":[%s],"default":"%s","provider":"%s"}`,
strings.Join(items, ","), s.tts.DefaultVoice(), s.tts.ProviderName())
}

// handleRSS generates and serves the RSS feed
Expand Down
Loading