package main import ( "bytes" "context" "embed" "encoding/json" "flag" "fmt" "io/ioutil" "iter" "log" "net" "net/http" "os" "os/signal" "regexp" "strconv" "strings" "syscall" "text/template" "time" ) const COUNT = 20 var urlPattern = regexp.MustCompile(`^/([0-9]+|urn:ard:show:[a-z0-9]+)(\.rss|\.m3u|\/)$`) var origin = "" const QUERY = ` query ProgramSetEpisodesQuery($id:ID!, $offset:Int!, $count:Int!) { result:programSet(id:$id) { id title synopsis sharingUrl image{ url1X1 } publicationService{ title } editorialCategory{ title } items(offset:$offset first:$count orderBy:PUBLISH_DATE_DESC filter:{isPublished:{equalTo:true}}) { pageInfo{hasNextPage endCursor} nodes{ id title publishDate summary duration sharingUrl image{ url1X1 } audios{ url size mimeType } } } } }` const SEARCH_QUERY = ` query Search($q:String!) { programSets(orderBy:LAST_ITEM_MODIFIED_DESC, filter:{numberOfElements:{greaterThan: 0}, or:[{title:{likeInsensitive:$q}}, {synopsis:{likeInsensitive:$q}}]}, first: 50) { nodes{ id coreId title synopsis sharingUrl numberOfElements lastItemModified image{ url1X1 } publicationService{ id title } editorialCategory{ id title } } } }` var CATEGORIES = map[string]string{ "Doku & Reportage": "Society & Culture", "True Crime": "True Crime", "Hörbuch": "Fiction", "Hörspiel": "Fiction", "Politik & Hintergrund": "News", "Krieg in der Ukraine": "News", "Geschichte": "History", "Wissen": "Education", "Musik entdecken": "Music", "Leben": "Society & Culture", "Liebe, Sex & Psychologie": "Society & Culture", "Comedy & Satire": "Comedy", "Sport": "Sports", "Für Kinder": "Kids & Family", "Retro": "Society & Culture", "Gesellschaft": "Society & Culture", "Wirtschaft": "Business", "Religion & Philosophie": "Society & Culture", "Kultur": "Society & Culture", } //go:embed templates var tplData embed.FS var tplFuncs = template.FuncMap{ "reverse": reverse, "trim": strings.TrimSpace, "date": formatDate, "rssDate": rssDate, "imgWidth": imgWidth, "itunesCategory": itunesCategory, } var tpl = template.Must(template.New("root").Funcs(tplFuncs).ParseFS(tplData, "templates/*")) type Item struct { Title string `json:"title"` SharingUrl string `json:"sharingUrl"` PublishDate string `json:"publishDate"` Summary string `json:"summary"` Duration int `json:"duration"` Image struct { Url1X1 string `json:"url1X1"` } `json:"image"` Audios []struct { Url string `json:"url"` Size int `json:"size"` MimeType string `json:"mimeType"` } `json:"audios"` } type Show struct { CoreId string `json:"coreId"` Title string `json:"title"` Synopsis string `json:"synopsis"` SharingUrl string `json:"sharingUrl"` Image struct { Url1X1 string `json:"url1X1"` } `json:"image"` PublicationService struct { Title string `json:"title"` } `json:"publicationService"` EditorialCategory struct { Title string `json:"title"` } `json:"editorialCategory"` Items struct { PageInfo struct { HasNextPage bool `json:"hasNextPage"` } `json:"pageInfo"` Nodes []Item `json:"nodes"` } `json:"items"` } type ShowResponse struct { Data struct { Result *Show `json:"result"` } `json:"data"` } type SearchResponse struct { Data struct { ProgramSets struct { Nodes []Show `json:"nodes"` } `json:"programSets"` } `json:"data"` } type ShowContext struct { Request *http.Request Show Show Id string Origin string NextOffset int } type SearchContext struct { Request *http.Request Origin string Q string Shows []Show } func reverse(items []Item) iter.Seq[Item] { return func(yield func(Item) bool) { for i := len(items) - 1; i >= 0; i-- { if !yield(items[i]) { return } } } } func formatDate(s string) (string, error) { t, err := time.Parse(time.RFC3339, s) if err != nil { return "", err } return t.Format("02.01.2006"), nil } func rssDate(s string) (string, error) { t, err := time.Parse(time.RFC3339, s) if err != nil { return "", err } return t.Format(time.RFC822), nil } func imgWidth(width int, url string) string { return strings.Replace(url, "{width}", strconv.Itoa(width), 1) } func itunesCategory(category string) string { itunesCategory, exists := CATEGORIES[category] if exists { return itunesCategory } else { return category } } func fetchShow(id string, offset int) (Show, error) { url := "https://api.ardaudiothek.de/graphql" jsonValue, _ := json.Marshal(map[string]interface{}{ "query": QUERY, "variables": map[string]interface{}{ "id": id, "offset": offset, "count": COUNT, }, }) resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonValue)) if err != nil { return Show{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Show{}, fmt.Errorf("error fetching data: %s", resp.Status) } body, _ := ioutil.ReadAll(resp.Body) var response ShowResponse if err = json.Unmarshal(body, &response); err != nil { return Show{}, err } if response.Data.Result == nil { return Show{}, fmt.Errorf("not found") } return *response.Data.Result, nil } func handle(w http.ResponseWriter, r *http.Request) { matches := urlPattern.FindStringSubmatch(r.URL.Path) if matches == nil { http.Error(w, "Not Found", http.StatusNotFound) return } offsetStr := r.URL.Query().Get("offset") offset, err := strconv.Atoi(offsetStr) if err != nil { offset = 0 } show, err := fetchShow(matches[1], offset) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } ctx := ShowContext{ Request: r, Show: show, Id: matches[1], Origin: origin, NextOffset: offset + COUNT, } if matches[2] == ".rss" { w.Header().Set("Content-Type", "text/xml") err = tpl.ExecuteTemplate(w, "show.rss", ctx) } else if matches[2] == ".m3u" { w.Header().Set("Content-Type", "text/plain") err = tpl.ExecuteTemplate(w, "show.m3u", ctx) } else if matches[2] == "/" { w.Header().Set("Content-Type", "text/html") err = tpl.ExecuteTemplate(w, "show.html", ctx) } if err != nil { log.Printf("ERROR: %s", err) } } func handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") url := "https://api.ardaudiothek.de/graphql" jsonValue, _ := json.Marshal(map[string]interface{}{ "query": SEARCH_QUERY, "variables": map[string]interface{}{ "q": fmt.Sprintf("%%%s%%", strings.ReplaceAll(q, " ", "%")), }, }) resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonValue)) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { http.Error(w, "Internal Server Error", http.StatusNotFound) return } body, _ := ioutil.ReadAll(resp.Body) var response SearchResponse if err = json.Unmarshal(body, &response); err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } ctx := SearchContext{ Request: r, Origin: origin, Q: q, Shows: response.Data.ProgramSets.Nodes, } w.Header().Set("Content-Type", "text/html") err = tpl.ExecuteTemplate(w, "search.html", ctx) if err != nil { log.Printf("ERROR: %s", err) } } func getListener(port int) (net.Listener, error) { if os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()) { f := os.NewFile(uintptr(3), "systemd-socket") return net.FileListener(f) } else { addr := fmt.Sprintf("localhost:%d", port) return net.Listen("tcp", addr) } } func main() { port := 8000 flag.Usage = func() { fmt.Fprintf(os.Stderr, "audiothek [-p PORT] [-o ORIGIN]\n") flag.PrintDefaults() } flag.IntVar(&port, "p", 8000, "port") flag.StringVar(&origin, "o", "", "origin for links") flag.Parse() http.HandleFunc("/", handle) http.HandleFunc("/search/", handleSearch) ctx, unregisterSignals := signal.NotifyContext( context.Background(), os.Interrupt, syscall.SIGTERM, ) ctxFactory := func(l net.Listener) context.Context { return ctx } server := &http.Server{BaseContext: ctxFactory} go func() { listener, err := getListener(port) if err == nil { log.Printf("Serving on %s", listener.Addr()) err = server.Serve(listener) } if err != nil && err != http.ErrServerClosed { log.Fatal(err) } }() <-ctx.Done() unregisterSignals() log.Println("Shutting down server…") server.Shutdown(context.Background()) }