Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Comparing changes

Choose two branches to see what's changed or to start a new pull request. If you need to, you can also compare across forks.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also compare across forks.
  • 2 commits
  • 4 files changed
  • 0 commit comments
  • 1 contributor
Commits on Apr 04, 2013
@dbrgn Added asyncproc.py d02a240
@dbrgn Basic playback control works now.
- Removed mpylayer, controlling mplayer directly via asyncproc.py
  instead.
- Added api functions for playing, skipping and reporting songs.
- Tuned client for playback and volume control.
2be3036
View
106 orochi/api.py
@@ -3,10 +3,8 @@
import os
import sys
-import time
import requests
-import mpylayer
def env(key):
@@ -33,7 +31,6 @@ def __init__(self):
'Accept': 'application/json',
})
self.play_token = None
- self.current_track = None
def _get(self, resource, params={}, **kwargs):
"""Do a GET request to the specified API resource.
@@ -119,24 +116,99 @@ def search_mix(self, query, sort='hot', page=1, per_page=20):
})
return data['mixes']
+ def _playback_control(self, mix_id, command):
+ """Used to do play/next/skip requests.
+
+ Args:
+ mix_id:
+ The 8tracks mix id to start playing.
+ command:
+ The command to execute (play/next/skip).
+
+ Returns:
+ Information about the set, including track data.
+
+ """
+ play_token = self._obtain_play_token()
+ resource = 'sets/{token}/{command}.json'.format(token=play_token, command=command)
+ data = self._get(resource, {
+ 'mix_id': mix_id,
+ })
+ return data['set']
+
def play_mix(self, mix_id):
+ """Start a mix playback.
+
+ Args:
+ mix_id:
+ The 8tracks mix id to start playing.
+
+ Returns:
+ Information about the playing set, including track data.
+
+ """
+ return self._playback_control(mix_id, 'play')
+
+ def next_track(self, mix_id):
+ """Request the next track after a track has regularly finished playing.
+
+ If you want to skip a track, use ``skip_track`` instead.
+
+ Args:
+ mix_id:
+ The currently playing 8tracks mix id.
+
+ Returns:
+ New set information, including track data.
+
+ """
+ return self._playback_control(mix_id, 'next')
+
+ def skip_track(self, mix_id):
+ """Skip a track.
+
+ Note that the caller has the responsibility to check whether the user
+ is allowed to skip a track or not.
+
+ Args:
+ mix_id:
+ The currently playing 8tracks mix id.
+
+ Returns:
+ New set information, including track data.
+
+ """
+ return self._playback_control(mix_id, 'skip')
+
+ def report_track(self, mix_id, track_id):
+ """Report a track as played.
+
+ In order to be legal and pay royalties properly, 8tracks must report
+ every performance of every song played to SoundExchange. A
+ "performance" is counted when the 30 second mark of a song is reached.
+ So at 30 seconds, you must call this function.
+
+ Args:
+ mix_id:
+ The currently playing 8tracks mix id.
+ track_id:
+ The id of the track to report.
+
+ Returns:
+ TODO
+
+ Raises:
+ TODO
+
+ """
play_token = self._obtain_play_token()
- data = self._get('sets/{token}/play.json'.format(token=play_token), {
+ data = self._get('sets/{token}/report.json'.format(token=play_token), {
'mix_id': mix_id,
+ 'track_id': track_id,
})
- self.current_track = data['set']['track']
- print('Track url: ' + self.current_track['url'])
- #Track: {u'performer': u'Yukon Blonde', u'name': u'Brides Song', u'url': u'https://dtp6gm33au72i.cloudfront.net/tf/000/796/'
-
-
-## Get song
-#
-#params = {'mix_id': mix['id']}
-#r = requests.get(BASE_URL + 'sets/{token}/play.json'.format(token=play_token), params=params, headers=HEADERS)
-#data_track = r.json()
-#track = data_track['set']['track']
-#
-#print('Now playing "{track[name]}" by "{track[performer]}"...'.format(track=track))
+ import ipdb; ipdb.set_trace()
+
+
#mp = mpylayer.MPlayerControl()
#mp.loadfile(track['url'])
#time.sleep(1)
View
335 orochi/asyncproc.py
@@ -0,0 +1,335 @@
+# -*- coding: utf-8 -*-
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The text of the license conditions can be read at
+# <http://www.lysator.liu.se/~bellman/download/gpl-3.0.txt>
+# or at <http://www.gnu.org/licenses/>.
+
+
+__rcsId__ = """$Id: asyncproc.py,v 1.9 2007/08/06 18:29:24 bellman Exp $"""
+__author__ = "Thomas Bellman <bellman@lysator.liu.se>"
+__url__ = "http://www.lysator.liu.se/~bellman/download/"
+__licence__ = "GNU General Publice License version 3 or later"
+
+
+import os
+import time
+import errno
+import signal
+import threading
+import subprocess
+
+
+__all__ = [ 'Process', 'with_timeout', 'Timeout' ]
+
+
+class Timeout(Exception):
+ """Exception raised by with_timeout() when the operation takes too long.
+ """
+ pass
+
+
+def with_timeout(timeout, func, *args, **kwargs):
+ """Call a function, allowing it only to take a certain amount of time.
+ Parameters:
+ - timeout The time, in seconds, the function is allowed to spend.
+ This must be an integer, due to limitations in the
+ SIGALRM handling.
+ - func The function to call.
+ - *args Non-keyword arguments to pass to func.
+ - **kwargs Keyword arguments to pass to func.
+
+ Upon successful completion, with_timeout() returns the return value
+ from func. If a timeout occurs, the Timeout exception will be raised.
+
+ If an alarm is pending when with_timeout() is called, with_timeout()
+ tries to restore that alarm as well as possible, and call the SIGALRM
+ signal handler if it would have expired during the execution of func.
+ This may cause that signal handler to be executed later than it would
+ normally do. In particular, calling with_timeout() from within a
+ with_timeout() call with a shorter timeout, won't interrupt the inner
+ call. I.e.,
+ with_timeout(5, with_timeout, 60, time.sleep, 120)
+ won't interrupt the time.sleep() call until after 60 seconds.
+ """
+
+ class SigAlarm(Exception):
+ """Internal exception used only within with_timeout().
+ """
+ pass
+
+ def alarm_handler(signum, frame):
+ raise SigAlarm()
+
+ oldalarm = signal.alarm(0)
+ oldhandler = signal.signal(signal.SIGALRM, alarm_handler)
+ try:
+ try:
+ t0 = time.time()
+ signal.alarm(timeout)
+ retval = func(*args, **kwargs)
+ except SigAlarm:
+ raise Timeout("Function call took too long", func, timeout)
+ finally:
+ signal.alarm(0)
+ signal.signal(signal.SIGALRM, oldhandler)
+ if oldalarm != 0:
+ t1 = time.time()
+ remaining = oldalarm - int(t1 - t0 + 0.5)
+ if remaining <= 0:
+ # The old alarm has expired.
+ os.kill(os.getpid(), signal.SIGALRM)
+ else:
+ signal.alarm(remaining)
+
+ return retval
+
+
+
+class Process(object):
+ """Manager for an asynchronous process.
+ The process will be run in the background, and its standard output
+ and standard error will be collected asynchronously.
+
+ Since the collection of output happens asynchronously (handled by
+ threads), the process won't block even if it outputs large amounts
+ of data and you do not call Process.read*().
+
+ Similarly, it is possible to send data to the standard input of the
+ process using the write() method, and the caller of write() won't
+ block even if the process does not drain its input.
+
+ On the other hand, this can consume large amounts of memory,
+ potentially even exhausting all memory available.
+
+ Parameters are identical to subprocess.Popen(), except that stdin,
+ stdout and stderr default to subprocess.PIPE instead of to None.
+ Note that if you set stdout or stderr to anything but PIPE, the
+ Process object won't collect that output, and the read*() methods
+ will always return empty strings. Also, setting stdin to something
+ other than PIPE will make the write() method raise an exception.
+ """
+
+ def __init__(self, *params, **kwparams):
+ if len(params) <= 3:
+ kwparams.setdefault('stdin', subprocess.PIPE)
+ if len(params) <= 4:
+ kwparams.setdefault('stdout', subprocess.PIPE)
+ if len(params) <= 5:
+ kwparams.setdefault('stderr', subprocess.PIPE)
+ self.__pending_input = []
+ self.__collected_outdata = []
+ self.__collected_errdata = []
+ self.__exitstatus = None
+ self.__lock = threading.Lock()
+ self.__inputsem = threading.Semaphore(0)
+ # Flag telling feeder threads to quit
+ self.__quit = False
+
+ self.__process = subprocess.Popen(*params, **kwparams)
+
+ if self.__process.stdin:
+ self.__stdin_thread = threading.Thread(
+ name="stdin-thread",
+ target=self.__feeder, args=(self.__pending_input,
+ self.__process.stdin))
+ self.__stdin_thread.setDaemon(True)
+ self.__stdin_thread.start()
+ if self.__process.stdout:
+ self.__stdout_thread = threading.Thread(
+ name="stdout-thread",
+ target=self.__reader, args=(self.__collected_outdata,
+ self.__process.stdout))
+ self.__stdout_thread.setDaemon(True)
+ self.__stdout_thread.start()
+ if self.__process.stderr:
+ self.__stderr_thread = threading.Thread(
+ name="stderr-thread",
+ target=self.__reader, args=(self.__collected_errdata,
+ self.__process.stderr))
+ self.__stderr_thread.setDaemon(True)
+ self.__stderr_thread.start()
+
+ def __del__(self, __killer=os.kill, __sigkill=signal.SIGKILL):
+ if self.__exitstatus is None:
+ __killer(self.pid(), __sigkill)
+
+ def pid(self):
+ """Return the process id of the process.
+ Note that if the process has died (and successfully been waited
+ for), that process id may have been re-used by the operating
+ system.
+ """
+ return self.__process.pid
+
+ def kill(self, signal):
+ """Send a signal to the process.
+ Raises OSError, with errno set to ECHILD, if the process is no
+ longer running.
+ """
+ if self.__exitstatus is not None:
+ # Throwing ECHILD is perhaps not the most kosher thing to do...
+ # ESRCH might be considered more proper.
+ raise OSError(errno.ECHILD, os.strerror(errno.ECHILD))
+ os.kill(self.pid(), signal)
+
+ def wait(self, flags=0):
+ """Return the process' termination status.
+
+ If bitmask parameter 'flags' contains os.WNOHANG, wait() will
+ return None if the process hasn't terminated. Otherwise it
+ will wait until the process dies.
+
+ It is permitted to call wait() several times, even after it
+ has succeeded; the Process instance will remember the exit
+ status from the first successful call, and return that on
+ subsequent calls.
+ """
+ if self.__exitstatus is not None:
+ return self.__exitstatus
+ pid,exitstatus = os.waitpid(self.pid(), flags)
+ if pid == 0:
+ return None
+ if os.WIFEXITED(exitstatus) or os.WIFSIGNALED(exitstatus):
+ self.__exitstatus = exitstatus
+ # If the process has stopped, we have to make sure to stop
+ # our threads. The reader threads will stop automatically
+ # (assuming the process hasn't forked), but the feeder thread
+ # must be signalled to stop.
+ if self.__process.stdin:
+ self.closeinput()
+ # We must wait for the reader threads to finish, so that we
+ # can guarantee that all the output from the subprocess is
+ # available to the .read*() methods.
+ # And by the way, it is the responsibility of the reader threads
+ # to close the pipes from the subprocess, not our.
+ if self.__process.stdout:
+ self.__stdout_thread.join()
+ if self.__process.stderr:
+ self.__stderr_thread.join()
+ return exitstatus
+
+ def terminate(self, graceperiod=1):
+ """Terminate the process, with escalating force as needed.
+ First try gently, but increase the force if it doesn't respond
+ to persuassion. The levels tried are, in order:
+ - close the standard input of the process, so it gets an EOF.
+ - send SIGTERM to the process.
+ - send SIGKILL to the process.
+ terminate() waits up to GRACEPERIOD seconds (default 1) before
+ escalating the level of force. As there are three levels, a total
+ of (3-1)*GRACEPERIOD is allowed before the process is SIGKILL:ed.
+ GRACEPERIOD must be an integer, and must be at least 1.
+ If the process was started with stdin not set to PIPE, the
+ first level (closing stdin) is skipped.
+ """
+ if self.__process.stdin:
+ # This is rather meaningless when stdin != PIPE.
+ self.closeinput()
+ try:
+ return with_timeout(graceperiod, self.wait)
+ except Timeout:
+ pass
+
+ self.kill(signal.SIGTERM)
+ try:
+ return with_timeout(graceperiod, self.wait)
+ except Timeout:
+ pass
+
+ self.kill(signal.SIGKILL)
+ return self.wait()
+
+ def __reader(self, collector, source):
+ """Read data from source until EOF, adding it to collector.
+ """
+ while True:
+ data = os.read(source.fileno(), 65536)
+ self.__lock.acquire()
+ collector.append(data)
+ self.__lock.release()
+ if data == "":
+ source.close()
+ break
+ return
+
+ def __feeder(self, pending, drain):
+ """Feed data from the list pending to the file drain.
+ """
+ while True:
+ self.__inputsem.acquire()
+ self.__lock.acquire()
+ if not pending and self.__quit:
+ drain.close()
+ self.__lock.release()
+ break
+ data = pending.pop(0)
+ self.__lock.release()
+ drain.write(data)
+
+ def read(self):
+ """Read data written by the process to its standard output.
+ """
+ self.__lock.acquire()
+ outdata = "".join(self.__collected_outdata)
+ del self.__collected_outdata[:]
+ self.__lock.release()
+ return outdata
+
+ def readerr(self):
+ """Read data written by the process to its standard error.
+ """
+ self.__lock.acquire()
+ errdata = "".join(self.__collected_errdata)
+ del self.__collected_errdata[:]
+ self.__lock.release()
+ return errdata
+
+ def readboth(self):
+ """Read data written by the process to its standard output and error.
+ Return value is a two-tuple ( stdout-data, stderr-data ).
+
+ WARNING! The name of this method is ugly, and may change in
+ future versions!
+ """
+ self.__lock.acquire()
+ outdata = "".join(self.__collected_outdata)
+ del self.__collected_outdata[:]
+ errdata = "".join(self.__collected_errdata)
+ del self.__collected_errdata[:]
+ self.__lock.release()
+ return outdata,errdata
+
+ def _peek(self):
+ self.__lock.acquire()
+ output = "".join(self.__collected_outdata)
+ error = "".join(self.__collected_errdata)
+ self.__lock.release()
+ return output,error
+
+ def write(self, data):
+ """Send data to a process's standard input.
+ """
+ if self.__process.stdin is None:
+ raise ValueError("Writing to process with stdin not a pipe")
+ self.__lock.acquire()
+ self.__pending_input.append(data)
+ self.__inputsem.release()
+ self.__lock.release()
+
+ def closeinput(self):
+ """Close the standard input of a process, so it receives EOF.
+ """
+ self.__lock.acquire()
+ self.__quit = True
+ self.__inputsem.release()
+ self.__lock.release()
View
87 orochi/client.py
@@ -3,16 +3,26 @@
import os
import cmd
+import time
from string import Template
from textwrap import TextWrapper
+try:
+ from shlex import quote
+except ImportError: # Python < 3.3
+ from pipes import quote
from .api import EightTracksAPI
+from .asyncproc import Process
+
+
+LOADFILE_TIMEOUT = 6
class CmdExitMixin(object):
"""A mixin for a Cmd instance that provides the exit and quit command."""
def do_exit(self, s):
+ print('Goodbye.')
return True
def help_exit(self):
@@ -36,6 +46,7 @@ class Client(CmdExitMixin, cmd.Cmd, object):
def preloop(self):
self.api = EightTracksAPI()
self.mix_ids = {}
+ self.volume = 100
return super(Client, self).preloop()
def precmd(self, line):
@@ -75,12 +86,11 @@ def do_play(self, s):
try:
mix_id = self.mix_ids[int(s)]
except ValueError:
- print('Invalid mix number. Please run a search first and then '
+ print('*** Invalid mix number: Please run a search first and then '
'specify a mix number to play.')
except KeyError:
- print('Mix with number {i} not found. Did you run a search yet?'.format(i=s))
+ print('*** Mix with number {i} not found: Did you run a search yet?'.format(i=s))
else:
- self.api.play_mix(mix_id)
i = PlayCommand(mix_id, self)
i.prompt = '{0}:{1})> '.format(self.prompt[:-3], mix_id)
i.cmdloop()
@@ -94,29 +104,96 @@ class PlayCommand(cmd.Cmd, object):
def __init__(self, mix_id, parent_cmd, *args, **kwargs):
self.mix_id = mix_id
+ self.parent_cmd = parent_cmd
self.api = parent_cmd.api
+
r = super(PlayCommand, self).__init__(*args, **kwargs)
+
+ # Initialize mplayer slave session with line buffer
+ self.p = Process(['mplayer', '-slave', '-quiet', '-idle'], bufsize=1)
+
+ # Play first track at max volume
+ self.status = self.api.play_mix(mix_id)
+ self._play(self.status['track']['url'])
+ self.p.write('volume {} 1\n'.format(self.parent_cmd.volume))
self.do_status('')
+
return r
def emptyline(self):
"""Don't repeat last command on empty line."""
pass
+ def _play(self, url):
+ """Play the specified file using mplayer's ``loadfile`` and wait for
+ the command to finish.
+
+ Args:
+ url:
+ The URL of the file to play.
+
+ Raises:
+ RuntimeError:
+ Raised if the loadfile command didn't return inside
+ `LOADFILE_TIMEOUT` seconds. This is done by checking the
+ subprocess' stdout for `Starting playback...`. The second
+ argument to RuntimeError is mplayer's stdout.
+
+ """
+ if url.startswith('https'):
+ url = 'http' + url[5:]
+ self.p.write('loadfile {}\n'.format(quote(url)))
+
+ # Wait for loadfile command to finish
+ start = time.time()
+ while 1:
+ if self.p.read().endswith('Starting playback...\n'):
+ break
+ if time.time() - start > LOADFILE_TIMEOUT:
+ raise RuntimeError("Playback didn't start inside {}s. ".format(LOADFILE_TIMEOUT) +
+ "Something must have gone wrong.", self.p.readerr())
+ time.sleep(0.05)
+
+ def do_pause(self, s):
+ self.p.write('pause\n')
+
+ def help_pause(self):
+ print('Pause or resume the playback.')
+
def do_stop(self, s):
print('Stopping playback...')
+ self.p.write('stop\n')
+ self.p.terminate()
return True
def help_stop(self):
print('Stop the playback and exit play mode.')
+ def do_volume(self, s):
+ try:
+ vol = int(s)
+ assert 0 <= vol <= 100
+ except (ValueError, AssertionError):
+ print('*** ValueError: Argument must be a number between 0 and 100.')
+ else:
+ self.parent_cmd.volume = vol
+ self.p.write('volume {} 1\n'.format(vol))
+
+ def help_volume(self):
+ print('Syntax: volume <amount>')
+ print('Change playback volume. The argument must be a number between 0 and 100.')
+
def do_status(self, s):
- print('Now playing "{0[name]}" by "{0[performer]}".'.format(self.api.current_track))
+ track = self.status['track']
+ print('Now playing "{0[name]}" by "{0[performer]}".'.format(track))
def help_status(self):
- print('Syntax: status')
print('Show the status of the currently playing song.')
+ do_EOF = do_stop
+ help_EOF = help_stop
+
+
if __name__ == '__main__':
client = Client()
client.cmdloop()
View
1  requirements.txt
@@ -1,2 +1 @@
requests==1.2.0
-mpylayer==0.2a1

No commit comments for this range

Something went wrong with that request. Please try again.