diff --git a/README.md b/README.md index ac8111d..56528f1 100644 --- a/README.md +++ b/README.md @@ -33,25 +33,13 @@ Descarga el archivo .zip generado por Github e instálalo. * Colecciones * Watching * Listas de reproducción -* Sincronizar progreso de visionado con Filmin +* Sincronizar progreso de visionado con Filmin (Roto por ahora) * Soporte para Portugal y México * Watch Later ### Prioridades * Integración con Up Next -* Festivales -* Ordenar - -## Desarrollo -### Reversing -#### Static -Todos los tokens `CLIENT_ID` y `CLIENT_ID_SECRET` se encontraron decompilando la aplicación de Android. - -Personalmente uso [**jadx**](https://github.com/skylot/jadx) para la decompilación. - -#### Runtime -Gran parte de los endpoints del archivo `api.py` se han encontrado interceptando las peticiones HTTPS de la aplicación de Android - -El setup que uso personalmente es: -- [**mitmproxy**](https://mitmproxy.org), para interceptar las solicitudes -- [**MagiskTrustCert**](https://github.com/NVISOsecurity/MagiskTrustUserCerts), módulo de [Magisk](https://github.com/topjohnwu/Magisk) para el tráfico HTTPS +* Paginación +* Adaptar más métodos de `api.py` a la nueva uapi +* Migrar de `ListItem` a `InfoTagVideo` +* Arreglar la sincronización con Filmin diff --git a/addon.py b/addon.py index 0c955fb..e3380ba 100644 --- a/addon.py +++ b/addon.py @@ -1,3 +1,5 @@ +""" Runner """ + from resources.lib.app import run run() diff --git a/addon.xml b/addon.xml index 7bba190..538c8ad 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -9,18 +9,26 @@ video + Use Filmin on Kodi - NON-OFFICIAL client for watching content of Filmin + Client to watch Filmin content + Unofficial client, created by and for the community + Utiliza Filmin en Kodi - Cliente NO OFICIAL para visualizar contenido de Filmin - es + Cliente para visualizar contenido de Filmin + Cliente no oficial, creado por y para la comunidad + + es pt all GPL-3.0-or-later - https://github.com/pablouser1/plugin.video.filmin + https://filmin.com https://github.com/pablouser1/plugin.video.filmin - v1.6.4 (28/12/2023) - Ver después y mejor arte + v1.7.0 (02/01/2024) + - Sincronización con Filmin deshabilitada temporalmente + - Refactoring del código siguiendo los estándares PEP + - enums convertidos a enums del standard lib + - Config renombrado a Settings, junto con la global config resources/icon.png diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 8bd1ceb..6ab3c29 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -30,8 +30,8 @@ msgid "Region" msgstr "Region" msgctxt "#40003" -msgid "Allow syncing with Filmin" -msgstr "Allow syncing with Filmin" +msgid "Allow syncing with Filmin (BROKEN, IGNORED FOR NOW)" +msgstr "Allow syncing with Filmin (BROKEN, IGNORED FOR NOW)" msgctxt "#40004" msgid "Allow using tickets" @@ -175,8 +175,8 @@ msgid "Tickets" msgstr "Tickets" msgctxt "#40051" -msgid "This content is not avaiable. Do you want to rent it using a ticket? You have %d tickets left" -msgstr "This content is not avaiable. Do you want to rent it using a ticket? You have %d tickets left" +msgid "This content is not available. Do you want to rent it using a ticket? You have %d tickets left" +msgstr "This content is not available. Do you want to rent it using a ticket? You have %d tickets left" msgctxt "#40052" msgid "Choose a version" diff --git a/resources/language/resource.language.es_es/strings.po b/resources/language/resource.language.es_es/strings.po index 07d2b13..acd14c2 100644 --- a/resources/language/resource.language.es_es/strings.po +++ b/resources/language/resource.language.es_es/strings.po @@ -31,7 +31,7 @@ msgstr "Región" msgctxt "#40003" msgid "Allow syncing with Filmin" -msgstr "Permitir sincronización de visionado con Filmin" +msgstr "Permitir sincronización de visionado con Filmin (ROTO, IGNORADO POR AHORA)" msgctxt "#40004" msgid "Allow using tickets" @@ -175,7 +175,7 @@ msgid "Tickets" msgstr "Tickets" msgctxt "#40051" -msgid "This content is not avaiable. Do you want to rent it using a ticket? You have %d tickets left" +msgid "This content is not available. Do you want to rent it using a ticket? You have %d tickets left" msgstr "Este contenido no está disponible. ¿Quieres alquilarlo usando un ticket? Tienes %d tickets restantes" msgctxt "#40052" diff --git a/resources/lib/api.py b/resources/lib/api.py index 5730e8d..2c53e96 100644 --- a/resources/lib/api.py +++ b/resources/lib/api.py @@ -1,214 +1,317 @@ +""" HTTPS Api for Filmin """ + import requests -from xbmc import getLanguage, ISO_639_1 -from .exceptions.ApiV3Exception import ApiV3Exception -from .exceptions.UApiException import UApiException -from .exceptions.DialogException import DialogException -from .helpers.Misc import isDrm +from .exceptions.apiv3 import ApiV3Exception +from .exceptions.uapi import UApiException +from .exceptions.dialog import DialogException +from .helpers.misc import is_drm +from .helpers.headers import Headers + class Api: + """ + Class for handling API calls to Filmin + TODO: Split class into modules + """ + s = requests.Session() # Taken from es.filmin.app.BuildConfig TOKENS = { # Spain - 'es': { - 'CLIENT_ID': 'zXZXrpum7ayGcWlo', - 'CLIENT_SECRET': 'yICstBCQ8CKB8RF6KuDmr9R20xtfyYbm' + "es": { + "CLIENT_ID": "zXZXrpum7ayGcWlo", + "CLIENT_SECRET": "yICstBCQ8CKB8RF6KuDmr9R20xtfyYbm", }, # Portugal - 'pt': { - 'CLIENT_ID': 'zhiv2IKILLYNZ3pq', - 'CLIENT_SECRET': 'kzPKMK2aXJzFoHNWOCR6gcd60WTK1BL3' + "pt": { + "CLIENT_ID": "zhiv2IKILLYNZ3pq", + "CLIENT_SECRET": "kzPKMK2aXJzFoHNWOCR6gcd60WTK1BL3", }, # México - 'mx': { - 'CLIENT_ID': 'sse7QwjpcNoZgGZO', - 'CLIENT_SECRET': '2yqTm7thQLc2NQUQSbKehn7xrg1Pi59q' - } + "mx": { + "CLIENT_ID": "sse7QwjpcNoZgGZO", + "CLIENT_SECRET": "2yqTm7thQLc2NQUQSbKehn7xrg1Pi59q", + }, } - CLIENT_ID = "" - CLIENT_SECRET = "" - - DEVICE_MODEL = 'Kodi' - DEVICE_OS_VERSION = '12' - CLIENT_VERSION = "4.4.0" + client_id = "" + client_secret = "" - domain = 'es' + domain = "es" def __init__(self, domain: str): - self.s.headers["clientlanguage"] = getLanguage(ISO_639_1, True) - - self.s.headers["clientversion"] = self.CLIENT_VERSION - self.s.headers["X-Client-Version"] = self.CLIENT_VERSION - - self.s.headers["devicemodel"] = self.DEVICE_MODEL - self.s.headers["X-Device-Model"] = self.DEVICE_MODEL - - self.s.headers['deviceosversion'] = self.DEVICE_OS_VERSION - self.s.headers['X-Device-OS-Version'] = self.DEVICE_OS_VERSION + # Set headers + Headers.set_common(self.s) + Headers.set_old(self.s) + Headers.set_new(self.s) self.domain = domain tokens = self.TOKENS[domain] - self.CLIENT_ID = tokens['CLIENT_ID'] - self.CLIENT_SECRET = tokens['CLIENT_SECRET'] + self.client_id = tokens["CLIENT_ID"] + self.client_secret = tokens["CLIENT_SECRET"] + + self.s.headers["X-Client-Id"] = self.client_id + + def _get_base_url(self, uapi: bool = False) -> str: + """ + Get the base URL used depending on your domain - self.s.headers["X-Client-Id"] = self.CLIENT_ID + Parameters: + uapi - Use new Filmin Api + Source: + es.filmin.app.injector.modules.RestApiUrlProviderEx + """ - def getApiBaseUrl(self, useUapi: bool = False)-> str: - # Extracted from Android app: es.filmin.app.injector.modules.RestApiUrlProviderEx - subdomain = "uapi" if useUapi else "api" - host = "filminlatino" if self.domain == 'mx' else 'filmin' + subdomain = "uapi" if uapi else "api" + host = "filminlatino" if self.domain == "mx" else "filmin" return f"https://{subdomain}.{host}.{self.domain}" - def makeRequest(self, endpoint: str, method = 'GET', body: dict = {}, query: dict = {}, useUapi: bool = False): - base_url = self.getApiBaseUrl(useUapi) - res = self.s.request(method, base_url + endpoint, json=body, params=query) + def _req( + self, + endpoint: str, + body: dict = None, + query: dict = None, + uapi: bool = False + ): + """ + Sends the request + """ + + method = "GET" + + if body is not None: + method = "POST" + + base_url = self._get_base_url(uapi) + res = self.s.request( + method, + base_url + endpoint, + json=body, + params=query + ) # Avoid non JSON response - if res.headers.get('Content-Type') != 'application/json': - raise DialogException('Non JSON response') + if res.headers.get("Content-Type") != "application/json": + raise DialogException("Non JSON response") res_json = res.json() if res.ok: return res_json - if useUapi: - raise UApiException(res_json['error']) - raise ApiV3Exception(res_json['errors']) - - def login(self, username: str, password: str)-> dict: - res = self.makeRequest('/oauth/access_token', 'POST', { - "client_id": self.CLIENT_ID, - "client_secret": self.CLIENT_SECRET, - "grant_type": "password", - "password": password, - "username": username - }) + if uapi: + raise UApiException(res_json["error"]) + raise ApiV3Exception(res_json["errors"]) + + def login(self, username: str, password: str) -> dict: + """ + Login into Filmin using a username and a password + """ + + res = self._req( + "/oauth/access_token", + body={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "password", + "password": password, + "username": username, + }, + ) return res - def profiles(self)-> list: - res = self.makeRequest('/auth/profiles', useUapi=True) + def profiles(self) -> list: + """ + Get all profiles available + """ + + res = self._req("/auth/profiles", uapi=True) return res def logout(self): - self.makeRequest('/oauth/logout', 'POST') + """ + Logout of Filmin + Returns void + """ + + self._req("/oauth/logout", body={}) def user(self): - res = self.makeRequest(endpoint='/user') - return res['data'] + """ + Get user data + """ + + res = self._req(endpoint="/user") + return res["data"] def genres(self): - res = self.makeRequest(endpoint='/genres') - return res['data'] + """ + Get all media genres available (Action, Adventure...) + """ + + res = self._req(endpoint="/genres") + return res["data"] + + def catalog( + self, + item_type: str = "", + genre: int = -1, + subgenre: int = -1 + ): + """ + Filter media available by genre and subgenre + """ - def catalog(self, item_type: str = '', genre: int = -1, subgenre: int = -1): query = {} if item_type: - query['type'] = item_type + query["type"] = item_type if genre != -1 and subgenre != -1: - query['filter_entity'] = 'tag' - query['filter_id'] = subgenre + query["filter_entity"] = "tag" + query["filter_id"] = subgenre if genre != -1 and subgenre == -1: - query['filter_entity'] = 'genre' - query['filter_id'] = genre + query["filter_entity"] = "genre" + query["filter_id"] = genre + + res = self._req(endpoint="/media/catalog", query=query) + return res["data"] - res = self.makeRequest(endpoint='/media/catalog', query=query) - return res['data'] + def search(self, term: str) -> list: + """ + Search by title using a term + """ - def search(self, term: str)-> list: - res = self.makeRequest(endpoint='/searcher', query={ - 'q': term - }) + res = self._req(endpoint="/searcher", query={"q": term}) # Return only allowed items (tvshows, movies...) - return [item for item in res['data'] if 'type' in item] + return [item for item in res["data"] if "type" in item] - def purchased(self)-> list: - res = self.makeRequest(endpoint='/user/purchased/medias') - return res['data'] + def purchased(self) -> list: + """ + Get all media purchased + """ + + res = self._req(endpoint="/user/purchased/medias") + return res["data"] + + def highlighteds(self) -> list: + """ + Get trending, this is usually the first thing to show up in Android + """ - def highlighteds(self)-> list: items = [] - res = self.makeRequest(endpoint='/highlighteds/home') + res = self._req(endpoint="/highlighteds/home") - for item in res['data']: - items.append(item['item']['data']) + for item in res["data"]: + items.append(item["item"]["data"]) return items - def collections(self)-> list: - res = self.makeRequest(endpoint='/collections') - return res['data'] + def collections(self) -> list: + """ + Get all collections available + """ + + res = self._req(endpoint="/collections") + return res["data"] + + def collection(self, collection_id: int) -> list: + """ + Get all media from a specific collection + """ + + res = self._req(endpoint=f"/collections/{collection_id}/medias") + return res["data"] - def collection(self, collection_id: int)-> list: - res = self.makeRequest(endpoint=f'/collections/{collection_id}/medias') - return res['data'] + def watching(self) -> list: + """ + Get all unfinished media + """ - def watching(self)-> list: items = [] - res = self.makeRequest(endpoint='/auth/keep-watching', useUapi=True) - for item in res['data']: - items.append(item['media']) + res = self._req(endpoint="/auth/keep-watching", uapi=True) + for item in res["data"]: + items.append(item["media"]) return items - def playlists(self)-> list: + def playlists(self) -> list: """ Get user's playlists """ - res = self.makeRequest('/user/playlists') - return res['data'] + res = self._req("/user/playlists") + return res["data"] def playlist(self, playlist_id: int): - res = self.makeRequest(f'/user/playlists/{playlist_id}/medias') - return res['data'] + """ + Get all media for a playlist + """ + + res = self._req(f"/user/playlists/{playlist_id}/medias") + return res["data"] - def getMediaSimple(self, item_id: int): + def media_simple(self, item_id: int): """ Get details of media """ - res = self.makeRequest(endpoint=f'/media/{item_id}/simple') - return res['data'] + res = self._req(endpoint=f"/media/{item_id}/simple") + return res["data"] def seasons(self, item_id: int): - res = self.getMediaSimple(item_id) - return res['seasons']['data'] + """ + Get all seasons of a show + """ + + res = self.media_simple(item_id) + return res["seasons"]["data"] def episodes(self, item_id: int, season_id: int): + """ + Get all episodes of a season + """ + items = [] seasons = self.seasons(item_id) for season in seasons: - if int(season_id) == season['id']: + if int(season_id) == season["id"]: items = season["episodes"]["data"] return items - def watchLater(self)-> list: - res = self.makeRequest(endpoint='/auth/watch-later', useUapi=True) - return res['data'] + def watch_later(self) -> list: + """ + Get all media added to watch later + """ + + res = self._req(endpoint="/auth/watch-later", uapi=True) + return res["data"] + + def use_tickets(self, item_id: int): + """ + Rent media using a ticket + """ - def useTicket(self, item_id: int): - self.makeRequest(endpoint='/user/tickets/activate', method='POST', body={ - 'id': item_id - }) + self._req(endpoint="/user/tickets/activate", body={"id": item_id}) - def getStreams(self, item_id: int) -> dict: - res = self.makeRequest(endpoint=f'/version/{item_id}') + def streams(self, item_id: int) -> dict: + """ + Get all media versions available (dubbed, subtitled...) + """ + + res = self._req(endpoint=f"/version/{item_id}") streams = {} # -- Single feed -- # - if not 'feeds' in res: - if not isDrm(res.get('type', 'FLVURL')): + if "feeds" not in res: + if not is_drm(res.get("type", "FLVURL")): # Add support for v1 (DRM-Free) video - res['src'] = res.get('FLVURL') or res.get('src') - res['type'] = 'FLVURL' + res["src"] = res.get("FLVURL") or res.get("src") + res["type"] = "FLVURL" # We have to convert it to the multi-feed response streams = { - 'feeds': [res], - 'media_viewing_id': res['media_viewing_id'], - 'xml': res['xml'] + "feeds": [res], + "media_viewing_id": res["media_viewing_id"], + "xml": res["xml"], } # -- More than one feed -- # else: @@ -218,8 +321,16 @@ def getStreams(self, item_id: int) -> dict: return streams # -- HELPERS -- # - def setToken(self, token: str): - self.s.headers["Authorization"] = f'Bearer {token}' + def set_token(self, token: str): + """ + Add auth token to HTTP session header + """ + + self.s.headers["Authorization"] = f"Bearer {token}" + + def set_profile_id(self, profile_id: str): + """ + Add profile id to HTTP session header + """ - def setProfileId(self, profile_id: str): - self.s.headers['x-user-profile-id'] = profile_id + self.s.headers["x-user-profile-id"] = profile_id diff --git a/resources/lib/app.py b/resources/lib/app.py index 42cd347..dc9de05 100644 --- a/resources/lib/app.py +++ b/resources/lib/app.py @@ -1,15 +1,21 @@ +""" Module entrypoint """ + from .routes import dispatch -from .session import askLogin -from .common import config, api +from .session import ask_login +from .common import settings, api, _PARAMS + def run(): + """App entrypoint""" + # Check if user already has a session, ask for credentials if not - if config.hasLoginData(): - token_info = config.getToken() - profile_id = config.getProfileId() - api.setToken(token_info['access']) - api.setProfileId(profile_id) + if settings.is_logged_in(): + token_info = settings.get_auth() + profile_id = settings.get_profile_id() + api.set_token(token_info["access"]) + api.set_profile_id(profile_id) else: - askLogin() + ask_login() - dispatch() + # Run view + dispatch(_PARAMS) diff --git a/resources/lib/common.py b/resources/lib/common.py index 9d4b903..1569106 100644 --- a/resources/lib/common.py +++ b/resources/lib/common.py @@ -1,10 +1,16 @@ +""" Constants used throughout the execution of the plugin """ + from sys import argv from urllib.parse import parse_qsl -from .config import Config +from .settings import Settings from .api import Api +# Plugin url in plugin:// notation. _URL = argv[0] +# Plugin handle as an integer number. _HANDLE = int(argv[1]) +# Plugin query as a dict _PARAMS = dict(parse_qsl(argv[2][1:])) -config = Config() -api = Api(config.getDomain()) + +settings = Settings() +api = Api(settings.get_domain()) diff --git a/resources/lib/config.py b/resources/lib/config.py deleted file mode 100644 index 1f80d2e..0000000 --- a/resources/lib/config.py +++ /dev/null @@ -1,43 +0,0 @@ -from xbmcaddon import Addon - -class Config: - addon = Addon('plugin.video.filmin') - - def getLocalizedString(self, l_id: int)-> str: - return self.addon.getLocalizedString(l_id) - - # Check if user has already has an access token - def hasLoginData(self)-> bool: - if self.addon.getSettingString('access_token'): - return True - return False - - def getDomain(self)-> str: - return self.addon.getSettingString('domain') - - def getToken(self)-> dict: - access = self.addon.getSettingString('access_token') - return { - 'access': access - } - - def getUserId(self)-> int: - return self.addon.getSettingInt('user_id') - - def getProfileId(self)-> str: - return self.addon.getSettingString('profile_id') - - def canBuy(self)-> bool: - return self.addon.getSettingBool('tickets') - - def canSync(self)-> bool: - return self.addon.getSettingBool('sync') - - def setAuth(self, access_token: str, refresh_token: str, username: str, user_id: int): - self.addon.setSettingString('access_token', access_token) - self.addon.setSettingString('refresh_token', refresh_token) - self.addon.setSettingString('username', username) - self.addon.setSettingInt('user_id', user_id) - - def setProfileId(self, profile_id: str): - self.addon.setSettingString('profile_id', profile_id) diff --git a/resources/lib/constants.py b/resources/lib/constants.py index 5adec31..3910f6c 100644 --- a/resources/lib/constants.py +++ b/resources/lib/constants.py @@ -1,20 +1,31 @@ -from .helpers.Misc import enum +""" Constants """ -ROUTES = enum( - HOME='home', - CATALOG='catalog', - SEARCH='search', - PURCHASED='purchased', - WATCHING='watching', - HIGHLIGHTEDS='highlighteds', - PLAYLISTS='playlists', - PLAYLIST='playlist', - COLLECTIONS='collections', - COLLECTION='collection', - SEASONS='seasons', - EPISODES='episodes', - WATCHLATER='watchlater', - PLAYER='player', - LOGOUT='logout', - PROFILE='profile' -) +from enum import Enum + + +class Routes(Enum): + """Available routes""" + + HOME = "home" + CATALOG = "catalog" + SEARCH = "search" + PURCHASED = "purchased" + WATCHING = "watching" + HIGHLIGHTEDS = "highlighteds" + PLAYLISTS = "playlists" + PLAYLIST = "playlist" + COLLECTIONS = "collections" + COLLECTION = "collection" + SEASONS = "seasons" + EPISODES = "episodes" + WATCHLATER = "watchlater" + PLAYER = "player" + LOGOUT = "logout" + PROFILE = "profile" + + +class MediaTypes(Enum): + """List of available media types""" + + VIDEOS = ("short", "film", "episode") + FOLDERS = ("serie", "season") diff --git a/resources/lib/dispatcher.py b/resources/lib/dispatcher.py index 5bb960f..51f2127 100644 --- a/resources/lib/dispatcher.py +++ b/resources/lib/dispatcher.py @@ -1,29 +1,51 @@ +""" Dispacher for router """ + from .common import _PARAMS +from .constants import Routes + class Dispatcher: + """ + Handle routing + """ + functions = {} args = {} - def register(self, route: str, args = []): - def add(f): - if route in self.functions: - raise Exception(f'{route} route already exists!') - self.functions[route] = f - self.args[route] = args - return f + def register(self, route: Routes, args: list = None): + """ + Add route to system + """ + + if args is None: + args = [] + + val = route.value + + def add(func): + if val in self.functions: + raise ValueError(f"{route} route already exists!") + + self.functions[val] = func + self.args[val] = args + return func return add def run(self, route: str): + """ + Run current route, taken from _PARMS from .config + """ + if route not in self.functions: - raise Exception('Route not valid') + raise ValueError("Route not valid") args = [] # Add args if self.args[route]: for arg in self.args[route]: if arg not in _PARAMS: - raise Exception('Param not found in URL!') + raise ValueError("Param not found in URL!") args.append(_PARAMS[arg]) diff --git a/resources/lib/exceptions/StreamException.py b/resources/lib/exceptions/StreamException.py deleted file mode 100644 index 846df37..0000000 --- a/resources/lib/exceptions/StreamException.py +++ /dev/null @@ -1,6 +0,0 @@ -from xbmcgui import Dialog - -class StreamException(Exception): - def __init__(self): - super().__init__() - Dialog().ok('Stream error', 'No available streams') diff --git a/resources/lib/exceptions/ApiV3Exception.py b/resources/lib/exceptions/apiv3.py similarity index 70% rename from resources/lib/exceptions/ApiV3Exception.py rename to resources/lib/exceptions/apiv3.py index e280caa..7be9f9a 100644 --- a/resources/lib/exceptions/ApiV3Exception.py +++ b/resources/lib/exceptions/apiv3.py @@ -1,9 +1,12 @@ +""" Apiv3 exception module """ from xbmcgui import Dialog + class ApiV3Exception(Exception): """ Throw exception when HTTP code is diferent from 2XX """ + def __init__(self, errors: str): super().__init__() - Dialog().ok('Filmin API Error', errors) + Dialog().ok("Filmin API Error", errors) diff --git a/resources/lib/exceptions/DialogException.py b/resources/lib/exceptions/dialog.py similarity index 71% rename from resources/lib/exceptions/DialogException.py rename to resources/lib/exceptions/dialog.py index 911e159..ecdac93 100644 --- a/resources/lib/exceptions/DialogException.py +++ b/resources/lib/exceptions/dialog.py @@ -1,9 +1,12 @@ +""" Dialog exception module """ from xbmcgui import Dialog + class DialogException(Exception): """ Generic exception using Dialogs """ + def __init__(self, message: str): super().__init__() - Dialog().ok('Error', message) + Dialog().ok("Error", message) diff --git a/resources/lib/exceptions/DRMException.py b/resources/lib/exceptions/drm.py similarity index 55% rename from resources/lib/exceptions/DRMException.py rename to resources/lib/exceptions/drm.py index e68ed0b..ceb4833 100644 --- a/resources/lib/exceptions/DRMException.py +++ b/resources/lib/exceptions/drm.py @@ -1,9 +1,15 @@ +""" DRM exception module """ from xbmcgui import Dialog + class DRMException(Exception): """ Throw exception when Inputstream helper does not start """ + def __init__(self): super().__init__() - Dialog().ok('DRM Error', 'Inputstream Helper is not active. Is it enabled?') + Dialog().ok( + "DRM Error", + "Inputstream Helper is not active. Is it enabled?" + ) diff --git a/resources/lib/exceptions/stream.py b/resources/lib/exceptions/stream.py new file mode 100644 index 0000000..54eec27 --- /dev/null +++ b/resources/lib/exceptions/stream.py @@ -0,0 +1,12 @@ +""" Stream exception module """ +from xbmcgui import Dialog + + +class StreamException(Exception): + """ + Throw exception when there are no available streams + """ + + def __init__(self): + super().__init__() + Dialog().ok("Stream error", "No available streams") diff --git a/resources/lib/exceptions/UApiException.py b/resources/lib/exceptions/uapi.py similarity index 69% rename from resources/lib/exceptions/UApiException.py rename to resources/lib/exceptions/uapi.py index c0cb983..16abcb3 100644 --- a/resources/lib/exceptions/UApiException.py +++ b/resources/lib/exceptions/uapi.py @@ -1,9 +1,12 @@ +""" UApi exception module """ from xbmcgui import Dialog + class UApiException(Exception): """ Throw exception when HTTP code is diferent from 2XX """ + def __init__(self, error: dict): super().__init__() - Dialog().ok('Filmin API Error', error['title']) + Dialog().ok("Filmin API Error", error["title"]) diff --git a/resources/lib/helpers/Art.py b/resources/lib/helpers/Art.py deleted file mode 100644 index 5b2c892..0000000 --- a/resources/lib/helpers/Art.py +++ /dev/null @@ -1,58 +0,0 @@ -class Art: - @staticmethod - def apiv3(artworks: list)-> dict: - """ - Sorts art for Filmin Menus - """ - arts = {} - highlighted = None - - for art in artworks: - if art['image_type'] == 'poster': - arts.update({"poster": art['path']}) - arts.update({"thumb": art['path']}) - elif art['image_type'] == 'card': - arts.update({"fanart": art['path']}) - elif art['image_type'] == 'highlighted': - highlighted = art['path'] - elif art['image_type'] == 'coverart': - arts.update({"banner": art['path']}) - arts.update({"landscape": art['path']}) - arts.update({"icon": art['path']}) - - if highlighted != None: - if arts.get("banner") == None: - arts.update({"banner": highlighted}) - if arts.get("landscape") == None: - arts.update({"landscape": highlighted}) - if arts.get("icon") == None: - arts.update({"icon": highlighted}) - - return arts - - @staticmethod - def uapi(item: dict)-> dict: - thumb = item.get("image_poster") - poster = item.get("image_poster") - fanart = item.get("image_card") - banner = item.get("image_coverart") - landscape = item.get("image_coverart") - icon = item.get("image_coverart") - highlighted = item.get("image_highlighted") - - if highlighted != None: - if banner == None: - banner = highlighted - if landscape == None: - landscape = highlighted - if icon == None: - icon = highlighted - - return { - "thumb": thumb, - "poster": poster, - "banner": banner, - "fanart": fanart, - "landscape": landscape, - "icon": icon - } diff --git a/resources/lib/helpers/ListItemExtra.py b/resources/lib/helpers/ListItemExtra.py deleted file mode 100644 index b1964de..0000000 --- a/resources/lib/helpers/ListItemExtra.py +++ /dev/null @@ -1,93 +0,0 @@ -from xbmcgui import ListItem -from .Art import Art -from ..common import config - -class ListItemExtra: - @staticmethod - def video(url: str, item: dict) -> ListItem: - if item.get('_type'): - list_item = ListItemExtra.videoUapi(url, item) - else: - list_item = ListItemExtra.videoApiv3(url, item) - - # Common - list_item.setProperty('isPlayable', 'true') - list_item.setIsFolder(False) - return list_item - - @staticmethod - def folder(url: str, item: dict) -> ListItem: - if item.get('_type'): - list_item = ListItemExtra.folderUapi(url, item) - else: - list_item = ListItemExtra.folderApiv3(url, item) - - return list_item - - @staticmethod - def videoUapi(url: str, item: dict) -> ListItem: - list_item = ListItem(item['title'], path=url) - info = { - "title": item["title"], - "year": item["year"], - "plot": item["excerpt"], - "director": item['director_names'], - "rating": item["avg_votes"], - "duration": item["duration_in_minutes"] * 60 # Filmin returns duration in minutes, Kodi wants it in seconds - } - list_item.setInfo('video', info) - # ART - list_item.setArt(Art.uapi(item)) - return list_item - - @staticmethod - def videoApiv3(url: str, item: dict) -> ListItem: - list_item = ListItem(item['title'], path=url) - info = { - "title": item["title"], - "originaltitle": item["original_title"], - "year": item["year"], - "plot": item["excerpt"], - "director": item["first_director"], - "rating": float(item["avg_votes_press"]) if item.get("avg_votes_press") else None, - "userrating": item["avg_votes_users"] if item.get("avg_votes_users") else None, - "duration": item["duration"] * 60 # Filmin returns duration in minutes, Kodi wants it in seconds - } - - if item.get('is_premier', False): - info['plot'] += f'\n\n({config.getLocalizedString(40047)})' - - list_item.setInfo('video', info) - # ART - art = Art.apiv3(item["imageResources"]["data"]) - list_item.setArt(art) - return list_item - - @staticmethod - def folderUapi(url: str, item: dict) -> ListItem: - list_item = ListItem(item['title'], path=url) - info = { - "title": item["title"], - "year": item["year"], - "plot": item["excerpt"], - "director": item['director_names'], - "rating": item["avg_votes"], - "duration": item["duration_in_minutes"] * 60 # Filmin returns duration in minutes, Kodi wants it in seconds - } - # ART - list_item.setArt(Art.uapi(item)) - return list_item - - @staticmethod - def folderApiv3(url: str, item: dict) -> ListItem: - list_item = ListItem(item['title'], path=url) - info = { - "title": item["title"], - "plot": item.get('excerpt') - } - list_item.setInfo('video', info) - if 'imageResources' in item: - art = Art.apiv3(item["imageResources"]["data"]) - list_item.setArt(art) - - return list_item diff --git a/resources/lib/helpers/Misc.py b/resources/lib/helpers/Misc.py deleted file mode 100644 index 026188b..0000000 --- a/resources/lib/helpers/Misc.py +++ /dev/null @@ -1,5 +0,0 @@ -def enum(**enums): - return type('Enum', (), enums) - -def isDrm(stream_type: str)-> bool: - return stream_type in ['dash+http+widevine', 'dash+https+widevine'] diff --git a/resources/lib/helpers/Types.py b/resources/lib/helpers/Types.py deleted file mode 100644 index f200bb0..0000000 --- a/resources/lib/helpers/Types.py +++ /dev/null @@ -1,3 +0,0 @@ -class Types: - videos = ("short", "film", "episode") - folders = ("serie", "season") diff --git a/resources/lib/helpers/art.py b/resources/lib/helpers/art.py new file mode 100644 index 0000000..df364d8 --- /dev/null +++ b/resources/lib/helpers/art.py @@ -0,0 +1,70 @@ +""" Art module """ + + +class Art: + """ + Convert Filmin art structure to Kodi + """ + + @staticmethod + def apiv3(artworks: list) -> dict: + """ + Apiv3 flavour + """ + + arts = {} + highlighted = None + + for art in artworks: + if art["image_type"] == "poster": + arts.update({"poster": art["path"]}) + arts.update({"thumb": art["path"]}) + elif art["image_type"] == "card": + arts.update({"fanart": art["path"]}) + elif art["image_type"] == "highlighted": + highlighted = art["path"] + elif art["image_type"] == "coverart": + arts.update({"banner": art["path"]}) + arts.update({"landscape": art["path"]}) + arts.update({"icon": art["path"]}) + + if highlighted is not None: + if arts.get("banner") is None: + arts.update({"banner": highlighted}) + if arts.get("landscape") is None: + arts.update({"landscape": highlighted}) + if arts.get("icon") is None: + arts.update({"icon": highlighted}) + + return arts + + @staticmethod + def uapi(item: dict) -> dict: + """ + Uapi flavour + """ + + thumb = item.get("image_poster") + poster = item.get("image_poster") + fanart = item.get("image_card") + banner = item.get("image_coverart") + landscape = item.get("image_coverart") + icon = item.get("image_coverart") + highlighted = item.get("image_highlighted") + + if highlighted is not None: + if banner is None: + banner = highlighted + if landscape is None: + landscape = highlighted + if icon is None: + icon = highlighted + + return { + "thumb": thumb, + "poster": poster, + "banner": banner, + "fanart": fanart, + "landscape": landscape, + "icon": icon, + } diff --git a/resources/lib/helpers/headers.py b/resources/lib/helpers/headers.py new file mode 100644 index 0000000..59202ca --- /dev/null +++ b/resources/lib/helpers/headers.py @@ -0,0 +1,45 @@ +""" Headers helper """ + +from requests import Session +from xbmc import getLanguage, ISO_639_1 + + +class Headers: + """Common Filmin headers""" + + DEVICE_MODEL = "Kodi" + DEVICE_OS_VERSION = "12" + CLIENT_VERSION = "4.14.0" + + @staticmethod + def set_new(session: Session): + """ + Updates session headers with X-* keys + Used in both apiv3 and uapi + """ + + session.headers.update({ + "X-Client-Version": Headers.CLIENT_VERSION, + "X-Device-Model": Headers.DEVICE_MODEL, + "X-Device-OS-Version": Headers.DEVICE_OS_VERSION, + }) + + @staticmethod + def set_old(session: Session): + """ + Updates session headers with old keys + Used for mediamark, apiv3 and uapi + """ + + session.headers.update({ + "clientversion": Headers.CLIENT_VERSION, + "devicemodel": Headers.DEVICE_MODEL, + "deviceosversion": Headers.DEVICE_OS_VERSION, + }) + + @staticmethod + def set_common(session: Session): + """Updates session headers with keys common for both old and new""" + session.headers.update({ + "clientlanguage": getLanguage(ISO_639_1, True) + }) diff --git a/resources/lib/helpers/listitem_extra.py b/resources/lib/helpers/listitem_extra.py new file mode 100644 index 0000000..a94e44f --- /dev/null +++ b/resources/lib/helpers/listitem_extra.py @@ -0,0 +1,117 @@ +""" ListItem Helper """ + +from xbmcgui import ListItem +from .art import Art +from ..common import settings + + +class ListItemExtra: + """Helper for ListItem generation from Filmin data""" + + @staticmethod + def video(url: str, item: dict) -> ListItem: + """ListItem for individiual video""" + + if item.get("_type"): + list_item = ListItemExtra.video_uapi(url, item) + else: + list_item = ListItemExtra.video_apiv3(url, item) + + # Common + list_item.setProperty("isPlayable", "true") + list_item.setIsFolder(False) + return list_item + + @staticmethod + def folder(url: str, item: dict) -> ListItem: + """ListItem for individiual folder""" + + if item.get("_type"): + list_item = ListItemExtra.folder_uapi(url, item) + else: + list_item = ListItemExtra.folder_apiv3(url, item) + + return list_item + + @staticmethod + def video_uapi(url: str, item: dict) -> ListItem: + """Video uapi flavour""" + + list_item = ListItem(item["title"], path=url) + info = { + "title": item["title"], + "year": item["year"], + "plot": item["excerpt"], + "director": item["director_names"], + "rating": item["avg_votes"], + # Filmin returns duration in minutes, Kodi wants it in seconds + "duration": item["duration_in_minutes"] * 60, + } + list_item.setInfo("video", info) + # ART + list_item.setArt(Art.uapi(item)) + return list_item + + @staticmethod + def video_apiv3(url: str, item: dict) -> ListItem: + """Video apiv3 flavour""" + + list_item = ListItem(item["title"], path=url) + info = { + "title": item["title"], + "originaltitle": item["original_title"], + "year": item["year"], + "plot": item["excerpt"], + "director": item["first_director"], + "rating": float(item["avg_votes_press"]) + if item.get("avg_votes_press") + else None, + "userrating": item["avg_votes_users"] + if item.get("avg_votes_users") + else None, + # Filmin returns duration in minutes, Kodi wants it in seconds + "duration": item["duration"] * 60, + } + + if item.get("is_premier", False): + info["plot"] += f"\n\n({settings.get_localized_string(40047)})" + + list_item.setInfo("video", info) + # ART + art = Art.apiv3(item["imageResources"]["data"]) + list_item.setArt(art) + return list_item + + @staticmethod + def folder_uapi(url: str, item: dict) -> ListItem: + """Folder uapi flavour""" + + list_item = ListItem(item["title"], path=url) + info = { + "title": item["title"], + "year": item["year"], + "plot": item["excerpt"], + "director": item["director_names"], + "rating": item["avg_votes"], + # Filmin returns duration in minutes, Kodi wants it in seconds + "duration": item["duration_in_minutes"] * 60, + } + + list_item.setInfo("video", info) + + # ART + list_item.setArt(Art.uapi(item)) + return list_item + + @staticmethod + def folder_apiv3(url: str, item: dict) -> ListItem: + """Folder apiv3 flavour""" + + list_item = ListItem(item["title"], path=url) + info = {"title": item["title"], "plot": item.get("excerpt")} + list_item.setInfo("video", info) + if "imageResources" in item: + art = Art.apiv3(item["imageResources"]["data"]) + list_item.setArt(art) + + return list_item diff --git a/resources/lib/helpers/misc.py b/resources/lib/helpers/misc.py new file mode 100644 index 0000000..bf96423 --- /dev/null +++ b/resources/lib/helpers/misc.py @@ -0,0 +1,19 @@ +""" Helpers that aren't from a specific category """ + +from urllib.parse import urlencode + + +def enum(**enums): + """Emulate enum type""" + return type("Enum", (), enums) + + +def is_drm(stream_type: str) -> bool: + """Checks if stream has DRM""" + return stream_type in ["dash+http+widevine", "dash+https+widevine"] + + +def build_kodi_url(url: str, query: dict) -> str: + """Converts a dictionary into an HTTP GET query""" + query_str = urlencode(query) + return f"{url}?{query_str}" diff --git a/resources/lib/helpers/Render.py b/resources/lib/helpers/render.py similarity index 55% rename from resources/lib/helpers/Render.py rename to resources/lib/helpers/render.py index a7800b2..8bf7cf9 100644 --- a/resources/lib/helpers/Render.py +++ b/resources/lib/helpers/render.py @@ -1,79 +1,98 @@ +""" Render helper """ + import xbmcgui import xbmcplugin -from ..common import _HANDLE, _URL, _PARAMS -from .Types import Types -from .ListItemExtra import ListItemExtra +from ..common import _HANDLE, _PARAMS, _URL +from ..constants import Routes, MediaTypes +from .misc import build_kodi_url +from .listitem_extra import ListItemExtra + class Render: + """Helper for Kodi menu building from Filmin data""" + @staticmethod - def static(items: list)-> list: + def static(items: list) -> list: """ Render static folders """ listing = [] for item in items: list_item = xbmcgui.ListItem(label=item["title"]) - info = { - "title": item["title"] - } - url = '{0}?route={1}'.format(_URL, item["id"]) - list_item.setInfo('video', info) + info = {"title": item["title"]} + url = build_kodi_url(_URL, { + "route": item["id"] + }) + list_item.setInfo("video", info) listing.append((url, list_item, True)) return listing @staticmethod - def videos(items: list)-> list: + def videos(items: list) -> list: """ Render videos fetched from Filmin API """ listing = [] for item in items: - url = '{0}?route=player&id={1}'.format(_URL, item["id"]) + url = build_kodi_url(_URL, { + "route": Routes.PLAYER.value, + "id": item["id"] + }) + list_item = ListItemExtra.video(url, item) listing.append((url, list_item, False)) return listing @staticmethod - def folders(items: list, route: str = '')-> list: + def folders(items: list, route: str = "") -> list: """ Render folders fetched from Filmin API """ listing = [] for item in items: if not route: - if item['type'] == Types.folders[0]: - route = 'seasons' - url = '{0}?route={1}&id={2}'.format(_URL, route, item["id"]) - if route == 'episodes': + if item["type"] == MediaTypes.FOLDERS.value[0]: + route = Routes.SEASONS.value + + query = { + "route": route, + "id": item["id"] + } + + if route == Routes.EPISODES.value: # Add show id to URL - url += '&item_id={0}'.format(_PARAMS['id']) + query.update({"item_id": _PARAMS["id"]}) + + url = build_kodi_url(_URL, query) + list_item = ListItemExtra.folder(url, item) listing.append((url, list_item, True)) return listing @staticmethod - def mix(items: list, goTo: str = '')-> list: + def mix(items: list, go_to: str = "") -> list: """ Render folder containing both folders and videos """ + videos = [] folders = [] for item in items: - if item["type"] in Types.videos: + if item["type"] in MediaTypes.VIDEOS.value: videos.append(item) else: folders.append(item) videos_listing = Render.videos(videos) - folders_listing = Render.folders(folders, goTo) + folders_listing = Render.folders(folders, go_to) listing = videos_listing + folders_listing return listing @staticmethod - def createDirectory(listing: list): + def create_directory(listing: list): """ Append folder to Kodi """ diff --git a/resources/lib/models/__init__.py b/resources/lib/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/models/mediamark_data.py b/resources/lib/models/mediamark_data.py new file mode 100644 index 0000000..abc11b5 --- /dev/null +++ b/resources/lib/models/mediamark_data.py @@ -0,0 +1,14 @@ +""" Mediamark model module """ +from dataclasses import dataclass + + +@dataclass +class MediamarkData: + """Wrapper for all data needed for mediamark""" + + user_id: int + profile_id: str + media_id: int + version_id: int + media_viewing_id: int + session_id: int diff --git a/resources/lib/player/Handler.py b/resources/lib/player/Handler.py deleted file mode 100644 index 0cec7d5..0000000 --- a/resources/lib/player/Handler.py +++ /dev/null @@ -1,87 +0,0 @@ -from xbmc import Monitor -from xbmcgui import Dialog, ListItem -from xbmcplugin import setResolvedUrl -from ..common import api, config, _HANDLE -from ..helpers.ListItemExtra import ListItemExtra -from ..helpers.Misc import isDrm -from .Player import Player -from ..exceptions.DRMException import DRMException -from ..exceptions.StreamException import StreamException - -class Play(): - PROTOCOL = 'mpd' - DRM = 'com.widevine.alpha' - item = {} - canWatch = True - bought = False - - def __init__(self, el_id: int): - self.item = api.getMediaSimple(el_id) - self.canWatch = len(self.item['user_data']['can_watch']['data']) > 0 if 'can_watch' in self.item['user_data'] else True - - def buyMedia(self): - user = api.user() - tickets = len(user['tickets']['data']) - self.bought = Dialog().yesno(config.getLocalizedString(40050), config.getLocalizedString(40051) % tickets) - if self.bought: - api.useTicket(self.item['id']) - - def versionPicker(self)-> dict: - """ - Return version that user selects - """ - versions_api = self.item['versions']['data'] - # Exclude offline versions - versions_filtered = [version for version in versions_api if not version['offline']] - versions_show = [] - for version_temp in versions_filtered: - label = '{0} - {1}'.format(version_temp['name'], version_temp['rightType']['name']) - list_item = ListItem(label=label) - versions_show.append(list_item) - - index = Dialog().select(config.getLocalizedString(40052), versions_show) - version = versions_filtered[index] - return version - - def start(self): - if not self.canWatch and config.canBuy(): - self.buyMedia() - - if self.canWatch or self.bought: - version = self.versionPicker() - subtitles_api = version['subtitles']['data'] - # Subtitles - subtitles = [] - for subtitle in subtitles_api: - subtitles.append(subtitle['subtitleFiles']['data'][0]['path']) - - streams = api.getStreams(version['id']) - stream = streams['feeds'][0] - play_item = ListItemExtra.videoApiv3(stream['src'], self.item) - play_item.setSubtitles(subtitles) - # Add DRM config - if isDrm(stream['type']): - import inputstreamhelper # pylint: disable=import-error - is_helper = inputstreamhelper.Helper(self.PROTOCOL, drm=self.DRM) - if is_helper.check_inputstream(): - play_item.setProperty('inputstream', is_helper.inputstream_addon) - play_item.setProperty('inputstream.adaptive.manifest_type', self.PROTOCOL) - play_item.setProperty('inputstream.adaptive.license_type', self.DRM) - play_item.setProperty('inputstream.adaptive.license_key', stream['license_url'] + '||R{SSM}|') - else: - raise DRMException() - # Start playing - monitor = Monitor() - player = Player(config.canSync(), - config.getUserId(), - config.getProfileId(), - self.item['id'], - version['id'], - streams['media_viewing_id'], - config.getToken()['access']) - player.play(listitem=play_item) - setResolvedUrl(_HANDLE, True, play_item) - while not monitor.abortRequested(): - monitor.waitForAbort(5) - else: - Dialog().ok('Error', config.getLocalizedString(40053)) diff --git a/resources/lib/player/Mediamark.py b/resources/lib/player/Mediamark.py deleted file mode 100644 index 51e1dcc..0000000 --- a/resources/lib/player/Mediamark.py +++ /dev/null @@ -1,71 +0,0 @@ -import requests -from xbmc import getLanguage, ISO_639_1 -from ..common import config - -class Mediamark: - BASE_URL = "https://bm.filmin.es/mediamarks" # Default URL - AUTH_TOKEN = "Njk1MzM5MjAtNDVmNi0xMWUzLThmOTYtMDgwMDIwMGM5YTY2" # I -- THINK -- this is hardcoded, I hope so. - s = requests.Session() - TOKEN = '' - USER_ID = -1 - PROFILE_ID = '' - MEDIA_ID = -1 - VERSION_ID = -1 - MEDIA_VIEWING_ID = -1 - SESSION_ID = -1 - - DEVICE_MODEL = 'Kodi' - DEVICE_OS_VERSION = '12' - CLIENT_VERSION = "4.2.440" - - def __init__(self, user_id: int, profile_id: str, media_id: int, version_id: int, media_viewing_id: int, session_id: str): - self.USER_ID = user_id - self.PROFILE_ID = profile_id - self.MEDIA_ID = media_id - self.VERSION_ID = version_id - self.MEDIA_VIEWING_ID = media_viewing_id - self.SESSION_ID = session_id - self.s.headers["Authorization"] = f'Token {self.AUTH_TOKEN}' - self.s.headers["clientlanguage"] = getLanguage(ISO_639_1, True) - self.s.headers["clientversion"] = self.CLIENT_VERSION - self.s.headers["devicemodel"] = self.DEVICE_MODEL - self.s.headers['deviceosversion'] = self.DEVICE_OS_VERSION - self.s.headers['X-User-Profile-Id'] = self.PROFILE_ID - self.setMarkBaseUrl(config.getDomain()) - - def setMarkBaseUrl(self, domain: str)-> str: - host = "filminlatino" if domain == 'mx' else 'filmin' - self.BASE_URL = f"https://bm.{host}.{domain}" - - def init(self): - res = self.s.post(self.BASE_URL + '/token', data={ - 'media_id': self.MEDIA_ID, - 'user_id': self.USER_ID, - 'profile_id': self.PROFILE_ID, - 'platform': 'android' - }) - - res_json = res.json() - self.TOKEN = res_json['data']['token'] - return res_json['data']['interval'] - - def getInitialPos(self)-> int: - res = self.s.get(self.BASE_URL, params={ - 'token': self.TOKEN - }) - - res_json = res.json() - return int(float(res_json['data']['position'])) - - def sync(self, time: int): - self.s.post(self.BASE_URL, data={ - 'token': self.TOKEN, - 'position': time, - 'version_id': self.VERSION_ID, - 'duration': 0, - 'media_id': self.MEDIA_ID, - 'media_viewing_id': self.MEDIA_VIEWING_ID, - 'session_id': self.SESSION_ID, - 'session_connections': 2, - 'subtitle_id': 0 - }) diff --git a/resources/lib/player/handler.py b/resources/lib/player/handler.py new file mode 100644 index 0000000..6eca1ef --- /dev/null +++ b/resources/lib/player/handler.py @@ -0,0 +1,128 @@ +""" Player handler """ + +from xbmc import Monitor +from xbmcgui import Dialog, ListItem +from xbmcplugin import setResolvedUrl +from ..common import api, settings, _HANDLE +from ..helpers.listitem_extra import ListItemExtra +from ..helpers.misc import is_drm +from .player import Player +from ..exceptions.drm import DRMException +from ..models.mediamark_data import MediamarkData + + +class PlayHandler: + """ + Handles playing media + Rents media if it needs to and chooses a valid stream + """ + + PROTOCOL = "mpd" + DRM = "com.widevine.alpha" + item = {} + can_watch = True + + def __init__(self, el_id: int): + self.item = api.media_simple(el_id) + if "can_watch" in self.item["user_data"]: + can_watch = self.item["user_data"]["can_watch"] + self.can_watch = len(can_watch["data"]) > 0 + + def buy_media(self): + """ + Asks user if they want to buy media, send request if true + """ + + user = api.user() + tickets = len(user["tickets"]["data"]) + self.can_watch = Dialog().yesno( + settings.get_localized_string(40050), + settings.get_localized_string(40051) % tickets, + ) + if self.can_watch: + api.use_tickets(self.item["id"]) + + def version_picker(self) -> dict: + """ + Return version that user selects + """ + versions_api = self.item["versions"]["data"] + # Exclude offline versions + versions_filtered = [v for v in versions_api if not v["offline"]] + v_show = [] + for v_tmp in versions_filtered: + label = f"{v_tmp['name']} - {v_tmp['rightType']['name']}" + list_item = ListItem(label=label) + v_show.append(list_item) + + index = Dialog().select(settings.get_localized_string(40052), v_show) + version = versions_filtered[index] + return version + + def start(self): + """ + Entrypoint for starting media playback + """ + + if not self.can_watch and settings.can_buy(): + self.buy_media() + + if not self.can_watch: + Dialog().ok("Error", settings.get_localized_string(40053)) + return + + version = self.version_picker() + + # Handle subtitles + subtitles_api = version["subtitles"]["data"] + subtitles = [] + for subtitle in subtitles_api: + subtitles.append(subtitle["subtitleFiles"]["data"][0]["path"]) + + # Handle stream + streams = api.streams(version["id"]) + stream = streams["feeds"][0] + + # Handle PlayItem + play_item = ListItemExtra.video_apiv3(stream["src"], self.item) + play_item.setSubtitles(subtitles) + + # Handle DRM + if is_drm(stream["type"]): + # pylint: disable-next=import-error,import-outside-toplevel + import inputstreamhelper + + is_helper = inputstreamhelper.Helper(self.PROTOCOL, drm=self.DRM) + if not is_helper.check_inputstream(): + # Couldn't get inputstream working :( + raise DRMException() + + play_item.setProperty("inputstream", is_helper.inputstream_addon) + play_item.setProperty( + "inputstream.adaptive.manifest_type", self.PROTOCOL) + play_item.setProperty( + "inputstream.adaptive.license_type", self.DRM) + play_item.setProperty( + "inputstream.adaptive.license_key", + stream["license_url"] + "||R{SSM}|" + ) + + # Start playing + monitor = Monitor() + player = Player( + # settings.can_sync(), + # Force false, mediamark is currently broken + False, + MediamarkData( + settings.get_user_id(), + settings.get_profile_id(), + self.item["id"], + version["id"], + streams["media_viewing_id"], + settings.get_auth()["access"], + ), + ) + player.play(listitem=play_item) + setResolvedUrl(_HANDLE, True, play_item) + while not monitor.abortRequested(): + monitor.waitForAbort(5) diff --git a/resources/lib/player/mediamark.py b/resources/lib/player/mediamark.py new file mode 100644 index 0000000..8b561c0 --- /dev/null +++ b/resources/lib/player/mediamark.py @@ -0,0 +1,83 @@ +""" Mediamark module """ + +import requests +from ..common import settings +from ..models.mediamark_data import MediamarkData +from ..helpers.headers import Headers + + +class Mediamark: + """Wrapper for Filmin mediamarks service""" + + # Base URL + base_url: str + # Hardcoded token, found in es.filmin.app.BuildConfig + AUTH_TOKEN = "Njk1MzM5MjAtNDVmNi0xMWUzLThmOTYtMDgwMDIwMGM5YTY2" + s = requests.Session() + token = "" + mm_data: MediamarkData + + def __init__(self, mm_data: MediamarkData): + self.mm_data = mm_data + Headers.set_common(self.s) + Headers.set_old(self.s) + + self.s.headers["Authorization"] = f"Token {self.AUTH_TOKEN}" + self.s.headers["X-User-Profile-Id"] = self.mm_data.profile_id + self._set_mark_baseurl(settings.get_domain()) + + def _set_mark_baseurl(self, domain: str) -> str: + host = "filminlatino" if domain == "mx" else "filmin" + self.base_url = f"https://bm.{host}.{domain}" + + def init(self) -> int: + """ + Send to Filmin that we started watching + Returns interval to send current position + """ + res = self.s.post( + self.base_url + "/token", + data={ + "media_id": self.mm_data.media_id, + "user_id": self.mm_data.user_id, + "profile_id": self.mm_data.profile_id, + "platform": "android", + }, + ) + + res_json = res.json() + self.token = res_json["data"]["token"] + return res_json["data"]["interval"] + + def get_initial_pos(self) -> int: + """ + Get last known position by Filmin + """ + + res = self.s.get(self.base_url, params={"token": self.token}) + + res_json = res.json() + return int(float(res_json["data"]["position"])) + + def sync(self, time: int): + """ + Send current position to server + """ + + self.s.post( + self.base_url, + data={ + "token": self.token, + "position": time, + "version_id": self.mm_data.version_id, + "duration": 0, + "media_id": self.mm_data.media_id, + "media_viewing_id": self.mm_data.media_viewing_id, + "session_id": self.mm_data.session_id, + # TODO, what does this do? + # _maybe_ how many devices are streaming??? + "session_connections": 2, + # TODO, send proper subtitle ID + "subtitle_id": 0, + }, + ) diff --git a/resources/lib/player/Player.py b/resources/lib/player/player.py similarity index 55% rename from resources/lib/player/Player.py rename to resources/lib/player/player.py index 5173bb0..c85b530 100644 --- a/resources/lib/player/Player.py +++ b/resources/lib/player/player.py @@ -1,7 +1,10 @@ -import xbmc -import xbmcgui +""" Player module """ + from threading import Timer -from .Mediamark import Mediamark +import xbmc +from ..models.mediamark_data import MediamarkData +from .mediamark import Mediamark + class Player(xbmc.Player): """ @@ -10,35 +13,43 @@ class Player(xbmc.Player): KODI TIMES ARE IN SECONDS FILMIN TIMES ARE IN MILISECONDS """ + can_sync = False mediamark: Mediamark timer: Timer - def __init__(self, can_sync: bool, user_id: int, profile_id: str, media_id: int, version_id: int, media_viewing_id: int, session_id: str): + def __init__(self, can_sync: bool, mm_data: MediamarkData): xbmc.Player.__init__(self) self.can_sync = can_sync if self.can_sync: - xbmc.log('Enabling sync to FILMIN', xbmc.LOGINFO) - self.mediamark = Mediamark(user_id, profile_id, media_id, version_id, media_viewing_id, session_id) + xbmc.log("Enabling sync to FILMIN", xbmc.LOGINFO) + self.mediamark = Mediamark(mm_data) def sync(self): + """ + If enabled, send position to filmin + """ + if self.can_sync: time = self.getTime() time_ms = time * 1000 - xbmc.log(f'Syncing to Filmin at {time} seconds', xbmc.LOGDEBUG) + xbmc.log(f"Syncing to Filmin at {time} seconds", xbmc.LOGDEBUG) self.mediamark.sync(time_ms) def onAVStarted(self): if self.can_sync: + # Set timer to send current position interval = self.mediamark.init() self.timer = Timer(interval / 1000, self.sync) self.timer.start() - filmin_position = self.mediamark.getInitialPos() / 1000 # Last position set by Filmin converted to seconds - kodi_position = self.getTime() # Kodi last position, already in seconds + # Last position set by Filmin converted to seconds + filmin_position = self.mediamark.get_initial_pos() / 1000 + # Kodi last position, already in seconds + kodi_position = self.getTime() # Move video to Filmin position seek_to = filmin_position - kodi_position - xbmc.log(f'Moving video to {seek_to} seconds relative', xbmc.LOGDEBUG) - self.seekTime(seek_to) # seekTime is relative to the current Kodi position + xbmc.log(f"Moving to {seek_to} seconds relative", xbmc.LOGDEBUG) + self.seekTime(seek_to) def onPlayBackSeek(self, time: int, seekOffset: int): self.sync() diff --git a/resources/lib/routes.py b/resources/lib/routes.py index 13db225..dfb26ff 100644 --- a/resources/lib/routes.py +++ b/resources/lib/routes.py @@ -1,90 +1,115 @@ +""" Module with all available route functions """ + from .dispatcher import Dispatcher -from .constants import ROUTES -from .common import _PARAMS +from .constants import Routes dispatcher = Dispatcher() -@dispatcher.register(ROUTES.HOME) +# pylint: disable=import-outside-toplevel + + +@dispatcher.register(Routes.HOME) def _home(): - from .views.MainMenu import MainMenu + from .views.mainmenu import MainMenu MainMenu().run() -@dispatcher.register(ROUTES.CATALOG) + +@dispatcher.register(Routes.CATALOG) def _catalog(): - from .views.Catalog import Catalog + from .views.catalog import Catalog Catalog().run() -@dispatcher.register(ROUTES.SEARCH) + +@dispatcher.register(Routes.SEARCH) def _search(): - from .views.Search import Search + from .views.search import Search Search().run() -@dispatcher.register(ROUTES.PURCHASED) + +@dispatcher.register(Routes.PURCHASED) def _purchased(): - from .views.Purchased import Purchased + from .views.purchased import Purchased Purchased().run() -@dispatcher.register(ROUTES.WATCHING) + +@dispatcher.register(Routes.WATCHING) def _watching(): - from .views.Watching import Watching + from .views.watching import Watching Watching().run() -@dispatcher.register(ROUTES.HIGHLIGHTEDS) + +@dispatcher.register(Routes.HIGHLIGHTEDS) def _highlighteds(): - from .views.Highlighteds import Highlighteds + from .views.highlighteds import Highlighteds Highlighteds().run() -@dispatcher.register(ROUTES.PLAYLISTS) + +@dispatcher.register(Routes.PLAYLISTS) def _playlists(): - from .views.Playlists import Playlists + from .views.playlists import Playlists Playlists().run() -@dispatcher.register(ROUTES.PLAYLIST, ['id']) + +@dispatcher.register(Routes.PLAYLIST, ["id"]) def _playlist(play_id: int): - from .views.Playlist import Playlist + from .views.playlist import Playlist Playlist(play_id).run() -@dispatcher.register(ROUTES.COLLECTIONS) + +@dispatcher.register(Routes.COLLECTIONS) def _collections(): - from .views.Collections import Collections + from .views.collections import Collections Collections().run() -@dispatcher.register(ROUTES.COLLECTION, ['id']) + +@dispatcher.register(Routes.COLLECTION, ["id"]) def _collection(collection_id: int): - from .views.Collection import Collection + from .views.collection import Collection Collection(collection_id).run() -@dispatcher.register(ROUTES.SEASONS, ['id']) + +@dispatcher.register(Routes.SEASONS, ["id"]) def _seasons(item_id: int): - from .views.Seasons import Seasons + from .views.seasons import Seasons Seasons(item_id).run() -@dispatcher.register(ROUTES.EPISODES, ['id', 'item_id']) + +@dispatcher.register(Routes.EPISODES, ["id", "item_id"]) def _episodes(season_id: int, show_id: int): - from .views.Episodes import Episodes + from .views.episodes import Episodes Episodes(season_id, show_id).run() -@dispatcher.register(ROUTES.WATCHLATER) -def _watchLater(): - from .views.WatchLater import WatchLater + +@dispatcher.register(Routes.WATCHLATER) +def _watch_later(): + from .views.watchlater import WatchLater WatchLater().run() -@dispatcher.register(ROUTES.PLAYER, ['id']) + +@dispatcher.register(Routes.PLAYER, ["id"]) def _player(item_id: int): - from .player.Handler import Play - play = Play(item_id) + from .player.handler import PlayHandler + play = PlayHandler(item_id) play.start() -@dispatcher.register(ROUTES.LOGOUT) + +@dispatcher.register(Routes.LOGOUT) def _logout(): - from .session import startLogout - startLogout() + from .session import start_logout + start_logout() + -@dispatcher.register(ROUTES.PROFILE) +@dispatcher.register(Routes.PROFILE) def _profile(): - from .session import changeProfile - changeProfile(notify=True) + from .session import change_profile + change_profile(notify=True) + + +def dispatch(params: dict): + """ + Run dispatcher + Gets current route from params argument + """ -def dispatch(): - route = _PARAMS.get('route', 'home') + route = params.get("route", Routes.HOME.value) dispatcher.run(route) diff --git a/resources/lib/session.py b/resources/lib/session.py index 122c58a..e3b9fb3 100644 --- a/resources/lib/session.py +++ b/resources/lib/session.py @@ -1,38 +1,62 @@ +""" +Auth module: +Login, change profiles and logout +""" + from xbmcgui import Dialog, ListItem -from .common import api, config +from .common import api, settings + -def askLogin(): - username = Dialog().input(config.getLocalizedString(40030)) - password = Dialog().input(config.getLocalizedString(40031)) +def ask_login(): + """ + Dialog auth and try to login + If OK set access token and profile token both in memory and disk + """ + + username = Dialog().input(settings.get_localized_string(40030)) + password = Dialog().input(settings.get_localized_string(40031)) if username and password: res = api.login(username, password) - api.setToken(res['access_token']) - changeProfile() + api.setToken(res["access_token"]) + change_profile() user = api.user() - config.setAuth(res['access_token'], res['refresh_token'], username, user['id']) - Dialog().ok('OK', config.getLocalizedString(40032)) + settings.set_auth( + res["access_token"], res["refresh_token"], username, user["id"] + ) + Dialog().ok("OK", settings.get_localized_string(40032)) + + +def change_profile(notify: bool = False): + """ + Gets all user's profiles and asks the user to choose one + If succesful save to memory and disk + """ -def changeProfile(notify: bool = False): items = [] res = api.profiles() - profiles = res['data'] + profiles = res["data"] for profile in profiles: - item = ListItem(label=profile['name']) + item = ListItem(label=profile["name"]) items.append(item) - index = Dialog().select(config.getLocalizedString(40033), items) - profile_id = profiles[index]['id'] + index = Dialog().select(settings.get_localized_string(40033), items) + profile_id = profiles[index]["id"] - api.setProfileId(profile_id) - config.setProfileId(profile_id) + api.set_profile_id(profile_id) + settings.set_profile_id(profile_id) if notify: - Dialog().ok('OK', config.getLocalizedString(40034)) - -def startLogout(): - config.setAuth('', '', '', 0) - config.setProfileId('') - api.setToken('') - api.setProfileId('') - Dialog().ok('OK', config.getLocalizedString(40035)) + Dialog().ok("OK", settings.get_localized_string(40034)) + + +def start_logout(): + """ + Wipe all login-related data + """ + + settings.set_auth("", "", "", 0) + settings.set_profile_id("") + api.set_token("") + api.set_profile_id("") + Dialog().ok("OK", settings.get_localized_string(40035)) diff --git a/resources/lib/settings.py b/resources/lib/settings.py new file mode 100644 index 0000000..c7734ee --- /dev/null +++ b/resources/lib/settings.py @@ -0,0 +1,95 @@ +""" +Basic settings module +""" + +from xbmcaddon import Addon + + +class Settings: + """ + Settings wrapper + """ + + addon = Addon("plugin.video.filmin") + + def get_localized_string(self, l_id: int) -> str: + """ + Get i18n string from its id + """ + + return self.addon.getLocalizedString(l_id) + + def is_logged_in(self) -> bool: + """ + Check if user has already has an access token + """ + + if self.addon.getSettingString("access_token"): + return True + return False + + def get_domain(self) -> str: + """ + Get Filmin domain + """ + + return self.addon.getSettingString("domain") + + def get_auth(self) -> dict: + """ + Get auth data stored + """ + + access = self.addon.getSettingString("access_token") + return {"access": access} + + def get_user_id(self) -> int: + """ + Get logged in user id, sometimes used for requests + """ + + return self.addon.getSettingInt("user_id") + + def get_profile_id(self) -> str: + """ + Get currently active profile id + """ + + return self.addon.getSettingString("profile_id") + + def can_buy(self) -> bool: + """ + Check if user allows to rent media using tickets + """ + + return self.addon.getSettingBool("tickets") + + def can_sync(self) -> bool: + """ + Check if user allows to send current play position of media + """ + + return self.addon.getSettingBool("sync") + + def set_auth( + self, + access_token: str, + refresh_token: str, + username: str, + user_id: int + ): + """ + Saves access & refresh token, username and user id to disk + """ + + self.addon.setSettingString("access_token", access_token) + self.addon.setSettingString("refresh_token", refresh_token) + self.addon.setSettingString("username", username) + self.addon.setSettingInt("user_id", user_id) + + def set_profile_id(self, profile_id: str): + """ + Saves profile id to disk + """ + + self.addon.setSettingString("profile_id", profile_id) diff --git a/resources/lib/views/Catalog.py b/resources/lib/views/Catalog.py deleted file mode 100644 index aa25b28..0000000 --- a/resources/lib/views/Catalog.py +++ /dev/null @@ -1,49 +0,0 @@ -import xbmcgui -from .Base import Base -from ..common import api, config - -class Catalog(Base): - has_dirs = True - has_videos = True - def setItems(self): - # TYPE (show or film) - allowed_types = [ - ('', config.getLocalizedString(40043)), - ('serie', config.getLocalizedString(40044)), - ('film', config.getLocalizedString(40045)), - ('short', config.getLocalizedString(40046)) - ] - allowed_types_listitem = [] - for allowed_type in allowed_types: - listitem = xbmcgui.ListItem(label=allowed_type[1]) - allowed_types_listitem.append(listitem) - - index = xbmcgui.Dialog().select(config.getLocalizedString(40040), allowed_types_listitem) - item_type = allowed_types[index][0] - - # GENRE (Action, adventure...) - genres = [{"id": -1, "name": config.getLocalizedString(40043)}] + api.genres() - genres_listitem = [] - for genre in genres: - listitem = xbmcgui.ListItem(label=genre['name']) - genres_listitem.append(listitem) - - index = xbmcgui.Dialog().select(config.getLocalizedString(40041), genres_listitem) - genre_picked = genres[index] - - # Allow picking subgenre only if there is a genre chosen - if genre_picked['id'] != -1: - subgenres = [{"id": -1, "name": config.getLocalizedString(40043)}] + genre_picked['subgenres']['data'] - subgenres_listitem = [] - for subgenre in subgenres: - listitem = xbmcgui.ListItem(label=subgenre['name']) - subgenres_listitem.append(listitem) - - index = xbmcgui.Dialog().select(config.getLocalizedString(40042), subgenres_listitem) - subgenre_picked = subgenres[index] - else: - subgenre_picked = { - "id": -1 - } - - self.items = api.catalog(item_type=item_type, genre=genre_picked['id'], subgenre=subgenre_picked['id']) diff --git a/resources/lib/views/Collections.py b/resources/lib/views/Collections.py deleted file mode 100644 index 68b842c..0000000 --- a/resources/lib/views/Collections.py +++ /dev/null @@ -1,9 +0,0 @@ -from .Base import Base -from ..common import api - -class Collections(Base): - has_dirs = True - folders_goTo = 'collection' - - def setItems(self): - self.items = api.collections() diff --git a/resources/lib/views/MainMenu.py b/resources/lib/views/MainMenu.py deleted file mode 100644 index 306fb8a..0000000 --- a/resources/lib/views/MainMenu.py +++ /dev/null @@ -1,42 +0,0 @@ -from .Base import Base -from ..common import config - -class MainMenu(Base): - """ - Main menu, default route. Does not have a path string - """ - static = True - items = [ - { - "id": "search", - "title": config.getLocalizedString(40020) - }, - { - "id": "watching", - "title": config.getLocalizedString(40021) - }, - { - "id": "catalog", - "title": config.getLocalizedString(40022) - }, - { - "id": "purchased", - "title": config.getLocalizedString(40023) - }, - { - "id": "highlighteds", - "title": config.getLocalizedString(40024) - }, - { - "id": "collections", - "title": config.getLocalizedString(40025) - }, - { - "id": "playlists", - "title": config.getLocalizedString(40026) - }, - { - "id": "watchlater", - "title": config.getLocalizedString(40027) - } - ] diff --git a/resources/lib/views/Playlists.py b/resources/lib/views/Playlists.py deleted file mode 100644 index 11092bd..0000000 --- a/resources/lib/views/Playlists.py +++ /dev/null @@ -1,9 +0,0 @@ -from .Base import Base -from ..common import api - -class Playlists(Base): - has_dirs = True - folders_goTo = 'playlist' - - def setItems(self): - self.items = api.playlists() diff --git a/resources/lib/views/Search.py b/resources/lib/views/Search.py deleted file mode 100644 index 3d6e596..0000000 --- a/resources/lib/views/Search.py +++ /dev/null @@ -1,16 +0,0 @@ -import xbmcgui -from .Base import Base -from ..common import api, config - -class Search(Base): - """ - Search function - """ - path = 'search' - has_dirs = True - has_videos = True - - def setItems(self): - search_term = xbmcgui.Dialog().input(config.getLocalizedString(40020), type=xbmcgui.INPUT_ALPHANUM) - if search_term: - self.items = api.search(search_term) diff --git a/resources/lib/views/WatchLater.py b/resources/lib/views/WatchLater.py deleted file mode 100644 index f4f9c6a..0000000 --- a/resources/lib/views/WatchLater.py +++ /dev/null @@ -1,9 +0,0 @@ -from .Base import Base -from ..common import api - -class WatchLater(Base): - has_dirs = True - has_videos = True - - def setItems(self): - self.items = api.watchLater() diff --git a/resources/lib/views/Base.py b/resources/lib/views/base.py similarity index 50% rename from resources/lib/views/Base.py rename to resources/lib/views/base.py index fa4d47f..07fbf70 100644 --- a/resources/lib/views/Base.py +++ b/resources/lib/views/base.py @@ -1,55 +1,36 @@ -from ..helpers.Render import Render +""" Base view """ +from ..helpers.render import Render -class Base: - """ - Main view class, skeleton for all views - """ - - """ - Path for Kodi to search - """ - path = '' - """ - Path that kodi will assign to all folder items - """ - folders_goTo = '' - - """ - Set to True if the endpoint has a pagination system TODO, make it work - """ - pages = False +class Base: + """Main view class, skeleton for all views""" - """ - True if is an static route with predefined items - """ static = False + """True if is an static route with predefined items""" - """ - True if the directory contains videos - """ has_videos = False + """True if the directory contains videos""" - """ - True if the directory is recursive - """ has_dirs = False + """True if the directory is recursive""" - """ - All items - """ items = [] + """All items""" - def setItems(self): - """ - Set item using API if necessary - """ - pass + folders_goTo = "" + """Path that kodi will assign to all folder items""" + + pagination = False + """Set to True if the endpoint has a pagination system + TODO: Make it work + """ + + def set_items(self): + """Set items using API if necessary""" def show(self): - """ - Renders folder depending of config - """ + """Renders folder into Kodi""" + listing = [] # Render static route if self.static: @@ -65,8 +46,11 @@ def show(self): elif self.has_videos and not self.has_dirs: listing = Render.videos(self.items) - Render.createDirectory(listing) + Render.create_directory(listing) def run(self): - self.setItems() + """ + Run view, this will set items and render them in Kodi + """ + self.set_items() self.show() diff --git a/resources/lib/views/catalog.py b/resources/lib/views/catalog.py new file mode 100644 index 0000000..1af5686 --- /dev/null +++ b/resources/lib/views/catalog.py @@ -0,0 +1,67 @@ +""" Catalog module """ +import xbmcgui +from .base import Base +from ..common import api, settings + + +class Catalog(Base): + """Catalog view""" + + has_dirs = True + has_videos = True + + def set_items(self): + # TYPE (show or film) + allowed_types = [ + ("", settings.get_localized_string(40043)), + ("serie", settings.get_localized_string(40044)), + ("film", settings.get_localized_string(40045)), + ("short", settings.get_localized_string(40046)), + ] + allowed_types_listitem = [] + for allowed_type in allowed_types: + listitem = xbmcgui.ListItem(label=allowed_type[1]) + allowed_types_listitem.append(listitem) + + index = xbmcgui.Dialog().select( + settings.get_localized_string(40040), allowed_types_listitem + ) + item_type = allowed_types[index][0] + + # GENRE (Action, adventure...) + genres = [ + {"id": -1, "name": settings.get_localized_string(40043)} + ] + api.genres() + genres_listitem = [] + for genre in genres: + listitem = xbmcgui.ListItem(label=genre["name"]) + genres_listitem.append(listitem) + + index = xbmcgui.Dialog().select( + settings.get_localized_string(40041), genres_listitem + ) + genre_picked = genres[index] + + # Allow picking subgenre only if there is a genre chosen + if genre_picked["id"] != -1: + subgenres = [ + {"id": -1, "name": settings.get_localized_string(40043)} + ] + genre_picked["subgenres"]["data"] + subgenres_listitem = [] + for subgenre in subgenres: + listitem = xbmcgui.ListItem(label=subgenre["name"]) + subgenres_listitem.append(listitem) + + index = xbmcgui.Dialog().select( + settings.get_localized_string(40042), subgenres_listitem + ) + + subgenre_picked = subgenres[index] + else: + subgenre_picked = {"id": -1} + + self.items = api.catalog( + item_type=item_type, + genre=genre_picked["id"], + subgenre=subgenre_picked["id"], + ) diff --git a/resources/lib/views/Collection.py b/resources/lib/views/collection.py similarity index 70% rename from resources/lib/views/Collection.py rename to resources/lib/views/collection.py index 5fff850..635c79c 100644 --- a/resources/lib/views/Collection.py +++ b/resources/lib/views/collection.py @@ -1,7 +1,11 @@ -from .Base import Base +""" Collection module """ +from .base import Base from ..common import api + class Collection(Base): + """Collection view""" + has_dirs = True has_videos = True collection_id = 0 @@ -9,5 +13,5 @@ class Collection(Base): def __init__(self, col_id: int): self.collection_id = col_id - def setItems(self): + def set_items(self): self.items = api.collection(self.collection_id) diff --git a/resources/lib/views/collections.py b/resources/lib/views/collections.py new file mode 100644 index 0000000..b66ef4e --- /dev/null +++ b/resources/lib/views/collections.py @@ -0,0 +1,13 @@ +""" Collections module """ +from .base import Base +from ..common import api + + +class Collections(Base): + """Collections view""" + + has_dirs = True + folders_goTo = "collection" + + def set_items(self): + self.items = api.collections() diff --git a/resources/lib/views/Episodes.py b/resources/lib/views/episodes.py similarity index 74% rename from resources/lib/views/Episodes.py rename to resources/lib/views/episodes.py index 74329a4..26e02ea 100644 --- a/resources/lib/views/Episodes.py +++ b/resources/lib/views/episodes.py @@ -1,7 +1,11 @@ -from .Base import Base +""" Episodes module """ +from .base import Base from ..common import api + class Episodes(Base): + """Episodes view""" + has_videos = True season_id = 0 show_id = 0 @@ -10,5 +14,5 @@ def __init__(self, season_id: int, show_id: int): self.season_id = season_id self.show_id = show_id - def setItems(self): + def set_items(self): self.items = api.episodes(self.show_id, self.season_id) diff --git a/resources/lib/views/Highlighteds.py b/resources/lib/views/highlighteds.py similarity index 55% rename from resources/lib/views/Highlighteds.py rename to resources/lib/views/highlighteds.py index aaf0535..1f235c4 100644 --- a/resources/lib/views/Highlighteds.py +++ b/resources/lib/views/highlighteds.py @@ -1,8 +1,13 @@ -from .Base import Base +""" Highlighteds module """ +from .base import Base from ..common import api + class Highlighteds(Base): + """Highlighteds view""" + has_dirs = True has_videos = True - def setItems(self): + + def set_items(self): self.items = api.highlighteds() diff --git a/resources/lib/views/mainmenu.py b/resources/lib/views/mainmenu.py new file mode 100644 index 0000000..d3d6aa7 --- /dev/null +++ b/resources/lib/views/mainmenu.py @@ -0,0 +1,46 @@ +""" MainMenu module """ +from .base import Base +from ..common import settings +from ..constants import Routes + + +class MainMenu(Base): + """ + Main menu, default route. Does not have a path string + """ + + static = True + items = [ + { + "id": Routes.SEARCH.value, + "title": settings.get_localized_string(40020) + }, + { + "id": Routes.WATCHING.value, + "title": settings.get_localized_string(40021) + }, + { + "id": Routes.CATALOG.value, + "title": settings.get_localized_string(40022) + }, + { + "id": Routes.PURCHASED.value, + "title": settings.get_localized_string(40023) + }, + { + "id": Routes.HIGHLIGHTEDS.value, + "title": settings.get_localized_string(40024), + }, + { + "id": Routes.COLLECTIONS.value, + "title": settings.get_localized_string(40025) + }, + { + "id": Routes.PLAYLISTS.value, + "title": settings.get_localized_string(40026) + }, + { + "id": Routes.WATCHLATER.value, + "title": settings.get_localized_string(40027) + } + ] diff --git a/resources/lib/views/Playlist.py b/resources/lib/views/playlist.py similarity index 70% rename from resources/lib/views/Playlist.py rename to resources/lib/views/playlist.py index 94f72c0..f4ae69b 100644 --- a/resources/lib/views/Playlist.py +++ b/resources/lib/views/playlist.py @@ -1,7 +1,11 @@ -from .Base import Base +""" Playlist module """ +from .base import Base from ..common import api + class Playlist(Base): + """Playlist view""" + has_dirs = True has_videos = True playlist_id = 0 @@ -9,5 +13,5 @@ class Playlist(Base): def __init__(self, play_id: int): self.playlist_id = play_id - def setItems(self): + def set_items(self): self.items = api.playlist(self.playlist_id) diff --git a/resources/lib/views/playlists.py b/resources/lib/views/playlists.py new file mode 100644 index 0000000..4b0a5ed --- /dev/null +++ b/resources/lib/views/playlists.py @@ -0,0 +1,13 @@ +""" Playlists module """ +from .base import Base +from ..common import api + + +class Playlists(Base): + """Playlists view""" + + has_dirs = True + folders_goTo = "playlist" + + def set_items(self): + self.items = api.playlists() diff --git a/resources/lib/views/Purchased.py b/resources/lib/views/purchased.py similarity index 56% rename from resources/lib/views/Purchased.py rename to resources/lib/views/purchased.py index 03bf118..4a2dd4e 100644 --- a/resources/lib/views/Purchased.py +++ b/resources/lib/views/purchased.py @@ -1,9 +1,14 @@ -from .Base import Base +""" Purchased module """ + +from .base import Base from ..common import api + class Purchased(Base): + """Purchased view""" + has_dirs = True has_videos = True - def setItems(self): + def set_items(self): self.items = api.purchased() diff --git a/resources/lib/views/search.py b/resources/lib/views/search.py new file mode 100644 index 0000000..6236600 --- /dev/null +++ b/resources/lib/views/search.py @@ -0,0 +1,19 @@ +""" Search module """ + +import xbmcgui +from .base import Base +from ..common import api, settings + + +class Search(Base): + """Search view""" + + has_dirs = True + has_videos = True + + def set_items(self): + search_term = xbmcgui.Dialog().input( + settings.get_localized_string(40020), type=xbmcgui.INPUT_ALPHANUM + ) + if search_term: + self.items = api.search(search_term) diff --git a/resources/lib/views/Seasons.py b/resources/lib/views/seasons.py similarity index 61% rename from resources/lib/views/Seasons.py rename to resources/lib/views/seasons.py index 93f41dd..87b6254 100644 --- a/resources/lib/views/Seasons.py +++ b/resources/lib/views/seasons.py @@ -1,13 +1,17 @@ -from .Base import Base +""" Seasons module """ +from .base import Base from ..common import api + class Seasons(Base): - folders_goTo = 'episodes' + """Seasons view""" + + folders_goTo = "episodes" item_id = 0 has_dirs = True def __init__(self, item_id: int): self.item_id = item_id - def setItems(self): + def set_items(self): self.items = api.seasons(self.item_id) diff --git a/resources/lib/views/Watching.py b/resources/lib/views/watching.py similarity index 56% rename from resources/lib/views/Watching.py rename to resources/lib/views/watching.py index c05cd9d..c08ffa6 100644 --- a/resources/lib/views/Watching.py +++ b/resources/lib/views/watching.py @@ -1,9 +1,13 @@ -from .Base import Base +""" Watching module """ +from .base import Base from ..common import api + class Watching(Base): + """Watching view""" + has_dirs = True has_videos = True - def setItems(self): + def set_items(self): self.items = api.watching() diff --git a/resources/lib/views/watchlater.py b/resources/lib/views/watchlater.py new file mode 100644 index 0000000..1063005 --- /dev/null +++ b/resources/lib/views/watchlater.py @@ -0,0 +1,13 @@ +""" WatchLater module """ +from .base import Base +from ..common import api + + +class WatchLater(Base): + """WatchLater view""" + + has_dirs = True + has_videos = True + + def set_items(self): + self.items = api.watch_later() diff --git a/tools/README.md b/tools/README.md index 237d099..e7de516 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,7 +1,6 @@ -# Herramientas de desarrollo -En esta carpeta encontrarás diferentes utilidades +# Desarrollo +En esta carpeta encontrarás diferentes utilidades para el desarrollo de este programa -## Dependencias Antes de emepezar, instala las dependencias de Python con: ```bash @@ -20,3 +19,16 @@ NOTA: Usamos el path `..` para ir al directorio raíz donde está el addon Puedes ejecutar la API manualmente copiando el script `api_m.example.py` a `api_m.py`. Aquí puedes hacer todas las pruebas que quieras sin tener que usar Kodi + +## Reversing +### Static +Todos los tokens `CLIENT_ID` y `CLIENT_ID_SECRET` se encontraron decompilando la aplicación de Android. + +Personalmente uso [**jadx**](https://github.com/skylot/jadx) para la decompilación. + +### Runtime +Gran parte de los endpoints del archivo `api.py` se han encontrado interceptando las peticiones HTTPS de la aplicación de Android + +El setup que uso personalmente es: +- [**mitmproxy**](https://mitmproxy.org), para interceptar las solicitudes +- [**MagiskTrustCert**](https://github.com/NVISOsecurity/MagiskTrustUserCerts), módulo de [Magisk](https://github.com/topjohnwu/Magisk) para el tráfico HTTPS diff --git a/tools/api_m.example.py b/tools/api_m.example.py index b96e08b..b4e8bb6 100644 --- a/tools/api_m.example.py +++ b/tools/api_m.example.py @@ -1,11 +1,16 @@ import json import sys -sys.path.append('..') # Add parent path to searchable list -from resources.lib.api import Api +import os + +PARENT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +sys.path.append(PARENT_PATH) # Add parent path to searchable list +from resources.lib.api import Api # noqa: E402 + def prettyPrint(data): print(json.dumps(data, indent=2)) + DOMAIN = "es" api = Api(DOMAIN) diff --git a/tools/xbmc.py b/tools/xbmc.py index ca61ec7..1599163 100644 --- a/tools/xbmc.py +++ b/tools/xbmc.py @@ -1,4 +1,7 @@ +# pylint: skip-file + ISO_639_1 = 0 + def getLanguage(format: int = 0, region: bool = False) -> str: return "es_ES" diff --git a/tools/xbmcgui.py b/tools/xbmcgui.py index 9deba46..f35675e 100644 --- a/tools/xbmcgui.py +++ b/tools/xbmcgui.py @@ -1,3 +1,6 @@ +# pylint: skip-file + + class Dialog: def ok(self, header: str, msg: str): print("--- {0} --- {1}".format(header, msg))