Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Continue playback where left off for specific URI schemes #1688

Closed
wants to merge 12 commits into from
Closed
16 changes: 16 additions & 0 deletions docs/config.rst
Expand Up @@ -119,6 +119,22 @@ Core config section

Default is ``false``.

.. confval:: core/continue_playback_types

Takes a comma-separated list of track types for which it will try
and remember at which position playback ends.
When playing the track again, it will automatically start at
that position.

Usually you want only longer tracks that you want to listen to
in multiple sittings, e.g. podcasts or audio books, to appear here.

The entries in this list should be URI schemes, compare :ref:`backend-api`.

Default is ``podcast``.
This means playback is only continued for tracks provided by the
Mopidy-Podcast backend.

.. _audio-config:

Audio configuration
Expand Down
1 change: 1 addition & 0 deletions mopidy/config/__init__.py
Expand Up @@ -25,6 +25,7 @@
# MPD supports at most 10k tracks, some clients segfault when this is exceeded.
_core_schema['max_tracklist_length'] = Integer(minimum=1)
_core_schema['restore_state'] = Boolean(optional=True)
_core_schema['continue_playback_types'] = String(optional=True)

_logging_schema = ConfigSchema('logging')
_logging_schema['color'] = Boolean()
Expand Down
1 change: 1 addition & 0 deletions mopidy/config/default.conf
Expand Up @@ -4,6 +4,7 @@ config_dir = $XDG_CONFIG_DIR/mopidy
data_dir = $XDG_DATA_DIR/mopidy
max_tracklist_length = 10000
restore_state = false
continue_playback_types = podcast

[logging]
color = true
Expand Down
26 changes: 26 additions & 0 deletions mopidy/core/playback.py
Expand Up @@ -6,6 +6,7 @@
from mopidy.compat import urllib
from mopidy.core import listener
from mopidy.internal import deprecation, models, validation
from mopidy.internal.tracker import PlaybackTracker

logger = logging.getLogger(__name__)

Expand All @@ -18,6 +19,7 @@ def __init__(self, audio, backends, core):
self.backends = backends
self.core = core
self._audio = audio
self.playback_tracker = PlaybackTracker(self, self.core._config)

self._stream_title = None
self._state = PlaybackState.STOPPED
Expand Down Expand Up @@ -209,6 +211,7 @@ def _on_end_of_stream(self):
if self._current_tl_track:
self._trigger_track_playback_ended(self.get_time_position())
self._set_current_tl_track(None)
self.playback_tracker.stop()

def _on_stream_changed(self, uri):
if self._last_position is None:
Expand Down Expand Up @@ -240,6 +243,7 @@ def _on_stream_changed(self, uri):

def _on_position_changed(self, position):
if self._pending_position is not None:
self.playback_tracker.save_position()
self._trigger_seeked(self._pending_position)
self._pending_position = None
if self._start_paused:
Expand Down Expand Up @@ -337,6 +341,7 @@ def next(self):
def pause(self):
"""Pause playback."""
backend = self._get_backend(self.get_current_tl_track())
self.playback_tracker.save_position()
# TODO: Wrap backend call in error handling.
if not backend or backend.playback.pause().get():
# TODO: switch to:
Expand Down Expand Up @@ -398,6 +403,8 @@ def play(self, tl_track=None, tlid=None):
logger.info('No playable track in the list.')
break

self._try_continue_playback(pending)

# TODO return result?

def _change(self, pending_tl_track, state):
Expand Down Expand Up @@ -447,6 +454,25 @@ def _change(self, pending_tl_track, state):

raise Exception('Unknown state: %s' % state)

def _try_continue_playback(self, tl_track):
try:
track = tl_track.track
except AttributeError:
return False

# Only try to continue playback if track is of a valid type
# as specified in core/continue_playback_types config,
# e.g. "podcast"
if not self.playback_tracker.is_track_enabled_for_tracking(track):
return False

# If we have a playback position from earlier,
# resume playback at that point
position = self.playback_tracker.get_last_position(track.uri)
if position:
logger.debug("Starting playback at %ss.", float(position) / 1000)
self._start_at_position = position

def previous(self):
"""
Change to the previous track.
Expand Down
127 changes: 127 additions & 0 deletions mopidy/internal/tracker.py
@@ -0,0 +1,127 @@
from __future__ import absolute_import, unicode_literals

import logging
import os
import sqlite3
from threading import Timer

import pykka

from mopidy.internal import path

logger = logging.getLogger(__name__)


class PlaybackTracker(object):
positions = {}

def __init__(self, playback_controller, config, *args, **kwargs):
self.playback_controller = playback_controller
self.config = config

self.enabled = True
self._timer = None
self.interval = 5 # save position every x seconds
self.is_running = False
try:
self.db_path = os.path.join(self._get_data_dir(),
b'playback_positions.db')
except (KeyError, TypeError):
self.enabled = False

# Get enabled types from core config
try:
enabled_types = self.config['core']['continue_playback_types']
self.types = tuple(enabled_types.split(','))
except (KeyError, TypeError):
self.types = ()
self.enabled = False

if self.enabled:
self.start()

def _setup_db(self):
return self._execute_db_query('''
CREATE TABLE playback_position
(id integer primary key autoincrement,
uri text not null unique,
position int)
''')

def _execute_db_query(self, *args):
con = sqlite3.connect(self.db_path)
c = con.cursor()
try:
result = c.execute(*args).fetchone()
except sqlite3.OperationalError:
# If the db isn't set up yet, do this first, then try again
self._setup_db()
result = c.execute(*args).fetchone()
con.commit()
con.close()

return result

def _run(self):
self.is_running = False
self.start()
self.save_position()

# Start the tracker, i.e. periodically saving the playback position
def start(self):
if self.enabled and not self.is_running:
self._timer = Timer(self.interval, self._run)
self._timer.daemon = True
self._timer.start()
self.is_running = True

# Stop the tracker
def stop(self):
if self._timer:
self._timer.cancel()
self.is_running = False

# Save the current playback position in ms to a Sqlite3 DB
def save_position(self):
if not self.enabled:
return False

track = self.playback_controller.get_current_track()
try:
time_position = self.playback_controller.get_time_position()
except pykka.ActorDeadError:
return False
if track and isinstance(time_position, (int, long)):
self._execute_db_query('''
INSERT OR REPLACE INTO playback_position
(uri, position) VALUES (?, ?)''',
(track.uri, time_position))
logger.debug("Saving playback position for %s at %dms"
% (track.name, time_position))
return True

# Try to retrieve a previous playback positoin from the DB
def get_last_position(self, track_uri):
if not self.enabled:
return None

try:
result = self._execute_db_query('''
SELECT position FROM playback_position
WHERE uri = ?''', (track_uri,))
position = result[0]
except TypeError:
position = None
return position

# Check if the file's "type" is enabled to be able to be continued
# We do this by checking the track's uri's prefix to be checked
# against a list given in the config
def is_track_enabled_for_tracking(self, track):
return track.uri.startswith(self.types)

# TODO: refactor this as it's a duplicate of core.actor._get_data_dir
def _get_data_dir(self):
data_dir_path = os.path.join(self.config['core']['data_dir'], b'core')
path.get_or_create_dir(data_dir_path)
return data_dir_path
70 changes: 69 additions & 1 deletion tests/core/test_playback.py
@@ -1,5 +1,7 @@
from __future__ import absolute_import, unicode_literals

import os
import tempfile
import unittest

import mock
Expand All @@ -9,6 +11,7 @@
from mopidy import backend, core
from mopidy.internal import deprecation
from mopidy.internal.models import PlaybackState
from mopidy.internal.tracker import PlaybackTracker
from mopidy.models import Track

from tests import dummy_audio
Expand Down Expand Up @@ -68,7 +71,13 @@ def __init__(self, config, audio):


class BaseTest(unittest.TestCase):
config = {'core': {'max_tracklist_length': 10000}}
config = {
'core': {
'max_tracklist_length': 10000,
# 'data_dir': tempfile.gettempdir(),
# 'continue_playback_types': 'podcast,dummy',
}
}
tracks = [Track(uri='dummy:a', length=1234),
Track(uri='dummy:b', length=1234),
Track(uri='dummy:c', length=1234)]
Expand Down Expand Up @@ -184,6 +193,65 @@ def test_play_tlid(self):
self.assertEqual(tl_tracks[1], current_tl_track)


class TestTracker(BaseTest):
def setUp(self, *args, **kwargs):
super(TestTracker, self).setUp(*args, **kwargs)

config = {
'core': {
'max_tracklist_length': 10000,
'data_dir': tempfile.gettempdir(),
'continue_playback_types': 'podcast,dummy',
}
}
self.playback.playback_tracker = PlaybackTracker(self.playback, config)
self.tracker = self.core.playback.playback_tracker
self.tl_tracks = self.core.tracklist.get_tl_tracks()

def test_setup_tracker_db(self):
tracker_db = os.path.join(self.tracker.config['core']['data_dir'],
b'core/playback_positions.db')

os.remove(tracker_db)

self.core.playback.play(self.tl_tracks[0])
self.replay_events()
self.tracker.save_position()

pos = self.tracker.get_last_position(
self.tl_tracks[0].track.uri)
self.assertEqual(pos, 0)

def test_continue_playback(self):
test_timepos = 571

self.core.playback.play(self.tl_tracks[0])
self.replay_events()

self.core.playback.seek(test_timepos)
self.tracker.save_position()

self.core.playback.play(self.tl_tracks[1])
self.replay_events()

self.core.playback.play(self.tl_tracks[0])
self.replay_events()

self.assertEqual(self.core.playback.get_time_position(), test_timepos)

def test_not_enabled_return_no_position(self):
track = self.tl_tracks[0].track.uri
self.tracker.enabled = False

pos = self.tracker.get_last_position(track)
self.assertEqual(pos, None)

def test_tracker_stopping(self):
self.assertEqual(self.tracker.is_running, True)
self.tracker.stop()
self.assertEqual(self.tracker.is_running, False)


class TestNextHandling(BaseTest):

def test_get_current_tl_track_next(self):
Expand Down