Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin

This commit is contained in:
Fabio Manganiello 2022-09-28 02:17:10 +02:00
commit deb25196d2
Signed by: blacklight
GPG key ID: D90FBA7F76362774
23 changed files with 1570 additions and 277 deletions

View file

@ -1,7 +1,33 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. Given the high speed of development in the first phase, changes are being
reported only starting from v0.20.2.
## [0.23.6] - 2022-09-19
### Fixed
- Fixed album_id and list of tracks on `music.tidal.get_album`.
## [0.23.5] - 2022-09-18
### Added
- Added support for web hooks returning their hook method responses back to the
HTTP client.
- Added [Tidal integration](https://git.platypush.tech/platypush/platypush/pulls/223)
- Added support for [OPML
subscriptions](https://git.platypush.tech/platypush/platypush/pulls/220) to
the `rss` plugin.
- Better support for bulk database operations on the `db` plugin.
### Fixed
- Now supporting YAML sections with empty configurations.
## [0.23.4] - 2022-08-28 ## [0.23.4] - 2022-08-28

View file

@ -48,6 +48,7 @@ Events
platypush/events/mqtt.rst platypush/events/mqtt.rst
platypush/events/music.rst platypush/events/music.rst
platypush/events/music.snapcast.rst platypush/events/music.snapcast.rst
platypush/events/music.tidal.rst
platypush/events/nextcloud.rst platypush/events/nextcloud.rst
platypush/events/nfc.rst platypush/events/nfc.rst
platypush/events/ngrok.rst platypush/events/ngrok.rst

View file

@ -0,0 +1,5 @@
``music.tidal``
===============
.. automodule:: platypush.message.event.music.tidal
:members:

View file

@ -0,0 +1,5 @@
``music.tidal``
===============
.. automodule:: platypush.plugins.music.tidal
:members:

View file

@ -95,6 +95,7 @@ Plugins
platypush/plugins/music.mpd.rst platypush/plugins/music.mpd.rst
platypush/plugins/music.snapcast.rst platypush/plugins/music.snapcast.rst
platypush/plugins/music.spotify.rst platypush/plugins/music.spotify.rst
platypush/plugins/music.tidal.rst
platypush/plugins/nextcloud.rst platypush/plugins/nextcloud.rst
platypush/plugins/ngrok.rst platypush/plugins/ngrok.rst
platypush/plugins/nmap.rst platypush/plugins/nmap.rst

View file

@ -25,7 +25,7 @@ from .message.response import Response
from .utils import set_thread_name, get_enabled_plugins from .utils import set_thread_name, get_enabled_plugins
__author__ = 'Fabio Manganiello <info@fabiomanganiello.com>' __author__ = 'Fabio Manganiello <info@fabiomanganiello.com>'
__version__ = '0.23.4' __version__ = '0.23.6'
logger = logging.getLogger('platypush') logger = logging.getLogger('platypush')

View file

@ -1,9 +1,11 @@
import json import json
from flask import Blueprint, abort, request, Response from flask import Blueprint, abort, request, make_response
from platypush.backend.http.app import template_folder from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import logger, send_message from platypush.backend.http.app.utils import logger, send_message
from platypush.config import Config
from platypush.event.hook import EventCondition
from platypush.message.event.http.hook import WebhookEvent from platypush.message.event.http.hook import WebhookEvent
@ -15,9 +17,23 @@ __routes__ = [
] ]
@hook.route('/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) def matches_condition(event: WebhookEvent, hook):
def _hook(hook_name): if isinstance(hook, dict):
""" Endpoint for custom webhooks """ if_ = hook['if'].copy()
if_['type'] = '.'.join([event.__module__, event.__class__.__qualname__])
condition = EventCondition.build(if_)
else:
condition = hook.condition
return event.matches_condition(condition)
@hook.route(
'/hook/<hook_name>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
)
def hook_route(hook_name):
"""Endpoint for custom webhooks"""
event_args = { event_args = {
'hook': hook_name, 'hook': hook_name,
@ -28,20 +44,54 @@ def _hook(hook_name):
} }
if event_args['data']: if event_args['data']:
# noinspection PyBroadException
try: try:
event_args['data'] = json.loads(event_args['data']) event_args['data'] = json.loads(event_args['data'])
except Exception as e: except Exception as e:
logger().warning('Not a valid JSON string: {}: {}'.format(event_args['data'], str(e))) logger().warning(
'Not a valid JSON string: %s: %s', event_args['data'], str(e)
)
event = WebhookEvent(**event_args) event = WebhookEvent(**event_args)
matching_hooks = [
hook
for hook in Config.get_event_hooks().values()
if matches_condition(event, hook)
]
try: try:
send_message(event) send_message(event)
return Response(json.dumps({'status': 'ok', **event_args}), mimetype='application/json') rs = default_rs = make_response(json.dumps({'status': 'ok', **event_args}))
headers = {}
status_code = 200
# If there are matching hooks, wait for their completion before returning
if matching_hooks:
rs = event.wait_response(timeout=60)
try:
rs = json.loads(rs.decode()) # type: ignore
except Exception:
pass
if isinstance(rs, dict) and '___data___' in rs:
# data + http_code + custom_headers return format
headers = rs.get('___headers___', {})
status_code = rs.get('___code___', status_code)
rs = rs['___data___']
if rs is None:
rs = default_rs
headers = {'Content-Type': 'application/json'}
rs = make_response(rs)
else:
headers = {'Content-Type': 'application/json'}
rs.status_code = status_code
rs.headers.update(headers)
return rs
except Exception as e: except Exception as e:
logger().exception(e) logger().exception(e)
logger().error('Error while dispatching webhook event {}: {}'.format(event, str(e))) logger().error('Error while dispatching webhook event %s: %s', event, str(e))
abort(500, str(e)) abort(500, str(e))

View file

@ -215,7 +215,9 @@ class Config:
) )
else: else:
section_config = file_config.get(section, {}) or {} section_config = file_config.get(section, {}) or {}
if not section_config.get('disabled'): if not (
hasattr(section_config, 'get') and section_config.get('disabled')
):
config[section] = section_config config[section] = section_config
return config return config

View file

@ -15,10 +15,10 @@ logger = logging.getLogger('platypush')
def parse(msg): def parse(msg):
""" Builds a dict given another dictionary or """Builds a dict given another dictionary or
a JSON UTF-8 encoded string/bytearray """ a JSON UTF-8 encoded string/bytearray"""
if isinstance(msg, bytes) or isinstance(msg, bytearray): if isinstance(msg, (bytes, bytearray)):
msg = msg.decode('utf-8') msg = msg.decode('utf-8')
if isinstance(msg, str): if isinstance(msg, str):
try: try:
@ -30,8 +30,8 @@ def parse(msg):
return msg return msg
class EventCondition(object): class EventCondition:
""" Event hook condition class """ """Event hook condition class"""
def __init__(self, type=Event.__class__, priority=None, **kwargs): def __init__(self, type=Event.__class__, priority=None, **kwargs):
""" """
@ -55,8 +55,8 @@ class EventCondition(object):
@classmethod @classmethod
def build(cls, rule): def build(cls, rule):
""" Builds a rule given either another EventRule, a dictionary or """Builds a rule given either another EventRule, a dictionary or
a JSON UTF-8 encoded string/bytearray """ a JSON UTF-8 encoded string/bytearray"""
if isinstance(rule, cls): if isinstance(rule, cls):
return rule return rule
@ -64,8 +64,7 @@ class EventCondition(object):
rule = parse(rule) rule = parse(rule)
assert isinstance(rule, dict), f'Not a valid rule: {rule}' assert isinstance(rule, dict), f'Not a valid rule: {rule}'
type = get_event_class_by_type( type = get_event_class_by_type(rule.pop('type') if 'type' in rule else 'Event')
rule.pop('type') if 'type' in rule else 'Event')
args = {} args = {}
for (key, value) in rule.items(): for (key, value) in rule.items():
@ -75,8 +74,8 @@ class EventCondition(object):
class EventAction(Request): class EventAction(Request):
""" Event hook action class. It is a special type of runnable request """Event hook action class. It is a special type of runnable request
whose fields can be configured later depending on the event context """ whose fields can be configured later depending on the event context"""
def __init__(self, target=None, action=None, **args): def __init__(self, target=None, action=None, **args):
if target is None: if target is None:
@ -99,16 +98,16 @@ class EventAction(Request):
return super().build(action) return super().build(action)
class EventHook(object): class EventHook:
""" Event hook class. It consists of one conditions and """Event hook class. It consists of one conditions and
one or multiple actions to be executed """ one or multiple actions to be executed"""
def __init__(self, name, priority=None, condition=None, actions=None): def __init__(self, name, priority=None, condition=None, actions=None):
""" Constructor. Takes a name, a EventCondition object and an event action """Constructor. Takes a name, a EventCondition object and an event action
procedure as input. It may also have a priority attached procedure as input. It may also have a priority attached
as a positive number. If multiple hooks match against an event, as a positive number. If multiple hooks match against an event,
only the ones that have either the maximum match score or the only the ones that have either the maximum match score or the
maximum pre-configured priority will be run. """ maximum pre-configured priority will be run."""
self.name = name self.name = name
self.condition = EventCondition.build(condition or {}) self.condition = EventCondition.build(condition or {})
@ -118,8 +117,8 @@ class EventHook(object):
@classmethod @classmethod
def build(cls, name, hook): def build(cls, name, hook):
""" Builds a rule given either another EventRule, a dictionary or """Builds a rule given either another EventRule, a dictionary or
a JSON UTF-8 encoded string/bytearray """ a JSON UTF-8 encoded string/bytearray"""
if isinstance(hook, cls): if isinstance(hook, cls):
return hook return hook
@ -146,14 +145,14 @@ class EventHook(object):
return cls(name=name, condition=condition, actions=actions, priority=priority) return cls(name=name, condition=condition, actions=actions, priority=priority)
def matches_event(self, event): def matches_event(self, event):
""" Returns an EventMatchResult object containing the information """Returns an EventMatchResult object containing the information
about the match between the event and this hook """ about the match between the event and this hook"""
return event.matches_condition(self.condition) return event.matches_condition(self.condition)
def run(self, event): def run(self, event):
""" Checks the condition of the hook against a particular event and """Checks the condition of the hook against a particular event and
runs the hook actions if the condition is met """ runs the hook actions if the condition is met"""
def _thread_func(result): def _thread_func(result):
set_thread_name('Event-' + self.name) set_thread_name('Event-' + self.name)
@ -163,7 +162,9 @@ class EventHook(object):
if result.is_match: if result.is_match:
logger.info('Running hook {} triggered by an event'.format(self.name)) logger.info('Running hook {} triggered by an event'.format(self.name))
threading.Thread(target=_thread_func, name='Event-' + self.name, args=(result,)).start() threading.Thread(
target=_thread_func, name='Event-' + self.name, args=(result,)
).start()
def hook(event_type=Event, **condition): def hook(event_type=Event, **condition):
@ -172,8 +173,14 @@ def hook(event_type=Event, **condition):
f.condition = EventCondition(type=event_type, **condition) f.condition = EventCondition(type=event_type, **condition)
@wraps(f) @wraps(f)
def wrapped(*args, **kwargs): def wrapped(event, *args, **kwargs):
return exec_wrapper(f, *args, **kwargs) from platypush.message.event.http.hook import WebhookEvent
response = exec_wrapper(f, event, *args, **kwargs)
if isinstance(event, WebhookEvent):
event.send_response(response)
return response
return wrapped return wrapped

View file

@ -1,10 +1,9 @@
import copy import copy
import hashlib
import json import json
import random
import re import re
import sys import sys
import time import time
import uuid
from datetime import date from datetime import date
@ -79,9 +78,7 @@ class Event(Message):
@staticmethod @staticmethod
def _generate_id(): def _generate_id():
"""Generate a unique event ID""" """Generate a unique event ID"""
return hashlib.md5( return ''.join(['{:02x}'.format(random.randint(0, 255)) for _ in range(16)])
str(uuid.uuid1()).encode()
).hexdigest() # lgtm [py/weak-sensitive-data-hashing]
def matches_condition(self, condition): def matches_condition(self, condition):
""" """

View file

@ -1,11 +1,26 @@
import json
import uuid
from platypush.message.event import Event from platypush.message.event import Event
from platypush.utils import get_redis
class WebhookEvent(Event): class WebhookEvent(Event):
""" """
Event triggered when a custom webhook is called. Event triggered when a custom webhook is called.
""" """
def __init__(self, *argv, hook, method, data=None, args=None, headers=None, **kwargs): def __init__(
self,
*argv,
hook,
method,
data=None,
args=None,
headers=None,
response=None,
**kwargs,
):
""" """
:param hook: Name of the invoked web hook, from http://host:port/hook/<hook> :param hook: Name of the invoked web hook, from http://host:port/hook/<hook>
:type hook: str :type hook: str
@ -21,10 +36,56 @@ class WebhookEvent(Event):
:param headers: Request headers :param headers: Request headers
:type args: dict :type args: dict
"""
super().__init__(hook=hook, method=method, data=data, args=args or {}, :param response: Response returned by the hook.
headers=headers or {}, *argv, **kwargs) :type args: dict | list | str
"""
# This queue is used to synchronize with the hook and wait for its completion
kwargs['response_queue'] = kwargs.get(
'response_queue', f'platypush/webhook/{str(uuid.uuid1())}'
)
super().__init__(
*argv,
hook=hook,
method=method,
data=data,
args=args or {},
headers=headers or {},
response=response,
**kwargs,
)
def send_response(self, response):
output = response.output
if isinstance(output, tuple):
# A 3-sized tuple where the second element is an int and the third
# is a dict represents an HTTP response in the format `(data,
# http_code headers)`.
if (
len(output) == 3
and isinstance(output[1], int)
and isinstance(output[2], dict)
):
output = {
'___data___': output[0],
'___code___': output[1],
'___headers___': output[2],
}
else:
# Normalize tuples to lists before serialization
output = list(output)
if isinstance(output, (dict, list)):
output = json.dumps(output)
if output is None:
output = ''
get_redis().rpush(self.args['response_queue'], output)
def wait_response(self, timeout=None):
rs = get_redis().blpop(self.args['response_queue'], timeout=timeout)
if rs and len(rs) > 1:
return rs[1]
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -0,0 +1,14 @@
from platypush.message.event import Event
class TidalEvent(Event):
"""Base class for Tidal events"""
class TidalPlaylistUpdatedEvent(TidalEvent):
"""
Event fired when a Tidal playlist is updated.
"""
def __init__(self, playlist_id: str, *args, **kwargs):
super().__init__(*args, playlist_id=playlist_id, **kwargs)

View file

@ -1,11 +1,13 @@
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from multiprocessing import RLock from multiprocessing import RLock
from typing import Generator from typing import Optional, Generator
from sqlalchemy import create_engine, Table, MetaData from sqlalchemy import create_engine, Table, MetaData
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.exc import CompileError
from sqlalchemy.orm import Session, sessionmaker, scoped_session from sqlalchemy.orm import Session, sessionmaker, scoped_session
from sqlalchemy.sql import and_, or_, text
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -23,10 +25,17 @@ class DbPlugin(Plugin):
def __init__(self, engine=None, *args, **kwargs): def __init__(self, engine=None, *args, **kwargs):
""" """
:param engine: Default SQLAlchemy connection engine string (e.g. ``sqlite:///:memory:`` or ``mysql://user:pass@localhost/test``) that will be used. You can override the default engine in the db actions. :param engine: Default SQLAlchemy connection engine string (e.g.
``sqlite:///:memory:`` or ``mysql://user:pass@localhost/test``)
that will be used. You can override the default engine in the db
actions.
:type engine: str :type engine: str
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` (see https://docs.sqlalchemy.org/en/latest/core/engines.html) :param args: Extra arguments that will be passed to
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) ``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine``
(see https:///docs.sqlalchemy.org/en/latest/core/engines.html)
""" """
super().__init__() super().__init__()
@ -46,7 +55,7 @@ class DbPlugin(Plugin):
return self.engine return self.engine
@staticmethod @staticmethod
def _build_condition(_, column, value): def _build_condition(table, column, value): # type: ignore
if isinstance(value, str): if isinstance(value, str):
value = "'{}'".format(value) value = "'{}'".format(value)
elif not isinstance(value, int) and not isinstance(value, float): elif not isinstance(value, int) and not isinstance(value, float):
@ -70,8 +79,12 @@ class DbPlugin(Plugin):
:type statement: str :type statement: str
:param engine: Engine to be used (default: default class engine) :param engine: Engine to be used (default: default class engine)
:type engine: str :type engine: str
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` (see https://docs.sqlalchemy.org/en/latest/core/engines.html) :param args: Extra arguments that will be passed to
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) ``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine``
(see https:///docs.sqlalchemy.org/en/latest/core/engines.html)
""" """
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
@ -107,24 +120,42 @@ class DbPlugin(Plugin):
return table, engine return table, engine
@action @action
def select(self, query=None, table=None, filter=None, engine=None, *args, **kwargs): def select(
self,
query=None,
table=None,
filter=None,
engine=None,
data: Optional[dict] = None,
*args,
**kwargs
):
""" """
Returns rows (as a list of hashes) given a query. Returns rows (as a list of hashes) given a query.
:param query: SQL to be executed :param query: SQL to be executed
:type query: str :type query: str
:param filter: Query WHERE filter expressed as a dictionary. This approach is preferred over specifying raw SQL :param filter: Query WHERE filter expressed as a dictionary. This
in ``query`` as the latter approach may be prone to SQL injection, unless you need to build some complex approach is preferred over specifying raw SQL
SQL logic. in ``query`` as the latter approach may be prone to SQL injection,
unless you need to build some complex SQL logic.
:type filter: dict :type filter: dict
:param table: If you specified a filter instead of a raw query, you'll have to specify the target table :param table: If you specified a filter instead of a raw query, you'll
have to specify the target table
:type table: str :type table: str
:param engine: Engine to be used (default: default class engine) :param engine: Engine to be used (default: default class engine)
:type engine: str :type engine: str
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` :param data: If ``query`` is an SQL string, then you can use
(see https://docs.sqlalchemy.org/en/latest/core/engines.html) SQLAlchemy's *placeholders* mechanism. You can specify placeholders
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` in the query for values that you want to be safely serialized, and
(seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) their values can be specified on the ``data`` attribute in a
``name`` ``value`` mapping format.
:param args: Extra arguments that will be passed to
``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine`` (see
https:///docs.sqlalchemy.org/en/latest/core/engines.html)
:returns: List of hashes representing the result rows. :returns: List of hashes representing the result rows.
Examples: Examples:
@ -137,7 +168,10 @@ class DbPlugin(Plugin):
"action": "db.select", "action": "db.select",
"args": { "args": {
"engine": "sqlite:///:memory:", "engine": "sqlite:///:memory:",
"query": "SELECT id, name FROM table" "query": "SELECT id, name FROM table WHERE name = :name",
"data": {
"name": "foobar"
}
} }
} }
@ -166,19 +200,24 @@ class DbPlugin(Plugin):
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
if isinstance(query, str):
query = text(query)
if table: if table:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
query = table.select() query = table.select()
if filter: if filter:
for (k,v) in filter.items(): for (k, v) in filter.items():
query = query.where(self._build_condition(table, k, v)) query = query.where(self._build_condition(table, k, v))
if query is None: if query is None:
raise RuntimeError('You need to specify either "query", or "table" and "filter"') raise RuntimeError(
'You need to specify either "query", or "table" and "filter"'
)
with engine.connect() as connection: with engine.connect() as connection:
result = connection.execute(query) result = connection.execute(query, **(data or {}))
columns = result.keys() columns = result.keys()
rows = [ rows = [
{col: row[i] for i, col in enumerate(list(columns))} {col: row[i] for i, col in enumerate(list(columns))}
@ -188,8 +227,16 @@ class DbPlugin(Plugin):
return rows return rows
@action @action
def insert(self, table, records, engine=None, key_columns=None, def insert(
on_duplicate_update=False, *args, **kwargs): self,
table,
records,
engine=None,
key_columns=None,
on_duplicate_update=False,
*args,
**kwargs
):
""" """
Inserts records (as a list of hashes) into a table. Inserts records (as a list of hashes) into a table.
@ -199,12 +246,25 @@ class DbPlugin(Plugin):
:type records: list :type records: list
:param engine: Engine to be used (default: default class engine) :param engine: Engine to be used (default: default class engine)
:type engine: str :type engine: str
:param key_columns: Set it to specify the names of the key columns for ``table``. Set it if you want your statement to be executed with the ``on_duplicate_update`` flag. :param key_columns: Set it to specify the names of the key columns for
``table``. Set it if you want your statement to be executed with
the ``on_duplicate_update`` flag.
:type key_columns: list :type key_columns: list
:param on_duplicate_update: If set, update the records in case of duplicate rows (default: False). If set, you'll need to specify ``key_columns`` as well. :param on_duplicate_update: If set, update the records in case of
duplicate rows (default: False). If set, you'll need to specify
``key_columns`` as well. If ``key_columns`` is set, existing
records are found but ``on_duplicate_update`` is false, then
existing records will be ignored.
:type on_duplicate_update: bool :type on_duplicate_update: bool
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` (see https://docs.sqlalchemy.org/en/latest/core/engines.html) :param args: Extra arguments that will be passed to
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) ``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine``
(see https:///docs.sqlalchemy.org/en/latest/core/engines.html)
:return: The inserted records, if the underlying engine supports the
``RETURNING`` statement, otherwise nothing.
Example: Example:
@ -232,25 +292,108 @@ class DbPlugin(Plugin):
} }
""" """
if on_duplicate_update:
assert (
key_columns
), 'on_duplicate_update requires key_columns to be specified'
if key_columns is None: if key_columns is None:
key_columns = [] key_columns = []
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
insert = table.insert().values(**record) insert_records = records
update_records = []
returned_records = []
with engine.connect() as connection:
# Upsert case
if key_columns:
insert_records, update_records = self._get_new_and_existing_records(
connection, table, records, key_columns
)
with connection.begin():
if insert_records:
insert = table.insert().values(insert_records)
ret = self._execute_try_returning(connection, insert)
if ret:
returned_records += ret
if update_records and on_duplicate_update:
ret = self._update(connection, table, update_records, key_columns)
if ret:
returned_records = ret + returned_records
if returned_records:
return returned_records
@staticmethod
def _execute_try_returning(connection, stmt):
ret = None
stmt_with_ret = stmt.returning('*')
try: try:
engine.execute(insert) ret = connection.execute(stmt_with_ret)
except Exception as e: except CompileError as e:
if on_duplicate_update and key_columns: if str(e).startswith('RETURNING is not supported'):
self.update(table=table, records=records, connection.execute(stmt)
key_columns=key_columns, engine=engine,
*args, **kwargs)
else: else:
raise e raise e
if ret:
return [
{col.name: getattr(row, col.name, None) for col in stmt.table.c}
for row in ret
]
def _get_new_and_existing_records(self, connection, table, records, key_columns):
records_by_key = {
tuple(record.get(k) for k in key_columns): record for record in records
}
query = table.select().where(
or_(
and_(
self._build_condition(table, k, record.get(k)) for k in key_columns
)
for record in records
)
)
existing_records = {
tuple(getattr(record, k, None) for k in key_columns): record
for record in connection.execute(query).all()
}
update_records = [
record for k, record in records_by_key.items() if k in existing_records
]
insert_records = [
record for k, record in records_by_key.items() if k not in existing_records
]
return insert_records, update_records
def _update(self, connection, table, records, key_columns):
updated_records = []
for record in records:
key = {k: v for (k, v) in record.items() if k in key_columns}
values = {k: v for (k, v) in record.items() if k not in key_columns}
update = table.update()
for (k, v) in key.items():
update = update.where(self._build_condition(table, k, v))
update = update.values(**values)
ret = self._execute_try_returning(connection, update)
if ret:
updated_records += ret
if updated_records:
return updated_records
@action @action
def update(self, table, records, key_columns, engine=None, *args, **kwargs): def update(self, table, records, key_columns, engine=None, *args, **kwargs):
""" """
@ -264,8 +407,15 @@ class DbPlugin(Plugin):
:type key_columns: list :type key_columns: list
:param engine: Engine to be used (default: default class engine) :param engine: Engine to be used (default: default class engine)
:type engine: str :type engine: str
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` (see https://docs.sqlalchemy.org/en/latest/core/engines.html) :param args: Extra arguments that will be passed to
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) ``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine``
(see https:///docs.sqlalchemy.org/en/latest/core/engines.html)
:return: The inserted records, if the underlying engine supports the
``RETURNING`` statement, otherwise nothing.
Example: Example:
@ -293,21 +443,10 @@ class DbPlugin(Plugin):
} }
} }
""" """
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
with engine.connect() as connection:
for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
key = { k:v for (k,v) in record.items() if k in key_columns } return self._update(connection, table, records, key_columns)
values = { k:v for (k,v) in record.items() if k not in key_columns }
update = table.update()
for (k,v) in key.items():
update = update.where(self._build_condition(table, k, v))
update = update.values(**values)
engine.execute(update)
@action @action
def delete(self, table, records, engine=None, *args, **kwargs): def delete(self, table, records, engine=None, *args, **kwargs):
@ -320,8 +459,12 @@ class DbPlugin(Plugin):
:type records: list :type records: list
:param engine: Engine to be used (default: default class engine) :param engine: Engine to be used (default: default class engine)
:type engine: str :type engine: str
:param args: Extra arguments that will be passed to ``sqlalchemy.create_engine`` (see https://docs.sqlalchemy.org/en/latest/core/engines.html) :param args: Extra arguments that will be passed to
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) ``sqlalchemy.create_engine`` (see
https://docs.sqlalchemy.org/en/latest/core/engines.html)
:param kwargs: Extra kwargs that will be passed to
``sqlalchemy.create_engine``
(see https:///docs.sqlalchemy.org/en/latest/core/engines.html)
Example: Example:
@ -344,14 +487,15 @@ class DbPlugin(Plugin):
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
with engine.connect() as connection, connection.begin():
for record in records: for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
delete = table.delete() delete = table.delete()
for (k,v) in record.items(): for (k, v) in record.items():
delete = delete.where(self._build_condition(table, k, v)) delete = delete.where(self._build_condition(table, k, v))
engine.execute(delete) connection.execute(delete)
def create_all(self, engine, base): def create_all(self, engine, base):
self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
@ -359,7 +503,9 @@ class DbPlugin(Plugin):
base.metadata.create_all(engine) base.metadata.create_all(engine)
@contextmanager @contextmanager
def get_session(self, engine=None, *args, **kwargs) -> Generator[Session, None, None]: def get_session(
self, engine=None, *args, **kwargs
) -> Generator[Session, None, None]:
engine = self.get_engine(engine, *args, **kwargs) engine = self.get_engine(engine, *args, **kwargs)
self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
with self._session_locks[engine.url]: with self._session_locks[engine.url]:

View file

@ -6,9 +6,17 @@ from platypush.message.response import Response
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.media import PlayerState from platypush.plugins.media import PlayerState
from platypush.plugins.music import MusicPlugin from platypush.plugins.music import MusicPlugin
from platypush.schemas.spotify import SpotifyDeviceSchema, SpotifyStatusSchema, SpotifyTrackSchema, \ from platypush.schemas.spotify import (
SpotifyHistoryItemSchema, SpotifyPlaylistSchema, SpotifyAlbumSchema, SpotifyEpisodeSchema, SpotifyShowSchema, \ SpotifyDeviceSchema,
SpotifyArtistSchema SpotifyStatusSchema,
SpotifyTrackSchema,
SpotifyHistoryItemSchema,
SpotifyPlaylistSchema,
SpotifyAlbumSchema,
SpotifyEpisodeSchema,
SpotifyShowSchema,
SpotifyArtistSchema,
)
class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
@ -45,9 +53,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
be printed on the application logs/stdout. be printed on the application logs/stdout.
""" """
def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None, **kwargs): def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
**kwargs,
):
MusicPlugin.__init__(self, **kwargs) MusicPlugin.__init__(self, **kwargs)
SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret, **kwargs) SpotifyMixin.__init__(
self, client_id=client_id, client_secret=client_secret, **kwargs
)
self._players_by_id = {} self._players_by_id = {}
self._players_by_name = {} self._players_by_name = {}
# Playlist ID -> snapshot ID and tracks cache # Playlist ID -> snapshot ID and tracks cache
@ -63,14 +78,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return dev return dev
@staticmethod @staticmethod
def _parse_datetime(dt: Optional[Union[str, datetime, int, float]]) -> Optional[datetime]: def _parse_datetime(
dt: Optional[Union[str, datetime, int, float]]
) -> Optional[datetime]:
if isinstance(dt, str): if isinstance(dt, str):
try: try:
dt = float(dt) dt = float(dt)
except (ValueError, TypeError): except (ValueError, TypeError):
return datetime.fromisoformat(dt) return datetime.fromisoformat(dt)
if isinstance(dt, int) or isinstance(dt, float): if isinstance(dt, (int, float)):
return datetime.fromtimestamp(dt) return datetime.fromtimestamp(dt)
return dt return dt
@ -85,18 +102,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
devices = self.spotify_user_call('/v1/me/player/devices').get('devices', []) devices = self.spotify_user_call('/v1/me/player/devices').get('devices', [])
self._players_by_id = { self._players_by_id = {
**self._players_by_id, **self._players_by_id,
**{ **{dev['id']: dev for dev in devices},
dev['id']: dev
for dev in devices
}
} }
self._players_by_name = { self._players_by_name = {
**self._players_by_name, **self._players_by_name,
**{ **{dev['name']: dev for dev in devices},
dev['name']: dev
for dev in devices
}
} }
return SpotifyDeviceSchema().dump(devices, many=True) return SpotifyDeviceSchema().dump(devices, many=True)
@ -118,7 +129,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
params={ params={
'volume_percent': volume, 'volume_percent': volume,
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
} },
) )
def _get_volume(self, device: Optional[str] = None) -> Optional[int]: def _get_volume(self, device: Optional[str] = None) -> Optional[int]:
@ -138,10 +149,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if device: if device:
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call('/v1/me/player/volume', params={ self.spotify_user_call(
'/v1/me/player/volume',
params={
'volume_percent': min(100, (self._get_volume() or 0) + delta), 'volume_percent': min(100, (self._get_volume() or 0) + delta),
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
}) },
)
@action @action
def voldown(self, delta: int = 5, device: Optional[str] = None): def voldown(self, delta: int = 5, device: Optional[str] = None):
@ -154,10 +168,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if device: if device:
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call('/v1/me/player/volume', params={ self.spotify_user_call(
'/v1/me/player/volume',
params={
'volume_percent': max(0, (self._get_volume() or 0) - delta), 'volume_percent': max(0, (self._get_volume() or 0) - delta),
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
}) },
)
@action @action
def play(self, resource: Optional[str] = None, device: Optional[str] = None): def play(self, resource: Optional[str] = None, device: Optional[str] = None):
@ -192,8 +209,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output status = self.status().output
state = 'play' \ state = (
if status.get('device_id') != device or status.get('state') != PlayerState.PLAY.value else 'pause' 'play'
if status.get('device_id') != device
or status.get('state') != PlayerState.PLAY.value
else 'pause'
)
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/{state}', f'/v1/me/player/{state}',
@ -212,7 +233,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
status = self.status().output status = self.status().output
if status.get('state') == PlayerState.PLAY.value: if status.get('state') == PlayerState.PLAY.value:
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/pause', '/v1/me/player/pause',
method='put', method='put',
) )
@ -230,7 +251,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
status = self.status().output status = self.status().output
if status.get('state') != PlayerState.PLAY.value: if status.get('state') != PlayerState.PLAY.value:
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/play', '/v1/me/player/play',
method='put', method='put',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -261,7 +282,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
""" """
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player', '/v1/me/player',
method='put', method='put',
json={ json={
'device_ids': [device], 'device_ids': [device],
@ -279,7 +300,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/next', '/v1/me/player/next',
method='post', method='post',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -297,7 +318,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/previous', '/v1/me/player/previous',
method='post', method='post',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -316,7 +337,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/seek', '/v1/me/player/seek',
method='put', method='put',
params={ params={
'position_ms': int(position * 1000), 'position_ms': int(position * 1000),
@ -338,13 +359,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if value is None: if value is None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output status = self.status().output
state = 'context' \ state = (
if status.get('device_id') != device or not status.get('repeat') else 'off' 'context'
if status.get('device_id') != device or not status.get('repeat')
else 'off'
)
else: else:
state = value is True state = value is True
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/repeat', '/v1/me/player/repeat',
method='put', method='put',
params={ params={
'state': 'context' if state else 'off', 'state': 'context' if state else 'off',
@ -366,12 +390,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if value is None: if value is None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output status = self.status().output
state = True if status.get('device_id') != device or not status.get('random') else False state = bool(status.get('device_id') != device or not status.get('random'))
else: else:
state = value is True state = value is True
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/shuffle', '/v1/me/player/shuffle',
method='put', method='put',
params={ params={
'state': state, 'state': state,
@ -380,8 +404,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
) )
@action @action
def history(self, limit: int = 20, before: Optional[Union[datetime, str, int]] = None, def history(
after: Optional[Union[datetime, str, int]] = None): self,
limit: int = 20,
before: Optional[Union[datetime, str, int]] = None,
after: Optional[Union[datetime, str, int]] = None,
):
""" """
Get a list of recently played track on the account. Get a list of recently played track on the account.
@ -396,21 +424,26 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
after = self._parse_datetime(after) after = self._parse_datetime(after)
assert not (before and after), 'before and after cannot both be set' assert not (before and after), 'before and after cannot both be set'
results = self._spotify_paginate_results('/v1/me/player/recently-played', results = self._spotify_paginate_results(
'/v1/me/player/recently-played',
limit=limit, limit=limit,
params={ params={
'limit': min(limit, 50), 'limit': min(limit, 50),
**({'before': before} if before else {}), **({'before': before} if before else {}),
**({'after': after} if after else {}), **({'after': after} if after else {}),
}) },
)
return SpotifyHistoryItemSchema().dump([ return SpotifyHistoryItemSchema().dump(
[
{ {
**item.pop('track'), **item.pop('track'),
**item, **item,
} }
for item in results for item in results
], many=True) ],
many=True,
)
@action @action
def add(self, resource: str, device: Optional[str] = None, **kwargs): def add(self, resource: str, device: Optional[str] = None, **kwargs):
@ -424,7 +457,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/queue', '/v1/me/player/queue',
method='post', method='post',
params={ params={
'uri': resource, 'uri': resource,
@ -472,7 +505,9 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return SpotifyTrackSchema().dump(track) return SpotifyTrackSchema().dump(track)
@action @action
def get_playlists(self, limit: int = 1000, offset: int = 0, user: Optional[str] = None): def get_playlists(
self, limit: int = 1000, offset: int = 0, user: Optional[str] = None
):
""" """
Get the user's playlists. Get the user's playlists.
@ -483,7 +518,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
""" """
playlists = self._spotify_paginate_results( playlists = self._spotify_paginate_results(
f'/v1/{"users/" + user if user else "me"}/playlists', f'/v1/{"users/" + user if user else "me"}/playlists',
limit=limit, offset=offset limit=limit,
offset=offset,
) )
return SpotifyPlaylistSchema().dump(playlists, many=True) return SpotifyPlaylistSchema().dump(playlists, many=True)
@ -491,36 +527,45 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
def _get_playlist(self, playlist: str) -> dict: def _get_playlist(self, playlist: str) -> dict:
playlists = self.get_playlists().output playlists = self.get_playlists().output
playlists = [ playlists = [
pl for pl in playlists if ( pl
pl['id'] == playlist or for pl in playlists
pl['uri'] == playlist or if (pl['id'] == playlist or pl['uri'] == playlist or pl['name'] == playlist)
pl['name'] == playlist
)
] ]
assert playlists, f'No such playlist ID, URI or name: {playlist}' assert playlists, f'No such playlist ID, URI or name: {playlist}'
return playlists[0] return playlists[0]
def _get_playlist_tracks_from_cache(self, id: str, snapshot_id: str, limit: Optional[int] = None, def _get_playlist_tracks_from_cache(
offset: int = 0) -> Optional[Iterable]: self, id: str, snapshot_id: str, limit: Optional[int] = None, offset: int = 0
) -> Optional[Iterable]:
snapshot = self._playlist_snapshots.get(id) snapshot = self._playlist_snapshots.get(id)
if ( if (
not snapshot or not snapshot
snapshot['snapshot_id'] != snapshot_id or or snapshot['snapshot_id'] != snapshot_id
(limit is None and snapshot['limit'] is not None) or (limit is None and snapshot['limit'] is not None)
): ):
return return
if limit is not None and snapshot['limit'] is not None: if limit is not None and snapshot['limit'] is not None:
stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset']) stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset'])
requested_range = (limit, limit + offset) requested_range = (limit, limit + offset)
if requested_range[0] < stored_range[0] or requested_range[1] > stored_range[1]: if (
requested_range[0] < stored_range[0]
or requested_range[1] > stored_range[1]
):
return return
return snapshot['tracks'] return snapshot['tracks']
def _cache_playlist_data(self, id: str, snapshot_id: str, tracks: Iterable[dict], limit: Optional[int] = None, def _cache_playlist_data(
offset: int = 0, **_): self,
id: str,
snapshot_id: str,
tracks: Iterable[dict],
limit: Optional[int] = None,
offset: int = 0,
**_,
):
self._playlist_snapshots[id] = { self._playlist_snapshots[id] = {
'id': id, 'id': id,
'tracks': tracks, 'tracks': tracks,
@ -530,7 +575,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
} }
@action @action
def get_playlist(self, playlist: str, with_tracks: bool = True, limit: Optional[int] = None, offset: int = 0): def get_playlist(
self,
playlist: str,
with_tracks: bool = True,
limit: Optional[int] = None,
offset: int = 0,
):
""" """
Get a playlist content. Get a playlist content.
@ -544,8 +595,10 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
playlist = self._get_playlist(playlist) playlist = self._get_playlist(playlist)
if with_tracks: if with_tracks:
playlist['tracks'] = self._get_playlist_tracks_from_cache( playlist['tracks'] = self._get_playlist_tracks_from_cache(
playlist['id'], snapshot_id=playlist['snapshot_id'], playlist['id'],
limit=limit, offset=offset snapshot_id=playlist['snapshot_id'],
limit=limit,
offset=offset,
) )
if playlist['tracks'] is None: if playlist['tracks'] is None:
@ -554,13 +607,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
**track, **track,
'track': { 'track': {
**track['track'], **track['track'],
'position': offset+i+1, 'position': offset + i + 1,
},
} }
} for i, track in enumerate(
for i, track in enumerate(self._spotify_paginate_results( self._spotify_paginate_results(
f'/v1/playlists/{playlist["id"]}/tracks', f'/v1/playlists/{playlist["id"]}/tracks',
limit=limit, offset=offset limit=limit,
)) offset=offset,
)
)
] ]
self._cache_playlist_data(**playlist, limit=limit, offset=offset) self._cache_playlist_data(**playlist, limit=limit, offset=offset)
@ -568,7 +624,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return SpotifyPlaylistSchema().dump(playlist) return SpotifyPlaylistSchema().dump(playlist)
@action @action
def add_to_playlist(self, playlist: str, resources: Union[str, Iterable[str]], position: Optional[int] = None): def add_to_playlist(
self,
playlist: str,
resources: Union[str, Iterable[str]],
position: Optional[int] = None,
):
""" """
Add one or more items to a playlist. Add one or more items to a playlist.
@ -585,11 +646,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
}, },
json={ json={
'uris': [ 'uris': [
uri.strip() for uri in ( uri.strip()
resources.split(',') if isinstance(resources, str) else resources for uri in (
resources.split(',')
if isinstance(resources, str)
else resources
) )
] ]
} },
) )
snapshot_id = response.get('snapshot_id') snapshot_id = response.get('snapshot_id')
@ -611,18 +675,27 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'tracks': [ 'tracks': [
{'uri': uri.strip()} {'uri': uri.strip()}
for uri in ( for uri in (
resources.split(',') if isinstance(resources, str) else resources resources.split(',')
if isinstance(resources, str)
else resources
) )
] ]
} },
) )
snapshot_id = response.get('snapshot_id') snapshot_id = response.get('snapshot_id')
assert snapshot_id is not None, 'Could not save playlist' assert snapshot_id is not None, 'Could not save playlist'
@action @action
def playlist_move(self, playlist: str, from_pos: int, to_pos: int, range_length: int = 1, def playlist_move(
resources: Optional[Union[str, Iterable[str]]] = None, **_): self,
playlist: str,
from_pos: int,
to_pos: int,
range_length: int = 1,
resources: Optional[Union[str, Iterable[str]]] = None,
**_,
):
""" """
Move or replace elements in a playlist. Move or replace elements in a playlist.
@ -641,12 +714,21 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'range_start': int(from_pos) + 1, 'range_start': int(from_pos) + 1,
'range_length': int(range_length), 'range_length': int(range_length),
'insert_before': int(to_pos) + 1, 'insert_before': int(to_pos) + 1,
**({'uris': [ **(
uri.strip() for uri in ( {
resources.split(',') if isinstance(resources, str) else resources 'uris': [
uri.strip()
for uri in (
resources.split(',')
if isinstance(resources, str)
else resources
) )
]} if resources else {}) ]
} }
if resources
else {}
),
},
) )
snapshot_id = response.get('snapshot_id') snapshot_id = response.get('snapshot_id')
@ -673,8 +755,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
@action @action
def search(self, query: Optional[Union[str, dict]] = None, limit: int = 50, offset: int = 0, type: str = 'track', def search(
**filter) -> Iterable[dict]: self,
query: Optional[Union[str, dict]] = None,
limit: int = 50,
offset: int = 0,
type: str = 'track',
**filter,
) -> Iterable[dict]:
""" """
Search for tracks matching a certain criteria. Search for tracks matching a certain criteria.
@ -714,12 +802,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
}.get('uri', []) }.get('uri', [])
uris = uri.split(',') if isinstance(uri, str) else uri uris = uri.split(',') if isinstance(uri, str) else uri
params = { params = (
{
'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]), 'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]),
} if uris else { }
if uris
else {
'q': self._make_filter(query, **filter), 'q': self._make_filter(query, **filter),
'type': type, 'type': type,
} }
)
response = self._spotify_paginate_results( response = self._spotify_paginate_results(
f'/v1/{type + "s" if uris else "search"}', f'/v1/{type + "s" if uris else "search"}',
@ -739,7 +831,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
track.get('track'), track.get('track'),
track.get('title'), track.get('title'),
track.get('popularity'), track.get('popularity'),
) ),
) )
schema_class = None schema_class = None
@ -759,6 +851,31 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return response return response
@action
def create_playlist(
self, name: str, description: Optional[str] = None, public: bool = False
):
"""
Create a playlist.
:param name: Playlist name.
:param description: Optional playlist description.
:param public: Whether the new playlist should be public
(default: False).
:return: .. schema:: spotify.SpotifyPlaylistSchema
"""
ret = self.spotify_user_call(
'/v1/users/me/playlists',
method='post',
json={
'name': name,
'description': description,
'public': public,
},
)
return SpotifyPlaylistSchema().dump(ret)
@action @action
def follow_playlist(self, playlist: str, public: bool = True): def follow_playlist(self, playlist: str, public: bool = True):
""" """
@ -774,7 +891,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
method='put', method='put',
json={ json={
'public': public, 'public': public,
} },
) )
@action @action
@ -792,10 +909,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
@staticmethod @staticmethod
def _uris_to_id(*uris: str) -> Iterable[str]: def _uris_to_id(*uris: str) -> Iterable[str]:
return [ return [uri.split(':')[-1] for uri in uris]
uri.split(':')[-1]
for uri in uris
]
@action @action
def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]: def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]:
@ -811,7 +925,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/albums', '/v1/me/albums',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action
@ -852,9 +967,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return [ return [
SpotifyTrackSchema().dump(item['track']) SpotifyTrackSchema().dump(item['track'])
for item in self._spotify_paginate_results( for item in self._spotify_paginate_results(
'/v1/me/tracks', '/v1/me/tracks', limit=limit, offset=offset
limit=limit,
offset=offset
) )
] ]
@ -898,7 +1011,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/episodes', '/v1/me/episodes',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action
@ -941,7 +1055,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/shows', '/v1/me/shows',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action

View file

@ -0,0 +1,397 @@
import json
import os
import pathlib
from datetime import datetime
from typing import Iterable, Optional, Union
from platypush.config import Config
from platypush.context import Variable, get_bus
from platypush.message.event.music.tidal import TidalPlaylistUpdatedEvent
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.music.tidal.workers import get_items
from platypush.schemas.tidal import (
TidalAlbumSchema,
TidalPlaylistSchema,
TidalArtistSchema,
TidalSearchResultsSchema,
TidalTrackSchema,
)
class MusicTidalPlugin(RunnablePlugin):
"""
Plugin to interact with the user's Tidal account and library.
Upon the first login, the application will prompt you with a link to
connect to your Tidal account. Once authorized, you should no longer be
required to explicitly login.
Triggers:
* :class:`platypush.message.event.music.TidalPlaylistUpdatedEvent`: when a user playlist
is updated.
Requires:
* **tidalapi** (``pip install 'tidalapi >= 0.7.0'``)
"""
_base_url = 'https://api.tidalhifi.com/v1/'
_default_credentials_file = os.path.join(
str(Config.get('workdir')), 'tidal', 'credentials.json'
)
def __init__(
self,
quality: str = 'high',
credentials_file: str = _default_credentials_file,
**kwargs,
):
"""
:param quality: Default audio quality. Default: ``high``.
Supported: [``loseless``, ``master``, ``high``, ``low``].
:param credentials_file: Path to the file where the OAuth session
parameters will be stored (default:
``<WORKDIR>/tidal/credentials.json``).
"""
from tidalapi import Quality
super().__init__(**kwargs)
self._credentials_file = os.path.expanduser(credentials_file)
self._user_playlists = {}
try:
self._quality = getattr(Quality, quality.lower())
except AttributeError:
raise AssertionError(
f'Invalid quality: {quality}. Supported values: '
f'{[q.name for q in Quality]}'
)
self._session = None
def _oauth_open_saved_session(self):
if not self._session:
return
try:
with open(self._credentials_file, 'r') as f:
data = json.load(f)
self._session.load_oauth_session(
data['token_type'], data['access_token'], data['refresh_token']
)
except Exception as e:
self.logger.warning('Could not load %s: %s', self._credentials_file, e)
def _oauth_create_new_session(self):
if not self._session:
return
self._session.login_oauth_simple(function=self.logger.warning) # type: ignore
if self._session.check_login():
data = {
'token_type': self._session.token_type,
'session_id': self._session.session_id,
'access_token': self._session.access_token,
'refresh_token': self._session.refresh_token,
}
pathlib.Path(os.path.dirname(self._credentials_file)).mkdir(
parents=True, exist_ok=True
)
with open(self._credentials_file, 'w') as outfile:
json.dump(data, outfile)
@property
def session(self):
from tidalapi import Config, Session
if self._session and self._session.check_login():
return self._session
# Attempt to reload the existing session from file
self._session = Session(config=Config(quality=self._quality))
self._oauth_open_saved_session()
if not self._session.check_login():
# Create a new session if we couldn't load an existing one
self._oauth_create_new_session()
assert (
self._session.user and self._session.check_login()
), 'Could not connect to TIDAL'
return self._session
@property
def user(self):
user = self.session.user
assert user, 'Not logged in'
return user
@action
def create_playlist(self, name: str, description: Optional[str] = None):
"""
Create a new playlist.
:param name: Playlist name.
:param description: Optional playlist description.
:return: .. schema:: tidal.TidalPlaylistSchema
"""
ret = self.user.create_playlist(name, description)
return TidalPlaylistSchema().dump(ret)
@action
def delete_playlist(self, playlist_id: str):
"""
Delete a playlist by ID.
:param playlist_id: ID of the playlist to delete.
"""
pl = self.session.playlist(playlist_id)
pl.delete()
@action
def edit_playlist(self, playlist_id: str, title=None, description=None):
"""
Edit a playlist's metadata.
:param name: New name.
:param description: New description.
"""
pl = self.session.playlist(playlist_id)
pl.edit(title=title, description=description)
@action
def get_playlists(self):
"""
Get the user's playlists (track lists are excluded).
:return: .. schema:: tidal.TidalPlaylistSchema(many=True)
"""
ret = self.user.playlists() + self.user.favorites.playlists()
return TidalPlaylistSchema().dump(ret, many=True)
@action
def get_playlist(self, playlist_id: str):
"""
Get the details of a playlist (including tracks).
:param playlist_id: Playlist ID.
:return: .. schema:: tidal.TidalPlaylistSchema
"""
pl = self.session.playlist(playlist_id)
pl._tracks = get_items(pl.tracks)
return TidalPlaylistSchema().dump(pl)
@action
def get_artist(self, artist_id: Union[str, int]):
"""
Get the details of an artist.
:param artist_id: Artist ID.
:return: .. schema:: tidal.TidalArtistSchema
"""
ret = self.session.artist(artist_id)
ret.albums = get_items(ret.get_albums)
return TidalArtistSchema().dump(ret)
@action
def get_album(self, album_id: Union[str, int]):
"""
Get the details of an album.
:param artist_id: Album ID.
:return: .. schema:: tidal.TidalAlbumSchema
"""
ret = self.session.album(album_id)
return TidalAlbumSchema(with_tracks=True).dump(ret)
@action
def get_track(self, track_id: Union[str, int]):
"""
Get the details of an track.
:param artist_id: Track ID.
:return: .. schema:: tidal.TidalTrackSchema
"""
ret = self.session.album(track_id)
return TidalTrackSchema().dump(ret)
@action
def search(
self,
query: str,
limit: int = 50,
offset: int = 0,
type: Optional[str] = None,
):
"""
Perform a search.
:param query: Query string.
:param limit: Maximum results that should be returned (default: 50).
:param offset: Search offset (default: 0).
:param type: Type of results that should be returned. Default: None
(return all the results that match the query). Supported:
``artist``, ``album``, ``track`` and ``playlist``.
:return: .. schema:: tidal.TidalSearchResultsSchema
"""
from tidalapi.artist import Artist
from tidalapi.album import Album
from tidalapi.media import Track
from tidalapi.playlist import Playlist
models = None
if type is not None:
if type == 'artist':
models = [Artist]
elif type == 'album':
models = [Album]
elif type == 'track':
models = [Track]
elif type == 'playlist':
models = [Playlist]
else:
raise AssertionError(f'Unsupported search type: {type}')
ret = self.session.search(query, models=models, limit=limit, offset=offset)
return TidalSearchResultsSchema().dump(ret)
@action
def get_download_url(self, track_id: str) -> str:
"""
Get the direct download URL of a track.
:param artist_id: Track ID.
"""
return self.session.track(track_id).get_url()
@action
def add_to_playlist(self, playlist_id: str, track_ids: Iterable[Union[str, int]]):
"""
Append one or more tracks to a playlist.
:param playlist_id: Target playlist ID.
:param track_ids: List of track IDs to append.
"""
pl = self.session.playlist(playlist_id)
pl.add(track_ids)
@action
def remove_from_playlist(
self,
playlist_id: str,
track_id: Optional[Union[str, int]] = None,
index: Optional[int] = None,
):
"""
Remove a track from a playlist.
Specify either the ``track_id`` or the ``index``.
:param playlist_id: Target playlist ID.
:param track_id: ID of the track to remove.
:param index: Index of the track to remove.
"""
assert not (
track_id is None and index is None
), 'Please specify either track_id or index'
pl = self.session.playlist(playlist_id)
if index:
pl.remove_by_index(index)
if track_id:
pl.remove_by_id(track_id)
@action
def add_track(self, track_id: Union[str, int]):
"""
Add a track to the user's collection.
:param track_id: Track ID.
"""
self.user.favorites.add_track(track_id)
@action
def add_album(self, album_id: Union[str, int]):
"""
Add an album to the user's collection.
:param album_id: Album ID.
"""
self.user.favorites.add_album(album_id)
@action
def add_artist(self, artist_id: Union[str, int]):
"""
Add an artist to the user's collection.
:param artist_id: Artist ID.
"""
self.user.favorites.add_artist(artist_id)
@action
def add_playlist(self, playlist_id: str):
"""
Add a playlist to the user's collection.
:param playlist_id: Playlist ID.
"""
self.user.favorites.add_playlist(playlist_id)
@action
def remove_track(self, track_id: Union[str, int]):
"""
Remove a track from the user's collection.
:param track_id: Track ID.
"""
self.user.favorites.remove_track(track_id)
@action
def remove_album(self, album_id: Union[str, int]):
"""
Remove an album from the user's collection.
:param album_id: Album ID.
"""
self.user.favorites.remove_album(album_id)
@action
def remove_artist(self, artist_id: Union[str, int]):
"""
Remove an artist from the user's collection.
:param artist_id: Artist ID.
"""
self.user.favorites.remove_artist(artist_id)
@action
def remove_playlist(self, playlist_id: str):
"""
Remove a playlist from the user's collection.
:param playlist_id: Playlist ID.
"""
self.user.favorites.remove_playlist(playlist_id)
def main(self):
while not self.should_stop():
playlists = self.session.user.playlists() # type: ignore
for pl in playlists:
last_updated_var = Variable(f'TIDAL_PLAYLIST_LAST_UPDATE[{pl.id}]')
prev_last_updated = last_updated_var.get()
if prev_last_updated:
prev_last_updated = datetime.fromisoformat(prev_last_updated)
if pl.last_updated > prev_last_updated:
get_bus().post(TidalPlaylistUpdatedEvent(playlist_id=pl.id))
if not prev_last_updated or pl.last_updated > prev_last_updated:
last_updated_var.set(pl.last_updated.isoformat())
self.wait_stop(self.poll_interval)

View file

@ -0,0 +1,9 @@
manifest:
events:
- platypush.message.event.music.TidalPlaylistUpdatedEvent: when a user playlist
is updated.
install:
pip:
- tidalapi >= 0.7.0
package: platypush.plugins.music.tidal
type: plugin

View file

@ -0,0 +1,56 @@
from concurrent.futures import ThreadPoolExecutor
from typing import Callable
def func_wrapper(args):
(f, offset, *args) = args
items = f(*args)
return [(i + offset, item) for i, item in enumerate(items)]
def get_items(
func: Callable,
*args,
parse: Callable = lambda _: _,
chunk_size: int = 100,
processes: int = 5,
):
"""
This function performs pagination on a function that supports
`limit`/`offset` parameters and it runs API requests in parallel to speed
things up.
"""
items = []
offsets = [-chunk_size]
remaining = chunk_size * processes
with ThreadPoolExecutor(
processes, thread_name_prefix=f'mopidy-tidal-{func.__name__}-'
) as pool:
while remaining == chunk_size * processes:
offsets = [offsets[-1] + chunk_size * (i + 1) for i in range(processes)]
pool_results = pool.map(
func_wrapper,
[
(
func,
offset,
*args,
chunk_size, # limit
offset, # offset
)
for offset in offsets
],
)
new_items = []
for results in pool_results:
new_items.extend(results)
remaining = len(new_items)
items.extend(new_items)
items = sorted([_ for _ in items if _], key=lambda item: item[0])
sorted_items = [item[1] for item in items]
return list(map(parse, sorted_items))

View file

@ -1,8 +1,13 @@
import datetime import datetime
import os
import queue import queue
import re
import threading import threading
import time import time
from typing import Optional, Collection
from dateutil.tz import tzutc
from typing import Iterable, Optional, Collection, Set
from xml.etree import ElementTree
import dateutil.parser import dateutil.parser
import requests import requests
@ -24,56 +29,67 @@ class RssPlugin(RunnablePlugin):
Requires: Requires:
* **feedparser** (``pip install feedparser``) * **feedparser** (``pip install feedparser``)
* **defusedxml** (``pip install defusedxml``)
""" """
user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \ user_agent = (
'Chrome/62.0.3202.94 Safari/537.36' 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/62.0.3202.94 Safari/537.36'
)
def __init__( def __init__(
self, subscriptions: Optional[Collection[str]] = None, poll_seconds: int = 300, self,
user_agent: str = user_agent, **kwargs subscriptions: Optional[Collection[str]] = None,
poll_seconds: int = 300,
user_agent: str = user_agent,
**kwargs,
): ):
""" """
:param subscriptions: List of feeds to monitor for updates, as URLs. :param subscriptions: List of feeds to monitor for updates, as URLs.
OPML URLs/local files are also supported.
:param poll_seconds: How often we should check for updates (default: 300 seconds). :param poll_seconds: How often we should check for updates (default: 300 seconds).
:param user_agent: Custom user agent to use for the requests. :param user_agent: Custom user agent to use for the requests.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.subscriptions = subscriptions or []
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self.user_agent = user_agent self.user_agent = user_agent
self._latest_timestamps = self._get_latest_timestamps() self._feeds_metadata = {}
self._feed_worker_queues = [queue.Queue()] * 5 self._feed_worker_queues = [queue.Queue()] * 5
self._feed_response_queue = queue.Queue() self._feed_response_queue = queue.Queue()
self._feed_workers = [] self._feed_workers = []
self._latest_entries = [] self._latest_entries = []
self.subscriptions = list(self._parse_subscriptions(subscriptions or []))
self._latest_timestamps = self._get_latest_timestamps()
@staticmethod @staticmethod
def _get_feed_latest_timestamp_varname(url: str) -> str: def _get_feed_latest_timestamp_varname(url: str) -> str:
return f'LATEST_FEED_TIMESTAMP[{url}]' return f'LATEST_FEED_TIMESTAMP[{url}]'
@classmethod @classmethod
def _get_feed_latest_timestamp(cls, url: str) -> Optional[datetime.datetime]: def _get_feed_latest_timestamp(cls, url: str) -> Optional[datetime.datetime]:
t = get_plugin('variable').get( t = (
cls._get_feed_latest_timestamp_varname(url) get_plugin('variable')
).output.get(cls._get_feed_latest_timestamp_varname(url)) .get(cls._get_feed_latest_timestamp_varname(url))
.output.get(cls._get_feed_latest_timestamp_varname(url))
)
if t: if t:
return dateutil.parser.isoparse(t) return dateutil.parser.isoparse(t)
def _get_latest_timestamps(self) -> dict: def _get_latest_timestamps(self) -> dict:
return { return {url: self._get_feed_latest_timestamp(url) for url in self.subscriptions}
url: self._get_feed_latest_timestamp(url)
for url in self.subscriptions
}
def _update_latest_timestamps(self) -> None: def _update_latest_timestamps(self) -> None:
variable = get_plugin('variable') variable = get_plugin('variable')
variable.set(**{ variable.set(
**{
self._get_feed_latest_timestamp_varname(url): latest_timestamp self._get_feed_latest_timestamp_varname(url): latest_timestamp
for url, latest_timestamp in self._latest_timestamps.items() for url, latest_timestamp in self._latest_timestamps.items()
}) }
)
@staticmethod @staticmethod
def _parse_content(entry) -> Optional[str]: def _parse_content(entry) -> Optional[str]:
@ -96,23 +112,30 @@ class RssPlugin(RunnablePlugin):
""" """
import feedparser import feedparser
feed = feedparser.parse(requests.get(url, headers={'User-Agent': self.user_agent}).text) feed = feedparser.parse(
requests.get(url, headers={'User-Agent': self.user_agent}).text
)
return RssFeedEntrySchema().dump( return RssFeedEntrySchema().dump(
sorted([ sorted(
[
{ {
'feed_url': url, 'feed_url': url,
'feed_title': getattr(feed.feed, 'title', None), 'feed_title': getattr(feed.feed, 'title', None),
'id': getattr(entry, 'id', None), 'id': getattr(entry, 'id', None),
'url': entry.link, 'url': entry.link,
'published': datetime.datetime.fromtimestamp(time.mktime(entry.published_parsed)), 'published': datetime.datetime.fromtimestamp(
time.mktime(entry.published_parsed)
),
'title': entry.title, 'title': entry.title,
'summary': getattr(entry, 'summary', None), 'summary': getattr(entry, 'summary', None),
'content': self._parse_content(entry), 'content': self._parse_content(entry),
} }
for entry in feed.entries for entry in feed.entries
if getattr(entry, 'published_parsed', None) if getattr(entry, 'published_parsed', None)
], key=lambda e: e['published']), ],
many=True key=lambda e: e['published'],
),
many=True,
) )
@action @action
@ -123,7 +146,9 @@ class RssPlugin(RunnablePlugin):
:param limit: Maximum number of entries to return (default: 20). :param limit: Maximum number of entries to return (default: 20).
:return: .. schema:: rss.RssFeedEntrySchema(many=True) :return: .. schema:: rss.RssFeedEntrySchema(many=True)
""" """
return sorted(self._latest_entries, key=lambda e: e['published'], reverse=True)[:limit] return sorted(self._latest_entries, key=lambda e: e['published'], reverse=True)[
:limit
]
def _feed_worker(self, q: queue.Queue): def _feed_worker(self, q: queue.Queue):
while not self.should_stop(): while not self.should_stop():
@ -133,18 +158,157 @@ class RssPlugin(RunnablePlugin):
continue continue
try: try:
self._feed_response_queue.put({ self._feed_response_queue.put(
{
'url': url, 'url': url,
'content': self.parse_feed(url).output, 'content': self.parse_feed(url).output,
}) }
)
except Exception as e: except Exception as e:
self._feed_response_queue.put({ self._feed_response_queue.put(
{
'url': url, 'url': url,
'error': e, 'error': e,
}) }
)
self._feed_response_queue.put(None) self._feed_response_queue.put(None)
def _parse_opml_lists(self, subs: Iterable[str]) -> Set[str]:
from defusedxml import ElementTree
feeds = set()
subs = set(subs)
content_by_sub = {}
urls = {sub for sub in subs if re.search(r'^https?://', sub)}
files = {os.path.expanduser(sub) for sub in subs if sub not in urls}
for url in urls:
try:
content_by_sub[url] = requests.get(
url,
headers={
'User-Agent': self.user_agent,
},
).text
except Exception as e:
self.logger.warning('Could not retrieve subscription %s: %s', url, e)
for file in files:
try:
with open(file, 'r') as f:
content_by_sub[file] = f.read()
except Exception as e:
self.logger.warning('Could not open file %s: %s', file, e)
for sub, content in content_by_sub.items():
root = ElementTree.fromstring(content.strip())
if root.tag != 'opml':
self.logger.warning('%s is not a valid OPML resource', sub)
continue
feeds.update(self._parse_feeds_from_outlines(root.findall('body/outline')))
return feeds
def _parse_feeds_from_outlines(
self,
outlines: Iterable[ElementTree.Element],
) -> Set[str]:
feeds = set()
outlines = list(outlines)
while outlines:
outline = outlines.pop(0)
if 'xmlUrl' in outline.attrib:
url = outline.attrib['xmlUrl']
feeds.add(url)
self._feeds_metadata[url] = {
**self._feeds_metadata.get(url, {}),
'title': outline.attrib.get('title'),
'description': outline.attrib.get('text'),
'url': outline.attrib.get('htmlUrl'),
}
for i, child in enumerate(outline.iter()):
if i > 0:
outlines.append(child)
return feeds
def _parse_subscriptions(self, subs: Iterable[str]) -> Iterable[str]:
import feedparser
self.logger.info('Parsing feed subscriptions')
feeds = set()
lists = set()
for sub in subs:
try:
# Check if it's an OPML list of feeds or an individual feed
feed = feedparser.parse(sub)
if feed.feed.get('opml'):
lists.add(sub)
else:
channel = feed.get('channel', {})
self._feeds_metadata[sub] = {
**self._feeds_metadata.get(sub, {}),
'title': channel.get('title'),
'description': channel.get('description'),
'url': channel.get('link'),
}
feeds.add(sub)
except Exception as e:
self.logger.warning('Could not parse %s: %s', sub, e)
feeds.update(self._parse_opml_lists(lists))
return feeds
@staticmethod
def _datetime_to_string(dt: datetime.datetime) -> str:
return dt.replace(tzinfo=tzutc()).strftime('%a, %d %b %Y %H:%M:%S %Z')
@action
def export_to_opml(self) -> str:
"""
Export the list of subscriptions into OPML format.
:return: The list of subscriptions as a string in OPML format.
"""
root = ElementTree.Element('opml', {'version': '2.0'})
head = ElementTree.Element('head')
title = ElementTree.Element('title')
title.text = 'Platypush feed subscriptions'
created = ElementTree.Element('dateCreated')
created.text = self._datetime_to_string(datetime.datetime.utcnow())
head.append(title)
head.append(created)
body = ElementTree.Element('body')
feeds = ElementTree.Element('outline', {'text': 'Feeds'})
for sub in self.subscriptions:
metadata = self._feeds_metadata.get(sub, {})
feed = ElementTree.Element(
'outline',
{
'xmlUrl': sub,
'text': metadata.get('description', metadata.get('title', sub)),
**({'htmlUrl': metadata['url']} if metadata.get('url') else {}),
**({'title': metadata['title']} if metadata.get('title') else {}),
},
)
feeds.append(feed)
body.append(feeds)
root.append(head)
root.append(body)
return ElementTree.tostring(root, encoding='utf-8', method='xml').decode()
def main(self): def main(self):
self._feed_workers = [ self._feed_workers = [
threading.Thread(target=self._feed_worker, args=(q,)) threading.Thread(target=self._feed_worker, args=(q,))
@ -154,12 +318,16 @@ class RssPlugin(RunnablePlugin):
for worker in self._feed_workers: for worker in self._feed_workers:
worker.start() worker.start()
self.logger.info(f'Initialized RSS plugin with {len(self.subscriptions)} subscriptions') self.logger.info(
f'Initialized RSS plugin with {len(self.subscriptions)} subscriptions'
)
while not self.should_stop(): while not self.should_stop():
responses = {} responses = {}
for i, url in enumerate(self.subscriptions): for i, url in enumerate(self.subscriptions):
worker_queue = self._feed_worker_queues[i % len(self._feed_worker_queues)] worker_queue = self._feed_worker_queues[
i % len(self._feed_worker_queues)
]
worker_queue.put(url) worker_queue.put(url)
time_start = time.time() time_start = time.time()
@ -168,12 +336,14 @@ class RssPlugin(RunnablePlugin):
new_entries = [] new_entries = []
while ( while (
not self.should_stop() and not self.should_stop()
len(responses) < len(self.subscriptions) and and len(responses) < len(self.subscriptions)
time.time() - time_start <= timeout and time.time() - time_start <= timeout
): ):
try: try:
response = self._feed_response_queue.get(block=True, timeout=max_time-time_start) response = self._feed_response_queue.get(
block=True, timeout=max_time - time_start
)
except queue.Empty: except queue.Empty:
self.logger.warning('RSS parse timeout') self.logger.warning('RSS parse timeout')
break break
@ -189,7 +359,9 @@ class RssPlugin(RunnablePlugin):
else: else:
responses[url] = response['content'] responses[url] = response['content']
responses = {k: v for k, v in responses.items() if not isinstance(v, Exception)} responses = {
k: v for k, v in responses.items() if not isinstance(v, Exception)
}
for url, response in responses.items(): for url, response in responses.items():
latest_timestamp = self._latest_timestamps.get(url) latest_timestamp = self._latest_timestamps.get(url)
@ -205,7 +377,7 @@ class RssPlugin(RunnablePlugin):
self._update_latest_timestamps() self._update_latest_timestamps()
self._latest_entries = new_entries self._latest_entries = new_entries
time.sleep(self.poll_seconds) self.wait_stop(self.poll_seconds)
def stop(self): def stop(self):
super().stop() super().stop()

View file

@ -4,5 +4,6 @@ manifest:
install: install:
pip: pip:
- feedparser - feedparser
- defusedxml
package: platypush.plugins.rss package: platypush.plugins.rss
type: plugin type: plugin

View file

@ -37,7 +37,7 @@ class TorrentPlugin(Plugin):
torrent_state = {} torrent_state = {}
transfers = {} transfers = {}
# noinspection HttpUrlsUsage # noinspection HttpUrlsUsage
default_popcorn_base_url = 'http://popcorn-ru.tk' default_popcorn_base_url = 'http://popcorn-time.ga'
def __init__(self, download_dir=None, torrent_ports=None, imdb_key=None, popcorn_base_url=default_popcorn_base_url, def __init__(self, download_dir=None, torrent_ports=None, imdb_key=None, popcorn_base_url=default_popcorn_base_url,
**kwargs): **kwargs):

228
platypush/schemas/tidal.py Normal file
View file

@ -0,0 +1,228 @@
from marshmallow import Schema, fields, pre_dump, post_dump
from platypush.schemas import DateTime
class TidalSchema(Schema):
pass
class TidalArtistSchema(TidalSchema):
id = fields.String(
required=True,
dump_only=True,
metadata={
'example': '3288612',
'description': 'Artist ID',
},
)
url = fields.String(
required=True,
dump_only=True,
metadata={
'description': 'Artist Tidal URL',
'example': 'https://tidal.com/artist/3288612',
},
)
name = fields.String(required=True)
@pre_dump
def _prefill_url(self, data, *_, **__):
data.url = f'https://tidal.com/artist/{data.id}'
return data
class TidalAlbumSchema(TidalSchema):
def __init__(self, *args, with_tracks=False, **kwargs):
super().__init__(*args, **kwargs)
self._with_tracks = with_tracks
id = fields.String(
required=True,
dump_only=True,
metadata={
'example': '45288612',
'description': 'Album ID',
},
)
url = fields.String(
required=True,
dump_only=True,
metadata={
'description': 'Album Tidal URL',
'example': 'https://tidal.com/album/45288612',
},
)
name = fields.String(required=True)
artist = fields.Nested(TidalArtistSchema)
duration = fields.Int(metadata={'description': 'Album duration, in seconds'})
year = fields.Integer(metadata={'example': 2003})
num_tracks = fields.Int(metadata={'example': 10})
tracks = fields.List(fields.Dict(), attribute='_tracks')
@pre_dump
def _prefill_url(self, data, *_, **__):
data.url = f'https://tidal.com/album/{data.id}'
return data
@pre_dump
def _cache_tracks(self, data, *_, **__):
if self._with_tracks:
album_id = str(data.id)
self.context[album_id] = {
'tracks': data.tracks(),
}
return data
@post_dump
def _dump_tracks(self, data, *_, **__):
if self._with_tracks:
album_id = str(data['id'])
ctx = self.context.pop(album_id, {})
data['tracks'] = TidalTrackSchema().dump(ctx.pop('tracks', []), many=True)
return data
class TidalTrackSchema(TidalSchema):
id = fields.String(
required=True,
dump_only=True,
metadata={
'example': '25288614',
'description': 'Track ID',
},
)
url = fields.String(
required=True,
dump_only=True,
metadata={
'description': 'Track Tidal URL',
'example': 'https://tidal.com/track/25288614',
},
)
artist = fields.Nested(TidalArtistSchema)
album = fields.Nested(TidalAlbumSchema)
name = fields.String(metadata={'description': 'Track title'})
duration = fields.Int(metadata={'description': 'Track duration, in seconds'})
track_num = fields.Int(
metadata={'description': 'Index of the track within the album'}
)
@pre_dump
def _prefill_url(self, data, *_, **__):
data.url = f'https://tidal.com/track/{data.id}'
return data
class TidalPlaylistSchema(TidalSchema):
id = fields.String(
required=True,
dump_only=True,
attribute='uuid',
metadata={
'example': '2b288612-34f5-11ed-b42d-001500e8f607',
'description': 'Playlist ID',
},
)
url = fields.String(
required=True,
dump_only=True,
metadata={
'description': 'Playlist Tidal URL',
'example': 'https://tidal.com/playlist/2b288612-34f5-11ed-b42d-001500e8f607',
},
)
name = fields.String(required=True)
description = fields.String()
duration = fields.Int(metadata={'description': 'Playlist duration, in seconds'})
public = fields.Boolean(attribute='publicPlaylist')
owner = fields.String(
attribute='creator',
metadata={
'description': 'Playlist creator/owner ID',
},
)
num_tracks = fields.Int(
attribute='numberOfTracks',
default=0,
metadata={
'example': 42,
'description': 'Number of tracks in the playlist',
},
)
created_at = DateTime(
attribute='created',
metadata={
'description': 'When the playlist was created',
},
)
last_updated_at = DateTime(
attribute='lastUpdated',
metadata={
'description': 'When the playlist was last updated',
},
)
tracks = fields.Nested(TidalTrackSchema, many=True)
def _flatten_object(self, data, *_, **__):
if not isinstance(data, dict):
data = {
'created': data.created,
'creator': data.creator.id,
'description': data.description,
'duration': data.duration,
'lastUpdated': data.last_updated,
'uuid': data.id,
'name': data.name,
'numberOfTracks': data.num_tracks,
'publicPlaylist': data.public,
'tracks': getattr(data, '_tracks', []),
}
return data
def _normalize_owner(self, data, *_, **__):
owner = data.pop('owner', data.pop('creator', None))
if owner:
if isinstance(owner, dict):
owner = owner['id']
data['creator'] = owner
return data
def _normalize_name(self, data, *_, **__):
if data.get('title'):
data['name'] = data.pop('title')
return data
@pre_dump
def normalize(self, data, *_, **__):
if not isinstance(data, dict):
data = self._flatten_object(data)
self._normalize_name(data)
self._normalize_owner(data)
if 'tracks' not in data:
data['tracks'] = []
return data
class TidalSearchResultsSchema(TidalSchema):
artists = fields.Nested(TidalArtistSchema, many=True)
albums = fields.Nested(TidalAlbumSchema, many=True)
tracks = fields.Nested(TidalTrackSchema, many=True)
playlists = fields.Nested(TidalPlaylistSchema, many=True)

View file

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.23.4 current_version = 0.23.6
commit = True commit = True
tag = True tag = True

View file

@ -28,7 +28,7 @@ backend = pkg_files('platypush/backend')
setup( setup(
name="platypush", name="platypush",
version="0.23.4", version="0.23.6",
author="Fabio Manganiello", author="Fabio Manganiello",
author_email="info@fabiomanganiello.com", author_email="info@fabiomanganiello.com",
description="Platypush service", description="Platypush service",
@ -64,7 +64,7 @@ setup(
'zeroconf>=0.27.0', 'zeroconf>=0.27.0',
'tz', 'tz',
'python-dateutil', 'python-dateutil',
'cryptography', # 'cryptography',
'pyjwt', 'pyjwt',
'marshmallow', 'marshmallow',
'frozendict', 'frozendict',
@ -86,7 +86,7 @@ setup(
# Support for MQTT backends # Support for MQTT backends
'mqtt': ['paho-mqtt'], 'mqtt': ['paho-mqtt'],
# Support for RSS feeds parser # Support for RSS feeds parser
'rss': ['feedparser'], 'rss': ['feedparser', 'defusedxml'],
# Support for PDF generation # Support for PDF generation
'pdf': ['weasyprint'], 'pdf': ['weasyprint'],
# Support for Philips Hue plugin # Support for Philips Hue plugin