├── LICENSE.md ├── README.md ├── TODO.md ├── customsd_example.png ├── overcast-sonos.py ├── overcast.py ├── requirements.txt └── utilities.py /LICENSE.md: -------------------------------------------------------------------------------- 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. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play [Overcast](https://overcast.fm/) podcasts on your Sonos. 2 | 3 | * Fast! 4 | * Remembers last played position 5 | * Syncs play position back to the Overcast service every 30 seconds or when paused/stopped 6 | * Removes episodes from Overcast when completed 7 | 8 | Usage: 9 | 10 | 1. `pip install -r requirements.txt` 11 | 12 | 1. `OVERCAST_USERNAME=you@there.com OVERCAST_PASSWORD=1234 python overcast-sonos.py` 13 | 14 | 1. Go to `http://:1400/customsd.htm`, and enter: 15 | 16 | - SID - some unique SID (255 works if you've not done this before 17 | - Service Name - some name, `overcast` works 18 | - Endpoint URL and Secure Endpoint URL: `http://:8140/overcast-sonos` 19 | - Presentation map: version = 1, Uri = `http://:8140/presentation_map` 20 | - Authentication SOAP header policy: "Anonymous" 21 | - Check the boxes in the [example screenshot](./customsd_example.png) 22 | 23 | 1. In your Sonos controller, go to "Add Music Service", then add the service above. 24 | 25 | See the [TODO list](./TODO.md) for some nice-to-haves. 26 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - tests yay 2 | - refactor to be a WSGI app 3 | - convert to username/password auth 4 | - http://musicpartners.sonos.com/node/82 5 | - looks like I'll have to hack the soap dispatcher to somehow save headers :( 6 | - add a picture to the actual Overcast Sonos service 7 | 8 | Maybe: 9 | 10 | - deploy as an app? 11 | - this would mean I'd have access to people's overcast passwords.... 12 | 13 | DONE: 14 | - write a README with instructions 15 | - update play status - reportPlaySeconds/Status SOAP call can map to overcast play status XHR 16 | - not sure how to tell when something's finished to let overcast know, figure that out 17 | - art! 18 | - actually gather episode duration 19 | - resolve redirects to find the actual media URL - sonos doesn't seem to follow redirects 20 | - properly implement getLastUpdate 21 | - refactor getMetadata not to be repetetive and ugly 22 | - load episode lists faster and only call get_episode_detail if absolutely needed 23 | - refactor the way episode_id is used (sometimes with a slash, sometimes without) 24 | - "delete" or remove an episode once it has finished playing (currently it just shows as 00:00 remaining and must be manually finished on some other way) 25 | -------------------------------------------------------------------------------- /customsd_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobian/overcast-sonos/dd4604a8d4b3cd7a89c863d7d6c4e7acdee9486a/customsd_example.png -------------------------------------------------------------------------------- /overcast-sonos.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import uuid 4 | from overcast import Overcast, utilities 5 | from pysimplesoap.server import SoapDispatcher, SOAPHandler 6 | from BaseHTTPServer import HTTPServer 7 | 8 | logging.basicConfig(level=logging.DEBUG) 9 | log = logging.getLogger('overcast-sonos') 10 | 11 | list_active_episodes_in_root = True 12 | allow_all_active_episodes_as_playlist = True 13 | 14 | 15 | class customSOAPHandler(SOAPHandler): 16 | 17 | def do_GET(self): 18 | log.debug('PATH ==> %s', self.path) 19 | if self.path == '/presentation_map': 20 | self.send_response(200) 21 | self.send_header('Content-type', 'text/xml') 22 | self.end_headers() 23 | self.wfile.write(''' 24 | 25 | 26 | 27 | LIST 28 | 29 | 30 | 31 | ''') 32 | return 33 | else: 34 | return SOAPHandler.do_GET(self) 35 | 36 | 37 | dispatcher = SoapDispatcher('overcast-sonos', 38 | location='http://localhost:8140/', 39 | namespace='http://www.sonos.com/Services/1.1', 40 | trace=True, 41 | debug=True 42 | ) 43 | 44 | overcast = Overcast(os.environ['OVERCAST_USERNAME'], os.environ['OVERCAST_PASSWORD']) 45 | 46 | mediaCollection = {'id': str, 47 | 'title': str, 48 | 'itemType': str, 49 | 'artistId': str, 50 | 'artist': str, 51 | 'albumArtURI': str, 52 | 'canPlay': bool, 53 | 'canEnumerate': bool, 54 | 'canAddToFavorites': bool, 55 | 'canScroll': bool, 56 | 'canSkip': bool} 57 | 58 | positionInformation = {'id': str, 59 | 'index': int, # always 0, "reserved for future use" by Sonos 60 | 'offsetMillis': int} 61 | 62 | trackMetadata = {'artist': str, 63 | 'albumArtist': str, 64 | 'albumArtURI': str, 65 | 'genreId': str, 66 | 'duration': int, 67 | 'canResume': bool} 68 | 69 | mediaMetadata = {'id': str, 70 | 'title': str, 71 | 'mimeType': str, 72 | 'itemType': str, 73 | 'trackMetadata': trackMetadata} 74 | 75 | ### 76 | 77 | 78 | def getSessionId(username, password): 79 | log.debug('at=getSessionId username=%s password=%s', username, password) 80 | return username 81 | 82 | 83 | dispatcher.register_function( 84 | 'getSessionId', getSessionId, 85 | returns={'getSessionIdResult': str}, 86 | args={'username': str, 'password': str} 87 | ) 88 | 89 | ### 90 | 91 | 92 | def getMetadata(id, index, count, recursive=False): 93 | log.debug('at=getMetadata id=%s index=%s count=%s recursive=%s', id, index, count, recursive) 94 | 95 | if id == 'root': 96 | response = {'getMetadataResult': []} 97 | response['getMetadataResult'].append( 98 | {'mediaCollection': { 99 | 'id': 'podcasts', 100 | 'title': 'Podcasts', 101 | 'itemType': 'albumList', 102 | 'canPlay': False, 103 | 'albumArtURI': 'http://is3.mzstatic.com/image/thumb/Purple111/v4/20/5b/5e/205b5ef7-ee0e-7d0c-2d11-12f611c579f4/source/175x175bb.jpg', 104 | }}) 105 | response['getMetadataResult'].append( 106 | {'mediaCollection': { 107 | 'id': 'episodes', 108 | 'title': 'All Active Episodes', 109 | 'itemType': 'playlist', 110 | 'canPlay': allow_all_active_episodes_as_playlist, 111 | 'albumArtURI': 'http://is3.mzstatic.com/image/thumb/Purple111/v4/20/5b/5e/205b5ef7-ee0e-7d0c-2d11-12f611c579f4/source/175x175bb.jpg', 112 | }}) 113 | if list_active_episodes_in_root: 114 | all_episodes = overcast.get_active_episodes() 115 | episodes = all_episodes[index:index+count] 116 | response['getMetadataResult'].append({'index': index, 'count': len(episodes) + 2, 'total': len(all_episodes) + 2}) 117 | for episode in episodes: 118 | response['getMetadataResult'].append({ 119 | 'mediaMetadata': { 120 | 'id': 'episodes/' + episode['id'], 121 | 'title': episode['podcast_title'] + " - " + episode['title'], 122 | 'mimeType': episode['audio_type'], 123 | 'itemType': 'track', 124 | 'trackMetadata': { 125 | 'artist': episode['podcast_title'], 126 | 'album': episode['podcast_title'], 127 | 'albumArtist': episode['podcast_title'], 128 | 'albumArtURI': episode['albumArtURI'], 129 | 'genreId': 'podcast', 130 | 'canResume': True, 131 | } 132 | } 133 | }) 134 | 135 | elif id == 'episodes': 136 | all_episodes = overcast.get_active_episodes(get_details=True) 137 | episodes = all_episodes[index:index+count] 138 | response = {'getMetadataResult': [{'index': index, 'count': len(episodes), 'total': len(all_episodes)}]} 139 | for episode in episodes: 140 | response['getMetadataResult'].append({ 141 | 'mediaMetadata': { 142 | 'id': 'episodes/' + episode['id'], 143 | 'title': episode['title'], 144 | 'mimeType': episode['audio_type'], 145 | 'itemType': 'track', 146 | 'trackMetadata': { 147 | 'artist': episode['podcast_title'], 148 | 'albumArtist': episode['podcast_title'], 149 | 'albumArtURI': episode['albumArtURI'], 150 | 'genreId': 'podcast', 151 | 'duration': episode['duration'], 152 | 'canResume': True, 153 | } 154 | } 155 | }) 156 | 157 | elif id == 'podcasts': 158 | all_podcasts = overcast.get_all_podcasts() 159 | podcasts = all_podcasts[index:index+count] 160 | response = {'getMetadataResult': [{'index': index, 'count': len(podcasts), 'total': len(all_podcasts)}]} 161 | for podcast in podcasts: 162 | response['getMetadataResult'].append({'mediaCollection': { 163 | 'id': 'podcasts/' + podcast['id'], 164 | 'title': podcast['title'], 165 | 'albumArtURI': podcast['albumArtURI'], 166 | 'itemType': 'album', 167 | 'canPlay': False, 168 | }}) 169 | 170 | elif id.startswith('podcasts/'): 171 | podcast_id = id.split('/', 1)[-1] 172 | all_episodes = overcast.get_all_podcast_episodes(podcast_id) 173 | episodes = all_episodes[index:index+count] 174 | response = {'getMetadataResult': [{'index': index, 'count': len(episodes), 'total': len(all_episodes)}]} 175 | for episode in episodes: 176 | response['getMetadataResult'].append({ 177 | 'mediaMetadata': { 178 | 'id': 'episodes/' + episode['id'], 179 | 'title': episode['title'], 180 | 'mimeType': episode['audio_type'], 181 | 'itemType': 'track', 182 | 'trackMetadata': { 183 | 'artist': episode['podcast_title'], 184 | 'albumArtist': episode['podcast_title'], 185 | 'albumArtURI': episode['albumArtURI'], 186 | 'genreId': 'podcast', 187 | 'canResume': True, 188 | } 189 | } 190 | }) 191 | 192 | else: 193 | logging.error('unknown getMetadata id id=%s', id) 194 | response = {'getMetadataResult': [{'index': 0, 'count': 0, 'total': 0}]} 195 | 196 | log.debug('at=getMetadata response=%s', response) 197 | return response 198 | 199 | 200 | dispatcher.register_function( 201 | 'getMetadata', getMetadata, 202 | returns={'getMetadataResult': {'index': int, 'count': int, 'total': int, 'mediaCollection': mediaCollection}}, 203 | args={'id': str, 'index': int, 'count': int, 'recursive': bool} 204 | ) 205 | 206 | ### 207 | 208 | 209 | def getMediaMetadata(id): 210 | log.debug('at=getMediaMetadata id=%s', id) 211 | _, episode_id = id.rsplit('/', 1) 212 | log.debug('at=getMediaMetadata episode_id=%s', episode_id) 213 | episode = overcast.get_episode_detail(episode_id) 214 | response = {'getMediaMetadataResult': { 215 | 'mediaMetadata': { 216 | 'id': id, 217 | 'title': episode['title'], 218 | 'mimeType': episode['audio_type'], 219 | 'itemType': 'track', 220 | 'trackMetadata': { 221 | 'artist': episode['podcast_title'], 222 | 'albumArtist': episode['podcast_title'], 223 | 'albumArtURI': episode['albumArtURI'], 224 | 'genreId': 'podcast', 225 | 'duration': episode['duration'], 226 | 'canResume': True, 227 | } 228 | } 229 | }} 230 | log.debug('at=getMediaMetadata response=%s', response) 231 | return response 232 | 233 | 234 | dispatcher.register_function( 235 | 'getMediaMetadata', getMediaMetadata, 236 | returns={'getMediaMetadataResult': mediaMetadata}, 237 | args={'id': str} 238 | ) 239 | 240 | ### 241 | 242 | 243 | def getMediaURI(id): 244 | log.debug('at=getMediaURI id=%s', id) 245 | _, episode_id = id.rsplit('/', 1) 246 | episode = overcast.get_episode_detail(episode_id) 247 | parsed_audio_uri = episode['parsed_audio_uri'] 248 | audio_uri = utilities.final_redirect_url(parsed_audio_uri) 249 | response = {'getMediaURIResult': audio_uri, 250 | 'positionInformation': { 251 | 'id': 'episodes/' + episode['id'], 252 | 'index': 0, 253 | 'offsetMillis': episode['offsetMillis'] 254 | }, 255 | } 256 | log.debug('at=getMediaURI response=%s', response) 257 | return response 258 | 259 | 260 | dispatcher.register_function( 261 | 'getMediaURI', getMediaURI, 262 | returns={'getMediaURIResult': str, 'positionInformation': positionInformation}, 263 | args={'id': str} 264 | ) 265 | 266 | ### 267 | 268 | 269 | def getLastUpdate(): 270 | log.debug('at=getLastUpdate') 271 | return {'getLastUpdateResult': {'catalog': str(uuid.uuid4()), 'favorites': '0', 'pollInterval': 60}} 272 | 273 | 274 | dispatcher.register_function( 275 | 'getLastUpdate', getLastUpdate, 276 | returns={'getLastUpdateResult': {'autoRefreshEnabled': bool, 'catalog': str, 'favorites': str, 'pollInterval': int}}, 277 | args={} 278 | ) 279 | 280 | ### 281 | 282 | 283 | def reportPlaySeconds(id, seconds, offsetMillis, contextId): 284 | episode_id = id.rsplit('/', 1)[-1] 285 | log.debug('at=reportPlaySeconds and id=%s, seconds=%d, offsetMillis=%d, contextId=%s, episode_id=%s', id, seconds, offsetMillis, contextId, episode_id) 286 | episode = overcast.get_episode_detail(episode_id) 287 | overcast.update_episode_offset(episode, offsetMillis/1000) 288 | return {'reportPlaySecondsResult': {'interval': 30}} 289 | 290 | 291 | dispatcher.register_function( 292 | 'reportPlaySeconds', reportPlaySeconds, 293 | returns={'reportPlaySecondsResult': {'interval': int}}, 294 | args={'id': str, 'seconds': int, 'offsetMillis': int, 'contextId': str} 295 | ) 296 | 297 | 298 | def reportPlayStatus(id, status, offsetMillis, contextId): 299 | episode_id = id.rsplit('/', 1)[-1] 300 | log.debug('at=reportPlayStatus and id=%s, status=%s, contextId=%s, offsetMillis=%d, episode_id=%s', id, status, contextId, offsetMillis, episode_id) 301 | episode = overcast.get_episode_detail(episode_id) 302 | overcast.update_episode_offset(episode, offsetMillis/1000) 303 | 304 | 305 | dispatcher.register_function( 306 | 'reportPlayStatus', reportPlayStatus, 307 | returns={}, 308 | args={'id': str, 'status': str, 'offsetMillis': int, 'contextId': str} 309 | ) 310 | 311 | 312 | def setPlayedSeconds(id, seconds, offsetMillis, contextId): 313 | episode_id = id.rsplit('/', 1)[-1] 314 | log.debug('at=setPlayedSeconds and id=%s, seconds=%d, offsetMillis=%d, contextId=%s, episode_id=%s', id, seconds, offsetMillis, contextId, episode_id) 315 | episode = overcast.get_episode_detail(episode_id) 316 | overcast.update_episode_offset(episode, offsetMillis/1000) 317 | 318 | 319 | dispatcher.register_function( 320 | 'setPlayedSeconds', setPlayedSeconds, 321 | returns={}, 322 | args={'id': str, 'seconds': int, 'offsetMillis': int, 'contextId': str} 323 | ) 324 | 325 | 326 | if __name__ == '__main__': 327 | log.info('at=start') 328 | httpd = HTTPServer(("", 8140), customSOAPHandler) 329 | httpd.dispatcher = dispatcher 330 | httpd.serve_forever() 331 | -------------------------------------------------------------------------------- /overcast.py: -------------------------------------------------------------------------------- 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 | import utilities 11 | import logging 12 | import threading 13 | 14 | log = logging.getLogger('overcast-sonos') 15 | 16 | 17 | class Overcast(object): 18 | 19 | def __init__(self, email, password): 20 | self.session = requests.session() 21 | r = self.session.post('https://overcast.fm/login', {'email': email, 'password': password}) 22 | doc = lxml.html.fromstring(r.content) 23 | alert = doc.cssselect('div.alert') 24 | if alert: 25 | raise Exception("Can't login: {}".format(alert[0].text_content().strip())) 26 | 27 | def _get_html(self, url): 28 | return lxml.html.fromstring(self.session.get(url).content) 29 | 30 | def get_active_episodes(self, get_details=False): 31 | active_episodes = [] 32 | active_episodes_dictionary = {} 33 | doc = self._get_html('https://overcast.fm/podcasts') 34 | for index, cell in enumerate(doc.cssselect('a.episodecell')): 35 | if 'href' in cell.attrib: 36 | if get_details: 37 | episode_id = cell.attrib['href'] 38 | time_remaining_seconds = self.get_episode_time_remaining_seconds_from_episode_cell(cell, False) 39 | t = threading.Thread(target=self.add_episode_detail_to, args=(active_episodes_dictionary, index, episode_id, time_remaining_seconds)) 40 | t.setDaemon(True) 41 | t.start() 42 | else: 43 | active_episodes.append({ 44 | 'id': urlparse.urljoin('https://overcast.fm', cell.attrib['href']).lstrip('/'), 45 | 'title': cell.cssselect('div.titlestack div.title')[0].text_content(), 46 | 'audio_type': 'audio/mpeg', 47 | 'podcast_title': cell.cssselect('div.titlestack div.caption2')[0].text_content(), 48 | 'albumArtURI': cell.cssselect('img')[0].attrib['src'], 49 | 'duration': -1, 50 | }) 51 | 52 | main_thread = threading.currentThread() 53 | for t in threading.enumerate(): 54 | if t is not main_thread: 55 | log.debug('''Joining on thread %s''', t.getName()) 56 | t.join() 57 | 58 | if not active_episodes: 59 | active_episodes = [active_episodes_dictionary[key] for key in sorted(active_episodes_dictionary)] 60 | 61 | return active_episodes 62 | 63 | def add_episode_detail_to(self, ordered_episodes, key, episode_id, time_remaining_seconds=None): 64 | ordered_episodes[key] = self.get_episode_detail(episode_id, time_remaining_seconds) 65 | 66 | def get_episode_detail(self, episode_id, time_remaining_seconds=None): 67 | episode_href = urlparse.urljoin('https://overcast.fm', episode_id) 68 | doc = self._get_html(episode_href) 69 | 70 | time_elapsed_seconds = int(doc.cssselect('audio#audioplayer')[0].attrib['data-start-time']) 71 | time_remaining_seconds = time_remaining_seconds or self.get_episode_time_remaining_seconds(episode_id, doc) 72 | duration = time_elapsed_seconds + time_remaining_seconds 73 | if time_elapsed_seconds == duration: 74 | duration = -1 75 | 76 | return { 77 | 'id': episode_href.lstrip('/'), 78 | 'title': doc.cssselect('div.centertext h2')[0].text_content(), 79 | 'podcast_title': doc.cssselect('div.centertext h3 a')[0].text_content(), 80 | 'offsetMillis': time_elapsed_seconds * 1000, 81 | 'duration': duration, 82 | 'data_item_id': doc.cssselect('audio#audioplayer')[0].attrib['data-item-id'], 83 | 'data_sync_version': doc.cssselect('audio#audioplayer')[0].attrib['data-sync-version'], 84 | 'albumArtURI': doc.cssselect('div.fullart_container img')[0].attrib['src'], 85 | 'parsed_audio_uri': doc.cssselect('audio#audioplayer source')[0].attrib['src'], 86 | 'audio_type': doc.cssselect('audio#audioplayer source')[0].attrib['type'], 87 | 'delete_episode_uri': doc.cssselect('a#delete_episode_button')[0].attrib['href'] 88 | } 89 | 90 | def get_episode_time_remaining_seconds(self, episode_id, episode_html): 91 | log.debug('''getting the remaining time. episode id is %s''', episode_id) 92 | podcast_id = episode_html.cssselect('div.centertext h3 a')[0].attrib['href'] 93 | podcast_href = urlparse.urljoin('https://overcast.fm', podcast_id) 94 | doc = self._get_html(podcast_href) 95 | 96 | for cell in doc.cssselect('a.extendedepisodecell'): 97 | if episode_id in cell.attrib['href']: 98 | return self.get_episode_time_remaining_seconds_from_episode_cell(cell, True) 99 | 100 | def get_episode_time_remaining_seconds_from_episode_cell(self, cell, is_extended_cell): 101 | unparsed_time_remaining_index = 1 if is_extended_cell else 2 102 | unparsed_time_remaining = cell.cssselect('div.singleline')[unparsed_time_remaining_index].text_content() 103 | time_remaining_seconds = utilities.duration_in_seconds(unparsed_time_remaining) 104 | return time_remaining_seconds 105 | 106 | def get_all_podcasts(self): 107 | doc = self._get_html('https://overcast.fm/podcasts') 108 | return [ 109 | { 110 | 'id': cell.attrib['href'].lstrip('/'), 111 | 'title': cell.cssselect('div.title')[0].text_content(), 112 | 'albumArtURI': cell.cssselect('img')[0].attrib['src'], 113 | } 114 | for cell in doc.cssselect('a.feedcell') 115 | if 'href' in cell.attrib 116 | ] 117 | 118 | def get_all_podcast_episodes(self, podcast_id): 119 | """ 120 | get all episodes (played or not) for a podcast. 121 | """ 122 | podcast_href = urlparse.urljoin('https://overcast.fm', podcast_id) 123 | doc = self._get_html(podcast_href) 124 | albumArtURI = doc.cssselect('img.art')[0].attrib['src'] 125 | podcast_title = doc.cssselect('h2.centertext')[0].text_content() 126 | return [ 127 | # NOTE: If the hardcoded audio_type causes any problems, just uncomment the line below and comment out the dictionary below it. 128 | # self.get_episode_detail(cell.attrib['href']) 129 | { 130 | 'id': urlparse.urljoin('https://overcast.fm', cell.attrib['href']).lstrip('/'), 131 | 'title': cell.cssselect('div.titlestack div.title')[0].text_content(), 132 | 'audio_type': 'audio/mpeg', 133 | 'podcast_title': podcast_title, 134 | 'albumArtURI': albumArtURI, 135 | } 136 | for cell in doc.cssselect('a.extendedepisodecell') 137 | if 'href' in cell.attrib 138 | ] 139 | 140 | def update_episode_offset(self, episode, updated_offset_seconds): 141 | log.debug("updated_offset_seconds = %d and duration = %d", updated_offset_seconds, episode['duration']) 142 | url = 'https://overcast.fm/podcasts/set_progress/' + episode['data_item_id'] 143 | params = {'p': updated_offset_seconds, 'speed': 0, 'v': episode['data_sync_version']} 144 | log.debug('Updating offset of episode with id %s to %d', episode['id'], updated_offset_seconds) 145 | self.session.post(url, params) 146 | if updated_offset_seconds >= episode['duration']: 147 | self.delete_episode(episode) 148 | 149 | def delete_episode(self, episode): 150 | url = 'https://overcast.fm' + episode['delete_episode_uri'] 151 | log.debug('Deleting episode with id %s', episode['id']) 152 | self.session.post(url) 153 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 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 7 | -------------------------------------------------------------------------------- /utilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions. 3 | 4 | Little utility functions to help you along :) 5 | """ 6 | 7 | import requests 8 | import logging 9 | 10 | log = logging.getLogger('overcast-sonos') 11 | 12 | # We used to look for a 'hh:mm:ss' string and then parse that to seconds, but now overcast only returns minutes :( 13 | # def duration_in_seconds(str): 14 | # seconds = -1 15 | # try: 16 | # strings = str.split(' ') 17 | # for string in strings: 18 | # if ":" in string: 19 | # list = string.split(":") 20 | # list.reverse() 21 | # for i, x in enumerate(list): 22 | # seconds += int(x) * (60**i) 23 | # break 24 | # except: 25 | # log.debug('''Couldn't parse the episode's duration in seconds from the string %s.''', str) 26 | # pass 27 | # 28 | # log.debug('''Parsed the episode's duration in seconds from the string %s -> %d''', str, seconds) 29 | # 30 | # return seconds 31 | 32 | # turns a string like 'Feb 24 - 36 min left' into seconds 33 | 34 | 35 | def duration_in_seconds(str): 36 | seconds = -1 37 | try: 38 | strings = str.split() 39 | minuteIndex = strings.index('min') - 1 40 | seconds = int(strings[minuteIndex]) * 60 41 | except: 42 | log.debug('''Couldn't parse the episode's duration in seconds from the string %s.''', str) 43 | pass 44 | 45 | log.debug('''Parsed the episode's duration in seconds from the string %s -> %d''', str, seconds) 46 | 47 | return seconds 48 | 49 | 50 | def final_redirect_url(url): 51 | redirected_url = requests.head(url, allow_redirects=True).url 52 | if url != redirected_url: 53 | log.debug('''Redirected url '%s' to '%s'.''', url, redirected_url) 54 | return redirected_url 55 | --------------------------------------------------------------------------------