Skip to content

Commit

Permalink
New API/Mopidy model
Browse files Browse the repository at this point in the history
  • Loading branch information
quodrum-glas committed Feb 12, 2024
1 parent 33b41e1 commit c9e14f1
Show file tree
Hide file tree
Showing 40 changed files with 1,454 additions and 4,327 deletions.
25 changes: 15 additions & 10 deletions mopidy_tidal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import logging
import os
import sys
from importlib import metadata
from itertools import chain

from mopidy import config, ext
from tidalapi import Quality

__version__ = "0.3.4"

Expand All @@ -17,6 +18,7 @@


class Extension(ext.Extension):

dist_name = "Mopidy-Tidal"
ext_name = "tidal"
version = __version__
Expand All @@ -26,18 +28,21 @@ def get_default_config(self):
return config.read(conf_file)

def get_config_schema(self):
schema = super().get_config_schema()
schema["quality"] = config.String(
choices=["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"]
schema = super(Extension, self).get_config_schema()
schema["client_id"] = config.Secret()
schema["client_secret"] = config.Secret(optional=True)
schema["quality"] = config.String(choices=[e.value for e in Quality])
schema["playlist_cache_refresh_secs"] = config.Integer(
optional=True,
choices=chain(range(10, 60, 10), range(60, 600, 60), range(600, 3601, 600))
)
schema["search_result_count"] = config.Integer(
optional=True,
choices=range(0, 201, 50)
)
schema["client_id"] = config.String(optional=True)
schema["client_secret"] = config.String(optional=True)
schema["playlist_cache_refresh_secs"] = config.Integer(optional=True)
schema["lazy"] = config.Boolean(optional=True)
schema["login_method"] = config.String(choices=["BLOCK", "HACK"])
schema["login_web_port"] = config.Integer(optional=True, choices=range(8000, 9000))
return schema

def setup(self, registry):
from .backend import TidalBackend

registry.add("backend", TidalBackend)
100 changes: 100 additions & 0 deletions mopidy_tidal/auth_http_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import threading
from functools import partial
from string import whitespace

from mopidy_tidal.session import PersistentSession, NonLimitedInputDeviceLogin

try:
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from urllib import unquote
except ImportError:
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import unquote

HTML_BODY = """<!DOCTYPE html>
<html>
<head>
<title>TIDAL OAuth Login</title>
</head>
<body>
<h1>KEEP THIS TAB OPEN</h1>
<a href={authurl} target="_blank" rel="noopener noreferrer">Click here to be forwarded to TIDAL Login page</a>
{interactive}
</body>
</html>
""".format

INTERACTIVE_HTML_BODY = """
<p>...then, after login, copy the whole final URL of the page you ended up to.</p>
<p>Probably a "not found" page, nevertheless we need the whole URL as is.</p>
<form method="post">
<label for="code">Paste here your final URL location:</label>
<input type="url" id="code" name="code">
<input type="submit" value="Submit">
</form>
"""

def start_oauth_deamon(session, port):
handler = partial(HTTPHandler, session)
daemon = threading.Thread(
name="TidalOAuthLogin",
target=HTTPServer(('', port), handler).serve_forever
)
daemon.daemon = True # Set as a daemon so it will be killed once the main thread is dead.
daemon.start()


class HTTPHandler(BaseHTTPRequestHandler, object):

def __init__(self, session: PersistentSession, *args, **kwargs):
self.login_handler = LoginHandler(session)
super().__init__(*args, **kwargs)

def do_GET(self):
self.login_handler.login1()
self.send_response(200)
self.end_headers()
interactive = INTERACTIVE_HTML_BODY if self.login_handler.login2 else ''
self.wfile.write(HTML_BODY(authurl=self.login_handler.login_url, interactive=interactive).encode())

def do_POST(self):
content_length = int(self.headers.get("Content-Length"), 0)
body = self.rfile.read(content_length).decode()
try:
form = {k: v for k, v in (p.split("=", 1) for p in body.split("&"))}
code_url = unquote(form['code'].strip(whitespace))
except:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Malformed request")
raise
else:
try:
self.login_handler.login2(code_url)
self.send_response(200)
self.end_headers()
self.wfile.write(b"Success!\nCredentials auto-refresh is on.\nEnjoy your music!")
except:
self.send_response(401)
self.end_headers()
self.wfile.write(b"Failed to get final key! :(")
raise


class LoginHandler:
login_url = None
login1 = None
login2 = None
def __init__(self, session: PersistentSession):
if session.config.client_id:
response_handler = partial(self.__setattr__, 'login_url')
if session.config.client_secret:
self.login1 = partial(session.login_oauth_simple, response_handler)
else:
alt_login = NonLimitedInputDeviceLogin(session)
self.login1 = partial(alt_login.login_oauth_simple, response_handler)
self.login2 = alt_login.login_oauth_simple_auth_code
else:
raise ValueError("At least client_id must be set")
166 changes: 46 additions & 120 deletions mopidy_tidal/backend.py
Original file line number Diff line number Diff line change
@@ -1,148 +1,74 @@
from __future__ import unicode_literals

import json
import logging
from concurrent.futures import Future
from pathlib import Path
from typing import Optional
import os
import time

from mopidy import backend
from pykka import ThreadingActor
from tidalapi import Config, Quality, Session
from tidalapi.session import LinkLogin
from tidalapi import Config, Quality

from mopidy_tidal import Extension, context, library, playback, playlists
from mopidy_tidal.auth_http_server import start_oauth_deamon
from mopidy_tidal.session import PersistentSession

logger = logging.getLogger(__name__)


def _connecting_log(msg: str, level="info"):
getattr(logger, level)("Connecting to TIDAL... " + msg)
OAUTH_JSON = "tidal.oauth.json"


class TidalBackend(ThreadingActor, backend.Backend):
EXT = Extension.ext_name

def __init__(self, config, audio):
context.set_config(config[self.EXT])
super().__init__()
self._active_session: Optional[Session] = None
self._logged_in = False
self.session = None
self._config = config
context.set_config(self._config)
self.playback = playback.TidalPlaybackProvider(audio=audio, backend=self)
self.library = library.TidalLibraryProvider(backend=self)
self.playlists = playlists.TidalPlaylistsProvider(backend=self)
self.uri_schemes = ["tidal"]
self._login_future: Optional[Future] = None
self._login_url: Optional[str] = None
self.login_method = "BLOCK"
self.data_dir = Path(Extension.get_data_dir(self._config))
self.oauth_file = self.data_dir / "tidal-oauth.json"
self.lazy_connect = False
self.playlists = playlists.TidalPlaylistsProvider(
backend=self,
playlist_cache_ttl=self.get_config("playlist_cache_refresh_secs")
)
self.uri_schemes = [self.EXT]

@property
def session(self):
if not self.logged_in:
self._login()
return self._active_session
def get_config(self, item):
return self._config[self.EXT].get(item)

@property
def logged_in(self):
if not self._logged_in:
self._load_oauth_session()
return self._logged_in

def _save_oauth_session(self):
# create a new session
if self._active_session.check_login():
# store current OAuth session
data = {}
data["token_type"] = {"data": self._active_session.token_type}
data["session_id"] = {"data": self._active_session.session_id}
data["access_token"] = {"data": self._active_session.access_token}
data["refresh_token"] = {"data": self._active_session.refresh_token}
logger.debug("Saving OAuth session to %s" % self.oauth_file)
with self.oauth_file.open("w") as outfile:
json.dump(data, outfile)
self._logged_in = True
logger.info("TIDAL Login OK")
def get_dir(self, folder):
method = getattr(Extension, f"get_{folder}_dir", None)
if not method:
raise ValueError(f"Not a valid folder: {folder}")
return method(self._config)

def on_start(self):
user_config = self._config["tidal"]
quality = user_config["quality"]
_connecting_log("Quality = %s" % quality)
client_id = self.get_config("client_id")
client_secret = self.get_config("client_secret")
quality = self.get_config("quality")
oauth_file_location = os.path.join(self.get_dir("data"), OAUTH_JSON)
config = Config(quality=Quality(quality))
client_id = user_config["client_id"]
client_secret = user_config["client_secret"]
self.login_method = user_config["login_method"]
self.lazy_connect = user_config["lazy"]
if self.login_method == "HACK" and not user_config["lazy"]:
_connecting_log(
"HACK login implies lazy connection, setting lazy=True.",
level="warning",
)
self.lazy_connect = True
_connecting_log(f"login method {self.login_method}.")
if (client_id and not client_secret) or (client_secret and not client_id):
_connecting_log(
"always provide client_id and client_secret together", "warning"
)
_connecting_log("using default client id & client secret from python-tidal")

if client_id and client_secret:
_connecting_log("client id & client secret from config section are used")
if client_id:
config.client_id = client_id
config.api_token = client_id
config.client_secret = client_secret

if not client_id and not client_secret:
_connecting_log("using default client id & client secret from python-tidal")

self._active_session = Session(config)
if not self.lazy_connect:
self._login()

def _login(self):
self._load_oauth_session()
if not self._active_session.check_login():
logger.info("Creating new OAuth session...")
self._active_session.login_oauth_simple(function=logger.info)
self._save_oauth_session()

if not self._active_session.check_login():
logger.info("TIDAL Login KO")
raise ConnectionError("Failed to log in.")

@property
def logging_in(self) -> bool:
"""Are we currently waiting for user confirmation to log in?"""
return bool(self._login_future and self._login_future.running())

@property
def login_url(self) -> Optional[str]:
if not self._logged_in and not self.logging_in:
login_url, self._login_future = self._active_session.login_oauth()
self._login_future.add_done_callback(lambda *_: self._save_oauth_session())
# self._login_future.add_done_callback(lambda: self.logged_in=True)
self._login_url = login_url.verification_uri_complete
return f"https://{self._login_url}" if self._login_url else None

def _load_oauth_session(self):
self.session = PersistentSession(config, authentication_local_storage=oauth_file_location)
logger.info("Connecting to TIDAL... Requested Quality = %s" % quality)
try:
oauth_file = self.oauth_file
with open(oauth_file) as f:
logger.info("Loading OAuth session from %s...", oauth_file)
data = json.load(f)

assert self._active_session, "No session loaded"
args = {
"token_type": data.get("token_type", {}).get("data"),
"access_token": data.get("access_token", {}).get("data"),
"refresh_token": data.get("refresh_token", {}).get("data"),
}

self._active_session.load_oauth_session(**args)
except Exception as e:
logger.info("Could not load OAuth session from %s: %s", oauth_file, e)

if self._active_session.check_login():
self.session.load_oauth_session_from_file()
except FileNotFoundError:
login_web_port = self.get_config("login_web_port")
start_oauth_deamon(self.session, login_web_port)
logger.info(f"No authentication found. Please visit http://localhost:{login_web_port} to authenticate")
max_time = time.time() + 300
while time.time() < max_time:
if self.session.check_login():
break
else:
logger.info(f"Time left to complete authentication: ${int(max_time - time.time())}sec")
time.sleep(15)
if self.session.check_login():
logger.info("TIDAL Login OK")
self._logged_in = True
subscription = self.session.request.basic_request('GET', f'users/{self.session.user.id}/subscription').json()
logger.info("HighestSoundQuality: {highestSoundQuality}".format(**subscription))
else:
logger.info("TIDAL Login KO")
40 changes: 40 additions & 0 deletions mopidy_tidal/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from functools import wraps
from cachetools import LRUCache, cached
from cachetools.keys import hashkey


_by_uri_cache = LRUCache(maxsize=16*1024)
_items_cache = LRUCache(maxsize=16*1024)
_futures_cache = LRUCache(maxsize=16*1024)

cached_by_uri = cached(
_by_uri_cache,
key=lambda *args, uri, **kwargs: hash(uri),
)
cached_items = cached(
_items_cache,
key=lambda item, *args, **kwargs: hashkey(item.uri, item.last_modified),
)
cached_future = cached(
_futures_cache,
key=lambda *args, uri, **kwargs: hash(uri),
)


def cache_by_uri(_callable):
@wraps(_callable)
def wrapper(*args, **kwargs):
item = _callable(*args, **kwargs)
_by_uri_cache[hash(item.ref.uri)] = item
return item
return wrapper


def cache_future(_callable):
@wraps(_callable)
def wrapper(*args, **kwargs):
item = _callable(*args, **kwargs)
if item:
_futures_cache[hash(item.ref.uri)] = item
return item
return wrapper
4 changes: 2 additions & 2 deletions mopidy_tidal/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def set_config(cfg):
_ctx["config"] = cfg


def get_config():
def get_config(item):
if not _ctx["config"]:
raise ValueError("Extension configuration not set.")
return _ctx["config"]
return _ctx["config"].get(item)
Loading

0 comments on commit c9e14f1

Please sign in to comment.