2018-07-13 02:46:02 +10:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
2017-12-14 18:29:38 +11:00
|
|
|
"""
|
|
|
|
The Plex Companion master python file
|
|
|
|
"""
|
2018-07-13 02:46:02 +10:00
|
|
|
from __future__ import absolute_import, division, unicode_literals
|
2017-12-10 00:35:08 +11:00
|
|
|
from logging import getLogger
|
2016-12-21 02:38:04 +11:00
|
|
|
from threading import Thread
|
2018-01-07 01:19:12 +11:00
|
|
|
from Queue import Empty
|
2016-12-21 02:38:04 +11:00
|
|
|
from socket import SHUT_RDWR
|
2017-03-14 07:39:07 +11:00
|
|
|
from urllib import urlencode
|
2018-04-09 16:13:54 +10:00
|
|
|
from xbmc import sleep, executebuiltin, Player
|
2016-01-23 01:37:20 +11:00
|
|
|
|
2018-06-22 03:24:37 +10:00
|
|
|
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
|
|
|
|
from .plex_api import API
|
|
|
|
from . import utils
|
|
|
|
from . import plex_functions as PF
|
|
|
|
from . import playlist_func as PL
|
|
|
|
from . import playback
|
|
|
|
from . import json_rpc as js
|
|
|
|
from . import playqueue as PQ
|
|
|
|
from . import variables as v
|
|
|
|
from . import state
|
2017-03-05 03:54:24 +11:00
|
|
|
|
2016-09-03 01:20:19 +10:00
|
|
|
###############################################################################
|
2016-01-23 01:37:20 +11:00
|
|
|
|
2018-06-22 03:24:37 +10:00
|
|
|
LOG = getLogger('PLEX.plex_companion')
|
2016-09-03 01:20:19 +10:00
|
|
|
|
|
|
|
###############################################################################
|
|
|
|
|
|
|
|
|
2018-06-22 03:24:37 +10:00
|
|
|
def update_playqueue_from_PMS(playqueue,
|
|
|
|
playqueue_id=None,
|
|
|
|
repeat=None,
|
|
|
|
offset=None,
|
|
|
|
transient_token=None):
|
|
|
|
"""
|
|
|
|
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
|
|
|
in playqueue_id if we need to fetch a new playqueue
|
|
|
|
|
|
|
|
repeat = 0, 1, 2
|
|
|
|
offset = time offset in Plextime (milliseconds)
|
|
|
|
"""
|
|
|
|
LOG.info('New playqueue %s received from Plex companion with offset '
|
|
|
|
'%s, repeat %s', playqueue_id, offset, repeat)
|
|
|
|
# Safe transient token from being deleted
|
|
|
|
if transient_token is None:
|
|
|
|
transient_token = playqueue.plex_transient_token
|
2018-06-22 04:43:39 +10:00
|
|
|
with state.LOCK_PLAYQUEUES:
|
2018-06-22 03:24:37 +10:00
|
|
|
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
2018-08-04 04:45:10 +10:00
|
|
|
try:
|
|
|
|
xml.attrib
|
|
|
|
except AttributeError:
|
|
|
|
LOG.error('Could now download playqueue %s', playqueue_id)
|
|
|
|
return
|
2018-06-22 03:24:37 +10:00
|
|
|
playqueue.clear()
|
|
|
|
try:
|
|
|
|
PL.get_playlist_details_from_xml(playqueue, xml)
|
|
|
|
except PL.PlaylistError:
|
|
|
|
LOG.error('Could not get playqueue ID %s', playqueue_id)
|
|
|
|
return
|
|
|
|
playqueue.repeat = 0 if not repeat else int(repeat)
|
|
|
|
playqueue.plex_transient_token = transient_token
|
|
|
|
playback.play_xml(playqueue, xml, offset)
|
|
|
|
|
|
|
|
|
|
|
|
@utils.thread_methods(add_suspends=['PMS_STATUS'])
|
2016-12-21 02:38:04 +11:00
|
|
|
class PlexCompanion(Thread):
|
2016-07-21 02:36:31 +10:00
|
|
|
"""
|
2017-12-14 18:29:38 +11:00
|
|
|
Plex Companion monitoring class. Invoke only once
|
2016-07-21 02:36:31 +10:00
|
|
|
"""
|
2018-01-07 01:19:12 +11:00
|
|
|
def __init__(self):
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.info("----===## Starting PlexCompanion ##===----")
|
2018-01-07 01:19:12 +11:00
|
|
|
# Init Plex Companion queue
|
2016-01-23 01:37:20 +11:00
|
|
|
# Start GDM for server/client discovery
|
2016-04-05 18:57:30 +10:00
|
|
|
self.client = plexgdm.plexgdm()
|
2017-12-10 02:30:52 +11:00
|
|
|
self.client.clientDetails()
|
2018-01-01 23:28:39 +11:00
|
|
|
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
|
2016-07-24 02:06:47 +10:00
|
|
|
# kodi player instance
|
2018-04-09 16:13:54 +10:00
|
|
|
self.player = Player()
|
2017-12-14 18:29:38 +11:00
|
|
|
self.httpd = False
|
2017-12-21 19:28:06 +11:00
|
|
|
self.subscription_manager = None
|
2016-12-21 02:38:04 +11:00
|
|
|
Thread.__init__(self)
|
2016-01-23 01:37:20 +11:00
|
|
|
|
2017-12-21 19:28:06 +11:00
|
|
|
def _process_alexa(self, data):
|
2018-06-22 03:24:37 +10:00
|
|
|
xml = PF.GetPlexMetadata(data['key'])
|
2017-12-21 19:28:06 +11:00
|
|
|
try:
|
|
|
|
xml[0].attrib
|
|
|
|
except (AttributeError, IndexError, TypeError):
|
2018-01-01 23:28:39 +11:00
|
|
|
LOG.error('Could not download Plex metadata for: %s', data)
|
2017-12-21 19:28:06 +11:00
|
|
|
return
|
|
|
|
api = API(xml[0])
|
2018-02-12 00:42:49 +11:00
|
|
|
if api.plex_type() == v.PLEX_TYPE_ALBUM:
|
2017-12-21 19:28:06 +11:00
|
|
|
LOG.debug('Plex music album detected')
|
2018-01-07 01:19:12 +11:00
|
|
|
PQ.init_playqueue_from_plex_children(
|
2018-02-12 00:42:49 +11:00
|
|
|
api.plex_id(),
|
2018-01-07 01:19:12 +11:00
|
|
|
transient_token=data.get('token'))
|
2018-02-08 21:16:39 +11:00
|
|
|
elif data['containerKey'].startswith('/playQueues/'):
|
2018-06-22 03:24:37 +10:00
|
|
|
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
|
|
|
|
xml = PF.DownloadChunks('{server}/playQueues/%s?' % container_key)
|
2018-02-08 21:16:39 +11:00
|
|
|
if xml is None:
|
|
|
|
# "Play error"
|
2018-06-22 03:24:37 +10:00
|
|
|
utils.dialog('notification',
|
|
|
|
utils.lang(29999),
|
|
|
|
utils.lang(30128),
|
|
|
|
icon='{error}')
|
2018-02-08 21:16:39 +11:00
|
|
|
return
|
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2018-02-12 00:42:49 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
|
2018-02-08 21:16:39 +11:00
|
|
|
playqueue.clear()
|
2018-06-22 03:24:37 +10:00
|
|
|
PL.get_playlist_details_from_xml(playqueue, xml)
|
2018-02-08 21:22:26 +11:00
|
|
|
playqueue.plex_transient_token = data.get('token')
|
2018-02-08 21:16:39 +11:00
|
|
|
if data.get('offset') != '0':
|
|
|
|
offset = float(data['offset']) / 1000.0
|
|
|
|
else:
|
|
|
|
offset = None
|
2018-06-22 03:24:37 +10:00
|
|
|
playback.play_xml(playqueue, xml, offset)
|
2017-12-21 19:28:06 +11:00
|
|
|
else:
|
|
|
|
state.PLEX_TRANSIENT_TOKEN = data.get('token')
|
2018-02-08 21:16:39 +11:00
|
|
|
if data.get('offset') != '0':
|
|
|
|
state.RESUMABLE = True
|
|
|
|
state.RESUME_PLAYBACK = True
|
2018-06-22 03:24:37 +10:00
|
|
|
playback.playback_triage(api.plex_id(),
|
|
|
|
api.plex_type(),
|
|
|
|
resolve=False)
|
2017-12-21 19:28:06 +11:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _process_node(data):
|
|
|
|
"""
|
|
|
|
E.g. watch later initiated by Companion. Basically navigating Plex
|
|
|
|
"""
|
|
|
|
state.PLEX_TRANSIENT_TOKEN = data.get('key')
|
|
|
|
params = {
|
|
|
|
'mode': 'plex_node',
|
|
|
|
'key': '{server}%s' % data.get('key'),
|
2018-04-16 02:13:48 +10:00
|
|
|
'offset': data.get('offset')
|
2017-12-21 19:28:06 +11:00
|
|
|
}
|
|
|
|
executebuiltin('RunPlugin(plugin://%s?%s)'
|
|
|
|
% (v.ADDON_ID, urlencode(params)))
|
|
|
|
|
|
|
|
def _process_playlist(self, data):
|
|
|
|
# Get the playqueue ID
|
2018-06-22 03:24:37 +10:00
|
|
|
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
|
2017-12-21 19:28:06 +11:00
|
|
|
try:
|
2018-01-07 01:19:12 +11:00
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2017-12-21 19:28:06 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
|
|
|
except KeyError:
|
|
|
|
# E.g. Plex web does not supply the media type
|
|
|
|
# Still need to figure out the type (video vs. music vs. pix)
|
2018-06-22 03:24:37 +10:00
|
|
|
xml = PF.GetPlexMetadata(data['key'])
|
2017-12-21 19:28:06 +11:00
|
|
|
try:
|
|
|
|
xml[0].attrib
|
|
|
|
except (AttributeError, IndexError, TypeError):
|
|
|
|
LOG.error('Could not download Plex metadata')
|
|
|
|
return
|
|
|
|
api = API(xml[0])
|
2018-01-07 01:19:12 +11:00
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2018-02-12 00:42:49 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
|
2018-06-22 03:24:37 +10:00
|
|
|
update_playqueue_from_PMS(playqueue,
|
|
|
|
playqueue_id=container_key,
|
|
|
|
repeat=query.get('repeat'),
|
|
|
|
offset=data.get('offset'),
|
|
|
|
transient_token=data.get('token'))
|
|
|
|
|
2017-12-21 19:28:06 +11:00
|
|
|
def _process_streams(self, data):
|
|
|
|
"""
|
|
|
|
Plex Companion client adjusted audio or subtitle stream
|
|
|
|
"""
|
2018-01-07 01:19:12 +11:00
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2017-12-21 19:28:06 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
|
|
|
pos = js.get_position(playqueue.playlistid)
|
|
|
|
if 'audioStreamID' in data:
|
|
|
|
index = playqueue.items[pos].kodi_stream_index(
|
|
|
|
data['audioStreamID'], 'audio')
|
|
|
|
self.player.setAudioStream(index)
|
|
|
|
elif 'subtitleStreamID' in data:
|
|
|
|
if data['subtitleStreamID'] == '0':
|
|
|
|
self.player.showSubtitles(False)
|
|
|
|
else:
|
|
|
|
index = playqueue.items[pos].kodi_stream_index(
|
|
|
|
data['subtitleStreamID'], 'subtitle')
|
|
|
|
self.player.setSubtitleStream(index)
|
|
|
|
else:
|
|
|
|
LOG.error('Unknown setStreams command: %s', data)
|
|
|
|
|
|
|
|
def _process_refresh(self, data):
|
|
|
|
"""
|
|
|
|
example data: {'playQueueID': '8475', 'commandID': '11'}
|
|
|
|
"""
|
2018-06-22 03:24:37 +10:00
|
|
|
xml = PL.get_pms_playqueue(data['playQueueID'])
|
2017-12-21 19:28:06 +11:00
|
|
|
if xml is None:
|
|
|
|
return
|
|
|
|
if len(xml) == 0:
|
|
|
|
LOG.debug('Empty playqueue received - clearing playqueue')
|
2018-06-22 03:24:37 +10:00
|
|
|
plex_type = PL.get_plextype_from_xml(xml)
|
2017-12-21 19:28:06 +11:00
|
|
|
if plex_type is None:
|
|
|
|
return
|
2018-01-07 01:19:12 +11:00
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2017-12-21 19:28:06 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
|
|
|
playqueue.clear()
|
|
|
|
return
|
2018-01-07 01:19:12 +11:00
|
|
|
playqueue = PQ.get_playqueue_from_type(
|
2017-12-21 19:28:06 +11:00
|
|
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
2018-06-22 03:24:37 +10:00
|
|
|
update_playqueue_from_PMS(playqueue, data['playQueueID'])
|
2017-12-21 19:28:06 +11:00
|
|
|
|
2017-12-14 18:29:38 +11:00
|
|
|
def _process_tasks(self, task):
|
2016-07-22 23:04:42 +10:00
|
|
|
"""
|
2016-12-28 23:14:21 +11:00
|
|
|
Processes tasks picked up e.g. by Companion listener, e.g.
|
|
|
|
{'action': 'playlist',
|
|
|
|
'data': {'address': 'xyz.plex.direct',
|
|
|
|
'commandID': '7',
|
|
|
|
'containerKey': '/playQueues/6669?own=1&repeat=0&window=200',
|
|
|
|
'key': '/library/metadata/220493',
|
|
|
|
'machineIdentifier': 'xyz',
|
|
|
|
'offset': '0',
|
|
|
|
'port': '32400',
|
|
|
|
'protocol': 'https',
|
|
|
|
'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd',
|
|
|
|
'type': 'video'}}
|
2016-07-22 23:04:42 +10:00
|
|
|
"""
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.debug('Processing: %s', task)
|
2016-07-22 23:04:42 +10:00
|
|
|
data = task['data']
|
2017-03-06 03:51:58 +11:00
|
|
|
if task['action'] == 'alexa':
|
2018-06-22 04:43:39 +10:00
|
|
|
with state.LOCK_PLAYQUEUES:
|
|
|
|
self._process_alexa(data)
|
2017-03-06 03:51:58 +11:00
|
|
|
elif (task['action'] == 'playlist' and
|
2017-01-03 01:41:38 +11:00
|
|
|
data.get('address') == 'node.plexapp.com'):
|
2017-12-21 19:28:06 +11:00
|
|
|
self._process_node(data)
|
2017-01-03 01:41:38 +11:00
|
|
|
elif task['action'] == 'playlist':
|
2018-06-22 04:43:39 +10:00
|
|
|
with state.LOCK_PLAYQUEUES:
|
|
|
|
self._process_playlist(data)
|
2017-05-31 21:44:04 +10:00
|
|
|
elif task['action'] == 'refreshPlayQueue':
|
2018-06-22 04:43:39 +10:00
|
|
|
with state.LOCK_PLAYQUEUES:
|
|
|
|
self._process_refresh(data)
|
2017-12-16 02:11:19 +11:00
|
|
|
elif task['action'] == 'setStreams':
|
2018-06-02 02:43:56 +10:00
|
|
|
try:
|
|
|
|
self._process_streams(data)
|
|
|
|
except KeyError:
|
|
|
|
pass
|
2017-12-16 02:11:19 +11:00
|
|
|
|
2016-01-23 01:37:20 +11:00
|
|
|
def run(self):
|
2017-12-14 18:29:38 +11:00
|
|
|
"""
|
2017-12-21 19:28:06 +11:00
|
|
|
Ensure that sockets will be closed no matter what
|
2017-12-14 18:29:38 +11:00
|
|
|
"""
|
2017-05-12 16:22:36 +10:00
|
|
|
try:
|
2017-12-14 18:29:38 +11:00
|
|
|
self._run()
|
2017-05-12 16:22:36 +10:00
|
|
|
finally:
|
|
|
|
try:
|
|
|
|
self.httpd.socket.shutdown(SHUT_RDWR)
|
|
|
|
except AttributeError:
|
|
|
|
pass
|
|
|
|
finally:
|
|
|
|
try:
|
|
|
|
self.httpd.socket.close()
|
|
|
|
except AttributeError:
|
|
|
|
pass
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.info("----===## Plex Companion stopped ##===----")
|
2017-05-12 16:22:36 +10:00
|
|
|
|
2017-12-14 18:29:38 +11:00
|
|
|
def _run(self):
|
2017-05-12 16:22:36 +10:00
|
|
|
httpd = self.httpd
|
2016-04-03 01:46:23 +11:00
|
|
|
# Cache for quicker while loops
|
|
|
|
client = self.client
|
2018-02-12 00:57:39 +11:00
|
|
|
stopped = self.stopped
|
|
|
|
suspended = self.suspended
|
2016-04-03 01:46:23 +11:00
|
|
|
|
|
|
|
# Start up instances
|
2017-12-14 18:29:38 +11:00
|
|
|
request_mgr = httppersist.RequestMgr()
|
2018-01-07 01:19:12 +11:00
|
|
|
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
|
|
|
|
self.player)
|
2017-12-15 01:54:28 +11:00
|
|
|
self.subscription_manager = subscription_manager
|
2016-07-22 23:04:42 +10:00
|
|
|
|
2018-06-22 03:24:37 +10:00
|
|
|
if utils.settings('plexCompanion') == 'true':
|
2016-04-27 01:15:05 +10:00
|
|
|
# Start up httpd
|
|
|
|
start_count = 0
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
httpd = listener.ThreadedHTTPServer(
|
|
|
|
client,
|
2017-12-14 18:29:38 +11:00
|
|
|
subscription_manager,
|
2017-12-10 02:30:52 +11:00
|
|
|
('', v.COMPANION_PORT),
|
2016-04-27 01:15:05 +10:00
|
|
|
listener.MyHandler)
|
|
|
|
httpd.timeout = 0.95
|
|
|
|
break
|
|
|
|
except:
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.error("Unable to start PlexCompanion. Traceback:")
|
2016-12-21 02:38:04 +11:00
|
|
|
import traceback
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.error(traceback.print_exc())
|
2016-12-21 02:38:04 +11:00
|
|
|
sleep(3000)
|
2016-04-27 01:15:05 +10:00
|
|
|
if start_count == 3:
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.error("Error: Unable to start web helper.")
|
2016-04-27 01:15:05 +10:00
|
|
|
httpd = False
|
|
|
|
break
|
|
|
|
start_count += 1
|
|
|
|
else:
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.info('User deactivated Plex Companion')
|
2016-04-03 01:46:23 +11:00
|
|
|
client.start_all()
|
2016-01-23 01:37:20 +11:00
|
|
|
message_count = 0
|
2016-08-08 04:52:49 +10:00
|
|
|
if httpd:
|
2017-12-14 18:29:38 +11:00
|
|
|
thread = Thread(target=httpd.handle_request)
|
2016-08-08 04:52:49 +10:00
|
|
|
|
2018-02-12 00:57:39 +11:00
|
|
|
while not stopped():
|
2016-03-11 02:02:46 +11:00
|
|
|
# If we are not authorized, sleep
|
|
|
|
# Otherwise, we trigger a download which leads to a
|
|
|
|
# re-authorizations
|
2018-02-12 00:57:39 +11:00
|
|
|
while suspended():
|
|
|
|
if stopped():
|
2016-03-24 02:07:09 +11:00
|
|
|
break
|
2016-12-21 02:38:04 +11:00
|
|
|
sleep(1000)
|
2016-01-23 01:37:20 +11:00
|
|
|
try:
|
2016-08-08 04:52:49 +10:00
|
|
|
message_count += 1
|
2016-04-27 01:15:05 +10:00
|
|
|
if httpd:
|
2017-12-14 18:29:38 +11:00
|
|
|
if not thread.isAlive():
|
2016-08-11 03:03:37 +10:00
|
|
|
# Use threads cause the method will stall
|
2017-12-14 18:29:38 +11:00
|
|
|
thread = Thread(target=httpd.handle_request)
|
|
|
|
thread.start()
|
2016-04-27 01:15:05 +10:00
|
|
|
|
2016-08-08 04:52:49 +10:00
|
|
|
if message_count == 3000:
|
|
|
|
message_count = 0
|
2016-04-27 01:15:05 +10:00
|
|
|
if client.check_client_registration():
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.debug('Client is still registered')
|
2016-04-27 01:15:05 +10:00
|
|
|
else:
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.debug('Client is no longer registered. Plex '
|
|
|
|
'Companion still running on port %s',
|
|
|
|
v.COMPANION_PORT)
|
2017-02-20 03:07:42 +11:00
|
|
|
client.register_as_client()
|
2016-04-03 01:46:23 +11:00
|
|
|
# Get and set servers
|
2016-08-08 04:52:49 +10:00
|
|
|
if message_count % 30 == 0:
|
2017-12-14 18:29:38 +11:00
|
|
|
subscription_manager.serverlist = client.getServerList()
|
|
|
|
subscription_manager.notify()
|
2016-08-08 04:52:49 +10:00
|
|
|
if not httpd:
|
|
|
|
message_count = 0
|
2016-01-23 01:37:20 +11:00
|
|
|
except:
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.warn("Error in loop, continuing anyway. Traceback:")
|
2016-12-21 02:38:04 +11:00
|
|
|
import traceback
|
2017-12-14 18:29:38 +11:00
|
|
|
LOG.warn(traceback.format_exc())
|
2016-07-22 23:04:42 +10:00
|
|
|
# See if there's anything we need to process
|
|
|
|
try:
|
2018-01-07 01:19:12 +11:00
|
|
|
task = state.COMPANION_QUEUE.get(block=False)
|
2017-12-10 00:35:08 +11:00
|
|
|
except Empty:
|
2016-07-22 23:04:42 +10:00
|
|
|
pass
|
|
|
|
else:
|
|
|
|
# Got instructions, process them
|
2017-12-14 18:29:38 +11:00
|
|
|
self._process_tasks(task)
|
2018-01-07 01:19:12 +11:00
|
|
|
state.COMPANION_QUEUE.task_done()
|
2016-08-08 04:52:49 +10:00
|
|
|
# Don't sleep
|
|
|
|
continue
|
2017-03-06 02:43:06 +11:00
|
|
|
sleep(50)
|
2018-01-01 23:28:39 +11:00
|
|
|
subscription_manager.signal_stop()
|
2016-04-03 01:46:23 +11:00
|
|
|
client.stop_all()
|