"""All constants for Music Assistant."""
-__version__ = "0.0.81"
+__version__ = "0.0.82"
REQUIRED_PYTHON_VER = "3.7"
# configuration keys/attributes
UNIQUE(item_id, provider));"""
)
+ await db_conn.execute(
+ """CREATE TABLE IF NOT EXISTS playlog(
+ item_id INTEGER NOT NULL,
+ provider TEXT NOT NULL,
+ timestamp REAL,
+ UNIQUE(item_id, provider));"""
+ )
+
await db_conn.execute(
"""CREATE TABLE IF NOT EXISTS thumbs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
"""All classes and helpers for the Configuration."""
import copy
+import datetime
import json
import logging
import os
def add_app_token(self, token_info: dict):
"""Add token to config."""
client_id = token_info["client_id"]
- self[CONF_KEY_SECURITY_APP_TOKENS][client_id] = token_info
+ self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][
+ client_id
+ ] = token_info
+ self.conf_mgr.save()
+
+ def set_last_login(self, client_id: str):
+ """Set last login to client."""
+ if (
+ client_id
+ not in self.conf_mgr.stored_config[CONF_KEY_SECURITY][
+ CONF_KEY_SECURITY_APP_TOKENS
+ ]
+ ):
+ return
+ self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][
+ client_id
+ ]["last_login"] = datetime.datetime.utcnow().timestamp()
+ self.conf_mgr.save()
def revoke_app_token(self, client_id):
"""Revoke a token registered for an app."""
- self[CONF_KEY_SECURITY_APP_TOKENS].pop(client_id)
+ return_info = self.conf_mgr.stored_config[CONF_KEY_SECURITY][
+ CONF_KEY_SECURITY_APP_TOKENS
+ ].pop(client_id)
+ self.conf_mgr.save()
+ self.conf_mgr.mass.signal_event(
+ EVENT_CONFIG_CHANGED, (CONF_KEY_SECURITY, CONF_KEY_SECURITY_APP_TOKENS)
+ )
+ return return_info
def is_token_revoked(self, token_info: dict):
- """Return bool is token is revoked."""
+ """Return bool if token is revoked."""
if not token_info.get("app_id"):
# short lived token does not have app_id and is not stored so can't be revoked
return False
label=token_info["app_id"],
description="App connected to MusicAssistant API",
store_hashed=False,
+ value={
+ "expires": token_info.get("exp"),
+ "last_login": token_info.get("last_login"),
+ },
)
for client_id, token_info in self.conf_mgr.stored_config[
CONF_KEY_SECURITY
# pylint: disable=too-many-lines
import logging
import os
+from datetime import datetime
from typing import List, Optional, Union
import aiosqlite
return result[0]
return None
+ async def async_mark_item_played(self, item_id: str, provider: str):
+ """Mark item as played in playlog."""
+ timestamp = datetime.utcnow().timestamp()
+ async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+ sql_query = """INSERT or REPLACE INTO playlog
+ (item_id, provider, timestamp) VALUES(?,?,?);"""
+ await db_conn.execute(sql_query, (item_id, provider, timestamp))
+ await db_conn.commit()
+
async def async_get_thumbnail_id(self, url, size):
"""Get/create id for thumbnail."""
async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
streamdetails.provider,
streamdetails.item_id,
)
+ await self.mass.database.async_mark_item_played(
+ streamdetails.item_id, streamdetails.provider
+ )
# send analyze job to background worker
# TODO: feed audio chunks to analyzer while streaming
"crossfade_duration": "Enable crossfade",
"group_delay": "Correction of groupdelay",
"security": "Security",
+ "app_tokens": "App tokens",
"power_control": "Power Control",
"volume_control": "Volume Control",
"desc_player_name": "Stel een aangepaste naam in voor deze speler.",
"crossfade_duration": "Crossfade inschakelen",
"security": "Beveiliging",
+ "app_tokens": "App tokens",
"group_delay": "Correctie van groepsvertraging",
"power_control": "Power Control",
"volume_control": "Volume Control",
from aiohttp import WSMsgType, web
from aiohttp.web import WebSocketResponse
from music_assistant.constants import (
- CONF_KEY_SECURITY,
- CONF_KEY_SECURITY_APP_TOKENS,
CONF_KEY_SECURITY_LOGIN,
CONF_PASSWORD,
CONF_USERNAME,
return web.json_response(self.discovery_info)
return self.discovery_info
+ @api_route("revoke_token")
+ async def async_revoke_token(self, client_id: str):
+ """Revoke token for client."""
+ return self.mass.config.security.revoke_app_token(client_id)
+
@api_route("get_token", False)
async def async_get_token(
self, username: str, password: str, app_id: str = ""
"app_id": app_id,
}
if app_id:
- token_info["exp"] = datetime.datetime.utcnow() + datetime.timedelta(
- days=365 * 10
- )
+ token_info["enabled"] = True
+ token_info["exp"] = (
+ datetime.datetime.utcnow() + datetime.timedelta(days=365 * 10)
+ ).timestamp()
else:
- token_info["exp"] = datetime.datetime.utcnow() + datetime.timedelta(
- hours=8
- )
- token = jwt.encode(token_info, self.jwt_key).decode()
+ token_info["exp"] = (
+ datetime.datetime.utcnow() + datetime.timedelta(hours=8)
+ ).timestamp()
+ token = jwt.encode(token_info, self.jwt_key, algorithm="HS256")
if app_id:
- self.mass.config.stored_config[CONF_KEY_SECURITY][
- CONF_KEY_SECURITY_APP_TOKENS
- ][client_id] = token_info
- self.mass.config.save()
+ self.mass.config.security.add_app_token(token_info)
token_info["token"] = token
return token_info
raise AuthenticationError("Invalid credentials")
except Exception as exc: # pylint:disable=broad-except
# log the error only
await self.__async_send_json(ws_client, error=str(exc), **json_msg)
- LOGGER.debug("Error with WS client", exc_info=exc)
+ LOGGER.error("Error with WS client", exc_info=exc)
# websocket disconnected
request.app["clients"].remove(ws_client)
async def __async_handle_auth(self, ws_client: WebSocketResponse, token: str):
"""Handle authentication with JWT token."""
- token_info = jwt.decode(token, self.mass.web.jwt_key)
+ token_info = jwt.decode(token, self.mass.web.jwt_key, algorithms=["HS256"])
if self.mass.config.security.is_token_revoked(token_info):
raise AuthenticationError("Token is revoked")
ws_client.authenticated = True
+ self.mass.config.security.set_last_login(token_info["client_id"])
# TODO: store token/app_id on ws_client obj and periodiclaly check if token is expired or revoked
await self.__async_send_json(ws_client, result="auth", data=token_info)