audiothek

alternative frontend for ardaudiothek.de  https://audiothek.ce9e.org/
git clone https://git.ce9e.org/audiothek.git

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 argparse
    2    -1 import json
    3    -1 from xml.sax.saxutils import escape
    4    -1 from xml.sax.saxutils import quoteattr
    5    -1 
    6    -1 import aiohttp
    7    -1 from aiohttp import web
    8    -1 
    9    -1 COUNT = 20
   10    -1 
   11    -1 QUERY = """
   12    -1 query ProgramSetEpisodesQuery($id:ID!, $offset:Int!, $count:Int!) {
   13    -1     result:programSet(id:$id) {
   14    -1         id
   15    -1         title
   16    -1         synopsis
   17    -1         sharingUrl
   18    -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                 id
   25    -1                 title
   26    -1                 publishDate
   27    -1                 summary
   28    -1                 duration
   29    -1                 sharingUrl
   30    -1                 image{ url1X1 }
   31    -1                 audios{ url size mimeType }
   32    -1             }
   33    -1         }
   34    -1     }
   35    -1 }
   36    -1 """.replace('    ', '').replace('\n', ' ').strip()
   37    -1 
   38    -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    -1 
   60    -1 
   61    -1 def indent(lines):
   62    -1     return [f'\t{line}' for line in lines]
   63    -1 
   64    -1 
   65    -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    -1 
   78    -1 
   79    -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 lines
  101    -1 
  102    -1 
  103    -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    -1 
  116    -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    -1 
  123    -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    -1 
  131    -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    -1 
  140    -1     return ''.join(f'{line}\n' for line in feed)
  141    -1 
  142    -1 
  143    -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    -1 
  154    -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.HTTPNotFound
  158    -1             response.raise_for_status()
  159    -1             data = await response.json()
  160    -1 
  161    -1     show = data['data']['result']
  162    -1 
  163    -1     if show is None:
  164    -1         raise web.HTTPNotFound
  165    -1 
  166    -1     return show
  167    -1 
  168    -1 
  169    -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    -1 
  174    -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    -1 
  180    -1     try:
  181    -1         body = render_feed(show, links)
  182    -1     except Exception:
  183    -1         print(f'While serving {request.url}')
  184    -1         raise
  185    -1 
  186    -1     return web.Response(
  187    -1         body=body.encode('utf8'),
  188    -1         content_type='text/xml',
  189    -1     )
  190    -1 
  191    -1 
  192    -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    -1 
  197    -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    -1 
  203    -1     return web.Response(
  204    -1         body=body.encode('utf8'),
  205    -1         content_type='text/plain',
  206    -1     )
  207    -1 
  208    -1 
  209    -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    -1 
  215    -1     global ORIGIN
  216    -1     ORIGIN = args.origin
  217    -1 
  218    -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)