Skip to content

Commit bcc4960

Browse files
committed
initial, very basic working thing\!
0 parents  commit bcc4960

File tree

5 files changed

+241
-0
lines changed

5 files changed

+241
-0
lines changed

LICENSE.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Copyright (c) 2015, Jacob Kaplan-Moss
2+
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without modification,
6+
are permitted provided that the following conditions are met:
7+
8+
* Redistributions of source code must retain the above copyright notice,
9+
this list of conditions and the following disclaimer.
10+
* Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
* Neither the name of overcast-sonos nor the names of its contributors
14+
may be used to endorse or promote products derived from this software
15+
without specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
21+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
22+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
23+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
24+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
25+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
26+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Whitespace-only changes.

overcast-sonos.py

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import os
2+
import logging
3+
from overcast import Overcast
4+
from pysimplesoap.server import SoapDispatcher, SOAPHandler
5+
from BaseHTTPServer import HTTPServer
6+
7+
logging.basicConfig(level=logging.DEBUG)
8+
9+
log = logging.getLogger('overcast-sonos')
10+
11+
dispatcher = SoapDispatcher('overcast-sonos',
12+
location = 'http://localhost:8080/',
13+
namespace = 'http://www.sonos.com/Services/1.1',
14+
trace = True,
15+
debug = True
16+
)
17+
18+
overcast = Overcast(os.environ['OVERCAST_USERNAME'], os.environ['OVERCAST_PASSWORD'])
19+
20+
mediaCollection = {'id': str,
21+
'title': str,
22+
'itemType': str,
23+
'artistId': str,
24+
'artist': str,
25+
'albumArtURI': str,
26+
'canPlay': bool,
27+
'canEnumerate': bool,
28+
'canAddToFavorites': bool,
29+
'canScroll': bool,
30+
'canSkip': bool}
31+
32+
trackMetadata = {'artist': str,
33+
'albumArtist': str,
34+
'genreId': str,
35+
'duration': int}
36+
37+
mediaMetadata = {'id': str,
38+
'title': str,
39+
'mimeType': str,
40+
'itemType': str,
41+
'trackMetadata': trackMetadata}
42+
43+
###
44+
45+
def getSessionId(username, password):
46+
log.debug('at=getSessionId username=%s password=%s', username, password)
47+
return username
48+
49+
dispatcher.register_function(
50+
'getSessionId', getSessionId,
51+
returns = {'getSessionIdResult': str},
52+
args = {'username': str, 'password': str}
53+
)
54+
55+
###
56+
57+
def getMetadata(id, index, count):
58+
log.debug('at=getMetadata id=%s index=%s count=%s', id, index, count)
59+
60+
if id == 'root':
61+
response = {'getMetadataResult': [
62+
{'index': 0, 'count': 1, 'total': 1},
63+
{'mediaCollection': {
64+
'id': 'episodes',
65+
'title': 'All Active Episodes',
66+
'itemType': 'container',
67+
'canPlay': False
68+
}},
69+
# {'mediaCollection': {
70+
# 'id': 'podcasts',
71+
# 'title': 'Podcasts',
72+
# 'itemType': 'container',
73+
# 'canPlay': False
74+
# }},
75+
]}
76+
77+
elif id == 'episodes':
78+
episodes = overcast.get_active_episodes()
79+
response = {'getMetadataResult': [{'index': 0, 'count': len(episodes), 'total': len(episodes)}]}
80+
for episode in episodes:
81+
response['getMetadataResult'].append({'mediaMetadata': {
82+
'id': 'episodes/' + episode['id'],
83+
'title': episode['title'],
84+
'mimeType': episode['audio_type'],
85+
'itemType': 'track',
86+
'trackMetadata': {
87+
'artist': episode['podcast_title'],
88+
'albumArtist': episode['podcast_title'],
89+
'genreId': 'podcast',
90+
'duration': episode['duration'],
91+
}
92+
}})
93+
94+
else:
95+
logging.error('unknown getMetadata id id=%s', id)
96+
response = {'getMetadataResult': [{'index': 0, 'count': 0, 'total': 0}]}
97+
98+
log.debug('at=getMetadata response=%s', response)
99+
return response
100+
101+
dispatcher.register_function(
102+
'getMetadata', getMetadata,
103+
returns = {'getMetadataResult': {'index': int, 'count': int, 'total': int, 'mediaCollection': mediaCollection}},
104+
args = {'id': str, 'index': int, 'count': int}
105+
)
106+
107+
###
108+
109+
def getMediaMetadata(id):
110+
log.debug('at=getMediaMetadata id=%s', id)
111+
_, episode_id = id.rsplit('/', 1)
112+
episode = overcast.get_episode_detail(episode_id)
113+
response = {'getMediaMetadataResult': {'mediaMetadata': {
114+
'id': id,
115+
'title': episode['title'],
116+
'mimeType': episode['audio_type'],
117+
'itemType': 'track',
118+
'trackMetadata': {
119+
'artist': episode['podcast_title'],
120+
'albumArtist': episode['podcast_title'],
121+
'genreId': 'podcast',
122+
'duration': episode['duration'],
123+
}
124+
}}}
125+
log.debug('at=getMediaMetadata response=%s', response)
126+
return response
127+
128+
dispatcher.register_function(
129+
'getMediaMetadata', getMediaMetadata,
130+
returns = {'getMediaMetadataResult': mediaMetadata},
131+
args = {'id': str}
132+
)
133+
134+
###
135+
136+
137+
def getMediaURI(id):
138+
log.debug('at=getMediaURI id=%s', id)
139+
_, episode_id = id.rsplit('/', 1)
140+
episode = overcast.get_episode_detail(episode_id)
141+
response = {'getMediaURIResult': episode['audio_uri']}
142+
log.debug('at=getMediaMetadata response=%s', response)
143+
return response
144+
145+
dispatcher.register_function(
146+
'getMediaURI', getMediaURI,
147+
returns = {'getMediaURIResult': str},
148+
args = {'id': str}
149+
)
150+
151+
###
152+
153+
def getLastUpdate():
154+
log.debug('at=getLastUpdate')
155+
return {'getLastUpdateResult': {'catalog': '0', 'favorites': '0', 'pollInterval': 60}}
156+
157+
dispatcher.register_function(
158+
'getLastUpdate', getLastUpdate,
159+
returns = {'getLastUpdateResult': {'catalog': str, 'favorites': str, 'pollInterval': int}},
160+
args = None
161+
)
162+
163+
if __name__ == '__main__':
164+
log.info('at=start')
165+
httpd = HTTPServer(("", 8080), SOAPHandler)
166+
httpd.dispatcher = dispatcher
167+
httpd.serve_forever()

overcast.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
An overcast "API".
3+
4+
Overcast doesn't really offer an official API, so this just sorta apes it.
5+
"""
6+
7+
import requests
8+
import lxml.html
9+
import urlparse
10+
11+
class Overcast(object):
12+
def __init__(self, email, password):
13+
self.session = requests.session()
14+
r = self.session.post('https://overcast.fm/login', {'email': email, 'password': password})
15+
doc = lxml.html.fromstring(r.content)
16+
alert = doc.cssselect('div.alert')
17+
if alert:
18+
raise Exception("Can't login: {}".format(alert[0].text_content().strip()))
19+
20+
def get_active_episodes(self):
21+
r = self.session.get('https://overcast.fm/podcasts')
22+
doc = lxml.html.fromstring(r.content)
23+
return [
24+
self.get_episode_detail(cell.attrib['href'])
25+
for cell in doc.cssselect('a.episodecell')
26+
if 'href' in cell.attrib
27+
]
28+
29+
def get_episode_detail(self, episode_id):
30+
episode_href = urlparse.urljoin('https://overcast.fm', episode_id)
31+
r = self.session.get(episode_href)
32+
doc = lxml.html.fromstring(r.content)
33+
return {
34+
'id': episode_href.rsplit('/', 1)[-1],
35+
'title': doc.cssselect('div.titlestack div.title')[0].text_content(),
36+
'podcast_title': doc.cssselect('div.titlestack div.caption2 a')[0].text_content(),
37+
'duration': 60, # fixme - fuck where do I get the duration from :(
38+
'audio_uri': doc.cssselect('audio#audioplayer source')[0].attrib['src'],
39+
'audio_type': doc.cssselect('audio#audioplayer source')[0].attrib['type'],
40+
}
41+

requirements.txt

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
cssselect==0.9.1
2+
lxml==3.5.0
3+
PySimpleSOAP==1.16
4+
requests==2.9.1
5+
soap2py==1.16
6+
wheel==0.24.0

0 commit comments

Comments
 (0)