├── LICENSE.txt ├── README.md ├── addon.py ├── addon.xml ├── changelog.txt ├── fanart.jpg ├── icon.png └── resources ├── __init__.py ├── language ├── resource.language.de_de │ └── strings.po └── resource.language.en_gb │ └── strings.po ├── lib ├── __init__.py ├── gui.py ├── helpers.py ├── http.py ├── kodi.py ├── recording.py ├── relive.py ├── settings.py └── stream.py └── settings.xml /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2020 Tobias Gruetzmacher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | ------------------------------------------------------------------------------ 24 | 25 | The image fanart.jpg is (c) 2013 Alain, and licenced under CC-BY 2.0, which 26 | can be found under https://creativecommons.org/licenses/by/2.0/ 27 | 28 | (Taken from https://www.flickr.com/photos/notalain/11644470323) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | media.ccc.de for Kodi 2 | ===================== 3 | 4 | ![Tests](https://github.com/voc/plugin.video.media-ccc-de/workflows/Tests/badge.svg) 5 | [![codecov](https://codecov.io/gh/voc/plugin.video.media-ccc-de/branch/main/graph/badge.svg)](https://codecov.io/gh/voc/plugin.video.media-ccc-de) 6 | 7 | This is the official Kodi plugin to browse content on media.ccc.de and watch 8 | live streams from streaming.media.ccc.de. 9 | 10 | The latest version should be available through Kodi's official plugin 11 | repository: 12 | 13 | - For Matrix (Kodi 19.x) and newer: https://kodi.tv/addons/matrix/plugin.video.media-ccc-de 14 | - For Leia (Kodi 18.x) and older: https://kodi.tv/addons/leia/plugin.video.media-ccc-de 15 | -------------------------------------------------------------------------------- /addon.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | import operator 5 | import sys 6 | 7 | import routing 8 | from xbmcgui import ListItem 9 | from xbmcplugin import (addDirectoryItem, endOfDirectory, setResolvedUrl, 10 | setContent) 11 | 12 | from resources.lib import http, kodi, settings 13 | from resources.lib.helpers import maybe_json, calc_aspect, json_date_to_info 14 | 15 | plugin = routing.Plugin() 16 | 17 | 18 | @plugin.route('/') 19 | @plugin.route('/dir/') 20 | def show_dir(subdir=''): 21 | try: 22 | data = get_index_data() 23 | except http.FetchError: 24 | return 25 | 26 | subdirs = set() 27 | if subdir == '': 28 | depth = 0 29 | 30 | addDirectoryItem(plugin.handle, plugin.url_for(show_live), 31 | ListItem('Live Streaming'), True) 32 | else: 33 | depth = len(subdir.split('/')) 34 | 35 | for event in sorted(data, key=operator.itemgetter('title')): 36 | top, down, children = split_pathname(event['slug'], depth) 37 | 38 | if top != subdir or down in subdirs: 39 | continue 40 | if children: 41 | addDirectoryItem(plugin.handle, plugin.url_for(show_dir, 42 | subdir=build_path(top, down)), 43 | ListItem(down.title()), True) 44 | subdirs.add(down) 45 | else: 46 | item = ListItem(event['title']) 47 | item.setLabel2(event['acronym']) 48 | item.setArt({'thumb': event['logo_url']}) 49 | url = plugin.url_for(show_conference, 50 | conf=event['url'].rsplit('/', 1)[1]) 51 | addDirectoryItem(plugin.handle, url, item, True) 52 | 53 | endOfDirectory(plugin.handle) 54 | 55 | 56 | @plugin.route('/conference/') 57 | def show_conference(conf): 58 | data = None 59 | relive = None 60 | try: 61 | data = http.fetch_data('conferences/' + conf) 62 | except http.FetchError: 63 | return 64 | 65 | try: 66 | relive = http.fetch_relive_index().by_conference(conf) 67 | except http.FetchError: 68 | pass # gui error shown by http class 69 | 70 | setContent(plugin.handle, 'movies') 71 | 72 | aspect = calc_aspect(maybe_json(data, 'aspect_ratio', '16:9')) 73 | 74 | for event in sorted(data['events'], key=operator.itemgetter('title')): 75 | item = ListItem(event['title']) 76 | item.setArt({'thumb': event['thumb_url']}) 77 | item.setProperty('IsPlayable', 'true') 78 | 79 | info = { 80 | 'cast': maybe_json(event, 'persons', []), 81 | 'credits': ", ".join(maybe_json(event, 'persons', [])), 82 | 'genre': " / ".join(maybe_json(event, 'tags', [])), 83 | 'plot': maybe_json(event, 'description', ''), 84 | 'tagline': maybe_json(event, 'subtitle', '') 85 | } 86 | json_date_to_info(event, 'date', info) 87 | item.setInfo('video', info) 88 | 89 | streamInfo = {} 90 | length = maybe_json(event, 'length', 0) 91 | if length > 0: 92 | streamInfo['duration'] = length 93 | if aspect: 94 | streamInfo['aspect'] = aspect 95 | item.addStreamInfo('video', streamInfo) 96 | 97 | url = plugin.url_for(resolve_event, 98 | event=event['url'].rsplit('/', 1)[1]) 99 | addDirectoryItem(plugin.handle, url, item, False) 100 | 101 | if relive is not None: 102 | relive_item = ListItem('ReLive (unreleased)') 103 | url = plugin.url_for(show_relive, conf=conf) 104 | addDirectoryItem(plugin.handle, url, relive_item, True) 105 | 106 | endOfDirectory(plugin.handle) 107 | 108 | 109 | @plugin.route('/relive/') 110 | def show_relive(conf): 111 | data = None 112 | try: 113 | relive = http.fetch_relive_index().by_conference(conf) 114 | data = http.fetch_relive_recordings(relive.get_url()) 115 | except http.FetchError: 116 | return 117 | 118 | setContent(plugin.handle, 'movies') 119 | 120 | data = data.unreleased() 121 | 122 | for recording in data: 123 | item = ListItem(recording.title) 124 | item.setArt({'thumb': recording.get_thumb_url()}) 125 | item.setProperty('IsPlayable', 'true') 126 | 127 | item.setInfo('video', { 128 | 'plot': recording.room, 129 | }) 130 | 131 | item.addStreamInfo('video', { 132 | 'duration': recording.duration 133 | }) 134 | 135 | addDirectoryItem(plugin.handle, recording.get_video_url(), item, False) 136 | 137 | endOfDirectory(plugin.handle) 138 | 139 | 140 | @plugin.route('/event/') 141 | @plugin.route('/event///') 142 | def resolve_event(event, quality=None, format=None): 143 | if quality not in settings.QUALITY: 144 | quality = settings.get_quality(plugin) 145 | if format not in settings.FORMATS: 146 | format = settings.get_format(plugin) 147 | 148 | data = None 149 | try: 150 | data = http.fetch_recordings(event) 151 | except http.FetchError: 152 | return 153 | want = data.recordings_sorted(quality, format) 154 | 155 | if len(want) > 0: 156 | http.count_view(event, want[0].url) 157 | setResolvedUrl(plugin.handle, True, ListItem(path=want[0].url)) 158 | 159 | 160 | @plugin.route('/live') 161 | def show_live(): 162 | quality = settings.get_quality(plugin) 163 | format = settings.get_format(plugin) 164 | prefer_dash = settings.prefer_dash(plugin) 165 | 166 | data = None 167 | try: 168 | data = http.fetch_live() 169 | except http.FetchError: 170 | return 171 | 172 | if len(data.conferences) == 0: 173 | entry = ListItem('No live event currently, go watch some recordings!') 174 | addDirectoryItem(plugin.handle, plugin.url_for(show_dir), entry, True) 175 | 176 | for conference in data.conferences: 177 | for room in conference.rooms: 178 | want = room.streams_sorted(quality, format, prefer_dash) 179 | 180 | # Assumption: want now starts with the "best" alternative, 181 | # followed by an arbitrary number of translations, after which 182 | # the first "worse" non-translated stream follows. 183 | 184 | for id, stream in enumerate(want): 185 | if id > 0 and not stream.translated: 186 | break 187 | extra = '' 188 | if stream.translated: 189 | extra = ' (Translated %i)' % id if id > 1 else ' (Translated)' 190 | talk_title = ' >> ' + room.current_talk_title if room.current_talk_title != '' else '' 191 | item = ListItem(conference.name + ': ' + room.display + talk_title + extra) 192 | item.setProperty('IsPlayable', 'true') 193 | if stream.type == 'dash': 194 | dashproperty = 'inputstream' 195 | if kodi.major_version() < 19: 196 | dashproperty += 'addon' 197 | item.setProperty(dashproperty, 'inputstream.adaptive') 198 | item.setProperty('inputstream.adaptive.manifest_type', 'mpd') 199 | 200 | addDirectoryItem(plugin.handle, stream.url, item, False) 201 | 202 | # MPEG-DASH includes all translated streams 203 | if stream.type == 'dash': 204 | break 205 | 206 | endOfDirectory(plugin.handle) 207 | 208 | 209 | # FIXME: @plugin.cached() 210 | def get_index_data(): 211 | return http.fetch_data('conferences')['conferences'] 212 | 213 | 214 | def split_pathname(name, depth): 215 | path = name.split('/') 216 | top = '/'.join(path[0:depth]) 217 | if depth < len(path): 218 | down = path[depth] 219 | else: 220 | down = None 221 | children = len(path) - 1 > depth 222 | return (top, down, children) 223 | 224 | 225 | def build_path(top, down): 226 | if top == '': 227 | return down 228 | else: 229 | return '/'.join((top, down)) 230 | 231 | 232 | if __name__ == '__main__': 233 | plugin.run(argv=sys.argv) 234 | -------------------------------------------------------------------------------- /addon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | video 10 | 11 | 12 | Videos of Chaos Computer Club events. 13 | Videos von Chaos Computer Club Veranstaltungen. 14 | This addon provides access to videos published on https://media.ccc.de/ (mostly lecture recordings of CCC events) 15 | Dieses Add-On ermöglicht den Zugriff auf Videos, die auf https://media.ccc.de/ veröffentlicht wurden (größtenteils Vortragsmitschnitte von CCC-Veranstaltungen) 16 | v0.3.0 (2022-12-27) 17 | - Add talk title to live streams (#35) 18 | - Show Re-Live videos (#34) 19 | - Test against modern Python versions 20 | de en 21 | all 22 | MIT 23 | https://media.ccc.de/ 24 | tobias-kodi@23.gs 25 | https://github.com/voc/plugin.video.media-ccc-de 26 | 27 | icon.png 28 | fanart.jpg 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | v0.3.0 (2022-12-27) 2 | - Add talk title to live streams (#35) 3 | - Show Re-Live videos (#34) 4 | - Test against modern Python versions 5 | 6 | v0.2.0 (2020-12-23) 7 | - The "F*** 2020" release 8 | - Remove option to fallback to unencrypted streams 9 | - Inital support for MPEG-DASH streams (#32) 10 | 11 | v0.1.2 (2018-01-01) 12 | - The guckwat! release 13 | - Select correct stream even when slides are present. (Fixes #20) 14 | 15 | v0.1.1 (2017-12-20) 16 | - The tuwat! release 17 | - Supports multiple translated streams (if available) 18 | 19 | v0.1.0 (2017-04-16) 20 | - EasterHegg 2017 release. 21 | - Use "slug" instead of obsolete "webgen_location". 22 | - Track coverage on Travis-CI. 23 | - Refactor GUI code into extra file. (also fixes #11) 24 | - Migrated translations from XML to PO. 25 | - Add "insecure" option for devices with old/broken TLS setup (fixes #2, #12). 26 | - Skip streams without urls. 27 | - Switch to streaming API v2. 28 | 29 | v0.0.5 (2016-02-29) 30 | - The everything-was-broken release 31 | - Fix route to capture complete path (fixes #5). 32 | - Update dependency to express required Kodi version. 33 | - Remove orig_mime since display_mime_type was dropped from the API. 34 | - Refactor recording code a bit (also fixes #9). 35 | - Use Travis-CI for some tests. 36 | - Make reading recording JSON a bit safer. 37 | - Add more metadata. 38 | 39 | v0.0.4 (2016-01-07) 40 | - The right-after-congress-release 41 | - switch to kodi-plugin-routing 42 | - better HTTP error handling 43 | - Prefer recordings with more audio streams (fixes #3) 44 | 45 | v0.0.3 (2015-08-13) 46 | - Live streaming of CCC events 47 | - Add a callback to increase view count on media.ccc.de 48 | 49 | v0.0.2 (2015-01-04) 50 | - Preferences for HD/SD and MP4/WEBM 51 | 52 | v0.0.1 (2014-12-26) 53 | - Initial version 54 | -------------------------------------------------------------------------------- /fanart.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/plugin.video.media-ccc-de/638fa38864e32dfea6b8a9dfc7e597e61b8cf170/fanart.jpg -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/plugin.video.media-ccc-de/638fa38864e32dfea6b8a9dfc7e597e61b8cf170/icon.png -------------------------------------------------------------------------------- /resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/plugin.video.media-ccc-de/638fa38864e32dfea6b8a9dfc7e597e61b8cf170/resources/__init__.py -------------------------------------------------------------------------------- /resources/language/resource.language.de_de/strings.po: -------------------------------------------------------------------------------- 1 | # Kodi language file 2 | # Addon id: plugin.video.media-ccc-de 3 | # Addon Provider: Tobias Gruetzmacher 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: Kodi-Addons\n" 7 | "POT-Creation-Date: 2016-12-27 13:07+0000\n" 8 | "PO-Revision-Date: 2020-12-23 12:00+0200\n" 9 | "Last-Translator: Tobias Gruetzmacher \n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Language: de\n" 14 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 15 | 16 | msgctxt "Addon Summary" 17 | msgid "Videos of Chaos Computer Club events." 18 | msgstr "Videos von Chaos Computer Club Veranstaltungen." 19 | 20 | msgctxt "Addon Description" 21 | msgid "This addon provides access to videos published on https://media.ccc.de/ (mostly lecture recordings of CCC events) " 22 | msgstr "Dieses Add-On ermöglicht den Zugriff auf Videos, die auf https://media.ccc.de/ veröffentlicht wurden (größtenteils Vortragsmitschnitte von CCC-Veranstaltungen)" 23 | 24 | msgctxt "#30000" 25 | msgid "media.ccc.de" 26 | msgstr "media.ccc.de" 27 | 28 | msgctxt "#30100" 29 | msgid "Quality" 30 | msgstr "Qualität" 31 | 32 | msgctxt "#30101" 33 | msgid "Prefer SD" 34 | msgstr "Bevorzuge SD" 35 | 36 | msgctxt "#30102" 37 | msgid "Prefer HD" 38 | msgstr "Bevorzuge HD" 39 | 40 | msgctxt "#30110" 41 | msgid "Format" 42 | msgstr "Format" 43 | 44 | msgctxt "#30111" 45 | msgid "Prefer MPEG (hardware-accelerated)" 46 | msgstr "Bevorzuge MPEG (hardware-beschleunigt)" 47 | 48 | msgctxt "#30112" 49 | msgid "Prefer WEBM (libre)" 50 | msgstr "Bevorzuge WEBM (libre)" 51 | 52 | msgctxt "#30120" 53 | msgid "Prefer MPEG-DASH for live streams" 54 | msgstr "Bevorzuge MPEG-DASH für Live-Streams" 55 | 56 | -------------------------------------------------------------------------------- /resources/language/resource.language.en_gb/strings.po: -------------------------------------------------------------------------------- 1 | # Kodi language file 2 | # Addon id: plugin.video.media-ccc-de 3 | # Addon Provider: Tobias Gruetzmacher 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: Kodi-Addons\n" 7 | "POT-Creation-Date: 2016-12-27 13:07+0000\n" 8 | "PO-Revision-Date: 2020-12-23 12:00+0200\n" 9 | "Last-Translator: Tobias Gruetzmacher \n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Language: en\n" 14 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 15 | 16 | msgctxt "Addon Summary" 17 | msgid "Videos of Chaos Computer Club events." 18 | msgstr "" 19 | 20 | msgctxt "Addon Description" 21 | msgid "This addon provides access to videos published on https://media.ccc.de/ (mostly lecture recordings of CCC events) " 22 | msgstr "" 23 | 24 | #Plugin name 25 | 26 | msgctxt "#30000" 27 | msgid "media.ccc.de" 28 | msgstr "" 29 | 30 | #Settings 31 | #empty strings from id 30001 to 30099 32 | 33 | msgctxt "#30100" 34 | msgid "Quality" 35 | msgstr "" 36 | 37 | msgctxt "#30101" 38 | msgid "Prefer SD" 39 | msgstr "" 40 | 41 | msgctxt "#30102" 42 | msgid "Prefer HD" 43 | msgstr "" 44 | 45 | #empty strings from id 30103 to 30109 46 | 47 | msgctxt "#30110" 48 | msgid "Format" 49 | msgstr "" 50 | 51 | msgctxt "#30111" 52 | msgid "Prefer MPEG (hardware-accelerated)" 53 | msgstr "" 54 | 55 | msgctxt "#30112" 56 | msgid "Prefer WEBM (libre)" 57 | msgstr "" 58 | 59 | msgctxt "#30120" 60 | msgid "Prefer MPEG-DASH for live streams" 61 | msgstr "" 62 | 63 | -------------------------------------------------------------------------------- /resources/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/plugin.video.media-ccc-de/638fa38864e32dfea6b8a9dfc7e597e61b8cf170/resources/lib/__init__.py -------------------------------------------------------------------------------- /resources/lib/gui.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | try: 5 | import xbmcgui 6 | except ImportError: 7 | import warnings 8 | warnings.warn('Not running under Kodi, GUI will not work!') 9 | xbmcgui = None 10 | 11 | 12 | class Fakexbmc(object): 13 | NOTIFICATION_ERROR = "ERR" 14 | NOTIFICATION_INFO = "INFO" 15 | 16 | class Dialog(object): 17 | def notification(self, title, msg, level, timeout): 18 | warnings.warn("%s: %s" % (level, msg)) 19 | 20 | 21 | if not xbmcgui: 22 | xbmcgui = Fakexbmc() 23 | 24 | 25 | def err(text): 26 | msg(text, xbmcgui.NOTIFICATION_ERROR, 30) 27 | 28 | 29 | def info(text): 30 | msg(text, xbmcgui.NOTIFICATION_INFO) 31 | 32 | 33 | def msg(text, level, time=15): 34 | xbmcgui.Dialog().notification('media.ccc.de', text, level, time * 1000) 35 | -------------------------------------------------------------------------------- /resources/lib/helpers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | 5 | def user_preference_sorter(prefer_quality, prefer_format, prefer_dash=False): 6 | def do_sort(obj): 7 | prio = 0 8 | 9 | if obj.type == 'dash': 10 | prio += 50 if prefer_dash else -50 11 | 12 | if obj.format == prefer_format: 13 | prio += 20 14 | 15 | # Bonus & penalty for exact matches, no score for "obj.hd == None" 16 | if obj.hd is True and prefer_quality == "hd": 17 | prio += 20 18 | elif obj.hd is False and prefer_quality == "sd": 19 | prio += 20 20 | elif obj.hd is True and prefer_quality == "sd": 21 | prio -= 10 22 | elif obj.hd is False and prefer_quality == "hd": 23 | prio -= 10 24 | 25 | # Prefer versions with "more" audio tracks 26 | try: 27 | translations = len(obj.languages) - 1 28 | prio += translations 29 | except AttributeError: 30 | pass 31 | 32 | # Prefer "native" over "translated" for now (streaming)... 33 | try: 34 | if obj.translated: 35 | prio -= 5 36 | except AttributeError: 37 | pass 38 | 39 | return -prio 40 | return do_sort 41 | 42 | 43 | def maybe_json(json, attr, default): 44 | try: 45 | return json[attr] if json is not None else default 46 | except KeyError: 47 | return default 48 | 49 | 50 | def json_date_to_info(json, field, info): 51 | if field not in json or not json[field] or len(json[field]) < 10: 52 | return 53 | 54 | try: 55 | y, m, d = [int(x) for x in json[field][0:10].split('-')] 56 | info['date'] = "%02d.%02d.%04d" % (d, m, y) 57 | info['year'] = y 58 | info['aired'] = "%04d-%02d-%02d" % (y, m, d) 59 | info['dateadded'] = "%04d-%02d-%02d" % (y, m, d) 60 | except ValueError: 61 | return 62 | 63 | 64 | def calc_aspect(s): 65 | try: 66 | aspect = [float(x) for x in s.split(':')] 67 | if len(aspect) == 2: 68 | return aspect[0] / aspect[1] 69 | except ValueError: 70 | return None 71 | -------------------------------------------------------------------------------- /resources/lib/http.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | import requests 5 | 6 | from .stream import Streams 7 | from .recording import Recordings 8 | from .relive import Relives, ReliveRecordings 9 | from . import gui 10 | 11 | BASE_URL = 'https://media.ccc.de/public/' 12 | LIVE_URL = 'https://streaming.media.ccc.de/streams/v2.json' 13 | RELIVE_URL = 'https://cdn.c3voc.de/relive/index.json' 14 | 15 | # BASE_URL = 'http://127.0.0.1:3000/public/' 16 | # LIVE_URL = 'http://127.0.0.1:3000/stream_v2.json' 17 | 18 | 19 | class FetchError(Exception): 20 | pass 21 | 22 | 23 | def fetch_data(what): 24 | try: 25 | req = requests.get(BASE_URL + what) 26 | return req.json() 27 | except requests.RequestException as e: 28 | gui.err('Can\'t fetch %s: %s' % (what, e)) 29 | raise FetchError(e) 30 | 31 | 32 | def count_view(event, src): 33 | try: 34 | data = {'event_id': event, 'src': src} 35 | requests.post(BASE_URL + 'recordings/count', data=data) 36 | except requests.RequestException as e: 37 | gui.info('Can\'t count view: %s' % e) 38 | 39 | 40 | def fetch_recordings(event): 41 | req = fetch_data('events/' + event) 42 | return Recordings(req) 43 | 44 | 45 | def fetch_live(): 46 | try: 47 | req = requests.get(LIVE_URL) 48 | return Streams(req.json()) 49 | except requests.exceptions.RequestException as e: 50 | gui.err('Can\'t fetch streams: %s' % e) 51 | raise FetchError(e) 52 | 53 | 54 | def fetch_relive_index(): 55 | try: 56 | req = requests.get(RELIVE_URL) 57 | return Relives(req.json()) 58 | except requests.exceptions.RequestException as e: 59 | gui.err('Can\'t fetch relive index: %s' % e) 60 | raise FetchError(e) 61 | 62 | 63 | def fetch_relive_recordings(url): 64 | try: 65 | req = requests.get(url) 66 | return ReliveRecordings(req.json()) 67 | except requests.exceptions.RequestException as e: 68 | gui.err('Can\'t fetch relive recordings: %s' % e) 69 | raise FetchError(e) 70 | -------------------------------------------------------------------------------- /resources/lib/kodi.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | import re 5 | 6 | try: 7 | import xbmc 8 | except ImportError: 9 | xbmc = None 10 | 11 | 12 | def major_version(): 13 | verstr = xbmc.getInfoLabel('System.BuildVersion') 14 | match = re.match(r'(\d+)\.', verstr) 15 | if match: 16 | return int(match.group(1)) 17 | return None 18 | 19 | 20 | def log(msg): 21 | if xbmc: 22 | xbmc.log(msg) 23 | else: 24 | print(msg) 25 | -------------------------------------------------------------------------------- /resources/lib/recording.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | from .helpers import user_preference_sorter, maybe_json 5 | from .kodi import log 6 | 7 | 8 | class Recordings(object): 9 | def __init__(self, json): 10 | self.recordings = [Recording(elem) for elem in json['recordings']] 11 | 12 | def recordings_sorted(self, quality, format, video=True): 13 | log('Requested quality %s and format %s' % (quality, format)) 14 | typematch = "video" if video else "audio" 15 | want = sorted(filter(lambda rec: (rec.type == typematch 16 | and not rec.folder.startswith('slides')), 17 | self.recordings), 18 | key=user_preference_sorter(quality, format)) 19 | log(str(want)) 20 | return want 21 | 22 | 23 | class Recording(object): 24 | def __init__(self, json): 25 | self.mime = maybe_json(json, 'mime_type', 'video/mp4') 26 | self.type, self.format = self.mime.split('/') 27 | self.hd = maybe_json(json, 'high_quality', True) 28 | self.url = json['recording_url'] 29 | self.length = maybe_json(json, 'length', 0) 30 | self.size = maybe_json(json, 'size', 0) 31 | self.folder = maybe_json(json, 'folder', '') 32 | lang = maybe_json(json, 'language', 'unk') 33 | if lang: 34 | self.languages = lang.split('-') 35 | else: 36 | self.languages = ('unk',) 37 | 38 | def __repr__(self): 39 | return "Recording" % (self.mime, self.hd, 40 | self.languages) 41 | 42 | def is_video(self): 43 | return self.type == 'video' 44 | 45 | def is_audio(self): 46 | return self.type == 'audio' 47 | -------------------------------------------------------------------------------- /resources/lib/relive.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | try: 5 | from urllib.parse import urlparse 6 | except ImportError: 7 | from urlparse import urlparse 8 | 9 | from .helpers import maybe_json 10 | 11 | 12 | class Relives(object): 13 | def __init__(self, json): 14 | self.recordings = [ReliveItem(elem) for elem in json] 15 | 16 | def by_conference(self, conf): 17 | items = list(filter(lambda x: x.project == conf, self.recordings)) 18 | # assumption: there is at most one entry per project 19 | return items[0] if len(items) == 1 else None 20 | 21 | 22 | class ReliveItem(object): 23 | def __init__(self, json): 24 | self.index_url = json['index_url'] 25 | self.project = json['project'] 26 | 27 | def get_url(self): 28 | return urlparse(self.index_url, 'https').geturl() 29 | 30 | 31 | class ReliveRecordings(object): 32 | def __init__(self, json): 33 | self.recordings = [ReliveRecording(el) for el in json] 34 | 35 | def unreleased(self): 36 | return list(filter(lambda rec: rec.mp4 != '', self.recordings)) 37 | 38 | 39 | class ReliveRecording(object): 40 | def __init__(self, json): 41 | self.duration = maybe_json(json, 'duration', 0) 42 | self.mp4 = maybe_json(json, 'mp4', '') 43 | self.thumbnail = maybe_json(json, 'thumbnail', '') 44 | self.room = maybe_json(json, 'room', '') 45 | self.title = json['title'] 46 | 47 | def get_video_url(self): 48 | return urlparse(self.mp4, 'https').geturl() 49 | 50 | def get_thumb_url(self): 51 | return urlparse(self.thumbnail, 'https').geturl() 52 | -------------------------------------------------------------------------------- /resources/lib/settings.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | from xbmcplugin import getSetting 5 | 6 | 7 | QUALITY = ["sd", "hd"] 8 | FORMATS = ["mp4", "webm"] 9 | 10 | 11 | def get_setting_int(plugin, name): 12 | val = getSetting(plugin.handle, name) 13 | if not val: 14 | val = '0' 15 | return int(val) 16 | 17 | 18 | def get_quality(plugin): 19 | return QUALITY[get_setting_int(plugin, 'quality')] 20 | 21 | 22 | def get_format(plugin): 23 | return FORMATS[get_setting_int(plugin, 'format')] 24 | 25 | 26 | def prefer_dash(plugin): 27 | val = getSetting(plugin.handle, 'dash') 28 | return val == 'true' 29 | -------------------------------------------------------------------------------- /resources/lib/stream.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function, division, absolute_import 3 | 4 | from .helpers import user_preference_sorter, maybe_json 5 | from .kodi import log 6 | 7 | 8 | class Streams(object): 9 | def __init__(self, json): 10 | self.conferences = [] 11 | for conference in json: 12 | self.conferences.append(Conference(conference)) 13 | 14 | 15 | class Conference(object): 16 | def __init__(self, json): 17 | self.rooms = [] 18 | # Ignore groups for now 19 | for group in json['groups']: 20 | self.rooms += [Room(elem, group['group']) 21 | for elem in group['rooms']] 22 | self.slug = json["slug"] 23 | self.name = json["conference"] 24 | 25 | 26 | class Room(object): 27 | def __init__(self, json, group=''): 28 | self.streams = [] 29 | for stream in json["streams"]: 30 | if len(stream["urls"]) > 0: 31 | for urlname, urldata in stream["urls"].items(): 32 | self.streams.append(Stream(urlname, urldata, stream)) 33 | self.slug = json["slug"] 34 | self.display = json["display"] 35 | if len(group) > 0: 36 | self.display = group + ": " + self.display 37 | 38 | self.current_talk_title = maybe_json(maybe_json(maybe_json(json, 'talks', {}), 'current', {}), 'title', '') 39 | 40 | def streams_sorted(self, quality, format, dash=False, video=True): 41 | log('Requested quality %s and format %s' % (quality, format)) 42 | typematch = ('video', 'dash') if video else ('audio', ) 43 | want = sorted(filter(lambda stream: stream.type in typematch, 44 | self.streams), 45 | key=user_preference_sorter(quality, format, dash)) 46 | return want 47 | 48 | 49 | class Stream(object): 50 | def __init__(self, name, data, stream): 51 | self.format = name 52 | if self.format == 'hls': 53 | self.format = 'mp4' 54 | self.hd = None 55 | if stream['videoSize'] is not None: 56 | self.hd = stream['videoSize'][0] >= 1280 57 | self.url = data['url'] 58 | self.translated = stream['isTranslated'] 59 | self.type = stream['type'] 60 | 61 | def __repr__(self): 62 | return '' % ( 63 | self.format, self.hd, self.type, self.translated) 64 | -------------------------------------------------------------------------------- /resources/settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | --------------------------------------------------------------------------------