750 lines
22 KiB
Python
750 lines
22 KiB
Python
import json
|
|
import threading
|
|
import time
|
|
|
|
from platypush.context import get_bus
|
|
from platypush.plugins import action
|
|
from platypush.plugins.media import MediaPlugin, PlayerState
|
|
from platypush.message.event.media import (
|
|
MediaPlayEvent,
|
|
MediaPauseEvent,
|
|
MediaStopEvent,
|
|
MediaSeekEvent,
|
|
MediaVolumeChangedEvent,
|
|
)
|
|
|
|
|
|
class MediaKodiPlugin(MediaPlugin):
|
|
"""
|
|
Plugin to interact with a Kodi media player instance
|
|
"""
|
|
|
|
# noinspection HttpUrlsUsage
|
|
def __init__(
|
|
self,
|
|
host,
|
|
http_port=8080,
|
|
websocket_port=9090,
|
|
username=None,
|
|
password=None,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
:param host: Kodi host name or IP
|
|
:type host: str
|
|
|
|
:param http_port: Kodi JSON RPC web port. Remember to enable "Allow remote control via HTTP"
|
|
in Kodi service settings -> advanced configuration and "Allow remote control from applications"
|
|
on this system and, optionally, on other systems if the Kodi server is on another machine
|
|
:type http_port: int
|
|
|
|
:param websocket_port: Kodi JSON RPC websocket port, used to receive player events
|
|
:type websocket_port: int
|
|
|
|
:param username: Kodi username (optional)
|
|
:type username: str
|
|
|
|
:param password: Kodi password (optional)
|
|
:type password: str
|
|
"""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.host = host
|
|
self.http_port = http_port
|
|
self.websocket_port = websocket_port
|
|
self.url = 'http://{}:{}/jsonrpc'.format(host, http_port)
|
|
self.websocket_url = 'ws://{}:{}/jsonrpc'.format(host, websocket_port)
|
|
self.username = username
|
|
self.password = password
|
|
self._ws = None
|
|
threading.Thread(target=self._websocket_thread()).start()
|
|
|
|
def _get_kodi(self):
|
|
from kodijson import Kodi
|
|
|
|
args = [self.url]
|
|
if self.username:
|
|
args += [self.username]
|
|
if self.password:
|
|
args += [self.password]
|
|
|
|
return Kodi(*args)
|
|
|
|
def _get_player_id(self):
|
|
kodi = self._get_kodi()
|
|
players = kodi.Player.GetActivePlayers().get('result', [])
|
|
if not players:
|
|
return None
|
|
|
|
return players.pop().get('playerid')
|
|
|
|
def _websocket_thread(self):
|
|
"""
|
|
Initialize the websocket JSON RPC interface, if available, to receive player notifications
|
|
"""
|
|
|
|
def thread_hndl():
|
|
try:
|
|
import websocket
|
|
except ImportError:
|
|
self.logger.warning(
|
|
'websocket-client is not installed, Kodi events will be disabled'
|
|
)
|
|
return
|
|
|
|
if not self._ws:
|
|
self._ws = websocket.WebSocketApp(
|
|
self.websocket_url,
|
|
on_message=self._on_ws_msg(),
|
|
on_error=self._on_ws_error(),
|
|
on_close=self._on_ws_close(),
|
|
)
|
|
|
|
self.logger.info('Kodi websocket interface for events started')
|
|
self._ws.run_forever()
|
|
|
|
return thread_hndl
|
|
|
|
def _post_event(self, evt_type, **evt):
|
|
bus = get_bus()
|
|
bus.post(evt_type(player=self.host, plugin='media.kodi', **evt))
|
|
|
|
def _on_ws_msg(self):
|
|
def hndl(*args):
|
|
msg = args[1] if len(args) > 1 else args[0]
|
|
self.logger.info("Received Kodi message: {}".format(msg))
|
|
msg = json.loads(msg)
|
|
method = msg.get('method')
|
|
|
|
if method == 'Player.OnPlay':
|
|
item = msg.get('params', {}).get('data', {}).get('item', {})
|
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
|
self._post_event(
|
|
MediaPlayEvent,
|
|
player_id=player.get('playerid'),
|
|
title=item.get('title'),
|
|
media_type=item.get('type'),
|
|
)
|
|
elif method == 'Player.OnPause':
|
|
item = msg.get('params', {}).get('data', {}).get('item', {})
|
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
|
self._post_event(
|
|
MediaPauseEvent,
|
|
player_id=player.get('playerid'),
|
|
title=item.get('title'),
|
|
media_type=item.get('type'),
|
|
)
|
|
elif method == 'Player.OnStop':
|
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
|
self._post_event(MediaStopEvent, player_id=player.get('playerid'))
|
|
elif method == 'Player.OnSeek':
|
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
|
position = self._time_obj_to_pos(player.get('seekoffset'))
|
|
self._post_event(
|
|
MediaSeekEvent, position=position, player_id=player.get('playerid')
|
|
)
|
|
elif method == 'Application.OnVolumeChanged':
|
|
volume = msg.get('params', {}).get('data', {}).get('volume')
|
|
self._post_event(MediaVolumeChangedEvent, volume=volume)
|
|
|
|
return hndl
|
|
|
|
def _on_ws_error(self):
|
|
def hndl(*args):
|
|
error = args[1] if len(args) > 1 else args[0]
|
|
self.logger.warning("Kodi websocket connection error: {}".format(error))
|
|
|
|
return hndl
|
|
|
|
def _on_ws_close(self):
|
|
def hndl(*_):
|
|
self._ws = None
|
|
self.logger.warning("Kodi websocket connection closed")
|
|
time.sleep(5)
|
|
self._websocket_thread()
|
|
|
|
return hndl
|
|
|
|
def _build_result(self, result):
|
|
status = self.status().output
|
|
status['result'] = result.get('result')
|
|
return status, result.get('error')
|
|
|
|
@action
|
|
def play(self, resource, *args, **kwargs):
|
|
"""
|
|
Open and play the specified file or URL
|
|
|
|
:param resource: URL or path to the media to be played
|
|
"""
|
|
|
|
youtube_id = self.get_youtube_id(resource)
|
|
if youtube_id:
|
|
try:
|
|
resource = self.get_youtube_url(youtube_id).output
|
|
except Exception as e:
|
|
self.logger.warning(
|
|
'youtube-dl error, falling back to Kodi YouTube plugin: {}'.format(
|
|
str(e)
|
|
)
|
|
)
|
|
resource = (
|
|
'plugin://plugin.video.youtube/?action=play_video&videoid='
|
|
+ youtube_id
|
|
)
|
|
|
|
if resource.startswith('file://'):
|
|
resource = resource[7:]
|
|
|
|
result = self._get_kodi().Player.Open(item={'file': resource})
|
|
if self.volume:
|
|
self.set_volume(volume=int(self.volume))
|
|
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def pause(self, player_id=None, *args, **kwargs):
|
|
"""
|
|
Play/pause the current media
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
result = self._get_kodi().Player.PlayPause(playerid=player_id)
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def get_active_players(self):
|
|
"""
|
|
Get the list of active players
|
|
"""
|
|
|
|
result = self._get_kodi().Player.GetActivePlayers()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def get_movies(self, *args, **kwargs):
|
|
"""
|
|
Get the list of movies on the Kodi server
|
|
"""
|
|
|
|
result = self._get_kodi().VideoLibrary.GetMovies()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def stop(self, player_id=None, *args, **kwargs):
|
|
"""
|
|
Stop the current media
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
result = self._get_kodi().Player.Stop(playerid=player_id)
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def notify(self, title, message, *args, **kwargs):
|
|
"""
|
|
Send a notification to the Kodi UI
|
|
"""
|
|
|
|
result = self._get_kodi().GUI.ShowNotification(title=title, message=message)
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def left(self, *args, **kwargs):
|
|
"""
|
|
Simulate a left input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Left()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def right(self, *args, **kwargs):
|
|
"""
|
|
Simulate a right input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Right()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def up(self, *args, **kwargs):
|
|
"""
|
|
Simulate an up input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Up()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def down(self, *args, **kwargs):
|
|
"""
|
|
Simulate a down input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Down()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def back_btn(self, *args, **kwargs):
|
|
"""
|
|
Simulate a back input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Back()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def select(self, *args, **kwargs):
|
|
"""
|
|
Simulate a select input event
|
|
"""
|
|
|
|
result = self._get_kodi().Input.Select()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def send_text(self, text, *args, **kwargs):
|
|
"""
|
|
Simulate a send_text input event
|
|
|
|
:param text: Text to send
|
|
:type text: str
|
|
"""
|
|
|
|
result = self._get_kodi().Input.SendText(text=text)
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def get_volume(self, *args, **kwargs):
|
|
result = self._get_kodi().Application.GetProperties(properties=['volume'])
|
|
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def volup(self, step=10.0, *args, **kwargs):
|
|
"""Volume up (default: +10%)"""
|
|
volume = (
|
|
self._get_kodi()
|
|
.Application.GetProperties(properties=['volume'])
|
|
.get('result', {})
|
|
.get('volume')
|
|
)
|
|
|
|
result = self._get_kodi().Application.SetVolume(
|
|
volume=int(min(volume + step, 100))
|
|
)
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def voldown(self, step=10.0, *args, **kwargs):
|
|
"""Volume down (default: -10%)"""
|
|
volume = (
|
|
self._get_kodi()
|
|
.Application.GetProperties(properties=['volume'])
|
|
.get('result', {})
|
|
.get('volume')
|
|
)
|
|
|
|
result = self._get_kodi().Application.SetVolume(
|
|
volume=int(max(volume - step, 0))
|
|
)
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def set_volume(self, volume, *args, **kwargs):
|
|
"""
|
|
Set the application volume
|
|
|
|
:param volume: Volume to set between 0 and 100
|
|
:type volume: int
|
|
"""
|
|
|
|
result = self._get_kodi().Application.SetVolume(volume=int(volume))
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def mute(self, *args, **kwargs):
|
|
"""
|
|
Mute/unmute the application
|
|
"""
|
|
|
|
muted = (
|
|
self._get_kodi()
|
|
.Application.GetProperties(properties=['muted'])
|
|
.get('result', {})
|
|
.get('muted')
|
|
)
|
|
|
|
result = self._get_kodi().Application.SetMute(mute=(not muted))
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def is_muted(self, *args, **kwargs):
|
|
"""
|
|
Return the muted status of the application
|
|
"""
|
|
|
|
result = self._get_kodi().Application.GetProperties(properties=['muted'])
|
|
return result.get('result')
|
|
|
|
@action
|
|
def scan_video_library(self, *args, **kwargs):
|
|
"""
|
|
Scan the video library
|
|
"""
|
|
|
|
result = self._get_kodi().VideoLibrary.Scan()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def scan_audio_library(self, *args, **kwargs):
|
|
"""
|
|
Scan the audio library
|
|
"""
|
|
|
|
result = self._get_kodi().AudioLibrary.Scan()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def clean_video_library(self, *args, **kwargs):
|
|
"""
|
|
Clean the video library
|
|
"""
|
|
|
|
result = self._get_kodi().VideoLibrary.Clean()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def clean_audio_library(self, *args, **kwargs):
|
|
"""
|
|
Clean the audio library
|
|
"""
|
|
|
|
result = self._get_kodi().AudioLibrary.Clean()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def quit(self, *args, **kwargs):
|
|
"""
|
|
Quit the application
|
|
"""
|
|
|
|
result = self._get_kodi().Application.Quit()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def get_songs(self, *args, **kwargs):
|
|
"""
|
|
Get the list of songs in the audio library
|
|
"""
|
|
|
|
result = self._get_kodi().Application.GetSongs()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def get_artists(self, *args, **kwargs):
|
|
"""
|
|
Get the list of artists in the audio library
|
|
"""
|
|
|
|
result = self._get_kodi().Application.GetArtists()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def get_albums(self, *args, **kwargs):
|
|
"""
|
|
Get the list of albums in the audio library
|
|
"""
|
|
|
|
result = self._get_kodi().Application.GetAlbums()
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def fullscreen(self, *args, **kwargs):
|
|
"""
|
|
Set/unset fullscreen mode
|
|
"""
|
|
|
|
fullscreen = (
|
|
self._get_kodi()
|
|
.GUI.GetProperties(properties=['fullscreen'])
|
|
.get('result', {})
|
|
.get('fullscreen')
|
|
)
|
|
|
|
result = self._get_kodi().GUI.SetFullscreen(fullscreen=(not fullscreen))
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def shuffle(self, player_id=None, shuffle=None, *args, **kwargs):
|
|
"""
|
|
Set/unset shuffle mode
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
if shuffle is None:
|
|
shuffle = (
|
|
self._get_kodi()
|
|
.Player.GetProperties(playerid=player_id, properties=['shuffled'])
|
|
.get('result', {})
|
|
.get('shuffled')
|
|
)
|
|
|
|
result = self._get_kodi().Player.SetShuffle(
|
|
playerid=player_id, shuffle=(not shuffle)
|
|
)
|
|
return result.get('result'), result.get('error')
|
|
|
|
@action
|
|
def repeat(self, player_id=None, repeat=None, *args, **kwargs):
|
|
"""
|
|
Set/unset repeat mode
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
if repeat is None:
|
|
repeat = (
|
|
self._get_kodi()
|
|
.Player.GetProperties(playerid=player_id, properties=['repeat'])
|
|
.get('result', {})
|
|
.get('repeat')
|
|
)
|
|
|
|
result = self._get_kodi().Player.SetRepeat(
|
|
playerid=player_id, repeat='off' if repeat in ('one', 'all') else 'off'
|
|
)
|
|
|
|
return result.get('result'), result.get('error')
|
|
|
|
@staticmethod
|
|
def _time_pos_to_obj(t):
|
|
hours = int(t / 3600)
|
|
minutes = int((t - hours * 3600) / 60)
|
|
seconds = t - hours * 3600 - minutes * 60
|
|
milliseconds = t - int(t)
|
|
|
|
return {
|
|
'hours': hours,
|
|
'minutes': minutes,
|
|
'seconds': seconds,
|
|
'milliseconds': milliseconds,
|
|
}
|
|
|
|
@staticmethod
|
|
def _time_obj_to_pos(t):
|
|
return (
|
|
t.get('hours', 0) * 3600
|
|
+ t.get('minutes', 0) * 60
|
|
+ t.get('seconds', 0)
|
|
+ t.get('milliseconds', 0) / 1000
|
|
)
|
|
|
|
@action
|
|
def seek(self, position, player_id=None, *args, **kwargs):
|
|
"""
|
|
Move to the specified time position in seconds
|
|
|
|
:param position: Seek time in seconds
|
|
:type position: float
|
|
:param player_id: ID of the target player (default: configured/current player).
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
position = self._time_pos_to_obj(position)
|
|
result = self._get_kodi().Player.Seek(playerid=player_id, value=position)
|
|
return self._build_result(result)
|
|
|
|
@action
|
|
def set_position(self, position, player_id=None, *args, **kwargs):
|
|
"""
|
|
Move to the specified time position in seconds
|
|
|
|
:param position: Seek time in seconds
|
|
:type position: float
|
|
:param player_id: ID of the target player (default: configured/current player).
|
|
"""
|
|
return self.seek(position=position, player_id=player_id, *args, **kwargs)
|
|
|
|
@action
|
|
def back(self, offset=30, player_id=None, *args, **kwargs):
|
|
"""
|
|
Move the player execution backward by delta_seconds
|
|
|
|
:param offset: Backward seek duration (default: 30 seconds)
|
|
:type offset: float
|
|
:param player_id: ID of the target player (default: configured/current player).
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
position = (
|
|
self._get_kodi()
|
|
.Player.GetProperties(playerid=player_id, properties=['time'])
|
|
.get('result', {})
|
|
.get('time', {})
|
|
)
|
|
|
|
position = self._time_obj_to_pos(position)
|
|
return self.seek(player_id=player_id, position=position)
|
|
|
|
@action
|
|
def forward(self, offset=30, player_id=None, *args, **kwargs):
|
|
"""
|
|
Move the player execution forward by delta_seconds
|
|
|
|
:param offset: Forward seek duration (default: 30 seconds)
|
|
:type offset: float
|
|
:param player_id: ID of the target player (default: configured/current player).
|
|
"""
|
|
|
|
if player_id is None:
|
|
player_id = self._get_player_id()
|
|
if player_id is None:
|
|
return None, 'No active players found'
|
|
|
|
position = (
|
|
self._get_kodi()
|
|
.Player.GetProperties(playerid=player_id, properties=['time'])
|
|
.get('result', {})
|
|
.get('time', {})
|
|
)
|
|
|
|
position = self._time_obj_to_pos(position)
|
|
return self.seek(player_id=player_id, position=position)
|
|
|
|
@action
|
|
def status(self, player_id=None):
|
|
media_props = {
|
|
'album': 'album',
|
|
'artist': 'artist',
|
|
'duration': 'duration',
|
|
'fanart': 'fanart',
|
|
'file': 'file',
|
|
'season': 'season',
|
|
'showtitle': 'showtitle',
|
|
'streamdetails': 'streamdetails',
|
|
'thumbnail': 'thumbnail',
|
|
'title': 'title',
|
|
'tvshowid': 'tvshowid',
|
|
'url': 'file',
|
|
}
|
|
|
|
app_props = {
|
|
'volume': 'volume',
|
|
'mute': 'muted',
|
|
}
|
|
|
|
player_props = {
|
|
"duration": "totaltime",
|
|
"position": "time",
|
|
"repeat": "repeat",
|
|
"seekable": "canseek",
|
|
'speed': 'speed',
|
|
"subtitles": "subtitles",
|
|
}
|
|
|
|
ret = {'state': PlayerState.IDLE.value}
|
|
|
|
try:
|
|
kodi = self._get_kodi()
|
|
players = kodi.Player.GetActivePlayers().get('result', [])
|
|
except Exception as e:
|
|
self.logger.debug(f'Could not get active players: {str(e)}')
|
|
return ret
|
|
|
|
ret['state'] = PlayerState.STOP.value
|
|
app = kodi.Application.GetProperties(
|
|
properties=list(set(app_props.values()))
|
|
).get('result', {})
|
|
|
|
for status_prop, kodi_prop in app_props.items():
|
|
ret[status_prop] = app.get(kodi_prop)
|
|
|
|
if not players:
|
|
return ret
|
|
|
|
if player_id is None:
|
|
player_id = players.pop().get('playerid')
|
|
else:
|
|
for p in players:
|
|
if p['player_id'] == player_id:
|
|
player_id = p
|
|
break
|
|
|
|
if player_id is None:
|
|
return ret
|
|
|
|
media = (
|
|
kodi.Player.GetItem(
|
|
playerid=player_id, properties=list(set(media_props.values()))
|
|
)
|
|
.get('result', {})
|
|
.get('item', {})
|
|
)
|
|
|
|
for status_prop, kodi_prop in media_props.items():
|
|
ret[status_prop] = media.get(kodi_prop)
|
|
|
|
player_info = kodi.Player.GetProperties(
|
|
playerid=player_id, properties=list(set(player_props.values()))
|
|
).get('result', {})
|
|
|
|
for status_prop, kodi_prop in player_props.items():
|
|
ret[status_prop] = player_info.get(kodi_prop)
|
|
|
|
if ret['duration']:
|
|
ret['duration'] = self._time_obj_to_pos(ret['duration'])
|
|
|
|
if ret['position']:
|
|
ret['position'] = self._time_obj_to_pos(ret['position'])
|
|
|
|
ret['state'] = (
|
|
PlayerState.PAUSE.value
|
|
if player_info.get('speed', 0) == 0
|
|
else PlayerState.PLAY.value
|
|
)
|
|
return ret
|
|
|
|
def toggle_subtitles(self, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
def set_subtitles(self, filename, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
def remove_subtitles(self, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
def is_playing(self, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
def load(self, resource, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|