- commit
- f0a2e663e09caf99fcfaa6f795690dd3fd850a49
- parent
- be0cd1a2cbe6f909ed7293adc123ae48238432b4
- Author
- Tobias Bengfort <tobias.bengfort@posteo.de>
- Date
- 2025-06-03 05:17
port to go
Diffstat
| A | .gitignore | 2 | ++ |
| A | PKGBUILD | 13 | +++++++++++++ |
| A | audiothek.go | 296 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | audiothek.py | 221 | ------------------------------------------------------------ |
4 files changed, 311 insertions, 221 deletions
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@ -1 1 audiothek -1 2 *.deb
diff --git a/PKGBUILD b/PKGBUILD
@@ -0,0 +1,13 @@
-1 1 pkgname='audiothek'
-1 2 pkgdesc='RSS Feeds for ARD Audiothek'
-1 3 arch=('amd64')
-1 4 license='MIT'
-1 5
-1 6 package() {
-1 7 go build -buildmode=pie -ldflags="-s -w" audiothek.go
-1 8
-1 9 install -D -m 755 audiothek "$pkgdir/usr/bin/audiothek"
-1 10 install -D -m 644 README.md "$pkgdir/usr/share/doc/audiothek/README.md"
-1 11 install -D -m 644 index.html "$pkgdir/var/www/audiothek/index.html"
-1 12 install -D -m 644 style.css "$pkgdir/var/www/audiothek/style.css"
-1 13 }
diff --git a/audiothek.go b/audiothek.go
@@ -0,0 +1,296 @@
-1 1 package main
-1 2
-1 3 import (
-1 4 "bytes"
-1 5 "context"
-1 6 "encoding/json"
-1 7 "flag"
-1 8 "fmt"
-1 9 "io/ioutil"
-1 10 "log"
-1 11 "net"
-1 12 "net/http"
-1 13 "os"
-1 14 "os/signal"
-1 15 "regexp"
-1 16 "strconv"
-1 17 "strings"
-1 18 "syscall"
-1 19 "text/template"
-1 20 )
-1 21
-1 22 const COUNT = 20
-1 23
-1 24 var urlPattern = regexp.MustCompile(`^/([0-9]+)\.(rss|m3u)$`)
-1 25 var origin = ""
-1 26
-1 27 const QUERY = `
-1 28 query ProgramSetEpisodesQuery($id:ID!, $offset:Int!, $count:Int!) {
-1 29 result:programSet(id:$id) {
-1 30 id
-1 31 title
-1 32 synopsis
-1 33 sharingUrl
-1 34 image{ url1X1 }
-1 35 publicationService{ title }
-1 36 editorialCategory{ title }
-1 37 items(offset:$offset first:$count orderBy:PUBLISH_DATE_DESC filter:{isPublished:{equalTo:true}}) {
-1 38 pageInfo{hasNextPage endCursor}
-1 39 nodes{
-1 40 id
-1 41 title
-1 42 publishDate
-1 43 summary
-1 44 duration
-1 45 sharingUrl
-1 46 image{ url1X1 }
-1 47 audios{ url size mimeType }
-1 48 }
-1 49 }
-1 50 }
-1 51 }`
-1 52
-1 53 var CATEGORIES = map[string]string{
-1 54 "Doku & Reportage": "Society & Culture",
-1 55 "True Crime": "True Crime",
-1 56 "Hörbuch": "Fiction",
-1 57 "Hörspiel": "Fiction",
-1 58 "Politik & Hintergrund": "News",
-1 59 "Krieg in der Ukraine": "News",
-1 60 "Geschichte": "History",
-1 61 "Wissen": "Education",
-1 62 "Musik entdecken": "Music",
-1 63 "Leben": "Society & Culture",
-1 64 "Liebe, Sex & Psychologie": "Society & Culture",
-1 65 "Comedy & Satire": "Comedy",
-1 66 "Sport": "Sports",
-1 67 "Für Kinder": "Kids & Family",
-1 68 "Retro": "Society & Culture",
-1 69 "Gesellschaft": "Society & Culture",
-1 70 "Wirtschaft": "Business",
-1 71 "Religion & Philosophie": "Society & Culture",
-1 72 "Kultur": "Society & Culture",
-1 73 }
-1 74
-1 75 type Item struct {
-1 76 Title string `json:"title"`
-1 77 SharingUrl string `json:"sharingUrl"`
-1 78 PublishDate string `json:"publishDate"`
-1 79 Summary string `json:"summary"`
-1 80 Duration int `json:"duration"`
-1 81 Image struct {
-1 82 Url1X1 string `json:"url1X1"`
-1 83 } `json:"image"`
-1 84 Audios []struct {
-1 85 Url string `json:"url"`
-1 86 Size int `json:"size"`
-1 87 MimeType string `json:"mimeType"`
-1 88 } `json:"audios"`
-1 89 }
-1 90
-1 91 type Show struct {
-1 92 Title string `json:"title"`
-1 93 Synopsis string `json:"synopsis"`
-1 94 SharingUrl string `json:"sharingUrl"`
-1 95 Image struct {
-1 96 Url1X1 string `json:"url1X1"`
-1 97 } `json:"image"`
-1 98 PublicationService struct {
-1 99 Title string `json:"title"`
-1 100 } `json:"publicationService"`
-1 101 EditorialCategory struct {
-1 102 Title string `json:"title"`
-1 103 } `json:"editorialCategory"`
-1 104 Items struct {
-1 105 PageInfo struct {
-1 106 HasNextPage bool `json:"hasNextPage"`
-1 107 } `json:"pageInfo"`
-1 108 Nodes []Item `json:"nodes"`
-1 109 } `json:"items"`
-1 110 }
-1 111
-1 112 type Response struct {
-1 113 Data struct {
-1 114 Result *Show `json:"result"`
-1 115 } `json:"data"`
-1 116 }
-1 117
-1 118 func escape(s string) string {
-1 119 return template.HTMLEscapeString(s)
-1 120 }
-1 121
-1 122 func fetchShow(id int, offset int) (Show, error) {
-1 123 url := "https://api.ardaudiothek.de/graphql"
-1 124 jsonValue, _ := json.Marshal(map[string]interface{}{
-1 125 "query": QUERY,
-1 126 "variables": map[string]interface{}{
-1 127 "id": id,
-1 128 "offset": offset,
-1 129 "count": COUNT,
-1 130 },
-1 131 })
-1 132
-1 133 resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonValue))
-1 134 if err != nil {
-1 135 return Show{}, err
-1 136 }
-1 137 defer resp.Body.Close()
-1 138
-1 139 if resp.StatusCode != http.StatusOK {
-1 140 return Show{}, fmt.Errorf("error fetching data: %s", resp.Status)
-1 141 }
-1 142
-1 143 body, _ := ioutil.ReadAll(resp.Body)
-1 144 var response Response
-1 145 if err = json.Unmarshal(body, &response); err != nil {
-1 146 return Show{}, err
-1 147 }
-1 148
-1 149 if response.Data.Result == nil {
-1 150 return Show{}, fmt.Errorf("not found")
-1 151 }
-1 152
-1 153 return *response.Data.Result, nil
-1 154 }
-1 155
-1 156 func renderItem(w http.ResponseWriter, item Item) {
-1 157 fmt.Fprintf(w, "\t\t<item>\n")
-1 158 defer fmt.Fprintf(w, "\t\t</item>\n")
-1 159 fmt.Fprintf(w, "\t\t\t<title>%s</title>\n", escape(item.Title))
-1 160 fmt.Fprintf(w, "\t\t\t<link>%s</link>\n", escape(item.SharingUrl))
-1 161 fmt.Fprintf(w, "\t\t\t<guid>%s</guid>\n", escape(item.SharingUrl))
-1 162 fmt.Fprintf(w, "\t\t\t<pubDate>%s</pubDate>\n", escape(item.PublishDate))
-1 163 fmt.Fprintf(w, "\t\t\t<description>%s</description>\n", escape(item.Summary))
-1 164 fmt.Fprintf(w, "\t\t\t<itunes:duration>%d</itunes:duration>\n", item.Duration)
-1 165 fmt.Fprintf(w, "\t\t\t<itunes:image href=\"%s\" />\n", escape(
-1 166 strings.Replace(item.Image.Url1X1, "{width}", "1400", 1),
-1 167 ))
-1 168 if len(item.Audios) > 0 {
-1 169 audio := item.Audios[0]
-1 170 fmt.Fprintf(w, "\t\t\t<enclosure url=\"%s\" length=\"%d\" type=\"%s\" />\n", escape(audio.Url), audio.Size, escape(audio.MimeType))
-1 171 }
-1 172 }
-1 173
-1 174 func renderFeed(w http.ResponseWriter, show Show, links map[string]string) {
-1 175 w.Header().Set("Content-Type", "text/xml")
-1 176 fmt.Fprintf(w, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
-1 177 fmt.Fprintf(w, "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n")
-1 178 defer fmt.Fprintf(w, "</rss>\n")
-1 179 fmt.Fprintf(w, "\t<channel>\n")
-1 180 defer fmt.Fprintf(w, "\t</channel>\n")
-1 181 fmt.Fprintf(w, "\t\t<title>%s</title>\n", escape(show.Title))
-1 182 fmt.Fprintf(w, "\t\t<description>%s</description>\n", escape(show.Synopsis))
-1 183 fmt.Fprintf(w, "\t\t<link>%s</link>\n", escape(show.SharingUrl))
-1 184 fmt.Fprintf(w, "\t\t<language>de-DE</language>\n")
-1 185 fmt.Fprintf(w, "\t\t<itunes:author>%s</itunes:author>\n", escape(show.PublicationService.Title))
-1 186 fmt.Fprintf(w, "\t\t<itunes:image href=\"%s\" />\n", escape(
-1 187 strings.Replace(show.Image.Url1X1, "{width}", "1400", 1),
-1 188 ))
-1 189 fmt.Fprintf(w, "\t\t<generator>audiothek.ce9e.org</generator>\n")
-1 190
-1 191 if show.EditorialCategory.Title != "" {
-1 192 category := strings.TrimSpace(show.EditorialCategory.Title)
-1 193 fmt.Fprintf(w, "\t\t<category>%s</category>\n", escape(category))
-1 194
-1 195 itunesCategory, exists := CATEGORIES[category]
-1 196 if !exists {
-1 197 itunesCategory = category
-1 198 }
-1 199 fmt.Fprintf(w, "\t\t<itunes:category text=\"%s\" />\n", escape(itunesCategory))
-1 200 }
-1 201
-1 202 n := len(show.Items.Nodes)
-1 203 for i := range n {
-1 204 renderItem(w, show.Items.Nodes[n-1-i])
-1 205 }
-1 206
-1 207 for rel, href := range links {
-1 208 fmt.Fprintf(w, "\t\t<atom:link href=\"%s\" rel=\"%s\" type=\"application/rss+xml\" />\n", escape(href), escape(rel))
-1 209 }
-1 210 }
-1 211
-1 212 func renderPlaylist(w http.ResponseWriter, show Show) {
-1 213 w.Header().Set("Content-Type", "text/plain")
-1 214 fmt.Fprintf(w, "#EXTM3U\n")
-1 215 n := len(show.Items.Nodes)
-1 216 for i := range n {
-1 217 item := show.Items.Nodes[n-1-i]
-1 218 if len(item.Audios) > 0 {
-1 219 fmt.Fprintf(w, "#EXTINF:%d,%s\n", item.Duration, item.Title)
-1 220 fmt.Fprintf(w, "%s\n", item.Audios[0].Url)
-1 221 }
-1 222 }
-1 223 }
-1 224
-1 225 func handle(w http.ResponseWriter, r *http.Request) {
-1 226 matches := urlPattern.FindStringSubmatch(r.URL.Path)
-1 227
-1 228 if matches == nil {
-1 229 http.Error(w, "Not Found", http.StatusNotFound)
-1 230 return
-1 231 }
-1 232
-1 233 id, _ := strconv.Atoi(matches[1])
-1 234 offsetStr := r.URL.Query().Get("offset")
-1 235 offset, err := strconv.Atoi(offsetStr)
-1 236 if err != nil {
-1 237 offset = 0
-1 238 }
-1 239
-1 240 show, err := fetchShow(id, offset)
-1 241 if err != nil {
-1 242 http.Error(w, err.Error(), http.StatusNotFound)
-1 243 return
-1 244 }
-1 245
-1 246 if matches[2] == "rss" {
-1 247 links := make(map[string]string)
-1 248
-1 249 if origin != "" {
-1 250 links["self"] = fmt.Sprintf("%s%s", origin, r.URL.RequestURI())
-1 251 if show.Items.PageInfo.HasNextPage {
-1 252 links["next"] = fmt.Sprintf("%s%s?offset=%d", origin, r.URL.Path, offset+COUNT)
-1 253 }
-1 254 }
-1 255
-1 256 renderFeed(w, show, links)
-1 257 } else if matches[2] == "m3u" {
-1 258 renderPlaylist(w, show)
-1 259 }
-1 260 }
-1 261
-1 262 func main() {
-1 263 port := "8000"
-1 264
-1 265 flag.Usage = func() {
-1 266 fmt.Fprintf(os.Stderr, "audiothek [-p PORT] [-o ORIGIN]")
-1 267 flag.PrintDefaults()
-1 268 }
-1 269
-1 270 flag.StringVar(&port, "p", "8000", "port")
-1 271 flag.StringVar(&origin, "o", "", "origin for links")
-1 272 flag.Parse()
-1 273
-1 274 addr := fmt.Sprintf("localhost:%s", port)
-1 275
-1 276 http.HandleFunc("/", handle)
-1 277
-1 278 ctx, unregisterSignals := signal.NotifyContext(
-1 279 context.Background(), os.Interrupt, syscall.SIGTERM,
-1 280 )
-1 281 ctxFactory := func(l net.Listener) context.Context { return ctx }
-1 282 server := &http.Server{Addr: addr, BaseContext: ctxFactory}
-1 283
-1 284 go func() {
-1 285 log.Printf("Serving on http://%s", addr)
-1 286 err := server.ListenAndServe()
-1 287 if err != http.ErrServerClosed {
-1 288 log.Fatal(err)
-1 289 }
-1 290 }()
-1 291
-1 292 <-ctx.Done()
-1 293 unregisterSignals()
-1 294 log.Println("Shutting down server…")
-1 295 server.Shutdown(context.Background())
-1 296 }
diff --git a/audiothek.py b/audiothek.py
@@ -1,221 +0,0 @@1 -1 import argparse2 -1 import json3 -1 from xml.sax.saxutils import escape4 -1 from xml.sax.saxutils import quoteattr5 -16 -1 import aiohttp7 -1 from aiohttp import web8 -19 -1 COUNT = 2010 -111 -1 QUERY = """12 -1 query ProgramSetEpisodesQuery($id:ID!, $offset:Int!, $count:Int!) {13 -1 result:programSet(id:$id) {14 -1 id15 -1 title16 -1 synopsis17 -1 sharingUrl18 -1 image{ url1X1 }19 -1 publicationService{ title }20 -1 editorialCategory{ title }21 -1 items(offset:$offset first:$count orderBy:PUBLISH_DATE_DESC filter:{isPublished:{equalTo:true}}) {22 -1 pageInfo{hasNextPage endCursor}23 -1 nodes{24 -1 id25 -1 title26 -1 publishDate27 -1 summary28 -1 duration29 -1 sharingUrl30 -1 image{ url1X1 }31 -1 audios{ url size mimeType }32 -1 }33 -1 }34 -1 }35 -1 }36 -1 """.replace(' ', '').replace('\n', ' ').strip()37 -138 -1 CATEGORIES = {39 -1 'Doku & Reportage': 'Society & Culture',40 -1 'True Crime': 'True Crime',41 -1 'Hörbuch': 'Fiction',42 -1 'Hörspiel': 'Fiction',43 -1 'Politik & Hintergrund': 'News',44 -1 'Krieg in der Ukraine': 'News',45 -1 'Geschichte': 'History',46 -1 'Wissen': 'Education',47 -1 'Musik entdecken': 'Music',48 -1 'Leben': 'Society & Culture',49 -1 'Liebe, Sex & Psychologie': 'Society & Culture',50 -1 'Comedy & Satire': 'Comedy',51 -1 'Sport': 'Sports',52 -1 'Für Kinder': 'Kids & Family',53 -1 'Retro': 'Society & Culture',54 -1 'Gesellschaft': 'Society & Culture',55 -1 'Wirtschaft': 'Business',56 -1 'Religion & Philosophie': 'Society & Culture',57 -1 'Kultur': 'Society & Culture',58 -1 }59 -160 -161 -1 def indent(lines):62 -1 return [f'\t{line}' for line in lines]63 -164 -165 -1 def el(tag, content=None, attrs={}):66 -1 a = ''.join(f' {k}={quoteattr(v)}' for k, v in attrs.items())67 -1 if content is None:68 -1 return f'<{tag}{a} />'69 -1 elif isinstance(content, list):70 -1 return [71 -1 f'<{tag}{a}>',72 -1 *indent(content),73 -1 f'</{tag}>',74 -1 ]75 -1 else:76 -1 return f'<{tag}{a}>{escape(content)}</{tag}>'77 -178 -179 -1 def render_items(items):80 -1 lines = []81 -1 for item in reversed(items):82 -1 fields = [83 -1 el('title', item['title']),84 -1 el('link', item['sharingUrl']),85 -1 el('guid', item['sharingUrl']),86 -1 el('pubDate', item['publishDate']),87 -1 el('description', item['summary']),88 -1 el('itunes:duration', str(item['duration'])),89 -1 el('itunes:image', attrs={90 -1 'href': item['image']['url1X1'].replace('{width}', '1400'),91 -1 }),92 -1 ]93 -1 if item['audios']:94 -1 fields.append(el('enclosure', attrs={95 -1 'url': item['audios'][0]['url'],96 -1 'length': str(item['audios'][0]['size'] or 0),97 -1 'type': item['audios'][0]['mimeType'],98 -1 }))99 -1 lines += el('item', fields)100 -1 return lines101 -1102 -1103 -1 def render_feed(show, links):104 -1 fields = [105 -1 el('title', show['title']),106 -1 el('description', show['synopsis']),107 -1 el('link', show['sharingUrl']),108 -1 el('language', 'de-DE'),109 -1 el('itunes:author', show['publicationService']['title']),110 -1 el('itunes:image', attrs={111 -1 'href': show['image']['url1X1'].replace('{width}', '1400'),112 -1 }),113 -1 el('generator', 'audiothek.ce9e.org'),114 -1 ]115 -1116 -1 if show['editorialCategory']:117 -1 title = show['editorialCategory']['title'].strip()118 -1 fields.append(el('category', title))119 -1 fields.append(el('itunes:category', attrs={120 -1 'text': CATEGORIES.get(title, title)121 -1 }))122 -1123 -1 fields += render_items(show['items']['nodes'])124 -1 for rel, href in links.items():125 -1 fields.append(el('atom:link', attrs={126 -1 'href': href,127 -1 'rel': rel,128 -1 'type': 'application/rss+xml',129 -1 }))130 -1131 -1 feed = [132 -1 '<?xml version="1.0" encoding="UTF-8"?>',133 -1 *el('rss', content=el('channel', fields), attrs={134 -1 'version': '2.0',135 -1 'xmlns:atom': 'http://www.w3.org/2005/Atom',136 -1 'xmlns:itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',137 -1 }),138 -1 ]139 -1140 -1 return ''.join(f'{line}\n' for line in feed)141 -1142 -1143 -1 async def fetch_show(id, offset):144 -1 url = 'https://api.ardaudiothek.de/graphql'145 -1 params = {146 -1 'query': QUERY,147 -1 'variables': json.dumps({148 -1 'id': id,149 -1 'offset': offset,150 -1 'count': COUNT,151 -1 }),152 -1 }153 -1154 -1 async with aiohttp.ClientSession() as session:155 -1 async with session.get(url, params=params) as response:156 -1 if response.status == 404:157 -1 raise web.HTTPNotFound158 -1 response.raise_for_status()159 -1 data = await response.json()160 -1161 -1 show = data['data']['result']162 -1163 -1 if show is None:164 -1 raise web.HTTPNotFound165 -1166 -1 return show167 -1168 -1169 -1 async def feed(request):170 -1 id = int(request.match_info['id'], 10)171 -1 offset = int(request.query.get('offset', '0'), 10)172 -1 show = await fetch_show(id, offset)173 -1174 -1 links = {}175 -1 if ORIGIN:176 -1 links['self'] = f'{ORIGIN}{request.url.path_qs}'177 -1 if show['items']['pageInfo']['hasNextPage']:178 -1 links['next'] = f'{ORIGIN}{request.url.path}?offset={offset + COUNT}'179 -1180 -1 try:181 -1 body = render_feed(show, links)182 -1 except Exception:183 -1 print(f'While serving {request.url}')184 -1 raise185 -1186 -1 return web.Response(187 -1 body=body.encode('utf8'),188 -1 content_type='text/xml',189 -1 )190 -1191 -1192 -1 async def playlist(request):193 -1 id = int(request.match_info['id'], 10)194 -1 offset = int(request.query.get('offset', '0'), 10)195 -1 show = await fetch_show(id, offset)196 -1197 -1 body = '#EXTM3U\n'198 -1 for item in reversed(show['items']['nodes']):199 -1 if item['audios']:200 -1 body += f'\n#EXTINF:{item["duration"]},{item["title"]}\n'201 -1 body += f'{item["audios"][0]["url"]}\n'202 -1203 -1 return web.Response(204 -1 body=body.encode('utf8'),205 -1 content_type='text/plain',206 -1 )207 -1208 -1209 -1 if __name__ == '__main__':210 -1 parser = argparse.ArgumentParser()211 -1 parser.add_argument('--port', type=int)212 -1 parser.add_argument('--origin')213 -1 args = parser.parse_args()214 -1215 -1 global ORIGIN216 -1 ORIGIN = args.origin217 -1218 -1 app = web.Application()219 -1 app.router.add_get('/{id:[0-9]+}.rss', feed)220 -1 app.router.add_get('/{id:[0-9]+}.m3u', playlist)221 -1 web.run_app(app, host='localhost', port=args.port)