1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
|
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pykka
from mopidy import listener
if TYPE_CHECKING:
from typing import Any, Dict, List, Optional, Set, TypeVar, Union
from typing_extensions import Literal
from mopidy.models import Image, Playlist, Ref, SearchResult, Track
# TODO Fix duplication with mopidy.internal.validation.TRACK_FIELDS_WITH_TYPES
TrackField = Literal[
"uri",
"track_name",
"album",
"artist",
"albumartist",
"composer",
"performer",
"track_no",
"genre",
"date",
"comment",
"disc_no",
"musicbrainz_albumid",
"musicbrainz_artistid",
"musicbrainz_trackid",
]
SearchField = Literal[TrackField, "any"]
DistinctField = TrackField
F = TypeVar("F")
QueryValue = Union[str, int]
Query = Dict[F, List[QueryValue]]
Uri = str
UriScheme = str
GstElement = TypeVar("GstElement")
logger = logging.getLogger(__name__)
class Backend:
"""Backend API
If the backend has problems during initialization it should raise
:exc:`mopidy.exceptions.BackendError` with a descriptive error message.
This will make Mopidy print the error message and exit so that the user can
fix the issue.
:param config: the entire Mopidy configuration
:type config: dict
:param audio: actor proxy for the audio subsystem
:type audio: :class:`pykka.ActorProxy` for :class:`mopidy.audio.Audio`
"""
#: Actor proxy to an instance of :class:`mopidy.audio.Audio`.
#:
#: Should be passed to the backend constructor as the kwarg ``audio``,
#: which will then set this field.
# TODO(typing) Replace Any with an ActorProxy[Audio] type
audio: Optional[Any] = None
#: The library provider. An instance of
#: :class:`~mopidy.backend.LibraryProvider`, or :class:`None` if
#: the backend doesn't provide a library.
library: Optional[LibraryProvider] = None
#: The playback provider. An instance of
#: :class:`~mopidy.backend.PlaybackProvider`, or :class:`None` if
#: the backend doesn't provide playback.
playback: Optional[PlaybackProvider] = None
#: The playlists provider. An instance of
#: :class:`~mopidy.backend.PlaylistsProvider`, or class:`None` if
#: the backend doesn't provide playlists.
playlists: Optional[PlaylistsProvider] = None
#: List of URI schemes this backend can handle.
uri_schemes: List[UriScheme] = []
# Because the providers is marked as pykka.traversable(), we can't get()
# them from another actor, and need helper methods to check if the
# providers are set or None.
def has_library(self) -> bool:
return self.library is not None
def has_library_browse(self) -> bool:
return (
self.library is not None and self.library.root_directory is not None
)
def has_playback(self) -> bool:
return self.playback is not None
def has_playlists(self) -> bool:
return self.playlists is not None
def ping(self) -> bool:
"""Called to check if the actor is still alive."""
return True
@pykka.traversable
class LibraryProvider:
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backend.Backend`
"""
root_directory: Optional[Ref] = None
"""
:class:`mopidy.models.Ref.directory` instance with a URI and name set
representing the root of this library's browse tree. URIs must
use one of the schemes supported by the backend, and name should
be set to a human friendly value.
*MUST be set by any class that implements* :meth:`LibraryProvider.browse`.
"""
def __init__(self, backend: Backend) -> None:
self.backend = backend
def browse(self, uri: Uri) -> List[Ref]:
"""
See :meth:`mopidy.core.LibraryController.browse`.
If you implement this method, make sure to also set
:attr:`root_directory`.
*MAY be implemented by subclass.*
"""
return []
def get_distinct(
self, field: DistinctField, query: Optional[Query[DistinctField]] = None
) -> Set[str]:
"""
See :meth:`mopidy.core.LibraryController.get_distinct`.
*MAY be implemented by subclass.*
Default implementation will simply return an empty set.
Note that backends should always return an empty set for unexpected
field types.
"""
return set()
def get_images(self, uris: List[Uri]) -> Dict[Uri, List[Image]]:
"""
See :meth:`mopidy.core.LibraryController.get_images`.
*MAY be implemented by subclass.*
Default implementation will simply return an empty dictionary.
"""
return {}
def lookup(self, uri: Uri) -> Dict[Uri, List[Track]]:
"""
See :meth:`mopidy.core.LibraryController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self, uri: Optional[Uri] = None) -> None:
"""
See :meth:`mopidy.core.LibraryController.refresh`.
*MAY be implemented by subclass.*
"""
pass
def search(
self,
query: Query[SearchField],
uris: Optional[List[Uri]] = None,
exact: bool = False,
) -> List[SearchResult]:
"""
See :meth:`mopidy.core.LibraryController.search`.
*MAY be implemented by subclass.*
.. versionadded:: 1.0
The ``exact`` param which replaces the old ``find_exact``.
"""
return []
@pykka.traversable
class PlaybackProvider:
"""
:param audio: the audio actor
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
:param backend: the backend
:type backend: :class:`mopidy.backend.Backend`
"""
def __init__(self, audio: Any, backend: Backend) -> None:
# TODO(typing) Replace Any with an ActorProxy[Audio] type
self.audio = audio
self.backend = backend
def pause(self) -> bool:
"""
Pause playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.pause_playback().get()
def play(self) -> bool:
"""
Start playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.start_playback().get()
def prepare_change(self) -> None:
"""
Indicate that an URI change is about to happen.
*MAY be reimplemented by subclass.*
It is extremely unlikely it makes sense for any backends to override
this. For most practical purposes it should be considered an internal
call between backends and core that backend authors should not touch.
"""
self.audio.prepare_change().get()
def translate_uri(self, uri: Uri) -> Optional[Uri]:
"""
Convert custom URI scheme to real playable URI.
*MAY be reimplemented by subclass.*
This is very likely the *only* thing you need to override as a backend
author. Typically this is where you convert any Mopidy specific URI
to a real URI and then return it. If you can't convert the URI just
return :class:`None`.
:param uri: the URI to translate
:type uri: string
:rtype: string or :class:`None` if the URI could not be translated
"""
return uri
def is_live(self, uri: Uri) -> bool:
"""
Decide if the URI should be treated as a live stream or not.
*MAY be reimplemented by subclass.*
Playing a source as a live stream disables buffering, which reduces
latency before playback starts, and discards data when paused.
:param uri: the URI
:type uri: string
:rtype: bool
"""
return False
def should_download(self, uri: Uri) -> bool:
"""
Attempt progressive download buffering for the URI or not.
*MAY be reimplemented by subclass.*
When streaming a fixed length file, the entire file can be buffered
to improve playback performance.
:param uri: the URI
:type uri: string
:rtype: bool
"""
return False
def on_source_setup(self, source: GstElement) -> None:
"""
Called when a new GStreamer source is created, allowing us to configure
the source. This runs in the audio thread so should not block.
*MAY be reimplemented by subclass.*
:param source: the GStreamer source element
:type source: GstElement
.. versionadded:: 3.4
"""
pass
def change_track(self, track: Track) -> bool:
"""
Switch to provided track.
*MAY be reimplemented by subclass.*
It is unlikely it makes sense for any backends to override
this. For most practical purposes it should be considered an internal
call between backends and core that backend authors should not touch.
The default implementation will call :meth:`translate_uri` which
is what you want to implement.
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
uri = self.translate_uri(track.uri)
if uri != track.uri:
logger.debug("Backend translated URI from %s to %s", track.uri, uri)
if not uri:
return False
self.audio.set_source_setup_callback(self.on_source_setup).get()
self.audio.set_uri(
uri,
live_stream=self.is_live(uri),
download=self.should_download(uri),
).get()
return True
def resume(self) -> bool:
"""
Resume playback at the same time position playback was paused.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.start_playback().get()
def seek(self, time_position: int) -> bool:
"""
Seek to a given time position.
*MAY be reimplemented by subclass.*
:param time_position: time position in milliseconds
:type time_position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.set_position(time_position).get()
def stop(self) -> bool:
"""
Stop playback.
*MAY be reimplemented by subclass.*
Should not be used for tracking if tracks have been played or when we
are done playing them.
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.stop_playback().get()
def get_time_position(self) -> int:
"""
Get the current time position in milliseconds.
*MAY be reimplemented by subclass.*
:rtype: int
"""
return self.audio.get_position().get()
@pykka.traversable
class PlaylistsProvider:
"""
A playlist provider exposes a collection of playlists, methods to
create/change/delete playlists in this collection, and lookup of any
playlist the backend knows about.
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backend.Backend` instance
"""
def __init__(self, backend: Backend) -> None:
self.backend = backend
def as_list(self) -> List[Ref]:
"""
Get a list of the currently available playlists.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlists. In other words, no information about the playlists' content
is given.
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 1.0
"""
raise NotImplementedError
def get_items(self, uri: Uri) -> Optional[List[Ref]]:
"""
Get the items in a playlist specified by ``uri``.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlist's items.
If a playlist with the given ``uri`` doesn't exist, it returns
:class:`None`.
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
.. versionadded:: 1.0
"""
raise NotImplementedError
def create(self, name: str) -> Optional[Playlist]:
"""
Create a new empty playlist with the given name.
Returns a new playlist with the given name and an URI, or :class:`None`
on failure.
*MUST be implemented by subclass.*
:param name: name of the new playlist
:type name: string
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
raise NotImplementedError
def delete(self, uri: Uri) -> bool:
"""
Delete playlist identified by the URI.
Returns :class:`True` if deleted, :class:`False` otherwise.
*MUST be implemented by subclass.*
:param uri: URI of the playlist to delete
:type uri: string
:rtype: :class:`bool`
.. versionchanged:: 2.2
Return type defined.
"""
raise NotImplementedError
def lookup(self, uri: Uri) -> Optional[Playlist]:
"""
Lookup playlist with given URI in both the set of playlists and in any
other playlist source.
Returns the playlists or :class:`None` if not found.
*MUST be implemented by subclass.*
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
raise NotImplementedError
def refresh(self) -> None:
"""
Refresh the playlists in :attr:`playlists`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def save(self, playlist: Playlist) -> Optional[Playlist]:
"""
Save the given playlist.
The playlist must have an ``uri`` attribute set. To create a new
playlist with an URI, use :meth:`create`.
Returns the saved playlist or :class:`None` on failure.
*MUST be implemented by subclass.*
:param playlist: the playlist to save
:type playlist: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
raise NotImplementedError
class BackendListener(listener.Listener):
"""
Marker interface for recipients of events sent by the backend actors.
Any Pykka actor that mixes in this class will receive calls to the methods
defined here when the corresponding events happen in a backend actor. This
interface is used both for looking up what actors to notify of the events,
and for providing default implementations for those listeners that are not
interested in all events.
Normally, only the Core actor should mix in this class.
"""
@staticmethod
def send(event: str, **kwargs: Any) -> None:
"""Helper to allow calling of backend listener events"""
listener.send(BackendListener, event, **kwargs)
def playlists_loaded(self) -> None:
"""
Called when playlists are loaded or refreshed.
*MAY* be implemented by actor.
"""
pass
|