Python3 Migrate

This commit is contained in:
MariuszC
2020-01-18 20:01:00 +01:00
parent ea05af2d15
commit 6cd7e0fe44
691 changed files with 201846 additions and 598 deletions

View 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

View 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

View 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]

View 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

View 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

View 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)

View 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)

View 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

View 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()