Revamp playback start, part 2
This commit is contained in:
parent
24f2f60209
commit
fb7eafb27a
6 changed files with 310 additions and 135 deletions
|
@ -291,12 +291,11 @@ class Movies(Items):
|
|||
path = playurl.replace(filename, "")
|
||||
if doIndirect:
|
||||
# Set plugin path and media flags using real filename
|
||||
path = "plugin://plugin.video.plexkodiconnect/movies/"
|
||||
path = "plugin://plugin.video.plexkodiconnect"
|
||||
params = {
|
||||
'filename': API.getKey(),
|
||||
'id': itemid,
|
||||
'dbid': movieid,
|
||||
'mode': "play"
|
||||
'mode': 'play',
|
||||
'plex_id': itemid,
|
||||
'plex_type': v.PLEX_TYPE_MOVIE
|
||||
}
|
||||
filename = "%s?%s" % (path, urlencode(params))
|
||||
playurl = filename
|
||||
|
|
|
@ -12,11 +12,11 @@ from PlexFunctions import scrobble
|
|||
from kodidb_functions import kodiid_from_filename
|
||||
from plexbmchelper.subscribers import LOCKER
|
||||
from PlexAPI import API
|
||||
import playqueue as PQ
|
||||
import json_rpc as js
|
||||
import playlist_func as PL
|
||||
import state
|
||||
import variables as v
|
||||
import playqueue as PQ
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -254,31 +254,16 @@ class KodiMonitor(Monitor):
|
|||
return
|
||||
playqueue.clear()
|
||||
|
||||
@LOCKER.lockthis
|
||||
def PlayBackStart(self, data):
|
||||
def _get_ids(self, json_item):
|
||||
"""
|
||||
Called whenever playback is started. Example data:
|
||||
{
|
||||
u'item': {u'type': u'movie', u'title': u''},
|
||||
u'player': {u'playerid': 1, u'speed': 1}
|
||||
}
|
||||
Unfortunately when using Widgets, Kodi doesn't tell us shit
|
||||
"""
|
||||
# Get the type of media we're playing
|
||||
try:
|
||||
kodi_type = data['item']['type']
|
||||
playerid = data['player']['playerid']
|
||||
except (TypeError, KeyError):
|
||||
LOG.info('Aborting playback report - item invalid for updates %s',
|
||||
data)
|
||||
return
|
||||
json_data = js.get_item(playerid)
|
||||
path = json_data.get('file')
|
||||
kodi_id = json_data.get('id')
|
||||
kodi_id = json_item.get('id')
|
||||
kodi_type = json_item.get('type')
|
||||
path = json_item.get('file')
|
||||
if not path and not kodi_id:
|
||||
LOG.info('Aborting playback report - no Kodi id or file for %s',
|
||||
json_data)
|
||||
return
|
||||
json_item)
|
||||
raise RuntimeError
|
||||
# Plex id will NOT be set with direct paths
|
||||
plex_id = state.PLEX_IDS.get(path)
|
||||
try:
|
||||
|
@ -306,28 +291,49 @@ class KodiMonitor(Monitor):
|
|||
except TypeError:
|
||||
# No plex id, hence item not in the library. E.g. clips
|
||||
pass
|
||||
info = js.get_player_props(playerid)
|
||||
state.PLAYER_STATES[playerid].update(info)
|
||||
state.PLAYER_STATES[playerid]['file'] = path
|
||||
state.PLAYER_STATES[playerid]['kodi_id'] = kodi_id
|
||||
state.PLAYER_STATES[playerid]['kodi_type'] = kodi_type
|
||||
state.PLAYER_STATES[playerid]['plex_id'] = plex_id
|
||||
state.PLAYER_STATES[playerid]['plex_type'] = plex_type
|
||||
LOG.debug('Set the player state: %s', state.PLAYER_STATES[playerid])
|
||||
# Check whether we need to init our playqueues (e.g. direct play)
|
||||
init = False
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
return kodi_id, kodi_type, plex_id, plex_type
|
||||
|
||||
@LOCKER.lockthis
|
||||
def PlayBackStart(self, data):
|
||||
"""
|
||||
Called whenever playback is started. Example data:
|
||||
{
|
||||
u'item': {u'type': u'movie', u'title': u''},
|
||||
u'player': {u'playerid': 1, u'speed': 1}
|
||||
}
|
||||
Unfortunately when using Widgets, Kodi doesn't tell us shit
|
||||
"""
|
||||
# Get the type of media we're playing
|
||||
try:
|
||||
playqueue.items[info['position']]
|
||||
kodi_type = data['item']['type']
|
||||
playerid = data['player']['playerid']
|
||||
except (TypeError, KeyError):
|
||||
LOG.info('Aborting playback report - item invalid for updates %s',
|
||||
data)
|
||||
return
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
info = js.get_player_props(playerid)
|
||||
json_item = js.get_item(playerid)
|
||||
path = json_item.get('file')
|
||||
pos = info['position'] if info['position'] != -1 else 0
|
||||
LOG.info('Detected position %s for %s', pos, playqueue)
|
||||
try:
|
||||
item = playqueue.items[pos]
|
||||
# See if playback.py already initiated playback
|
||||
init_done = item.init_done
|
||||
except IndexError:
|
||||
init = True
|
||||
if init is False and plex_id is not None:
|
||||
if plex_id != playqueue.items[info['position']].plex_id:
|
||||
init = True
|
||||
elif init is False and path != playqueue.items[info['position']].file:
|
||||
init = True
|
||||
if init is True:
|
||||
LOG.debug('Need to initialize Plex and PKC playqueue')
|
||||
init_done = False
|
||||
if init_done is True:
|
||||
kodi_id = item.kodi_id
|
||||
kodi_type = item.kodi_type
|
||||
plex_id = item.plex_id
|
||||
plex_type = item.plex_type
|
||||
else:
|
||||
try:
|
||||
kodi_id, kodi_type, plex_id, plex_type = self._get_ids(json_item)
|
||||
except RuntimeError:
|
||||
return
|
||||
LOG.info('Need to initialize Plex and PKC playqueue')
|
||||
if plex_id:
|
||||
PL.init_Plex_playlist(playqueue, plex_id=plex_id)
|
||||
else:
|
||||
|
@ -347,6 +353,14 @@ class KodiMonitor(Monitor):
|
|||
state.PLAYER_STATES[playerid]['container_key'] = container_key
|
||||
LOG.debug('Set the Plex container_key to: %s', container_key)
|
||||
|
||||
state.PLAYER_STATES[playerid].update(info)
|
||||
state.PLAYER_STATES[playerid]['file'] = path
|
||||
state.PLAYER_STATES[playerid]['kodi_id'] = kodi_id
|
||||
state.PLAYER_STATES[playerid]['kodi_type'] = kodi_type
|
||||
state.PLAYER_STATES[playerid]['plex_id'] = plex_id
|
||||
state.PLAYER_STATES[playerid]['plex_type'] = plex_type
|
||||
LOG.debug('Set the player state: %s', state.PLAYER_STATES[playerid])
|
||||
|
||||
def StartDirectPath(self, plex_id, type, currentFile):
|
||||
"""
|
||||
Set some additional stuff if playback was initiated by Kodi, not PKC
|
||||
|
|
|
@ -1,52 +1,213 @@
|
|||
"""
|
||||
Used to kick off Kodi playback
|
||||
"""
|
||||
from logging import getLogger
|
||||
from threading import Thread, Lock
|
||||
from urllib import urlencode
|
||||
|
||||
from xbmc import Player, getCondVisibility, sleep
|
||||
|
||||
from PlexAPI import API
|
||||
from PlexFunctions import GetPlexMetadata, init_plex_playqueue
|
||||
import plexdb_functions as plexdb
|
||||
import playlist_func as PL
|
||||
import playqueue as PQ
|
||||
from playutils import PlayUtils
|
||||
from PKC_listitem import PKC_ListItem, convert_PKC_to_listitem
|
||||
from pickler import Playback_Successful
|
||||
from utils import settings, dialog, language as lang
|
||||
from PKC_listitem import PKC_ListItem
|
||||
from pickler import pickle_me, Playback_Successful
|
||||
import json_rpc as js
|
||||
from utils import window, settings, dialog, language as lang, Lock_Function
|
||||
import variables as v
|
||||
import state
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger("PLEX." + __name__)
|
||||
LOCKER = Lock_Function(Lock())
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def playback_setup(plex_id, kodi_id, kodi_type, path):
|
||||
@LOCKER.lockthis
|
||||
def playback_triage(plex_id=None, plex_type=None, path=None):
|
||||
"""
|
||||
Get XML
|
||||
For the single element, e.g. including trailers and parts
|
||||
For playQueue (init by Companion or Alexa)
|
||||
Set up
|
||||
PKC/Kodi/Plex Playqueue
|
||||
Trailers
|
||||
Clips
|
||||
Several parts
|
||||
companion playqueue
|
||||
Alexa music album
|
||||
Hit this function for addon path playback, Plex trailers, etc.
|
||||
Will setup playback first, then on second call complete playback.
|
||||
|
||||
Returns Playback_Successful() with potentially a PKC_ListItem() attached
|
||||
(to be consumed by setResolvedURL)
|
||||
"""
|
||||
LOG.info('playback_triage called with plex_id %s, plex_type %s, path %s',
|
||||
plex_id, plex_type, path)
|
||||
if not state.AUTHENTICATED:
|
||||
LOG.error('Not yet authenticated for PMS, abort starting playback')
|
||||
# "Unauthorized for PMS"
|
||||
dialog('notification', lang(29999), lang(30017))
|
||||
# Don't cause second notification to appear
|
||||
return Playback_Successful()
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
pos = pos if pos != -1 else 0
|
||||
LOG.info('playQueue position: %s for %s', pos, playqueue)
|
||||
# Have we already initiated playback?
|
||||
init_done = True
|
||||
try:
|
||||
item = playqueue.items[pos]
|
||||
except IndexError:
|
||||
init_done = False
|
||||
else:
|
||||
init_done = item.init_done
|
||||
# Either init the playback now, or - on 2nd pass - kick off playback
|
||||
if init_done is False:
|
||||
playback_init(plex_id, path, playqueue)
|
||||
else:
|
||||
conclude_playback(playqueue, pos)
|
||||
|
||||
|
||||
def playback_init(plex_id, path, playqueue):
|
||||
"""
|
||||
Playback setup. Path is the original path PKC default.py has been called
|
||||
with
|
||||
"""
|
||||
contextmenu_play = window('plex_contextplay') == 'true'
|
||||
window('plex_contextplay', clear=True)
|
||||
xml = GetPlexMetadata(plex_id)
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (IndexError, TypeError, AttributeError):
|
||||
LOG.error('Could not get a PMS xml for plex id %s', plex_id)
|
||||
# "Play error"
|
||||
dialog('notification', lang(29999), lang(30128))
|
||||
return
|
||||
result = Playback_Successful()
|
||||
listitem = PKC_ListItem()
|
||||
# Set the original path again so Kodi will return a 2nd time to PKC
|
||||
listitem.setPath(path)
|
||||
api = API(xml[0])
|
||||
plex_type = api.getType()
|
||||
size_playlist = playqueue.kodi_pl.size()
|
||||
# Can return -1
|
||||
start_pos = max(playqueue.kodi_pl.getposition(), 0)
|
||||
LOG.info("Playlist size %s", size_playlist)
|
||||
LOG.info("Playlist starting position %s", start_pos)
|
||||
resume, _ = api.getRuntime()
|
||||
trailers = False
|
||||
if (api.getType() == v.PLEX_TYPE_MOVIE and
|
||||
not seektime and
|
||||
sizePlaylist < 2 and
|
||||
if (plex_type == v.PLEX_TYPE_MOVIE and
|
||||
not resume and
|
||||
size_playlist < 2 and
|
||||
settings('enableCinema') == "true"):
|
||||
if settings('askCinema') == "true":
|
||||
trailers = dialog('yesno', lang(29999), "Play trailers?")
|
||||
# "Play trailers?"
|
||||
trailers = dialog('yesno', lang(29999), lang(33016))
|
||||
trailers = True if trailers else False
|
||||
else:
|
||||
trailers = True
|
||||
# Post to the PMS. REUSE THE PLAYQUEUE!
|
||||
xml = init_plex_playqueue(plex_id,
|
||||
plex_lib_UUID,
|
||||
mediatype=api.getType(),
|
||||
xml.attrib.get('librarySectionUUID'),
|
||||
mediatype=plex_type,
|
||||
trailers=trailers)
|
||||
pass
|
||||
if xml is None:
|
||||
LOG.error('Could not get a playqueue xml for plex id %s, UUID %s',
|
||||
plex_id, xml.attrib.get('librarySectionUUID'))
|
||||
# "Play error"
|
||||
dialog('notification', lang(29999), lang(30128))
|
||||
return
|
||||
playqueue.clear()
|
||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||
stack = _prep_playlist_stack(xml)
|
||||
force_playback = False
|
||||
if (not getCondVisibility('Window.IsVisible(MyVideoNav.xml)') and
|
||||
not getCondVisibility('Window.IsVisible(VideoFullScreen.xml)')):
|
||||
LOG.info("Detected playback from widget")
|
||||
force_playback = True
|
||||
if force_playback is False:
|
||||
# Return the listelement for setResolvedURL
|
||||
result.listitem = listitem
|
||||
pickle_me(result)
|
||||
# Wait for the setResolvedUrl to have taken its course - ugly
|
||||
sleep(50)
|
||||
_process_stack(playqueue, stack)
|
||||
else:
|
||||
# Need to kickoff playback, not using setResolvedURL
|
||||
pickle_me(result)
|
||||
_process_stack(playqueue, stack)
|
||||
# Need a separate thread because Player won't return in time
|
||||
listitem.setProperty('StartOffset', str(resume))
|
||||
thread = Thread(target=Player().play,
|
||||
args=(playqueue.kodi_pl, ))
|
||||
thread.setDaemon(True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def conclude_playback_startup(playqueue_no,
|
||||
pos,
|
||||
plex_id=None,
|
||||
kodi_id=None,
|
||||
kodi_type=None,
|
||||
path=None):
|
||||
def _prep_playlist_stack(xml):
|
||||
stack = []
|
||||
for item in xml:
|
||||
api = API(item)
|
||||
with plexdb.Get_Plex_DB() as plex_db:
|
||||
plex_dbitem = plex_db.getItem_byId(api.getRatingKey())
|
||||
try:
|
||||
kodi_id = plex_dbitem[0]
|
||||
kodi_type = plex_dbitem[4]
|
||||
except TypeError:
|
||||
kodi_id = None
|
||||
kodi_type = None
|
||||
for part_no, _ in enumerate(item[0]):
|
||||
api.setPartNumber(part_no)
|
||||
if kodi_id is not None:
|
||||
# We don't need the URL, item is in the Kodi library
|
||||
path = None
|
||||
listitem = None
|
||||
else:
|
||||
# Need to redirect again to PKC to conclude playback
|
||||
params = {
|
||||
'mode': 'play',
|
||||
'plex_id': api.getRatingKey(),
|
||||
'plex_type': api.getType()
|
||||
}
|
||||
path = ('plugin://plugin.video.plexkodiconnect?%s'
|
||||
% (urlencode(params)))
|
||||
listitem = api.CreateListItemFromPlexItem()
|
||||
api.set_listitem_artwork(listitem)
|
||||
listitem.setPath(path)
|
||||
stack.append({
|
||||
'kodi_id': kodi_id,
|
||||
'kodi_type': kodi_type,
|
||||
'file': path,
|
||||
'xml_video_element': item,
|
||||
'listitem': listitem,
|
||||
'part_no': part_no
|
||||
})
|
||||
return stack
|
||||
|
||||
|
||||
def _process_stack(playqueue, stack):
|
||||
"""
|
||||
Takes our stack and adds the items to the PKC and Kodi playqueues.
|
||||
This needs to be done AFTER setResolvedURL
|
||||
"""
|
||||
for i, item in enumerate(stack):
|
||||
if item['kodi_id'] is not None:
|
||||
# Use Kodi id & JSON so we get full artwork
|
||||
playlist_item = PL.add_item_to_kodi_playlist(
|
||||
playqueue,
|
||||
i,
|
||||
kodi_id=item['kodi_id'],
|
||||
kodi_type=item['kodi_type'],
|
||||
xml_video_element=item['xml_video_element'])
|
||||
else:
|
||||
playlist_item = PL.add_listitem_to_Kodi_playlist(
|
||||
playqueue,
|
||||
i,
|
||||
item['listitem'],
|
||||
file=item['file'],
|
||||
xml_video_element=item['xml_video_element'])
|
||||
playlist_item.part = item['part_no']
|
||||
playlist_item.init_done = True
|
||||
|
||||
|
||||
def conclude_playback(playqueue, pos):
|
||||
"""
|
||||
ONLY if actually being played (e.g. at 5th position of a playqueue).
|
||||
|
||||
|
@ -63,17 +224,16 @@ def conclude_playback_startup(playqueue_no,
|
|||
"""
|
||||
result = Playback_Successful()
|
||||
listitem = PKC_ListItem()
|
||||
playqueue = PQ.PLAYQUEUES[playqueue_no]
|
||||
item = playqueue.items[pos]
|
||||
if item.xml is not None:
|
||||
# Got a Plex element
|
||||
api = API(item.xml)
|
||||
api.setPartNumber(item.part)
|
||||
api.CreateListItemFromPlexItem(listitem)
|
||||
if plex_id is not None:
|
||||
playutils = PlayUtils(api, item)
|
||||
playurl = playutils.getPlayUrl()
|
||||
elif path is not None:
|
||||
playurl = path
|
||||
item.playmethod = 'DirectStream'
|
||||
else:
|
||||
playurl = item.file
|
||||
listitem.setPath(playurl)
|
||||
if item.playmethod in ("DirectStream", "DirectPlay"):
|
||||
listitem.setSubtitles(api.externalSubs())
|
||||
|
@ -81,4 +241,4 @@ def conclude_playback_startup(playqueue_no,
|
|||
playutils.audio_subtitle_prefs(listitem)
|
||||
listitem.setPath(playurl)
|
||||
result.listitem = listitem
|
||||
return result
|
||||
pickle_me(result)
|
||||
|
|
|
@ -9,6 +9,7 @@ from xbmc import Player
|
|||
from PKC_listitem import PKC_ListItem
|
||||
from pickler import pickle_me, Playback_Successful
|
||||
from playbackutils import PlaybackUtils
|
||||
import playback
|
||||
from utils import window
|
||||
from PlexFunctions import GetPlexMetadata
|
||||
from PlexAPI import API
|
||||
|
@ -126,10 +127,10 @@ class Playback_Starter(Thread):
|
|||
params = dict(parse_qsl(params))
|
||||
mode = params.get('mode')
|
||||
LOG.debug('Received mode: %s, params: %s', mode, params)
|
||||
try:
|
||||
if mode == 'play':
|
||||
result = self.process_play(params.get('id'),
|
||||
params.get('dbid'))
|
||||
result = playback.playback_triage(plex_id=params.get('plex_id'),
|
||||
plex_type=params.get('plex_type'),
|
||||
path=params.get('path'))
|
||||
elif mode == 'companion':
|
||||
result = self.process_companion()
|
||||
elif mode == 'plex_node':
|
||||
|
@ -141,15 +142,8 @@ class Playback_Starter(Thread):
|
|||
elif mode == 'context_menu':
|
||||
ContextMenu()
|
||||
result = Playback_Successful()
|
||||
except:
|
||||
LOG.error('Error encountered for mode %s, params %s',
|
||||
mode, params)
|
||||
import traceback
|
||||
LOG.error(traceback.format_exc())
|
||||
# Let default.py know!
|
||||
pickle_me(None)
|
||||
else:
|
||||
pickle_me(result)
|
||||
# pickle_me(result)
|
||||
|
||||
def run(self):
|
||||
queue = state.COMMAND_PIPELINE_QUEUE
|
||||
|
|
|
@ -190,6 +190,8 @@ class Playlist_Item(object):
|
|||
guid = None [str] Weird Plex guid
|
||||
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer>
|
||||
playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode'
|
||||
part = 0 [int] part number if Plex video consists of mult. parts
|
||||
init_done = False Set to True only if run through playback init
|
||||
"""
|
||||
def __init__(self):
|
||||
self.id = None
|
||||
|
@ -203,8 +205,9 @@ class Playlist_Item(object):
|
|||
self.guid = None
|
||||
self.xml = None
|
||||
self.playmethod = None
|
||||
# Yet to be implemented: handling of a movie with several parts
|
||||
# If Plex video consists of several parts; part number
|
||||
self.part = 0
|
||||
self.init_done = False
|
||||
|
||||
def __repr__(self):
|
||||
"""
|
||||
|
@ -550,11 +553,11 @@ def add_item_to_PMS_playlist(playlist, pos, plex_id=None, kodi_item=None):
|
|||
|
||||
|
||||
def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
|
||||
file=None):
|
||||
file=None, xml_video_element=None):
|
||||
"""
|
||||
Adds an item to the KODI playlist only. WILL ALSO UPDATE OUR PLAYLISTS
|
||||
|
||||
Returns False if unsuccessful
|
||||
Returns the playlist item that was just added or None
|
||||
|
||||
file: str!
|
||||
"""
|
||||
|
@ -574,17 +577,20 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
|
|||
if reply.get('error') is not None:
|
||||
LOG.error('Could not add item to playlist. Kodi reply. %s', reply)
|
||||
playlist.is_kodi_onadd()
|
||||
return False
|
||||
return
|
||||
if xml_video_element is not None:
|
||||
item = playlist_item_from_xml(playlist, xml_video_element)
|
||||
item.kodi_id = kodi_id
|
||||
item.kodi_type = kodi_type
|
||||
item.file = file
|
||||
elif kodi_id is not None:
|
||||
item = playlist_item_from_kodi(
|
||||
{'id': kodi_id, 'type': kodi_type, 'file': file})
|
||||
if item.plex_id is not None:
|
||||
xml = GetPlexMetadata(item.plex_id)
|
||||
try:
|
||||
item.xml = xml[-1]
|
||||
except (TypeError, IndexError):
|
||||
LOG.error('Could not get metadata for playlist item %s', item)
|
||||
playlist.items.insert(pos, item)
|
||||
return True
|
||||
return item
|
||||
|
||||
|
||||
def move_playlist_item(playlist, before_pos, after_pos):
|
||||
|
@ -706,6 +712,7 @@ def add_listitem_to_Kodi_playlist(playlist, pos, listitem, file,
|
|||
item.file = file
|
||||
playlist.items.insert(pos, item)
|
||||
LOG.debug('Done inserting for %s', playlist)
|
||||
return item
|
||||
|
||||
|
||||
def remove_from_kodi_playlist(playlist, pos):
|
||||
|
|
|
@ -58,17 +58,18 @@ def init_playqueues():
|
|||
LOG.debug('Initialized the Kodi playqueues: %s', PLAYQUEUES)
|
||||
|
||||
|
||||
def get_playqueue_from_type(typus):
|
||||
def get_playqueue_from_type(kodi_playlist_type):
|
||||
"""
|
||||
Returns the playqueue according to the typus ('video', 'audio',
|
||||
'picture') passed in
|
||||
Returns the playqueue according to the kodi_playlist_type ('video',
|
||||
'audio', 'picture') passed in
|
||||
"""
|
||||
with LOCK:
|
||||
for playqueue in PLAYQUEUES:
|
||||
if playqueue.type == typus:
|
||||
if playqueue.type == kodi_playlist_type:
|
||||
break
|
||||
else:
|
||||
raise ValueError('Wrong playlist type passed in: %s' % typus)
|
||||
raise ValueError('Wrong playlist type passed in: %s',
|
||||
kodi_playlist_type)
|
||||
return playqueue
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue