postlund / pyatv

A client library for Apple TV and AirPlay devices
https://pyatv.dev
MIT License
893 stars 99 forks source link

Can't figure out the push api #107

Closed psyciknz closed 5 years ago

psyciknz commented 7 years ago

I've tried your example, but I'm no experienced with python enough to know if it's missing any thing.

I've got the following: import pyatv import asyncio

class PushListener:

    def playstatus_update(self, updater, playstatus):
        # Currently playing in playstatus
        print(playstatus)

    @staticmethod
    def playstatus_error(updater, exception):
        # Error in exception
        updater.start(initial_delay=10)

@asyncio.coroutine
def listen_to_updates(self):
    listener = PushListener()
    self.atv.push_updater.listener = listener
    self.atv.push_updater.start()

if __name__ == "__main__":
    listen_to_updates()

which now seems to run, as without the if main it'd get a syntax error. But then it just drops out. I assume I can just put in a loop. Would I be correct in that there's not loop/timer in the push_updater, or listener code?

postlund commented 7 years ago

The push API is implemented as a long lived future and dispatched on the event loop. So it is basically runs "in the background" by never finishing. My example in the documentation is actually quite wrong and misleading now that I look at it. Not sure what I was thinking. I can't really cook up a correct solution right now, but something like this should work in theory:

import sys
import asyncio
import pyatv

LOOP = asyncio.get_event_loop()

class PushListener:

    def playstatus_update(self, updater, playstatus):
        # Currently playing in playstatus
        print(playstatus)

    @staticmethod
    def playstatus_error(updater, exception):
        # Error in exception
        updater.start(initial_delay=10)

@asyncio.coroutine
def print_what_is_playing(loop):
    """Find a device and print what is playing."""
    print('Discovering devices on network...')
    atvs = yield from pyatv.scan_for_apple_tvs(loop, timeout=5)

    if not atvs:
        print('no device found', file=sys.stderr)
        return

    print('Connecting to {}'.format(atvs[0].address))
    atv = pyatv.connect_to_apple_tv(atvs[0], loop)

    try:
        listener = PushListener()
        atv.push_updater.listener = listener
        atv.push_updater.start()
        yield from asyncio.sleep(60)
    finally:
        # Do not forget to logout
        yield from atv.logout()

if __name__ == '__main__':
    LOOP.run_until_complete(print_what_is_playing(LOOP))

It should subscribe to updates, sleep for one minute and then shutdown. As I said, not sure if it works but maybe you can use it as a template and make some adjustments?

psyciknz commented 7 years ago

Bit rough and ready but seems to drop into a loop and puts out a new entry on each video.

import pyatv import asyncio import time

class PushListener:

def playstatus_update(self, updater, playstatus):
    # Currently playing in playstatus
    print(playstatus)

@staticmethod
def playstatus_error(updater, exception):
    # Error in exception
    print("error")
    updater.start(initial_delay=10)

@asyncio.coroutine def print_what_is_playing(): print("Discovering devices on network...") atvs = yield from pyatv.scan_for_apple_tvs(loop, timeout=5)

if not atvs:
   print('No device found', file=sys.stderr)
   return

print('Connecting to {}'.format(atvs[0].address))
atv = pyatv.connect_to_apple_tv(atvs[0], loop)

try:
    listener = PushListener()
    atv.push_updater.listener = listener
    atv.push_updater.start()
    while True:
        yield from asyncio.sleep(60)
        print('Aftter yield')
finally:
    # Do not forget to logout
    print('In Finally')
    yield from atv.logout()

if name == "main": loop = asyncio.get_event_loop() asyncio.async(print_what_is_playing())

loop.call_soon()

#    asyncio.gather(print_what_is_playing())
#)
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass
finally:
    print('step: loop.close()')
    loop.close()

I didn't run it for long enough in one video to see it it kept posting the video name.

On 1 September 2017 at 19:12, Pierre Ståhl notifications@github.com wrote:

The push API is implemented as a long lived future and dispatched on the event loop. So it is basically runs "in the background" by never finishing. My example in the documentation is actually quite wrong and misleading now that I look at it. Not sure what I was thinking. I can't really cook up a correct solution right now, but something like this should work in theory:

import sys import asyncio import pyatv

LOOP = asyncio.get_event_loop()

class PushListener:

def playstatus_update(self, updater, playstatus):
    # Currently playing in playstatus
    print(playstatus)

@staticmethod
def playstatus_error(updater, exception):
    # Error in exception
    updater.start(initial_delay=10)

@asyncio.coroutine def print_what_is_playing(loop): """Find a device and print what is playing.""" print('Discovering devices on network...') atvs = yield from pyatv.scan_for_apple_tvs(loop, timeout=5)

if not atvs:
    print('no device found', file=sys.stderr)
    return

print('Connecting to {}'.format(atvs[0].address))
atv = pyatv.connect_to_apple_tv(atvs[0], loop)

try:
    listener = PushListener()
    atv.push_updater.listener = listener
    atv.push_updater.start()
    yield from asyncio.sleep(60)
finally:
    # Do not forget to logout
    yield from atv.logout()

if name == 'main': LOOP.run_until_complete(print_what_is_playing(LOOP))

It should subscribe to updates, sleep for one minute and then shutdown. As I said, not sure if it works but maybe you can use it as a template and make some adjustments?

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/postlund/pyatv/issues/107#issuecomment-326507840, or mute the thread https://github.com/notifications/unsubscribe-auth/AFGkc6kJMIHBMq5xfv3Un2MG3wvsd2nlks5sd65ZgaJpZM4PJxRD .

postlund commented 7 years ago

Yeah, that looks about right. Unless you have any other coroutine you want to run, you can just do:

yield from atv.push_updater.start()

and that should basically run forever.

psyciknz commented 7 years ago

Cool, well done on the code.

So do you know if you can get the app that is playing the media? Looks like the quality of the title parm is pretty limited, ie episode name - will be a pain for me to try and pull from thetvdb.

I'm actually looking if I can push what's playing into trakt.tv - instead of each app doing it.

On 1 September 2017 at 20:43, Pierre Ståhl notifications@github.com wrote:

Yeah, that looks about right. Unless you have any other coroutine you want to run, you can just do:

yield from atv.push_updater.start()

and that should basically run forever.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/postlund/pyatv/issues/107#issuecomment-326525509, or mute the thread https://github.com/notifications/unsubscribe-auth/AFGkc8SyMQwQd0aH4HBRASeXPRjDwCdNks5sd8PCgaJpZM4PJxRD .

postlund commented 7 years ago

Top! I'll try to update the docs when I get some time and add a proper example as well.

Currently, no. Only the DMAP/DAAP protocol is implemented (mainly used by ATV <= gen 3), and the app concept used in ATV 4 (and later) is not backported to the protocol suite. Instead another protocol, MediaRemote, is used and that is not yet implemented in pyatv. Not sure if it's possible to extract any information on what apps are running or their metadata, but maybe. I will be working with @jeanregisser and hopefully have support for the MediaRemote protocol soon. But I cannot guarantee any additional metadata because of that. I can of course only provide what the device is exposing.

postlund commented 7 years ago

I want to give a short update here... There is a field in the playing metadata, ceSD, that seems to contain app-specific data. I started s01e08 of "Wet Hot American Summer: Ten Years Later" in Netflix on my ATV3 and got this:

$ atvremote -a --debug playing
...
DEBUG: _get_request: cmst: [container, dmcp.playstatus]
  mstt: 200 [uint, dmap.status]
  cmsr: 185 [uint, dmcp.serverrevision]
  caps: 4 [uint, dacp.playstatus]
  cash: 0 [uint, dacp.shufflestate]
  carp: 0 [uint, dacp.repeatstate]
  cafs: 0 [uint, dacp.fullscreen]
  cavs: 0 [uint, dacp.visualizer]
  cavc: False [bool, dacp.volumecontrollable]
  caas: 2 [uint, dacp.albumshuffle]
  caar: 6 [uint, dacp.albumrepeat]
  cafe: False [bool, dacp.fullscreenenabled]
  cave: False [bool, dacp.dacpvisualizerenabled]
  ceQA: 0 [uint, unknown tag]
  cann: End Summer Night's Dream [str, daap.nowplayingtrack]
  ceSD: b'bplist00\xd1\x01\x02RidX80121154\x08\x0b\x0e\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x17' [raw, unknown tag]
  cmmk: 2 [uint, dmcp.mediakind]
  casc: 1 [uint, unknown tag]
  caks: 6 [uint, unknown tag]
  cant: 1647627 [uint, dacp.remainingtime]
  cast: 1658030 [uint, dacp.tracklength]
  casu: 0 [uint, dacp.su]

The data is stored in a binary plist and can be decoded like this:

$ python -c "import plistlib; print(plistlib.loads(b'bplist00\xd1\x01\x02RidX80121154\x08\x0b\x0e\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x17'))"
{'id': '80121154'}

Logging into Netflix to my browser and starting the same episode gives me this URL:

https://www.netflix.com/watch/80121154?lots_of_other_metadata_here

Similarly, doing the same thing while watching a youtube video:

$ python -c "import plistlib; print(plistlib.loads(b'bplist00\xd1\x01\x02Rid[_QdPW8JrYzQ\x08\x0b\x0e\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a'))"
{'id': '_QdPW8JrYzQ'}

Which happens to correspond to https://www.youtube.com/watch?v=_QdPW8JrYzQ (which is the correct video).

This information, at least in theory, makes it possible to extract the actual metadata from somewhere else other than the device. For youtube there are libraries but Netflix does not have any open API, so it might be trickier. There are other sources out there, maybe one of those works. But... biggest issue is that I haven't found any metadata about which app the ID belongs to. So, yeah, that's an issue.

But a question to you @psyciknz: Can you try watching something on Netflix or Youtube on your ATV4 and run "atvremote -a --debug playing" and paste the result (as I did above)? It would be interesting to see if it contains the same data.

psyciknz commented 7 years ago

I've been meaning to ask about that, I did a pip install pyatv, but there's not atvremote on my system as far as I can tell.

postlund commented 7 years ago

It should be available "everywhere" AFAIK. But I would recommend that you set up a venv and install pyatv in that. Something like

virtualenv -p python3.4 venv
source venv/bin/activate
pip install pyatv
atvremote ...

Switch to the python version you use (but at least 3.4).

psyciknz commented 7 years ago

Ok, found it. Was under some local directory. I'll give that a go tonight

psyciknz commented 7 years ago

Plex is great (but unnecessary since it goes to trakt already), but includes the show title.

DEBUG: _login_request: mlog: [container, dmap.loginresponse]
  mstt: 200 [uint, dmap.status]
  mlid: 17 [uint, dmap.sessionid]

INFO: Logged in and got session id 17
DEBUG: GET URL: http://192.168.10.36:3689/ctrl-int/1/playstatusupdate?session-id=17&revision-number=0
DEBUG: _get_request: cmst: [container, dmcp.playstatus]
  mstt: 200 [uint, dmap.status]
  cmsr: 461 [uint, dmcp.serverrevision]
  cafs: 0 [uint, dacp.fullscreen]
  cafe: False [bool, dacp.fullscreenenabled]
  cave: False [bool, dacp.dacpvisualizerenabled]
  cavs: 0 [uint, dacp.visualizer]
  cant: 3310201 [uint, dacp.remainingtime]
  cast: 3336216 [uint, dacp.tracklength]
  caps: 4 [uint, dacp.playstatus]
  cash: 0 [uint, dacp.shufflestate]
  carp: 0 [uint, dacp.repeatstate]
  caar: 6 [uint, dacp.albumrepeat]
  caas: 2 [uint, dacp.albumshuffle]
  cann: S1 • E4: Cripples, Bastards, and Broken Things [str, daap.nowplayingtrack]
  cana: Game of Thrones [str, daap.nowplayingartist]
  canl: Season 1 [str, daap.nowplayingalbum]
  caks: 1 [uint, unknown tag]
  casc: 1 [uint, unknown tag]
  cavc: True [bool, dacp.volumecontrollable]
  casu: 0 [uint, dacp.su]

Media type: Music
Play state: Playing
     Title: S1 • E4: Cripples, Bastards, and Broken Things
    Artist: Game of Thrones
     Album: Season 1
  Position: 26/3336s (0.8%)
    Repeat: Off
   Shuffle: False
psyciknz commented 7 years ago

Ill try a streaming service next.

postlund commented 7 years ago

Did you get any result?

postlund commented 7 years ago

I extended the parsing code (https://github.com/postlund/pyatv/commit/003ff3dbf7316630c07c2894614ef8ff72aa7c7b) to support ceSD, so it will appear in a nicer format now on:

DEBUG: _get_request: cmst: [container, dmcp.playstatus]
  mstt: 200 [uint, dmap.status]
  cmsr: 290 [uint, dmcp.serverrevision]
  caps: 4 [uint, dacp.playstatus]
  cash: 0 [uint, dacp.shufflestate]
  carp: 0 [uint, dacp.repeatstate]
  cafs: 0 [uint, dacp.fullscreen]
  cavs: 0 [uint, dacp.visualizer]
  cavc: False [bool, dacp.volumecontrollable]
  caas: 2 [uint, dacp.albumshuffle]
  caar: 6 [uint, dacp.albumrepeat]
  cafe: False [bool, dacp.fullscreenenabled]
  cave: False [bool, dacp.dacpvisualizerenabled]
  ceQA: 0 [uint, unknown tag]
  cann: 30 Minutes or Less [str, daap.nowplayingtrack]
  ceSD: {'id': '70167074'} [bplist, playing metadata]
  cmmk: 2 [uint, dmcp.mediakind]
  casc: 1 [uint, unknown tag]
  caks: 6 [uint, unknown tag]
  cant: 2124216 [uint, dacp.remainingtime]
  cast: 4981017 [uint, dacp.tracklength]
  casu: 0 [uint, dacp.su]

Media type: Music
Play state: Playing
     Title: 30 Minutes or Less
  Position: 2857/4981s (57.4%)
    Repeat: Off
   Shuffle: False
psyciknz commented 7 years ago

I don't have a casd on this service DEBUG: GET URL: http://192.168.10.36:3689/ctrl-int/1/playstatusupdate?session-id=20&revision-number=0


From: Pierre Ståhl notifications@github.com Sent: Friday, September 8, 2017 7:44:45 AM To: postlund/pyatv Cc: psyciknz; Mention Subject: Re: [postlund/pyatv] Can't figure out the push api (#107)

I extended the parsing code (003ff3dhttps://github.com/postlund/pyatv/commit/003ff3dbf7316630c07c2894614ef8ff72aa7c7b) to support ceSD, so it will appear in a nicer format now on:

DEBUG: _get_request: cmst: [container, dmcp.playstatus] mstt: 200 [uint, dmap.status] cmsr: 290 [uint, dmcp.serverrevision] caps: 4 [uint, dacp.playstatus] cash: 0 [uint, dacp.shufflestate] carp: 0 [uint, dacp.repeatstate] cafs: 0 [uint, dacp.fullscreen] cavs: 0 [uint, dacp.visualizer] cavc: False [bool, dacp.volumecontrollable] caas: 2 [uint, dacp.albumshuffle] caar: 6 [uint, dacp.albumrepeat] cafe: False [bool, dacp.fullscreenenabled] cave: False [bool, dacp.dacpvisualizerenabled] ceQA: 0 [uint, unknown tag] cann: 30 Minutes or Less [str, daap.nowplayingtrack] ceSD: {'id': '70167074'} [bplist, playing metadata] cmmk: 2 [uint, dmcp.mediakind] casc: 1 [uint, unknown tag] caks: 6 [uint, unknown tag] cant: 2124216 [uint, dacp.remainingtime] cast: 4981017 [uint, dacp.tracklength] casu: 0 [uint, dacp.su]

Media type: Music Play state: Playing Title: 30 Minutes or Less Position: 2857/4981s (57.4%) Repeat: Off Shuffle: False

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/postlund/pyatv/issues/107#issuecomment-327903730, or mute the threadhttps://github.com/notifications/unsubscribe-auth/AFGkcw8OYaniKFcU3yhf4kRwts2KcUucks5sgEesgaJpZM4PJxRD.

postlund commented 7 years ago

I guess that tvOS maybe skips that one and fills in the other fields in a better manner instead. Good to know!

postlund commented 6 years ago

Hi,

playstatus_error doesn't have to be static, but if you remove the @staticmethod decorator you have to add self as first argument in the argument list to the method.

/Pierre

On Tue, Apr 10, 2018 at 8:40 PM, Serge Wagener notifications@github.com wrote:

Hi, I don't want to hijack this thread / issue, but my problem enters into this issues title. I have the callbacks in the same class where i use pyatv, so i set the callbacks to self like so:

def playstatus_update(self, updater, playstatus):
    """
    Callback for pyatv, is called on currently playing update
    """
    self.update_playing(playstatus)
    self._loop.create_task(self.update_artwork())

@staticmethod
def playstatus_error(updater, exception):
    """
    Callback for pyatv, is called on push update error
    """
    print("PushListener error: {0}".format(exception))
    #self.logger.warning("PushListener error: {0}".format(exception))
    updater.start(initial_delay=10)

def _push_listener_thread_worker(self):
    """
    Thread to run asyncio loop. This avoids blocking the main plugin thread
    """
    asyncio.set_event_loop(self._loop)
    self._atv.push_updater.listener = self
    self._atv.push_updater.start()
    while self._loop.is_running():
        pass
    try:
        self.logger.debug("Loop running")
        while True:
            self._loop.run_until_complete(asyncio.sleep(0.25))
    except:
        self.logger.debug('*** Error in loop.run_forever()')
        #raise

Push updates work like a charm (meanwhile). I can even insert remote_control tasks into the running loop.

The only thing i can't get to work is the playstatus_error callback. When i ctrl-c my application i guess the following error is when it tries to call the playstatus_error:

2018-04-10 20:00:40 ERROR base_events ATV listener Exception in callback None() handle: -- base_events.py:default_exception_handler:1258 Traceback (most recent call last): File "/usr/lib/python3.5/asyncio/events.py", line 126, in _run self._callback(*self._args) TypeError: 'NoneType' object is not callable

Why does it need to be a static method, and is it possible to get it to work like this, or must it be in a separate class ?

— You are receiving this because you were assigned. Reply to this email directly, view it on GitHub https://github.com/postlund/pyatv/issues/107#issuecomment-380205419, or mute the thread https://github.com/notifications/unsubscribe-auth/AFtH41Lg3sInX2vxJ2RQEChtucN5EY2Dks5tnPyWgaJpZM4PJxRD .

-- Mvh, Pierre Ståhl

postlund commented 5 years ago

I will close this as it will be part of the new documentation in #205.