forked from tehkillerbee/mopidy-tidal
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
33b41e1
commit c9e14f1
Showing
40 changed files
with
1,454 additions
and
4,327 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.