Python3 Migrate
This commit is contained in:
9
venv/lib/python3.7/site-packages/mopidy/core/__init__.py
Normal file
9
venv/lib/python3.7/site-packages/mopidy/core/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# flake8: noqa
|
||||
from .actor import Core
|
||||
from .history import HistoryController
|
||||
from .library import LibraryController
|
||||
from .listener import CoreListener
|
||||
from .mixer import MixerController
|
||||
from .playback import PlaybackController, PlaybackState
|
||||
from .playlists import PlaylistsController
|
||||
from .tracklist import TracklistController
|
||||
276
venv/lib/python3.7/site-packages/mopidy/core/actor.py
Normal file
276
venv/lib/python3.7/site-packages/mopidy/core/actor.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
|
||||
import pykka
|
||||
|
||||
import mopidy
|
||||
from mopidy import audio, backend, mixer
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.core.history import HistoryController
|
||||
from mopidy.core.library import LibraryController
|
||||
from mopidy.core.listener import CoreListener
|
||||
from mopidy.core.mixer import MixerController
|
||||
from mopidy.core.playback import PlaybackController
|
||||
from mopidy.core.playlists import PlaylistsController
|
||||
from mopidy.core.tracklist import TracklistController
|
||||
from mopidy.internal import path, storage, validation, versioning
|
||||
from mopidy.internal.models import CoreState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Core(
|
||||
pykka.ThreadingActor,
|
||||
audio.AudioListener,
|
||||
backend.BackendListener,
|
||||
mixer.MixerListener,
|
||||
):
|
||||
|
||||
library = None
|
||||
"""An instance of :class:`~mopidy.core.LibraryController`"""
|
||||
|
||||
history = None
|
||||
"""An instance of :class:`~mopidy.core.HistoryController`"""
|
||||
|
||||
mixer = None
|
||||
"""An instance of :class:`~mopidy.core.MixerController`"""
|
||||
|
||||
playback = None
|
||||
"""An instance of :class:`~mopidy.core.PlaybackController`"""
|
||||
|
||||
playlists = None
|
||||
"""An instance of :class:`~mopidy.core.PlaylistsController`"""
|
||||
|
||||
tracklist = None
|
||||
"""An instance of :class:`~mopidy.core.TracklistController`"""
|
||||
|
||||
def __init__(self, config=None, mixer=None, backends=None, audio=None):
|
||||
super().__init__()
|
||||
|
||||
self._config = config
|
||||
|
||||
self.backends = Backends(backends)
|
||||
|
||||
self.library = pykka.traversable(
|
||||
LibraryController(backends=self.backends, core=self)
|
||||
)
|
||||
self.history = pykka.traversable(HistoryController())
|
||||
self.mixer = pykka.traversable(MixerController(mixer=mixer))
|
||||
self.playback = pykka.traversable(
|
||||
PlaybackController(audio=audio, backends=self.backends, core=self)
|
||||
)
|
||||
self.playlists = pykka.traversable(
|
||||
PlaylistsController(backends=self.backends, core=self)
|
||||
)
|
||||
self.tracklist = pykka.traversable(TracklistController(core=self))
|
||||
|
||||
self.audio = audio
|
||||
|
||||
def get_uri_schemes(self):
|
||||
"""Get list of URI schemes we can handle"""
|
||||
futures = [b.uri_schemes for b in self.backends]
|
||||
results = pykka.get_all(futures)
|
||||
uri_schemes = itertools.chain(*results)
|
||||
return sorted(uri_schemes)
|
||||
|
||||
def get_version(self):
|
||||
"""Get version of the Mopidy core API"""
|
||||
return versioning.get_version()
|
||||
|
||||
def reached_end_of_stream(self):
|
||||
self.playback._on_end_of_stream()
|
||||
|
||||
def stream_changed(self, uri):
|
||||
self.playback._on_stream_changed(uri)
|
||||
|
||||
def position_changed(self, position):
|
||||
self.playback._on_position_changed(position)
|
||||
|
||||
def state_changed(self, old_state, new_state, target_state):
|
||||
# XXX: This is a temporary fix for issue #232 while we wait for a more
|
||||
# permanent solution with the implementation of issue #234. When the
|
||||
# Spotify play token is lost, the Spotify backend pauses audio
|
||||
# playback, but mopidy.core doesn't know this, so we need to update
|
||||
# mopidy.core's state to match the actual state in mopidy.audio. If we
|
||||
# don't do this, clients will think that we're still playing.
|
||||
|
||||
# We ignore cases when target state is set as this is buffering
|
||||
# updates (at least for now) and we need to get #234 fixed...
|
||||
if (
|
||||
new_state == PlaybackState.PAUSED
|
||||
and not target_state
|
||||
and self.playback.get_state() != PlaybackState.PAUSED
|
||||
):
|
||||
self.playback.set_state(new_state)
|
||||
self.playback._trigger_track_playback_paused()
|
||||
|
||||
def playlists_loaded(self):
|
||||
# Forward event from backend to frontends
|
||||
CoreListener.send("playlists_loaded")
|
||||
|
||||
def volume_changed(self, volume):
|
||||
# Forward event from mixer to frontends
|
||||
CoreListener.send("volume_changed", volume=volume)
|
||||
|
||||
def mute_changed(self, mute):
|
||||
# Forward event from mixer to frontends
|
||||
CoreListener.send("mute_changed", mute=mute)
|
||||
|
||||
def tags_changed(self, tags):
|
||||
if not self.audio or "title" not in tags:
|
||||
return
|
||||
|
||||
tags = self.audio.get_current_tags().get()
|
||||
if not tags:
|
||||
return
|
||||
|
||||
# TODO: this is a hack to make sure we don't emit stream title changes
|
||||
# for plain tracks. We need a better way to decide if something is a
|
||||
# stream.
|
||||
if "title" in tags and tags["title"]:
|
||||
title = tags["title"][0]
|
||||
current_track = self.playback.get_current_track()
|
||||
if current_track is not None and current_track.name != title:
|
||||
self.playback._stream_title = title
|
||||
CoreListener.send("stream_title_changed", title=title)
|
||||
|
||||
def setup(self):
|
||||
"""Do not call this function. It is for internal use at startup."""
|
||||
try:
|
||||
coverage = []
|
||||
if self._config and "restore_state" in self._config["core"]:
|
||||
if self._config["core"]["restore_state"]:
|
||||
coverage = [
|
||||
"tracklist",
|
||||
"mode",
|
||||
"play-last",
|
||||
"mixer",
|
||||
"history",
|
||||
]
|
||||
if len(coverage):
|
||||
self._load_state(coverage)
|
||||
except Exception as e:
|
||||
logger.warn("Restore state: Unexpected error: %s", str(e))
|
||||
|
||||
def teardown(self):
|
||||
"""Do not call this function. It is for internal use at shutdown."""
|
||||
try:
|
||||
if self._config and "restore_state" in self._config["core"]:
|
||||
if self._config["core"]["restore_state"]:
|
||||
self._save_state()
|
||||
except Exception as e:
|
||||
logger.warn("Unexpected error while saving state: %s", str(e))
|
||||
|
||||
def _get_data_dir(self):
|
||||
# get or create data director for core
|
||||
data_dir_path = (
|
||||
path.expand_path(self._config["core"]["data_dir"]) / "core"
|
||||
)
|
||||
path.get_or_create_dir(data_dir_path)
|
||||
return data_dir_path
|
||||
|
||||
def _get_state_file(self):
|
||||
return self._get_data_dir() / "state.json.gz"
|
||||
|
||||
def _save_state(self):
|
||||
"""
|
||||
Save current state to disk.
|
||||
"""
|
||||
|
||||
state_file = self._get_state_file()
|
||||
logger.info("Saving state to %s", state_file)
|
||||
|
||||
data = {}
|
||||
data["version"] = mopidy.__version__
|
||||
data["state"] = CoreState(
|
||||
tracklist=self.tracklist._save_state(),
|
||||
history=self.history._save_state(),
|
||||
playback=self.playback._save_state(),
|
||||
mixer=self.mixer._save_state(),
|
||||
)
|
||||
storage.dump(state_file, data)
|
||||
logger.debug("Saving state done")
|
||||
|
||||
def _load_state(self, coverage):
|
||||
"""
|
||||
Restore state from disk.
|
||||
|
||||
Load state from disk and restore it. Parameter ``coverage``
|
||||
limits the amount of data to restore. Possible
|
||||
values for ``coverage`` (list of one or more of):
|
||||
|
||||
- 'tracklist' fill the tracklist
|
||||
- 'mode' set tracklist properties (consume, random, repeat, single)
|
||||
- 'play-last' restore play state ('tracklist' also required)
|
||||
- 'mixer' set mixer volume and mute state
|
||||
- 'history' restore history
|
||||
|
||||
:param coverage: amount of data to restore
|
||||
:type coverage: list of strings
|
||||
"""
|
||||
|
||||
state_file = self._get_state_file()
|
||||
logger.info("Loading state from %s", state_file)
|
||||
|
||||
data = storage.load(state_file)
|
||||
|
||||
try:
|
||||
# Try only once. If something goes wrong, the next start is clean.
|
||||
state_file.unlink()
|
||||
except OSError:
|
||||
logger.info("Failed to delete %s", state_file)
|
||||
|
||||
if "state" in data:
|
||||
core_state = data["state"]
|
||||
validation.check_instance(core_state, CoreState)
|
||||
self.history._load_state(core_state.history, coverage)
|
||||
self.tracklist._load_state(core_state.tracklist, coverage)
|
||||
self.mixer._load_state(core_state.mixer, coverage)
|
||||
# playback after tracklist
|
||||
self.playback._load_state(core_state.playback, coverage)
|
||||
logger.debug("Loading state done")
|
||||
|
||||
|
||||
class Backends(list):
|
||||
def __init__(self, backends):
|
||||
super().__init__(backends)
|
||||
|
||||
self.with_library = collections.OrderedDict()
|
||||
self.with_library_browse = collections.OrderedDict()
|
||||
self.with_playback = collections.OrderedDict()
|
||||
self.with_playlists = collections.OrderedDict()
|
||||
|
||||
backends_by_scheme = {}
|
||||
|
||||
def name(b):
|
||||
return b.actor_ref.actor_class.__name__
|
||||
|
||||
for b in backends:
|
||||
try:
|
||||
has_library = b.has_library().get()
|
||||
has_library_browse = b.has_library_browse().get()
|
||||
has_playback = b.has_playback().get()
|
||||
has_playlists = b.has_playlists().get()
|
||||
except Exception:
|
||||
self.remove(b)
|
||||
logger.exception(
|
||||
"Fetching backend info for %s failed",
|
||||
b.actor_ref.actor_class.__name__,
|
||||
)
|
||||
|
||||
for scheme in b.uri_schemes.get():
|
||||
assert scheme not in backends_by_scheme, (
|
||||
f"Cannot add URI scheme {scheme!r} for {name(b)}, "
|
||||
f"it is already handled by {name(backends_by_scheme[scheme])}"
|
||||
)
|
||||
backends_by_scheme[scheme] = b
|
||||
|
||||
if has_library:
|
||||
self.with_library[scheme] = b
|
||||
if has_library_browse:
|
||||
self.with_library_browse[scheme] = b
|
||||
if has_playback:
|
||||
self.with_playback[scheme] = b
|
||||
if has_playlists:
|
||||
self.with_playlists[scheme] = b
|
||||
73
venv/lib/python3.7/site-packages/mopidy/core/history.py
Normal file
73
venv/lib/python3.7/site-packages/mopidy/core/history.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
|
||||
from mopidy import models
|
||||
from mopidy.internal.models import HistoryState, HistoryTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryController:
|
||||
def __init__(self):
|
||||
self._history = []
|
||||
|
||||
def _add_track(self, track):
|
||||
"""Add track to the playback history.
|
||||
|
||||
Internal method for :class:`mopidy.core.PlaybackController`.
|
||||
|
||||
:param track: track to add
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
"""
|
||||
if not isinstance(track, models.Track):
|
||||
raise TypeError("Only Track objects can be added to the history")
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
name_parts = []
|
||||
if track.artists:
|
||||
name_parts.append(
|
||||
", ".join([artist.name for artist in track.artists])
|
||||
)
|
||||
if track.name is not None:
|
||||
name_parts.append(track.name)
|
||||
name = " - ".join(name_parts)
|
||||
ref = models.Ref.track(uri=track.uri, name=name)
|
||||
|
||||
self._history.insert(0, (timestamp, ref))
|
||||
|
||||
def get_length(self):
|
||||
"""Get the number of tracks in the history.
|
||||
|
||||
:returns: the history length
|
||||
:rtype: int
|
||||
"""
|
||||
return len(self._history)
|
||||
|
||||
def get_history(self):
|
||||
"""Get the track history.
|
||||
|
||||
The timestamps are milliseconds since epoch.
|
||||
|
||||
:returns: the track history
|
||||
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
|
||||
"""
|
||||
return copy.copy(self._history)
|
||||
|
||||
def _save_state(self):
|
||||
# 500 tracks a 3 minutes -> 24 hours history
|
||||
count_max = 500
|
||||
count = 1
|
||||
history_list = []
|
||||
for timestamp, track in self._history:
|
||||
history_list.append(HistoryTrack(timestamp=timestamp, track=track))
|
||||
count += 1
|
||||
if count_max < count:
|
||||
logger.info("Limiting history to %s tracks", count_max)
|
||||
break
|
||||
return HistoryState(history=history_list)
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and "history" in coverage:
|
||||
self._history = [(h.timestamp, h.track) for h in state.history]
|
||||
351
venv/lib/python3.7/site-packages/mopidy/core/library.py
Normal file
351
venv/lib/python3.7/site-packages/mopidy/core/library.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import operator
|
||||
import urllib
|
||||
from collections.abc import Mapping
|
||||
|
||||
from mopidy import exceptions, models
|
||||
from mopidy.internal import validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _backend_error_handling(backend, reraise=None):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error(
|
||||
"%s backend returned bad data: %s",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
if reraise and isinstance(e, reraise):
|
||||
raise
|
||||
logger.exception(
|
||||
"%s backend caused an exception.",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
)
|
||||
|
||||
|
||||
class LibraryController:
|
||||
def __init__(self, backends, core):
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
|
||||
def _get_backend(self, uri):
|
||||
uri_scheme = urllib.parse.urlparse(uri).scheme
|
||||
return self.backends.with_library.get(uri_scheme, None)
|
||||
|
||||
def _get_backends_to_uris(self, uris):
|
||||
if uris:
|
||||
backends_to_uris = collections.defaultdict(list)
|
||||
for uri in uris:
|
||||
backend = self._get_backend(uri)
|
||||
if backend is not None:
|
||||
backends_to_uris[backend].append(uri)
|
||||
else:
|
||||
backends_to_uris = {
|
||||
b: None for b in self.backends.with_library.values()
|
||||
}
|
||||
return backends_to_uris
|
||||
|
||||
def browse(self, uri):
|
||||
"""
|
||||
Browse directories and tracks at the given ``uri``.
|
||||
|
||||
``uri`` is a string which represents some directory belonging to a
|
||||
backend. To get the intial root directories for backends pass
|
||||
:class:`None` as the URI.
|
||||
|
||||
Returns a list of :class:`mopidy.models.Ref` objects for the
|
||||
directories and tracks at the given ``uri``.
|
||||
|
||||
The :class:`~mopidy.models.Ref` objects representing tracks keep the
|
||||
track's original URI. A matching pair of objects can look like this::
|
||||
|
||||
Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...)
|
||||
Ref.track(uri='dummy:/foo.mp3', name='foo')
|
||||
|
||||
The :class:`~mopidy.models.Ref` objects representing directories have
|
||||
backend specific URIs. These are opaque values, so no one but the
|
||||
backend that created them should try and derive any meaning from them.
|
||||
The only valid exception to this is checking the scheme, as it is used
|
||||
to route browse requests to the correct backend.
|
||||
|
||||
For example, the dummy library's ``/bar`` directory could be returned
|
||||
like this::
|
||||
|
||||
Ref.directory(uri='dummy:directory:/bar', name='bar')
|
||||
|
||||
:param string uri: URI to browse
|
||||
:rtype: list of :class:`mopidy.models.Ref`
|
||||
|
||||
.. versionadded:: 0.18
|
||||
"""
|
||||
if uri is None:
|
||||
return self._roots()
|
||||
elif not uri.strip():
|
||||
return []
|
||||
validation.check_uri(uri)
|
||||
return self._browse(uri)
|
||||
|
||||
def _roots(self):
|
||||
directories = set()
|
||||
backends = self.backends.with_library_browse.values()
|
||||
futures = {b: b.library.root_directory for b in backends}
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
root = future.get()
|
||||
validation.check_instance(root, models.Ref)
|
||||
directories.add(root)
|
||||
return sorted(directories, key=operator.attrgetter("name"))
|
||||
|
||||
def _browse(self, uri):
|
||||
scheme = urllib.parse.urlparse(uri).scheme
|
||||
backend = self.backends.with_library_browse.get(scheme)
|
||||
|
||||
if not backend:
|
||||
return []
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
result = backend.library.browse(uri).get()
|
||||
validation.check_instances(result, models.Ref)
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
"""
|
||||
List distinct values for a given field from the library.
|
||||
|
||||
This has mainly been added to support the list commands the MPD
|
||||
protocol supports in a more sane fashion. Other frontends are not
|
||||
recommended to use this method.
|
||||
|
||||
:param string field: One of ``track``, ``artist``, ``albumartist``,
|
||||
``album``, ``composer``, ``performer``, ``date`` or ``genre``.
|
||||
:param dict query: Query to use for limiting results, see
|
||||
:meth:`search` for details about the query format.
|
||||
:rtype: set of values corresponding to the requested field type.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
validation.check_choice(field, validation.DISTINCT_FIELDS)
|
||||
query is None or validation.check_query(query) # TODO: normalize?
|
||||
|
||||
result = set()
|
||||
futures = {
|
||||
b: b.library.get_distinct(field, query)
|
||||
for b in self.backends.with_library.values()
|
||||
}
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
values = future.get()
|
||||
if values is not None:
|
||||
validation.check_instances(values, str)
|
||||
result.update(values)
|
||||
return result
|
||||
|
||||
def get_images(self, uris):
|
||||
"""Lookup the images for the given URIs
|
||||
|
||||
Backends can use this to return image URIs for any URI they know about
|
||||
be it tracks, albums, playlists. The lookup result is a dictionary
|
||||
mapping the provided URIs to lists of images.
|
||||
|
||||
Unknown URIs or URIs the corresponding backend couldn't find anything
|
||||
for will simply return an empty list for that URI.
|
||||
|
||||
:param uris: list of URIs to find images for
|
||||
:type uris: list of string
|
||||
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
validation.check_uris(uris)
|
||||
|
||||
futures = {
|
||||
backend: backend.library.get_images(backend_uris)
|
||||
for (backend, backend_uris) in self._get_backends_to_uris(
|
||||
uris
|
||||
).items()
|
||||
if backend_uris
|
||||
}
|
||||
|
||||
results = {uri: tuple() for uri in uris}
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
if future.get() is None:
|
||||
continue
|
||||
validation.check_instance(future.get(), Mapping)
|
||||
for uri, images in future.get().items():
|
||||
if uri not in uris:
|
||||
raise exceptions.ValidationError(
|
||||
f"Got unknown image URI: {uri}"
|
||||
)
|
||||
validation.check_instances(images, models.Image)
|
||||
results[uri] += tuple(images)
|
||||
return results
|
||||
|
||||
def lookup(self, uris):
|
||||
"""
|
||||
Lookup the given URIs.
|
||||
|
||||
If the URI expands to multiple tracks, the returned list will contain
|
||||
them all.
|
||||
|
||||
:param uris: track URIs
|
||||
:type uris: list of string
|
||||
:rtype: {uri: list of :class:`mopidy.models.Track`}
|
||||
"""
|
||||
validation.check_uris(uris)
|
||||
|
||||
futures = {}
|
||||
results = {u: [] for u in uris}
|
||||
|
||||
# TODO: lookup(uris) to backend APIs
|
||||
for backend, backend_uris in self._get_backends_to_uris(uris).items():
|
||||
if backend_uris:
|
||||
for u in backend_uris:
|
||||
futures[(backend, u)] = backend.library.lookup(u)
|
||||
|
||||
for (backend, u), future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
result = future.get()
|
||||
if result is not None:
|
||||
validation.check_instances(result, models.Track)
|
||||
# TODO Consider making Track.uri field mandatory, and
|
||||
# then remove this filtering of tracks without URIs.
|
||||
results[u] = [r for r in result if r.uri]
|
||||
|
||||
return results
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
Refresh library. Limit to URI and below if an URI is given.
|
||||
|
||||
:param uri: directory or track URI
|
||||
:type uri: string
|
||||
"""
|
||||
uri is None or validation.check_uri(uri)
|
||||
|
||||
futures = {}
|
||||
backends = {}
|
||||
uri_scheme = urllib.parse.urlparse(uri).scheme if uri else None
|
||||
|
||||
for backend_scheme, backend in self.backends.with_library.items():
|
||||
backends.setdefault(backend, set()).add(backend_scheme)
|
||||
|
||||
for backend, backend_schemes in backends.items():
|
||||
if uri_scheme is None or uri_scheme in backend_schemes:
|
||||
futures[backend] = backend.library.refresh(uri)
|
||||
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
future.get()
|
||||
|
||||
def search(self, query, uris=None, exact=False):
|
||||
"""
|
||||
Search the library for tracks where ``field`` contains ``values``.
|
||||
|
||||
``field`` can be one of ``uri``, ``track_name``, ``album``, ``artist``,
|
||||
``albumartist``, ``composer``, ``performer``, ``track_no``, ``genre``,
|
||||
``date``, ``comment``, or ``any``.
|
||||
|
||||
If ``uris`` is given, the search is limited to results from within the
|
||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
||||
to the local backend.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a' in any backend
|
||||
search({'any': ['a']})
|
||||
|
||||
# Returns results matching artist 'xyz' in any backend
|
||||
search({'artist': ['xyz']})
|
||||
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz' in any
|
||||
# backend
|
||||
search({'any': ['a', 'b'], 'artist': ['xyz']})
|
||||
|
||||
# Returns results matching 'a' if within the given URI roots
|
||||
# "file:///media/music" and "spotify:"
|
||||
search({'any': ['a']}, uris=['file:///media/music', 'spotify:'])
|
||||
|
||||
# Returns results matching artist 'xyz' and 'abc' in any backend
|
||||
search({'artist': ['xyz', 'abc']})
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:param uris: zero or more URI roots to limit the search to
|
||||
:type uris: list of string or :class:`None`
|
||||
:param exact: if the search should use exact matching
|
||||
:type exact: :class:`bool`
|
||||
:rtype: list of :class:`mopidy.models.SearchResult`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``exact`` keyword argument.
|
||||
"""
|
||||
query = _normalize_query(query)
|
||||
|
||||
uris is None or validation.check_uris(uris)
|
||||
validation.check_query(query)
|
||||
validation.check_boolean(exact)
|
||||
|
||||
if not query:
|
||||
return []
|
||||
|
||||
futures = {}
|
||||
for backend, backend_uris in self._get_backends_to_uris(uris).items():
|
||||
futures[backend] = backend.library.search(
|
||||
query=query, uris=backend_uris, exact=exact
|
||||
)
|
||||
|
||||
# Some of our tests check for LookupError to catch bad queries. This is
|
||||
# silly and should be replaced with query validation before passing it
|
||||
# to the backends.
|
||||
reraise = (TypeError, LookupError)
|
||||
|
||||
results = []
|
||||
for backend, future in futures.items():
|
||||
try:
|
||||
with _backend_error_handling(backend, reraise=reraise):
|
||||
result = future.get()
|
||||
if result is not None:
|
||||
validation.check_instance(result, models.SearchResult)
|
||||
results.append(result)
|
||||
except TypeError:
|
||||
backend_name = backend.actor_ref.actor_class.__name__
|
||||
logger.warning(
|
||||
'%s does not implement library.search() with "exact" '
|
||||
"support. Please upgrade it.",
|
||||
backend_name,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_query(query):
|
||||
broken_client = False
|
||||
# TODO: this breaks if query is not a dictionary like object...
|
||||
for (field, values) in query.items():
|
||||
if isinstance(values, str):
|
||||
broken_client = True
|
||||
query[field] = [values]
|
||||
if broken_client:
|
||||
logger.warning(
|
||||
"A client or frontend made a broken library search. Values in "
|
||||
"queries must be lists of strings, not a string. Please check what"
|
||||
" sent this query and file a bug. Query: %s",
|
||||
query,
|
||||
)
|
||||
if not query:
|
||||
logger.warning(
|
||||
"A client or frontend made a library search with an empty query. "
|
||||
"This is strongly discouraged. Please check what sent this query "
|
||||
"and file a bug."
|
||||
)
|
||||
return query
|
||||
187
venv/lib/python3.7/site-packages/mopidy/core/listener.py
Normal file
187
venv/lib/python3.7/site-packages/mopidy/core/listener.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from mopidy import listener
|
||||
|
||||
|
||||
class CoreListener(listener.Listener):
|
||||
|
||||
"""
|
||||
Marker interface for recipients of events sent by the core actor.
|
||||
|
||||
Any Pykka actor that mixes in this class will receive calls to the methods
|
||||
defined here when the corresponding events happen in the core 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.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def send(event, **kwargs):
|
||||
"""Helper to allow calling of core listener events"""
|
||||
listener.send(CoreListener, event, **kwargs)
|
||||
|
||||
def on_event(self, event, **kwargs):
|
||||
"""
|
||||
Called on all events.
|
||||
|
||||
*MAY* be implemented by actor. By default, this method forwards the
|
||||
event to the specific event methods.
|
||||
|
||||
:param event: the event name
|
||||
:type event: string
|
||||
:param kwargs: any other arguments to the specific event handlers
|
||||
"""
|
||||
# Just delegate to parent, entry mostly for docs.
|
||||
super().on_event(event, **kwargs)
|
||||
|
||||
def track_playback_paused(self, tl_track, time_position):
|
||||
"""
|
||||
Called whenever track playback is paused.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param tl_track: the track that was playing when playback paused
|
||||
:type tl_track: :class:`mopidy.models.TlTrack`
|
||||
:param time_position: the time position in milliseconds
|
||||
:type time_position: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def track_playback_resumed(self, tl_track, time_position):
|
||||
"""
|
||||
Called whenever track playback is resumed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param tl_track: the track that was playing when playback resumed
|
||||
:type tl_track: :class:`mopidy.models.TlTrack`
|
||||
:param time_position: the time position in milliseconds
|
||||
:type time_position: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def track_playback_started(self, tl_track):
|
||||
"""
|
||||
Called whenever a new track starts playing.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param tl_track: the track that just started playing
|
||||
:type tl_track: :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
pass
|
||||
|
||||
def track_playback_ended(self, tl_track, time_position):
|
||||
"""
|
||||
Called whenever playback of a track ends.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param tl_track: the track that was played before playback stopped
|
||||
:type tl_track: :class:`mopidy.models.TlTrack`
|
||||
:param time_position: the time position in milliseconds
|
||||
:type time_position: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def playback_state_changed(self, old_state, new_state):
|
||||
"""
|
||||
Called whenever playback state is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param old_state: the state before the change
|
||||
:type old_state: string from :class:`mopidy.core.PlaybackState` field
|
||||
:param new_state: the state after the change
|
||||
:type new_state: string from :class:`mopidy.core.PlaybackState` field
|
||||
"""
|
||||
pass
|
||||
|
||||
def tracklist_changed(self):
|
||||
"""
|
||||
Called whenever the tracklist is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlists_loaded(self):
|
||||
"""
|
||||
Called when playlists are loaded or refreshed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlist_changed(self, playlist):
|
||||
"""
|
||||
Called whenever a playlist is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param playlist: the changed playlist
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlist_deleted(self, uri):
|
||||
"""
|
||||
Called whenever a playlist is deleted.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param uri: the URI of the deleted playlist
|
||||
:type uri: string
|
||||
"""
|
||||
pass
|
||||
|
||||
def options_changed(self):
|
||||
"""
|
||||
Called whenever an option is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def volume_changed(self, volume):
|
||||
"""
|
||||
Called whenever the volume is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param volume: the new volume in the range [0..100]
|
||||
:type volume: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def mute_changed(self, mute):
|
||||
"""
|
||||
Called whenever the mute state is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param mute: the new mute state
|
||||
:type mute: boolean
|
||||
"""
|
||||
pass
|
||||
|
||||
def seeked(self, time_position):
|
||||
"""
|
||||
Called whenever the time position changes by an unexpected amount, e.g.
|
||||
at seek to a new time position.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param time_position: the position that was seeked to in milliseconds
|
||||
:type time_position: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def stream_title_changed(self, title):
|
||||
"""
|
||||
Called whenever the currently playing stream title changes.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param title: the new stream title
|
||||
:type title: string
|
||||
"""
|
||||
pass
|
||||
111
venv/lib/python3.7/site-packages/mopidy/core/mixer.py
Normal file
111
venv/lib/python3.7/site-packages/mopidy/core/mixer.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.internal import validation
|
||||
from mopidy.internal.models import MixerState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _mixer_error_handling(mixer):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error(
|
||||
"%s mixer returned bad data: %s",
|
||||
mixer.actor_ref.actor_class.__name__,
|
||||
e,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"%s mixer caused an exception.",
|
||||
mixer.actor_ref.actor_class.__name__,
|
||||
)
|
||||
|
||||
|
||||
class MixerController:
|
||||
def __init__(self, mixer):
|
||||
self._mixer = mixer
|
||||
|
||||
def get_volume(self):
|
||||
"""Get the volume.
|
||||
|
||||
Integer in range [0..100] or :class:`None` if unknown.
|
||||
|
||||
The volume scale is linear.
|
||||
"""
|
||||
if self._mixer is None:
|
||||
return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
volume = self._mixer.get_volume().get()
|
||||
volume is None or validation.check_integer(volume, min=0, max=100)
|
||||
return volume
|
||||
|
||||
return None
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""Set the volume.
|
||||
|
||||
The volume is defined as an integer in range [0..100].
|
||||
|
||||
The volume scale is linear.
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
validation.check_integer(volume, min=0, max=100)
|
||||
|
||||
if self._mixer is None:
|
||||
return False # TODO: 2.0 return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
result = self._mixer.set_volume(volume).get()
|
||||
validation.check_instance(result, bool)
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
def get_mute(self):
|
||||
"""Get mute state.
|
||||
|
||||
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
|
||||
unknown.
|
||||
"""
|
||||
if self._mixer is None:
|
||||
return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
mute = self._mixer.get_mute().get()
|
||||
mute is None or validation.check_instance(mute, bool)
|
||||
return mute
|
||||
|
||||
return None
|
||||
|
||||
def set_mute(self, mute):
|
||||
"""Set mute state.
|
||||
|
||||
:class:`True` to mute, :class:`False` to unmute.
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
validation.check_boolean(mute)
|
||||
if self._mixer is None:
|
||||
return False # TODO: 2.0 return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
result = self._mixer.set_mute(bool(mute)).get()
|
||||
validation.check_instance(result, bool)
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
def _save_state(self):
|
||||
return MixerState(volume=self.get_volume(), mute=self.get_mute())
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and "mixer" in coverage:
|
||||
self.set_mute(state.mute)
|
||||
if state.volume:
|
||||
self.set_volume(state.volume)
|
||||
558
venv/lib/python3.7/site-packages/mopidy/core/playback.py
Normal file
558
venv/lib/python3.7/site-packages/mopidy/core/playback.py
Normal file
@@ -0,0 +1,558 @@
|
||||
import logging
|
||||
import urllib
|
||||
|
||||
from pykka.messages import ProxyCall
|
||||
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, models, validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaybackController:
|
||||
def __init__(self, audio, backends, core):
|
||||
# TODO: these should be internal
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
self._audio = audio
|
||||
|
||||
self._stream_title = None
|
||||
self._state = PlaybackState.STOPPED
|
||||
|
||||
self._current_tl_track = None
|
||||
self._pending_tl_track = None
|
||||
|
||||
self._pending_position = None
|
||||
self._last_position = None
|
||||
self._previous = False
|
||||
|
||||
self._start_at_position = None
|
||||
self._start_paused = False
|
||||
|
||||
if self._audio:
|
||||
self._audio.set_about_to_finish_callback(
|
||||
self._on_about_to_finish_callback
|
||||
)
|
||||
|
||||
def _get_backend(self, tl_track):
|
||||
if tl_track is None:
|
||||
return None
|
||||
uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme
|
||||
return self.backends.with_playback.get(uri_scheme, None)
|
||||
|
||||
def get_current_tl_track(self):
|
||||
"""Get the currently playing or selected track.
|
||||
|
||||
Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
|
||||
"""
|
||||
return self._current_tl_track
|
||||
|
||||
def _set_current_tl_track(self, value):
|
||||
"""Set the currently playing or selected track.
|
||||
|
||||
*Internal:* This is only for use by Mopidy's test suite.
|
||||
"""
|
||||
self._current_tl_track = value
|
||||
|
||||
def get_current_track(self):
|
||||
"""
|
||||
Get the currently playing or selected track.
|
||||
|
||||
Extracted from :meth:`get_current_tl_track` for convenience.
|
||||
|
||||
Returns a :class:`mopidy.models.Track` or :class:`None`.
|
||||
"""
|
||||
return getattr(self.get_current_tl_track(), "track", None)
|
||||
|
||||
def get_current_tlid(self):
|
||||
"""
|
||||
Get the currently playing or selected TLID.
|
||||
|
||||
Extracted from :meth:`get_current_tl_track` for convenience.
|
||||
|
||||
Returns a :class:`int` or :class:`None`.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
return getattr(self.get_current_tl_track(), "tlid", None)
|
||||
|
||||
def get_stream_title(self):
|
||||
"""Get the current stream title or :class:`None`."""
|
||||
return self._stream_title
|
||||
|
||||
def get_state(self):
|
||||
"""Get The playback state."""
|
||||
|
||||
return self._state
|
||||
|
||||
def set_state(self, new_state):
|
||||
"""Set the playback state.
|
||||
|
||||
Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.
|
||||
|
||||
Possible states and transitions:
|
||||
|
||||
.. digraph:: state_transitions
|
||||
|
||||
"STOPPED" -> "PLAYING" [ label="play" ]
|
||||
"STOPPED" -> "PAUSED" [ label="pause" ]
|
||||
"PLAYING" -> "STOPPED" [ label="stop" ]
|
||||
"PLAYING" -> "PAUSED" [ label="pause" ]
|
||||
"PLAYING" -> "PLAYING" [ label="play" ]
|
||||
"PAUSED" -> "PLAYING" [ label="resume" ]
|
||||
"PAUSED" -> "STOPPED" [ label="stop" ]
|
||||
"""
|
||||
validation.check_choice(new_state, validation.PLAYBACK_STATES)
|
||||
|
||||
(old_state, self._state) = (self.get_state(), new_state)
|
||||
logger.debug("Changing state: %s -> %s", old_state, new_state)
|
||||
|
||||
self._trigger_playback_state_changed(old_state, new_state)
|
||||
|
||||
def get_time_position(self):
|
||||
"""Get time position in milliseconds."""
|
||||
if self._pending_position is not None:
|
||||
return self._pending_position
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
if backend:
|
||||
# TODO: Wrap backend call in error handling.
|
||||
return backend.playback.get_time_position().get()
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _on_end_of_stream(self):
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
if self._current_tl_track:
|
||||
self._trigger_track_playback_ended(self.get_time_position())
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
def _on_stream_changed(self, uri):
|
||||
if self._last_position is None:
|
||||
position = self.get_time_position()
|
||||
else:
|
||||
# This code path handles the stop() case, uri should be none.
|
||||
position, self._last_position = self._last_position, None
|
||||
|
||||
if self._pending_position is None:
|
||||
self._trigger_track_playback_ended(position)
|
||||
|
||||
self._stream_title = None
|
||||
if self._pending_tl_track:
|
||||
self._set_current_tl_track(self._pending_tl_track)
|
||||
self._pending_tl_track = None
|
||||
|
||||
if self._pending_position is None:
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
self._trigger_track_playback_started()
|
||||
seek_ok = False
|
||||
if self._start_at_position:
|
||||
seek_ok = self.seek(self._start_at_position)
|
||||
self._start_at_position = None
|
||||
if not seek_ok and self._start_paused:
|
||||
self.pause()
|
||||
self._start_paused = False
|
||||
else:
|
||||
self._seek(self._pending_position)
|
||||
|
||||
def _on_position_changed(self, position):
|
||||
if self._pending_position is not None:
|
||||
self._trigger_seeked(self._pending_position)
|
||||
self._pending_position = None
|
||||
if self._start_paused:
|
||||
self._start_paused = False
|
||||
self.pause()
|
||||
|
||||
def _on_about_to_finish_callback(self):
|
||||
"""Callback that performs a blocking actor call to the real callback.
|
||||
|
||||
This is passed to audio, which is allowed to call this code from the
|
||||
audio thread. We pass execution into the core actor to ensure that
|
||||
there is no unsafe access of state in core. This must block until
|
||||
we get a response.
|
||||
"""
|
||||
self.core.actor_ref.ask(
|
||||
ProxyCall(
|
||||
attr_path=["playback", "_on_about_to_finish"],
|
||||
args=[],
|
||||
kwargs={},
|
||||
)
|
||||
)
|
||||
|
||||
def _on_about_to_finish(self):
|
||||
if self._state == PlaybackState.STOPPED:
|
||||
return
|
||||
|
||||
# Unless overridden by other calls (e.g. next / previous / stop) this
|
||||
# will be the last position recorded until the track gets reassigned.
|
||||
# TODO: Check if case when track.length isn't populated needs to be
|
||||
# handled.
|
||||
self._last_position = self._current_tl_track.track.length
|
||||
|
||||
pending = self.core.tracklist.eot_track(self._current_tl_track)
|
||||
# avoid endless loop if 'repeat' is 'true' and no track is playable
|
||||
# * 2 -> second run to get all playable track in a shuffled playlist
|
||||
count = self.core.tracklist.get_length() * 2
|
||||
|
||||
while pending:
|
||||
backend = self._get_backend(pending)
|
||||
if backend:
|
||||
try:
|
||||
if backend.playback.change_track(pending.track).get():
|
||||
self._pending_tl_track = pending
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"%s backend caused an exception.",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
)
|
||||
|
||||
self.core.tracklist._mark_unplayable(pending)
|
||||
pending = self.core.tracklist.eot_track(pending)
|
||||
count -= 1
|
||||
if not count:
|
||||
logger.info("No playable track in the list.")
|
||||
break
|
||||
|
||||
def _on_tracklist_change(self):
|
||||
"""
|
||||
Tell the playback controller that the current playlist has changed.
|
||||
|
||||
Used by :class:`mopidy.core.TracklistController`.
|
||||
"""
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
if not tl_tracks:
|
||||
self.stop()
|
||||
self._set_current_tl_track(None)
|
||||
elif self.get_current_tl_track() not in tl_tracks:
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
def next(self):
|
||||
"""
|
||||
Change to the next track.
|
||||
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
state = self.get_state()
|
||||
current = self._pending_tl_track or self._current_tl_track
|
||||
# avoid endless loop if 'repeat' is 'true' and no track is playable
|
||||
# * 2 -> second run to get all playable track in a shuffled playlist
|
||||
count = self.core.tracklist.get_length() * 2
|
||||
|
||||
while current:
|
||||
pending = self.core.tracklist.next_track(current)
|
||||
if self._change(pending, state):
|
||||
break
|
||||
else:
|
||||
self.core.tracklist._mark_unplayable(pending)
|
||||
# TODO: this could be needed to prevent a loop in rare cases
|
||||
# if current == pending:
|
||||
# break
|
||||
current = pending
|
||||
count -= 1
|
||||
if not count:
|
||||
logger.info("No playable track in the list.")
|
||||
break
|
||||
|
||||
# TODO return result?
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
# TODO: Wrap backend call in error handling.
|
||||
if not backend or backend.playback.pause().get():
|
||||
# TODO: switch to:
|
||||
# backend.track(pause)
|
||||
# wait for state change?
|
||||
self.set_state(PlaybackState.PAUSED)
|
||||
self._trigger_track_playback_paused()
|
||||
|
||||
def play(self, tl_track=None, tlid=None):
|
||||
"""
|
||||
Play the given track, or if the given tl_track and tlid is
|
||||
:class:`None`, play the currently active track.
|
||||
|
||||
Note that the track **must** already be in the tracklist.
|
||||
|
||||
.. deprecated:: 3.0
|
||||
The ``tl_track`` argument. Use ``tlid`` instead.
|
||||
|
||||
:param tl_track: track to play
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param tlid: TLID of the track to play
|
||||
:type tlid: :class:`int` or :class:`None`
|
||||
"""
|
||||
if sum(o is not None for o in [tl_track, tlid]) > 1:
|
||||
raise ValueError('At most one of "tl_track" and "tlid" may be set')
|
||||
|
||||
tl_track is None or validation.check_instance(tl_track, models.TlTrack)
|
||||
tlid is None or validation.check_integer(tlid, min=1)
|
||||
|
||||
if tl_track:
|
||||
deprecation.warn("core.playback.play:tl_track_kwarg")
|
||||
|
||||
if tl_track is None and tlid is not None:
|
||||
for tl_track in self.core.tracklist.get_tl_tracks():
|
||||
if tl_track.tlid == tlid:
|
||||
break
|
||||
else:
|
||||
tl_track = None
|
||||
|
||||
if tl_track is not None:
|
||||
# TODO: allow from outside tracklist, would make sense given refs?
|
||||
assert tl_track in self.core.tracklist.get_tl_tracks()
|
||||
elif tl_track is None and self.get_state() == PlaybackState.PAUSED:
|
||||
self.resume()
|
||||
return
|
||||
|
||||
current = self._pending_tl_track or self._current_tl_track
|
||||
pending = tl_track or current or self.core.tracklist.next_track(None)
|
||||
# avoid endless loop if 'repeat' is 'true' and no track is playable
|
||||
# * 2 -> second run to get all playable track in a shuffled playlist
|
||||
count = self.core.tracklist.get_length() * 2
|
||||
|
||||
while pending:
|
||||
if self._change(pending, PlaybackState.PLAYING):
|
||||
break
|
||||
else:
|
||||
self.core.tracklist._mark_unplayable(pending)
|
||||
current = pending
|
||||
pending = self.core.tracklist.next_track(current)
|
||||
count -= 1
|
||||
if not count:
|
||||
logger.info("No playable track in the list.")
|
||||
break
|
||||
|
||||
# TODO return result?
|
||||
|
||||
def _change(self, pending_tl_track, state):
|
||||
self._pending_tl_track = pending_tl_track
|
||||
|
||||
if not pending_tl_track:
|
||||
self.stop()
|
||||
self._on_end_of_stream() # pretend an EOS happened for cleanup
|
||||
return True
|
||||
|
||||
backend = self._get_backend(pending_tl_track)
|
||||
if not backend:
|
||||
return False
|
||||
|
||||
# This must happen before prepare_change gets called, otherwise the
|
||||
# backend flushes the information of the track.
|
||||
self._last_position = self.get_time_position()
|
||||
|
||||
# TODO: Wrap backend call in error handling.
|
||||
backend.playback.prepare_change()
|
||||
|
||||
try:
|
||||
if not backend.playback.change_track(pending_tl_track.track).get():
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"%s backend caused an exception.",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
# TODO: Wrap backend calls in error handling.
|
||||
if state == PlaybackState.PLAYING:
|
||||
try:
|
||||
return backend.playback.play().get()
|
||||
except TypeError:
|
||||
# TODO: check by binding against underlying play method using
|
||||
# inspect and otherwise re-raise?
|
||||
logger.error(
|
||||
"%s needs to be updated to work with this "
|
||||
"version of Mopidy.",
|
||||
backend,
|
||||
)
|
||||
return False
|
||||
elif state == PlaybackState.PAUSED:
|
||||
return backend.playback.pause().get()
|
||||
elif state == PlaybackState.STOPPED:
|
||||
# TODO: emit some event now?
|
||||
self._current_tl_track = self._pending_tl_track
|
||||
self._pending_tl_track = None
|
||||
return True
|
||||
|
||||
raise Exception(f"Unknown state: {state}")
|
||||
|
||||
def previous(self):
|
||||
"""
|
||||
Change to the previous track.
|
||||
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
self._previous = True
|
||||
state = self.get_state()
|
||||
current = self._pending_tl_track or self._current_tl_track
|
||||
# avoid endless loop if 'repeat' is 'true' and no track is playable
|
||||
# * 2 -> second run to get all playable track in a shuffled playlist
|
||||
count = self.core.tracklist.get_length() * 2
|
||||
|
||||
while current:
|
||||
pending = self.core.tracklist.previous_track(current)
|
||||
if self._change(pending, state):
|
||||
break
|
||||
else:
|
||||
self.core.tracklist._mark_unplayable(pending)
|
||||
# TODO: this could be needed to prevent a loop in rare cases
|
||||
# if current == pending:
|
||||
# break
|
||||
current = pending
|
||||
count -= 1
|
||||
if not count:
|
||||
logger.info("No playable track in the list.")
|
||||
break
|
||||
|
||||
# TODO: no return value?
|
||||
|
||||
def resume(self):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.get_state() != PlaybackState.PAUSED:
|
||||
return
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
# TODO: Wrap backend call in error handling.
|
||||
if backend and backend.playback.resume().get():
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
# TODO: trigger via gst messages
|
||||
self._trigger_track_playback_resumed()
|
||||
# TODO: switch to:
|
||||
# backend.resume()
|
||||
# wait for state change?
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seeks to time position given in milliseconds.
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
# TODO: seek needs to take pending tracks into account :(
|
||||
validation.check_integer(time_position)
|
||||
|
||||
if time_position < 0:
|
||||
logger.debug("Client seeked to negative position. Seeking to zero.")
|
||||
time_position = 0
|
||||
|
||||
if not self.core.tracklist.get_length():
|
||||
return False
|
||||
|
||||
if self.get_state() == PlaybackState.STOPPED:
|
||||
self.play()
|
||||
|
||||
# We need to prefer the still playing track, but if nothing is playing
|
||||
# we fall back to the pending one.
|
||||
tl_track = self._current_tl_track or self._pending_tl_track
|
||||
if tl_track and tl_track.track.length is None:
|
||||
return False
|
||||
|
||||
if time_position < 0:
|
||||
time_position = 0
|
||||
elif time_position > tl_track.track.length:
|
||||
# TODO: GStreamer will trigger a about-to-finish for us, use that?
|
||||
self.next()
|
||||
return True
|
||||
|
||||
# Store our target position.
|
||||
self._pending_position = time_position
|
||||
|
||||
# Make sure we switch back to previous track if we get a seek while we
|
||||
# have a pending track.
|
||||
if self._current_tl_track and self._pending_tl_track:
|
||||
self._change(self._current_tl_track, self.get_state())
|
||||
else:
|
||||
return self._seek(time_position)
|
||||
|
||||
def _seek(self, time_position):
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
if not backend:
|
||||
return False
|
||||
# TODO: Wrap backend call in error handling.
|
||||
return backend.playback.seek(time_position).get()
|
||||
|
||||
def stop(self):
|
||||
"""Stop playing."""
|
||||
if self.get_state() != PlaybackState.STOPPED:
|
||||
self._last_position = self.get_time_position()
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
# TODO: Wrap backend call in error handling.
|
||||
if not backend or backend.playback.stop().get():
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
|
||||
def _trigger_track_playback_paused(self):
|
||||
logger.debug("Triggering track playback paused event")
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
"track_playback_paused",
|
||||
tl_track=self.get_current_tl_track(),
|
||||
time_position=self.get_time_position(),
|
||||
)
|
||||
|
||||
def _trigger_track_playback_resumed(self):
|
||||
logger.debug("Triggering track playback resumed event")
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
"track_playback_resumed",
|
||||
tl_track=self.get_current_tl_track(),
|
||||
time_position=self.get_time_position(),
|
||||
)
|
||||
|
||||
def _trigger_track_playback_started(self):
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
|
||||
logger.debug("Triggering track playback started event")
|
||||
tl_track = self.get_current_tl_track()
|
||||
self.core.tracklist._mark_playing(tl_track)
|
||||
self.core.history._add_track(tl_track.track)
|
||||
listener.CoreListener.send("track_playback_started", tl_track=tl_track)
|
||||
|
||||
def _trigger_track_playback_ended(self, time_position_before_stop):
|
||||
tl_track = self.get_current_tl_track()
|
||||
if tl_track is None:
|
||||
return
|
||||
|
||||
logger.debug("Triggering track playback ended event")
|
||||
|
||||
if not self._previous:
|
||||
self.core.tracklist._mark_played(self._current_tl_track)
|
||||
self._previous = False
|
||||
|
||||
# TODO: Use the lowest of track duration and position.
|
||||
listener.CoreListener.send(
|
||||
"track_playback_ended",
|
||||
tl_track=tl_track,
|
||||
time_position=time_position_before_stop,
|
||||
)
|
||||
|
||||
def _trigger_playback_state_changed(self, old_state, new_state):
|
||||
logger.debug("Triggering playback state change event")
|
||||
listener.CoreListener.send(
|
||||
"playback_state_changed", old_state=old_state, new_state=new_state
|
||||
)
|
||||
|
||||
def _trigger_seeked(self, time_position):
|
||||
# TODO: Trigger this from audio events?
|
||||
logger.debug("Triggering seeked event")
|
||||
listener.CoreListener.send("seeked", time_position=time_position)
|
||||
|
||||
def _save_state(self):
|
||||
return models.PlaybackState(
|
||||
tlid=self.get_current_tlid(),
|
||||
time_position=self.get_time_position(),
|
||||
state=self.get_state(),
|
||||
)
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and "play-last" in coverage and state.tlid is not None:
|
||||
if state.state == PlaybackState.PAUSED:
|
||||
self._start_paused = True
|
||||
if state.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
|
||||
self._start_at_position = state.time_position
|
||||
self.play(tlid=state.tlid)
|
||||
280
venv/lib/python3.7/site-packages/mopidy/core/playlists.py
Normal file
280
venv/lib/python3.7/site-packages/mopidy/core/playlists.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import urllib
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import validation
|
||||
from mopidy.models import Playlist, Ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _backend_error_handling(backend, reraise=None):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error(
|
||||
"%s backend returned bad data: %s",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
if reraise and isinstance(e, reraise):
|
||||
raise
|
||||
logger.exception(
|
||||
"%s backend caused an exception.",
|
||||
backend.actor_ref.actor_class.__name__,
|
||||
)
|
||||
|
||||
|
||||
class PlaylistsController:
|
||||
def __init__(self, backends, core):
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
|
||||
def get_uri_schemes(self):
|
||||
"""
|
||||
Get the list of URI schemes that support playlists.
|
||||
|
||||
:rtype: list of string
|
||||
|
||||
.. versionadded:: 2.0
|
||||
"""
|
||||
return list(sorted(self.backends.with_playlists.keys()))
|
||||
|
||||
def as_list(self):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
futures = {
|
||||
backend: backend.playlists.as_list()
|
||||
for backend in set(self.backends.with_playlists.values())
|
||||
}
|
||||
|
||||
results = []
|
||||
for b, future in futures.items():
|
||||
try:
|
||||
with _backend_error_handling(b, reraise=NotImplementedError):
|
||||
playlists = future.get()
|
||||
if playlists is not None:
|
||||
validation.check_instances(playlists, Ref)
|
||||
results.extend(playlists)
|
||||
except NotImplementedError:
|
||||
backend_name = b.actor_ref.actor_class.__name__
|
||||
logger.warning(
|
||||
"%s does not implement playlists.as_list(). "
|
||||
"Please upgrade it.",
|
||||
backend_name,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def get_items(self, uri):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
validation.check_uri(uri)
|
||||
|
||||
uri_scheme = urllib.parse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
items = backend.playlists.get_items(uri).get()
|
||||
items is None or validation.check_instances(items, Ref)
|
||||
return items
|
||||
|
||||
return None
|
||||
|
||||
def create(self, name, uri_scheme=None):
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
If ``uri_scheme`` matches an URI scheme handled by a current backend,
|
||||
that backend is asked to create the playlist. If ``uri_scheme`` is
|
||||
:class:`None` or doesn't match a current backend, the first backend is
|
||||
asked to create the playlist.
|
||||
|
||||
All new playlists must be created by calling this method, and **not**
|
||||
by creating new instances of :class:`mopidy.models.Playlist`.
|
||||
|
||||
:param name: name of the new playlist
|
||||
:type name: string
|
||||
:param uri_scheme: use the backend matching the URI scheme
|
||||
:type uri_scheme: string
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
if uri_scheme in self.backends.with_playlists:
|
||||
backends = [self.backends.with_playlists[uri_scheme]]
|
||||
else:
|
||||
backends = self.backends.with_playlists.values()
|
||||
|
||||
for backend in backends:
|
||||
with _backend_error_handling(backend):
|
||||
result = backend.playlists.create(name).get()
|
||||
if result is None:
|
||||
continue
|
||||
validation.check_instance(result, Playlist)
|
||||
listener.CoreListener.send("playlist_changed", playlist=result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def delete(self, uri):
|
||||
"""
|
||||
Delete playlist identified by the URI.
|
||||
|
||||
If the URI doesn't match the URI schemes handled by the current
|
||||
backends, nothing happens.
|
||||
|
||||
Returns :class:`True` if deleted, :class:`False` otherwise.
|
||||
|
||||
:param uri: URI of the playlist to delete
|
||||
:type uri: string
|
||||
:rtype: :class:`bool`
|
||||
|
||||
.. versionchanged:: 2.2
|
||||
Return type defined.
|
||||
"""
|
||||
validation.check_uri(uri)
|
||||
|
||||
uri_scheme = urllib.parse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if not backend:
|
||||
return False
|
||||
|
||||
success = False
|
||||
with _backend_error_handling(backend):
|
||||
success = backend.playlists.delete(uri).get()
|
||||
|
||||
if success is None:
|
||||
# Return type was defined in Mopidy 2.2. Assume everything went
|
||||
# well if the backend doesn't report otherwise.
|
||||
success = True
|
||||
|
||||
if success:
|
||||
listener.CoreListener.send("playlist_deleted", uri=uri)
|
||||
|
||||
return success
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
Lookup playlist with given URI in both the set of playlists and in any
|
||||
other playlist sources. Returns :class:`None` if not found.
|
||||
|
||||
:param uri: playlist URI
|
||||
:type uri: string
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
uri_scheme = urllib.parse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
playlist = backend.playlists.lookup(uri).get()
|
||||
playlist is None or validation.check_instance(playlist, Playlist)
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
# TODO: there is an inconsistency between library.refresh(uri) and this
|
||||
# call, not sure how to sort this out.
|
||||
def refresh(self, uri_scheme=None):
|
||||
"""
|
||||
Refresh the playlists in :attr:`playlists`.
|
||||
|
||||
If ``uri_scheme`` is :class:`None`, all backends are asked to refresh.
|
||||
If ``uri_scheme`` is an URI scheme handled by a backend, only that
|
||||
backend is asked to refresh. If ``uri_scheme`` doesn't match any
|
||||
current backend, nothing happens.
|
||||
|
||||
:param uri_scheme: limit to the backend matching the URI scheme
|
||||
:type uri_scheme: string
|
||||
"""
|
||||
# TODO: check: uri_scheme is None or uri_scheme?
|
||||
|
||||
futures = {}
|
||||
backends = {}
|
||||
playlists_loaded = False
|
||||
|
||||
for backend_scheme, backend in self.backends.with_playlists.items():
|
||||
backends.setdefault(backend, set()).add(backend_scheme)
|
||||
|
||||
for backend, backend_schemes in backends.items():
|
||||
if uri_scheme is None or uri_scheme in backend_schemes:
|
||||
futures[backend] = backend.playlists.refresh()
|
||||
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
future.get()
|
||||
playlists_loaded = True
|
||||
|
||||
if playlists_loaded:
|
||||
listener.CoreListener.send("playlists_loaded")
|
||||
|
||||
def save(self, playlist):
|
||||
"""
|
||||
Save the playlist.
|
||||
|
||||
For a playlist to be saveable, it must have the ``uri`` attribute set.
|
||||
You must not set the ``uri`` atribute yourself, but use playlist
|
||||
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
|
||||
which will always give you saveable playlists.
|
||||
|
||||
The method returns the saved playlist. The return playlist may differ
|
||||
from the saved playlist. E.g. if the playlist name was changed, the
|
||||
returned playlist may have a different URI. The caller of this method
|
||||
must throw away the playlist sent to this method, and use the
|
||||
returned playlist instead.
|
||||
|
||||
If the playlist's URI isn't set or doesn't match the URI scheme of a
|
||||
current backend, nothing is done and :class:`None` is returned.
|
||||
|
||||
:param playlist: the playlist
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
validation.check_instance(playlist, Playlist)
|
||||
|
||||
if playlist.uri is None:
|
||||
return # TODO: log this problem?
|
||||
|
||||
uri_scheme = urllib.parse.urlparse(playlist.uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
# TODO: we let AssertionError error through due to legacy tests :/
|
||||
with _backend_error_handling(backend, reraise=AssertionError):
|
||||
playlist = backend.playlists.save(playlist).get()
|
||||
playlist is None or validation.check_instance(playlist, Playlist)
|
||||
if playlist:
|
||||
listener.CoreListener.send(
|
||||
"playlist_changed", playlist=playlist
|
||||
)
|
||||
return playlist
|
||||
|
||||
return None
|
||||
620
venv/lib/python3.7/site-packages/mopidy/core/tracklist.py
Normal file
620
venv/lib/python3.7/site-packages/mopidy/core/tracklist.py
Normal file
@@ -0,0 +1,620 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.internal.models import TracklistState
|
||||
from mopidy.models import TlTrack, Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TracklistController:
|
||||
def __init__(self, core):
|
||||
self.core = core
|
||||
self._next_tlid = 1
|
||||
self._tl_tracks = []
|
||||
self._version = 0
|
||||
|
||||
self._consume = False
|
||||
self._random = False
|
||||
self._shuffled = []
|
||||
self._repeat = False
|
||||
self._single = False
|
||||
|
||||
def get_tl_tracks(self):
|
||||
"""Get tracklist as list of :class:`mopidy.models.TlTrack`."""
|
||||
return self._tl_tracks[:]
|
||||
|
||||
def get_tracks(self):
|
||||
"""Get tracklist as list of :class:`mopidy.models.Track`."""
|
||||
return [tl_track.track for tl_track in self._tl_tracks]
|
||||
|
||||
def get_length(self):
|
||||
"""Get length of the tracklist."""
|
||||
return len(self._tl_tracks)
|
||||
|
||||
def get_version(self):
|
||||
"""
|
||||
Get the tracklist version.
|
||||
|
||||
Integer which is increased every time the tracklist is changed. Is not
|
||||
reset before Mopidy is restarted.
|
||||
"""
|
||||
return self._version
|
||||
|
||||
def _increase_version(self):
|
||||
self._version += 1
|
||||
self.core.playback._on_tracklist_change()
|
||||
self._trigger_tracklist_changed()
|
||||
|
||||
def get_consume(self):
|
||||
"""Get consume mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are removed from the tracklist when they have been played.
|
||||
:class:`False`
|
||||
Tracks are not removed from the tracklist.
|
||||
"""
|
||||
return self._consume
|
||||
|
||||
def set_consume(self, value):
|
||||
"""Set consume mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are removed from the tracklist when they have been played.
|
||||
:class:`False`
|
||||
Tracks are not removed from the tracklist.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_consume() != value:
|
||||
self._trigger_options_changed()
|
||||
self._consume = value
|
||||
|
||||
def get_random(self):
|
||||
"""Get random mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are selected at random from the tracklist.
|
||||
:class:`False`
|
||||
Tracks are played in the order of the tracklist.
|
||||
"""
|
||||
return self._random
|
||||
|
||||
def set_random(self, value):
|
||||
"""Set random mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are selected at random from the tracklist.
|
||||
:class:`False`
|
||||
Tracks are played in the order of the tracklist.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_random() != value:
|
||||
self._trigger_options_changed()
|
||||
if value:
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
random.shuffle(self._shuffled)
|
||||
self._random = value
|
||||
|
||||
def get_repeat(self):
|
||||
"""
|
||||
Get repeat mode.
|
||||
|
||||
:class:`True`
|
||||
The tracklist is played repeatedly.
|
||||
:class:`False`
|
||||
The tracklist is played once.
|
||||
"""
|
||||
return self._repeat
|
||||
|
||||
def set_repeat(self, value):
|
||||
"""
|
||||
Set repeat mode.
|
||||
|
||||
To repeat a single track, set both ``repeat`` and ``single``.
|
||||
|
||||
:class:`True`
|
||||
The tracklist is played repeatedly.
|
||||
:class:`False`
|
||||
The tracklist is played once.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_repeat() != value:
|
||||
self._trigger_options_changed()
|
||||
self._repeat = value
|
||||
|
||||
def get_single(self):
|
||||
"""
|
||||
Get single mode.
|
||||
|
||||
:class:`True`
|
||||
Playback is stopped after current song, unless in ``repeat`` mode.
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
"""
|
||||
return self._single
|
||||
|
||||
def set_single(self, value):
|
||||
"""
|
||||
Set single mode.
|
||||
|
||||
:class:`True`
|
||||
Playback is stopped after current song, unless in ``repeat`` mode.
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_single() != value:
|
||||
self._trigger_options_changed()
|
||||
self._single = value
|
||||
|
||||
def index(self, tl_track=None, tlid=None):
|
||||
"""
|
||||
The position of the given track in the tracklist.
|
||||
|
||||
If neither *tl_track* or *tlid* is given we return the index of
|
||||
the currently playing track.
|
||||
|
||||
:param tl_track: the track to find the index of
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param tlid: TLID of the track to find the index of
|
||||
:type tlid: :class:`int` or :class:`None`
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
The *tlid* parameter
|
||||
"""
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
tlid is None or validation.check_integer(tlid, min=1)
|
||||
|
||||
if tl_track is None and tlid is None:
|
||||
tl_track = self.core.playback.get_current_tl_track()
|
||||
|
||||
if tl_track is not None:
|
||||
try:
|
||||
return self._tl_tracks.index(tl_track)
|
||||
except ValueError:
|
||||
pass
|
||||
elif tlid is not None:
|
||||
for i, tl_track in enumerate(self._tl_tracks):
|
||||
if tl_track.tlid == tlid:
|
||||
return i
|
||||
return None
|
||||
|
||||
def get_eot_tlid(self):
|
||||
"""
|
||||
The TLID of the track that will be played after the current track.
|
||||
|
||||
Not necessarily the same TLID as returned by :meth:`get_next_tlid`.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
|
||||
with deprecation.ignore("core.tracklist.eot_track"):
|
||||
eot_tl_track = self.eot_track(current_tl_track)
|
||||
|
||||
return getattr(eot_tl_track, "tlid", None)
|
||||
|
||||
def eot_track(self, tl_track):
|
||||
"""
|
||||
The track that will be played after the given track.
|
||||
|
||||
Not necessarily the same track as :meth:`next_track`.
|
||||
|
||||
.. deprecated:: 3.0
|
||||
Use :meth:`get_eot_tlid` instead.
|
||||
|
||||
:param tl_track: the reference track
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn("core.tracklist.eot_track")
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
if self.get_single() and self.get_repeat():
|
||||
return tl_track
|
||||
elif self.get_single():
|
||||
return None
|
||||
|
||||
# Current difference between next and EOT handling is that EOT needs to
|
||||
# handle "single", with that out of the way the rest of the logic is
|
||||
# shared.
|
||||
return self.next_track(tl_track)
|
||||
|
||||
def get_next_tlid(self):
|
||||
"""
|
||||
The tlid of the track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.next()`.
|
||||
|
||||
For normal playback this is the next track in the tracklist. If repeat
|
||||
is enabled the next track can loop around the tracklist. When random is
|
||||
enabled this should be a random track, all tracks should be played once
|
||||
before the tracklist repeats.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
|
||||
with deprecation.ignore("core.tracklist.next_track"):
|
||||
next_tl_track = self.next_track(current_tl_track)
|
||||
|
||||
return getattr(next_tl_track, "tlid", None)
|
||||
|
||||
def next_track(self, tl_track):
|
||||
"""
|
||||
The track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.next()`.
|
||||
|
||||
For normal playback this is the next track in the tracklist. If repeat
|
||||
is enabled the next track can loop around the tracklist. When random is
|
||||
enabled this should be a random track, all tracks should be played once
|
||||
before the tracklist repeats.
|
||||
|
||||
.. deprecated:: 3.0
|
||||
Use :meth:`get_next_tlid` instead.
|
||||
|
||||
:param tl_track: the reference track
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn("core.tracklist.next_track")
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
|
||||
if not self._tl_tracks:
|
||||
return None
|
||||
|
||||
if self.get_random() and not self._shuffled:
|
||||
if self.get_repeat() or not tl_track:
|
||||
logger.debug("Shuffling tracks")
|
||||
self._shuffled = self._tl_tracks[:]
|
||||
random.shuffle(self._shuffled)
|
||||
|
||||
if self.get_random():
|
||||
if self._shuffled:
|
||||
return self._shuffled[0]
|
||||
return None
|
||||
|
||||
next_index = self.index(tl_track)
|
||||
if next_index is None:
|
||||
next_index = 0
|
||||
else:
|
||||
next_index += 1
|
||||
|
||||
if self.get_repeat():
|
||||
if self.get_consume() and len(self._tl_tracks) == 1:
|
||||
return None
|
||||
else:
|
||||
next_index %= len(self._tl_tracks)
|
||||
elif next_index >= len(self._tl_tracks):
|
||||
return None
|
||||
|
||||
return self._tl_tracks[next_index]
|
||||
|
||||
def get_previous_tlid(self):
|
||||
"""
|
||||
Returns the TLID of the track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.previous()`.
|
||||
|
||||
For normal playback this is the previous track in the tracklist. If
|
||||
random and/or consume is enabled it should return the current track
|
||||
instead.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
|
||||
with deprecation.ignore("core.tracklist.previous_track"):
|
||||
previous_tl_track = self.previous_track(current_tl_track)
|
||||
|
||||
return getattr(previous_tl_track, "tlid", None)
|
||||
|
||||
def previous_track(self, tl_track):
|
||||
"""
|
||||
Returns the track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.previous()`.
|
||||
|
||||
For normal playback this is the previous track in the tracklist. If
|
||||
random and/or consume is enabled it should return the current track
|
||||
instead.
|
||||
|
||||
.. deprecated:: 3.0
|
||||
Use :meth:`get_previous_tlid` instead.
|
||||
|
||||
:param tl_track: the reference track
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn("core.tracklist.previous_track")
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
|
||||
if self.get_repeat() or self.get_consume() or self.get_random():
|
||||
return tl_track
|
||||
|
||||
position = self.index(tl_track)
|
||||
|
||||
if position in (None, 0):
|
||||
return None
|
||||
|
||||
# Since we know we are not at zero we have to be somewhere in the range
|
||||
# 1 - len(tracks) Thus 'position - 1' will always be within the list.
|
||||
return self._tl_tracks[position - 1]
|
||||
|
||||
def add(self, tracks=None, at_position=None, uris=None):
|
||||
"""
|
||||
Add tracks to the tracklist.
|
||||
|
||||
If ``uris`` is given instead of ``tracks``, the URIs are
|
||||
looked up in the library and the resulting tracks are added to the
|
||||
tracklist.
|
||||
|
||||
If ``at_position`` is given, the tracks are inserted at the given
|
||||
position in the tracklist. If ``at_position`` is not given, the tracks
|
||||
are appended to the end of the tracklist.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
|
||||
:param tracks: tracks to add
|
||||
:type tracks: list of :class:`mopidy.models.Track` or :class:`None`
|
||||
:param at_position: position in tracklist to add tracks
|
||||
:type at_position: int or :class:`None`
|
||||
:param uris: list of URIs for tracks to add
|
||||
:type uris: list of string or :class:`None`
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``uris`` argument.
|
||||
|
||||
.. deprecated:: 1.0
|
||||
The ``tracks`` argument. Use ``uris``.
|
||||
"""
|
||||
if sum(o is not None for o in [tracks, uris]) != 1:
|
||||
raise ValueError('Exactly one of "tracks" or "uris" must be set')
|
||||
|
||||
tracks is None or validation.check_instances(tracks, Track)
|
||||
uris is None or validation.check_uris(uris)
|
||||
validation.check_integer(at_position or 0)
|
||||
|
||||
if tracks:
|
||||
deprecation.warn("core.tracklist.add:tracks_arg")
|
||||
|
||||
if tracks is None:
|
||||
tracks = []
|
||||
track_map = self.core.library.lookup(uris=uris)
|
||||
for uri in uris:
|
||||
tracks.extend(track_map[uri])
|
||||
|
||||
tl_tracks = []
|
||||
max_length = self.core._config["core"]["max_tracklist_length"]
|
||||
|
||||
for track in tracks:
|
||||
if self.get_length() >= max_length:
|
||||
raise exceptions.TracklistFull(
|
||||
f"Tracklist may contain at most {max_length:d} tracks."
|
||||
)
|
||||
|
||||
tl_track = TlTrack(self._next_tlid, track)
|
||||
self._next_tlid += 1
|
||||
if at_position is not None:
|
||||
self._tl_tracks.insert(at_position, tl_track)
|
||||
at_position += 1
|
||||
else:
|
||||
self._tl_tracks.append(tl_track)
|
||||
tl_tracks.append(tl_track)
|
||||
|
||||
if tl_tracks:
|
||||
self._increase_version()
|
||||
|
||||
return tl_tracks
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Clear the tracklist.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
"""
|
||||
self._tl_tracks = []
|
||||
self._increase_version()
|
||||
|
||||
def filter(self, criteria):
|
||||
"""
|
||||
Filter the tracklist by the given criteria.
|
||||
|
||||
Each rule in the criteria consists of a model field and a list of
|
||||
values to compare it against. If the model field matches any of the
|
||||
values, it may be returned.
|
||||
|
||||
Only tracks that match all the given criteria are returned.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
|
||||
filter({'tlid': [1, 2, 3, 4]})
|
||||
|
||||
# Returns track with URIs 'xyz' or 'abc'
|
||||
filter({'uri': ['xyz', 'abc']})
|
||||
|
||||
# Returns track with a matching TLIDs (1, 3 or 6) and a
|
||||
# matching URI ('xyz' or 'abc')
|
||||
filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']})
|
||||
|
||||
:param criteria: one or more rules to match by
|
||||
:type criteria: dict, of (string, list) pairs
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
tlids = criteria.pop("tlid", [])
|
||||
validation.check_query(criteria, validation.TRACKLIST_FIELDS)
|
||||
validation.check_instances(tlids, int)
|
||||
|
||||
matches = self._tl_tracks
|
||||
for (key, values) in criteria.items():
|
||||
matches = [ct for ct in matches if getattr(ct.track, key) in values]
|
||||
if tlids:
|
||||
matches = [ct for ct in matches if ct.tlid in tlids]
|
||||
return matches
|
||||
|
||||
def move(self, start, end, to_position):
|
||||
"""
|
||||
Move the tracks in the slice ``[start:end]`` to ``to_position``.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
|
||||
:param start: position of first track to move
|
||||
:type start: int
|
||||
:param end: position after last track to move
|
||||
:type end: int
|
||||
:param to_position: new position for the tracks
|
||||
:type to_position: int
|
||||
"""
|
||||
if start == end:
|
||||
end += 1
|
||||
|
||||
tl_tracks = self._tl_tracks
|
||||
|
||||
# TODO: use validation helpers?
|
||||
assert start < end, "start must be smaller than end"
|
||||
assert start >= 0, "start must be at least zero"
|
||||
assert end <= len(
|
||||
tl_tracks
|
||||
), "end can not be larger than tracklist length"
|
||||
assert to_position >= 0, "to_position must be at least zero"
|
||||
assert to_position <= len(
|
||||
tl_tracks
|
||||
), "to_position can not be larger than tracklist length"
|
||||
|
||||
new_tl_tracks = tl_tracks[:start] + tl_tracks[end:]
|
||||
for tl_track in tl_tracks[start:end]:
|
||||
new_tl_tracks.insert(to_position, tl_track)
|
||||
to_position += 1
|
||||
self._tl_tracks = new_tl_tracks
|
||||
self._increase_version()
|
||||
|
||||
def remove(self, criteria):
|
||||
"""
|
||||
Remove the matching tracks from the tracklist.
|
||||
|
||||
Uses :meth:`filter()` to lookup the tracks to remove.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
|
||||
:param criteria: one or more rules to match by
|
||||
:type criteria: dict, of (string, list) pairs
|
||||
:rtype: list of :class:`mopidy.models.TlTrack` that were removed
|
||||
"""
|
||||
tl_tracks = self.filter(criteria)
|
||||
for tl_track in tl_tracks:
|
||||
position = self._tl_tracks.index(tl_track)
|
||||
del self._tl_tracks[position]
|
||||
self._increase_version()
|
||||
return tl_tracks
|
||||
|
||||
def shuffle(self, start=None, end=None):
|
||||
"""
|
||||
Shuffles the entire tracklist. If ``start`` and ``end`` is given only
|
||||
shuffles the slice ``[start:end]``.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
|
||||
:param start: position of first track to shuffle
|
||||
:type start: int or :class:`None`
|
||||
:param end: position after last track to shuffle
|
||||
:type end: int or :class:`None`
|
||||
"""
|
||||
tl_tracks = self._tl_tracks
|
||||
|
||||
# TOOD: use validation helpers?
|
||||
if start is not None and end is not None:
|
||||
assert start < end, "start must be smaller than end"
|
||||
|
||||
if start is not None:
|
||||
assert start >= 0, "start must be at least zero"
|
||||
|
||||
if end is not None:
|
||||
assert end <= len(tl_tracks), (
|
||||
"end can not be larger than " + "tracklist length"
|
||||
)
|
||||
|
||||
before = tl_tracks[: start or 0]
|
||||
shuffled = tl_tracks[start:end]
|
||||
after = tl_tracks[end or len(tl_tracks) :]
|
||||
random.shuffle(shuffled)
|
||||
self._tl_tracks = before + shuffled + after
|
||||
self._increase_version()
|
||||
|
||||
def slice(self, start, end):
|
||||
"""
|
||||
Returns a slice of the tracklist, limited by the given start and end
|
||||
positions.
|
||||
|
||||
:param start: position of first track to include in slice
|
||||
:type start: int
|
||||
:param end: position after last track to include in slice
|
||||
:type end: int
|
||||
:rtype: :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
# TODO: validate slice?
|
||||
return self._tl_tracks[start:end]
|
||||
|
||||
def _mark_playing(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
if self.get_random() and tl_track in self._shuffled:
|
||||
self._shuffled.remove(tl_track)
|
||||
|
||||
def _mark_unplayable(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
logger.warning("Track is not playable: %s", tl_track.track.uri)
|
||||
if self.get_consume() and tl_track is not None:
|
||||
self.remove({"tlid": [tl_track.tlid]})
|
||||
if self.get_random() and tl_track in self._shuffled:
|
||||
self._shuffled.remove(tl_track)
|
||||
|
||||
def _mark_played(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
if self.get_consume() and tl_track is not None:
|
||||
self.remove({"tlid": [tl_track.tlid]})
|
||||
return True
|
||||
return False
|
||||
|
||||
def _trigger_tracklist_changed(self):
|
||||
if self.get_random():
|
||||
self._shuffled = self._tl_tracks[:]
|
||||
random.shuffle(self._shuffled)
|
||||
else:
|
||||
self._shuffled = []
|
||||
|
||||
logger.debug("Triggering event: tracklist_changed()")
|
||||
listener.CoreListener.send("tracklist_changed")
|
||||
|
||||
def _trigger_options_changed(self):
|
||||
logger.debug("Triggering options changed event")
|
||||
listener.CoreListener.send("options_changed")
|
||||
|
||||
def _save_state(self):
|
||||
return TracklistState(
|
||||
tl_tracks=self._tl_tracks,
|
||||
next_tlid=self._next_tlid,
|
||||
consume=self.get_consume(),
|
||||
random=self.get_random(),
|
||||
repeat=self.get_repeat(),
|
||||
single=self.get_single(),
|
||||
)
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state:
|
||||
if "mode" in coverage:
|
||||
self.set_consume(state.consume)
|
||||
self.set_random(state.random)
|
||||
self.set_repeat(state.repeat)
|
||||
self.set_single(state.single)
|
||||
if "tracklist" in coverage:
|
||||
self._next_tlid = max(state.next_tlid, self._next_tlid)
|
||||
self._tl_tracks = list(state.tl_tracks)
|
||||
self._increase_version()
|
||||
Reference in New Issue
Block a user