Internet radio browser GUI for music/video streams from various directory services.

⌈⌋ branch:  streamtuner2


Diff

Differences From Artifact [39c956323b]:

To Artifact [030a641d51]:


1

2
3
4
5
6
7
8

9
10
11


12

13
14
15
16
17
18
19
20
21
22

23
24
25
26







27
28
29
30
31
32
33
1
2
3
4
5
6
7
8

9
10
11

12
13
14
15
16
17
18
19
20
21
22
23
24

25
26
27
28

29
30
31
32
33
34
35
36
37
38
39
40
41
42

+






-
+


-
+
+

+









-
+



-
+
+
+
+
+
+
+






# api: streamtuner2
# encoding: utf-8
# title: Shoutcast.com
# description: Primary list of shoutcast servers (now managed by radionomy).
# type: channel
# category: radio
# author: Mario
# original: Jean-Yves Lefort
# version: 1.7
# version: 1.8
# url: http://directory.shoutcast.com/
# config:
#    { name: shoutcast_format, type: select, select: pls|m3u|xspf|json, value: pls, description: "Shoutcast playlist format to retrieve" }
#    { name: shoutcast_format, type: select, select: pls|m3u|xspf|raw, value: pls, description: "Shoutcast playlist format to retrieve" }
#    { name: shoutcast_api, type: bool, value: 0, description: "Use Shoutcast developer API (key from .netrc)" }
# priority: default
# suggests: python:shoutcast-api
# png:
#   iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAelJREFUOI2NU0toE2EYnM12t2wLkhSXSIgEJMHFQ2naQ+kpoPYQoaXH3gRFsegloUhRQTyU2oOgggQUzzlEQomIBzU+EHooBIol0cOGLqFFFiJ5SB5skvFU6ebRduA7/DAz
#   /PM9BJLoh3Q6zVQqBZfLhXq9jlAohHA4LHTzhvqJ2+02c7kcgsEgfD4fRFFEPp+HZVmUJEk41kAURcHv99Pj8cAwDGiaBkVR0C0GAJDsW7VajYVCgYlEguVymZZlsVKpcG1tlYd5fX8AAIqiCF6vF6VSibIsI5lMYvvDE1xymwDu/ec5BhkcIJPJIHJzFqf372P1cgMf
#   f46cLIKu61yJXufr5VO0voyzEZ/k8sI4s9ns0RFarRZjL56inIshekWGenYS6IzhR9PCntRBIBCw8XsiFItFNLMxPJgfwVjDi4Y8g2b9DILaMKZGd2Ca5tEGiqJg2xjF200H6J+AvKtjeG8T3998xW5nAk6n08bviSBJEqhewLlpN4bMHfwxfuH5J8J98SGerS/B4XDY
#   d+FwQ6rVKm8vXeP++6vku2lu3FEZubFIXdc5qNm2x93ILZobszRfaYwuaIzH4wOFfafwt7CFb59/Y0uYx8rLR1BVtXd1u2AzCMwsQg6cx+O5uWOFBxAGnfNJ8Q/z/DNTtgbnsgAAAABJRU5ErkJggg==
# depends: re, ahttp
# extraction-method: json, regex
#
# Shoutcast is a server software for audio streaming. It automatically spools
# station information on shoutcast.com, which today lists over 60000 radios.
# station information on shoutcast.com, which today lists over 85000 radios.
#
# It has been aquired by Radionomy in 2014. Since then significant changes
# took place. The former yellow pages API got deprecated. Streamtuner2 now
# utilizes the AJAX interface for speedy playlist discovery
# utilizes the AJAX interface for speedy playlist discovery.
#
# Optionally can use the https://pypi.org/project/shoutcast-api/ for
# scanning stations. But that requires a developer API key in .netrc
# via `machine shoutcast.com\n password XYZ`. (UNTESTED, and still using
# standard url lookup handler. Their API is actually meant for setting up
# a shoutcast-like website mirror, unsuitable for desktop apps.)
#


import ahttp
from json import loads as json_decode
import re
from config import *
43
44
45
46
47
48
49
50

51
52
53
54
55
56
57
58

59
60
61
62


63
64
65
66





67
68
69

70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
91

92
93
94
95
96
97
98
99
100
101
102
103
104






105
106
107
108










109
110
111
112
113
114
115
52
53
54
55
56
57
58

59

60
61
62
63
64
65

66
67
68


69
70
71
72
73

74
75
76
77
78
79


80
81
82
83

84
85
86

87
88
89
90
91
92
93
94
95
96
97
98
99
100

101
102
103
104
105
106
107

108

109
110
111

112
113
114
115
116
117
118



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135







-
+
-






-
+


-
-
+
+



-
+
+
+
+
+

-
-
+



-



-
+













-
+






-

-



-
+
+
+
+
+
+

-
-
-
+
+
+
+
+
+
+
+
+
+






# the eligibility of open source desktop apps for an authhash.
#
# Therefore we'll be retrieving stuff from the homepage still. The new
# interface conveniently uses JSON already, so let's use that:
#
#   POST http://www.shoutcast.com/Home/BrowseByGenre {genrename: Pop}
#
# We do need a catmap now too, but that's easy to aquire and will be kept
# Catmap actually has become redundant.
# within the cache dirs.
#
class shoutcast(channels.ChannelPlugin):

    # attrs
    base_url = "http://directory.shoutcast.com/"
    listformat = "pls"
    has_search = False
    has_search = False   # may be True now
    
    # categories
    categories = []
    catmap = {"Choral": 35, "Winter": 275, "JROCK": 306, "Motown": 237, "Political": 290, "Tango": 192, "Ska": 22, "Comedy": 283, "Decades": 212, "European": 143, "Reggaeton": 189, "Islamic": 307, "Freestyle": 114, "French": 145, "Western": 53, "Dancepunk": 6, "News": 287, "Xtreme": 23, "Bollywood": 138, "Celtic": 141, "Kids": 278, "Filipino": 144, "Hanukkah": 270, "Greek": 146, "Punk": 21, "Spiritual": 211, "Industrial": 14, "Baroque": 33, "Talk": 282, "JPOP": 227, "Scanner": 291, "Mediterranean": 154, "Swing": 174, "Themes": 89, "IDM": 75, "40s": 214, "Funk": 236, "Rap": 110, "House": 74, "Educational": 285, "Caribbean": 140, "Misc": 295, "30s": 213, "Anniversary": 266, "Sports": 293, "International": 134, "Tribute": 107, "Piano": 41, "Romantic": 42, "90s": 219, "Latin": 177, "Grunge": 10, "Dubstep": 312, "Government": 286, "Country": 44, "Salsa": 191, "Hardcore": 11, "Afrikaans": 309, "Downtempo": 69, "Merengue": 187, "Psychedelic": 260, "Female": 95, "Bop": 167, "Tribal": 80, "Metal": 195, "70s": 217, "Tejano": 193, "Exotica": 55, "Anime": 277, "BlogTalk": 296, "African": 135, "Patriotic": 101, "Blues": 24, "Turntablism": 119, "Chinese": 142, "Garage": 72, "Dance": 66, "Valentine": 273, "Barbershop": 222, "Alternative": 1, "Technology": 294, "Folk": 82, "Klezmer": 152, "Samba": 315, "Turkish": 305, "Trance": 79, "Dub": 245, "Rock": 250, "Polka": 59, "Modern": 39, "Lounge": 57, "Indian": 149, "Hindi": 148, "Brazilian": 139, "Eclectic": 93, "Korean": 153, "Creole": 316, "Dancehall": 244, "Surf": 264, "Reggae": 242, "Goth": 9, "Oldies": 226, "Zouk": 162, "Environmental": 207, "Techno": 78, "Adult": 90, "Rockabilly": 262, "Wedding": 274, "Russian": 157, "Sexy": 104, "Chill": 92, "Opera": 40, "Emo": 8, "Experimental": 94, "Showtunes": 280, "Breakbeat": 65, "Jungle": 76, "Soundtracks": 276, "LoFi": 15, "Metalcore": 202, "Bachata": 178, "Kwanzaa": 272, "Banda": 179, "Americana": 46, "Classical": 32, "German": 302, "Tamil": 160, "Bluegrass": 47, "Halloween": 269, "College": 300, "Ambient": 63, "Birthday": 267, "Meditation": 210, "Electronic": 61, "50s": 215, "Chamber": 34, "Heartache": 96, "Britpop": 3, "Soca": 158, "Grindcore": 199, "Reality": 103, "00s": 303, "Symphony": 43, "Pop": 220, "Ranchera": 188, "Electro": 71, "Christmas": 268, "Christian": 123, "Progressive": 77, "Jazz": 163, "Trippy": 108, "Instrumental": 97, "Tropicalia": 194, "Fusion": 170, "Healing": 209, "Glam": 255, "80s": 218, "KPOP": 308, "Worldbeat": 161, "Mixtapes": 117, "60s": 216, "Mariachi": 186, "Soul": 240, "Cumbia": 181, "Inspirational": 122, "Impressionist": 38, "Gospel": 129, "Disco": 68, "Arabic": 136, "Idols": 225, "Ragga": 247, "Demo": 67, "LGBT": 98, "Honeymoon": 271, "Japanese": 150, "Community": 284, "Weather": 317, "Asian": 137, "Hebrew": 151, "Flamenco": 314, "Shuffle": 105}
    categories = ["Top 500", 'Alternative', ['Adult Alternative', 'Britpop', 'Classic Alternative', 'College', 'Dancepunk', 'Dream Pop', 'Emo', 'Goth', 'Grunge', 'Hardcore', 'Indie Pop', 'Indie Rock', 'Industrial', 'LoFi', 'Modern Rock', 'New Wave', 'Noise Pop', 'Post Punk', 'Power Pop', 'Punk', 'Ska', 'Xtreme'], 'Blues', ['Acoustic Blues','Chicago Blues','Contemporary Blues','Country Blues','Delta Blues','Electric Blues','Cajun and Zydeco'],'Classical', ['Baroque','Chamber','Choral','Classical Period','Early Classical','Impressionist','Modern','Opera','Piano','Romantic','Symphony'],'Country', ['Alt Country','Americana','Bluegrass','Classic Country','Contemporary Bluegrass','Contemporary Country','Honky Tonk','Hot Country Hits','Western'],'Easy Listening', ['Exotica','Light Rock','Lounge','Orchestral Pop','Polka','Space Age Pop'],'Electronic', ['Acid House','Ambient','Big Beat','Breakbeat','Dance','Demo','Disco','Downtempo','Drum and Bass','Electro','Garage','Hard House','House','IDM','Jungle','Progressive','Techno','Trance','Tribal','Trip Hop','Dubstep'],'Folk', ['Alternative Folk','Contemporary Folk','Folk Rock','New Acoustic','Traditional Folk','World Folk','Old Time'],'Themes', ['Adult','Best Of','Chill','Eclectic','Experimental','Female','Heartache','Instrumental','LGBT','Love and Romance','Party Mix','Patriotic','Rainy Day Mix','Reality','Sexy','Shuffle','Travel Mix','Tribute','Trippy','Work Mix'],'Rap', ['Alternative Rap','Dirty South','East Coast Rap','Freestyle','Hip Hop','Gangsta Rap','Mixtapes','Old School','Turntablism','Underground Hip Hop','West Coast Rap'],'Inspirational', ['Christian','Christian Metal','Christian Rap','Christian Rock','Classic Christian','Contemporary Gospel','Gospel','Praise and Worship','Sermons and Services','Southern Gospel','Traditional Gospel'],'International', ['African','Arabic','Asian','Bollywood','Brazilian','Caribbean','Celtic','Chinese','European','Filipino','French','Greek','Hawaiian and Pacific','Hindi','Indian','Japanese','Hebrew','Klezmer','Korean','Mediterranean','Middle Eastern','North American','Russian','Soca','South American','Tamil','Worldbeat','Zouk','German','Turkish','Islamic','Afrikaans','Creole'],'Jazz', ['Acid Jazz','Avant Garde','Big Band','Bop','Classic Jazz','Cool Jazz','Fusion','Hard Bop','Latin Jazz','Smooth Jazz','Swing','Vocal Jazz','World Fusion'],'Latin', ['Bachata','Banda','Bossa Nova','Cumbia','Latin Dance','Latin Pop','Latin Rap and Hip Hop','Latin Rock','Mariachi','Merengue','Ranchera','Reggaeton','Regional Mexican','Salsa','Tango','Tejano','Tropicalia','Flamenco','Samba'],'Metal', ['Black Metal','Classic Metal','Extreme Metal','Grindcore','Hair Metal','Heavy Metal','Metalcore','Power Metal','Progressive Metal','Rap Metal','Death Metal','Thrash Metal'],'New Age', ['Environmental','Ethnic Fusion','Healing','Meditation','Spiritual'],'Decades', ['30s','40s','50s','60s','70s','80s','90s','00s'],'Pop', ['Adult Contemporary','Barbershop','Bubblegum Pop','Dance Pop','Idols','Oldies','JPOP','Soft Rock','Teen Pop','Top 40','World Pop','KPOP'],'R&B and Urban', ['Classic R&B','Contemporary R&B','Doo Wop','Funk','Motown','Neo Soul','Quiet Storm','Soul','Urban Contemporary'],'Reggae', ['Contemporary Reggae','Dancehall','Dub','Pop Reggae','Ragga','Rock Steady','Reggae Roots'],'Rock', ['Adult Album Alternative','British Invasion','Classic Rock','Garage Rock','Glam','Hard Rock','Jam Bands','Piano Rock','Prog Rock','Psychedelic','Rock & Roll','Rockabilly','Singer and Songwriter','Surf','JROCK','Celtic Rock'],'Seasonal and Holiday', ['Anniversary','Birthday','Christmas','Halloween','Hanukkah','Honeymoon','Kwanzaa','Valentine','Wedding','Winter'],'Soundtracks', ['Anime','Kids','Original Score','Showtunes','Video Game Music'],'Talk', ['Comedy','Community','Educational','Government','News','Old Time Radio','Other Talk','Political','Scanner','Spoken Word','Sports','Technology','BlogTalk'],'Misc', [],'Public Radio', ['News','Talk','College','Sports','Weather']]
    catmap = {}
    
    # redefine
    streams = {}
    

    # API usage
    api_key = None
    api_stations = None
    
    def init2(self, parent):
        if "shoutcast_format" in conf:
            self.listformat = conf.shoutcast_format
        self.listformat = conf.shoutcast_format
    
        
    # Extracts the category list from www.shoutcast.com,
    # stores a catmap (title => id)
    def update_categories(self):
        html = ahttp.get(self.base_url)
        #log.DATA( html )
        self.categories = []
        self.categories = ["Top 500"]
        
        # Genre list in sidebar
        """
           <li id="genre-3" class="sub-genre " genreid="3" parentgenreid="1">
                <a href="/Genre?name=Britpop" onclick="return loadStationsByGenre('Britpop', 3, 1);">Britpop</a>
           </li>
        """
        rx = re.compile(r"loadStationsByGenre\(  '([^']+)' [,\s]* (\d+) [,\s]* (\d+)  \)", re.X)
        subs = rx.findall(html)

        # group
        current = []
        for (title, id, main) in subs:
            self.catmap[title] = int(id)
            #self.catmap[title] = int(id)
            if not int(main):
                self.categories.append(title)
                current = []
                self.categories.append(current)
            else:
                current.append(title)

        # .categories/.catmap get saved by reload_categories()
        pass
        

    # downloads stream list from shoutcast for given category
    def update_streams(self, cat):
    def update_streams(self, cat, search=None):
        if conf.get("shoutcast_api"):
            try:
                return self.update_streams_api(cat)
            except Exception as e:
                log.ERR(e)

        # page
        url = self.base_url + "Home/BrowseByGenre"
        params = { "genrename": cat }
        # page (always one result set á 500 entries)
        if cat in ("top", "Top", "Top 500"):
            url = self.base_url + "Home/Top"
            params = {}
        elif cat:
            url = self.base_url + "Home/BrowseByGenre"
            params = { "genrename": cat }
        elif search:
            url = self.base_url + "Search/UpdateSearch"
            params = { "query": search }
        referer = self.base_url
        try:
            json = ahttp.get(url, params=params, referer=referer, post=1, ajax=1)
            json = json_decode(json)
        except:
            log.ERR("HTTP request or JSON decoding failed. Outdated python/requests perhaps.")
            return []
129
130
131
132
133
134
135
136
137


138
139

140
141
142
143
144
145
146
147
148

149
150
151
152
153
154
155




























156
157
158
159
160
161
162

163
164
149
150
151
152
153
154
155


156
157
158

159
160
161
162
163
164
165
166
167

168
169
170
171
172
173
174

175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208

209
210
211







-
-
+
+

-
+








-
+






-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+






-
+

            IsRadionomy:true
            Listeners:159
            Name:"AOLMRadio"
            StreamUrl:null
        """
        entries = []
        for e in json:
            if self.listformat == "json":
                url = "urn:shoutcast:" + str(e["ID"])
            if conf.shoutcast_format in ("raw","json","href"):
                url = "urn:shoutcast:{}".format(e.get("ID",0))
            else:
                url = "http://yp.shoutcast.com/sbin/tunein-station.%s?id=%s" % (self.listformat, e.get("ID", "0"))
                url = "http://yp.shoutcast.com/sbin/tunein-station.%s?id=%s" % (conf.shoutcast_format, e.get("ID", "0"))
            entries.append({
                "id": int(e.get("ID", 0)),
                "genre": str(e.get("Genre", "")),
                "title": str(e.get("Name", "")),
                "playing": str(e.get("CurrentTrack", "")),
                "bitrate": int(e.get("Bitrate", 0)),
                "listeners": int(e.get("Listeners", 0)),
                "url": url,
                "listformat": self.listformat,
                "listformat": conf.shoutcast_format,
                "homepage": "",
                "format": str(e.get("Format", ""))
            })

        #log.DATA(entries)
        return entries

        
    
    # import API module
    def prepare_api(self):
        if not self.api_key:
            self.api_key = conf.netrc(["shoutcast.com"])[2]
        if not self.api_stations:
            from shoutcast_api import stations
            self.api_stations = stations

    # via developer API
    def update_streams_api(self, cat):
        self.prepare_api()
        r = []
        for e in self.api_stations.get_stations_by_genre(self.api_key, cat):
            r.append({
                "title": e["name"],
                "id": e["id"],
                "bitrate": e["br"],
                "playing": e["ct"],
                "img": e["logo_url"],
                "listeners": e["lc"],
                "url": "urn:shoutcast:{}".format(e["id"]),
                "genre": e["genre"],
                "format": "audio/mpeg",
                "listformat": "href",
            })
        return r

    # in case we're using AJAX lookups over tunein-station.pls
    def resolve_urn(self, row):
        if not row.get("id") or not row.get("url", "").startswith("urn:shoutcast:"):
            return
        url = ahttp.get("https://directory.shoutcast.com/Player/GetStreamUrl", {"station":row["id"]}, post=1)
        row["url"] = url.strip('"')
        row["url"] = json_decode(url)  # response is just a string literal of streaming url
        return row["url"]