mirror of
https://github.com/jellyfin/jellyfin-kodi.git
synced 2024-11-10 04:06:11 +00:00
Fix watched feedback and added General command
Everything in the remote control is supported except for audiostream and subtitleindex. Turns out the watched playcount bug was indeed a feedback, so to prevent this I'm skipping the first message that has the itemId right after marking watched.
This commit is contained in:
parent
6aeabc2e3f
commit
79e4bd8a6a
6 changed files with 152 additions and 58 deletions
|
@ -71,8 +71,18 @@ class DownloadUtils():
|
||||||
url = "{server}/mediabrowser/Sessions/Capabilities/Full"
|
url = "{server}/mediabrowser/Sessions/Capabilities/Full"
|
||||||
data = {
|
data = {
|
||||||
'PlayableMediaTypes': "Audio,Video",
|
'PlayableMediaTypes': "Audio,Video",
|
||||||
'SupportedCommands': "Play,Playstate,SendString,DisplayMessage,PlayNext",
|
'SupportsMediaControl': True,
|
||||||
'SupportsMediaControl': True
|
'SupportedCommands': (
|
||||||
|
|
||||||
|
"MoveUp,MoveDown,MoveLeft,MoveRight,Select,"
|
||||||
|
"Back,ToggleContextMenu,ToggleFullscreen,ToggleOsdMenu,"
|
||||||
|
"GoHome,PageUp,NextLetter,GoToSearch,"
|
||||||
|
"GoToSettings,PageDown,PreviousLetter,TakeScreenshot,"
|
||||||
|
"VolumeUp,VolumeDown,ToggleMute,SendString,DisplayMessage,"
|
||||||
|
|
||||||
|
"Mute,Unmute,SetVolume,"
|
||||||
|
"Play,Playstate,PlayNext"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logMsg("Capabilities URL: %s" % url, 2)
|
self.logMsg("Capabilities URL: %s" % url, 2)
|
||||||
|
|
|
@ -42,8 +42,9 @@ class Kodi_Monitor(xbmc.Monitor):
|
||||||
item = jsondata.get("item").get("id")
|
item = jsondata.get("item").get("id")
|
||||||
type = jsondata.get("item").get("type")
|
type = jsondata.get("item").get("type")
|
||||||
prop = WINDOW.getProperty('Played%s%s' % (type,item))
|
prop = WINDOW.getProperty('Played%s%s' % (type,item))
|
||||||
|
processWatched = WINDOW.getProperty('played_skipWatched')
|
||||||
|
|
||||||
if (playcount != None) and (prop != "true"):
|
if (playcount != None) and (prop != "true") and (processWatched != "true"):
|
||||||
WINDOW.setProperty("Played%s%s" % (type,item), "true")
|
WINDOW.setProperty("Played%s%s" % (type,item), "true")
|
||||||
utils.logMsg("MB# Sync","Kodi_Monitor--> VideoLibrary.OnUpdate : " + str(data),2)
|
utils.logMsg("MB# Sync","Kodi_Monitor--> VideoLibrary.OnUpdate : " + str(data),2)
|
||||||
WriteKodiVideoDB().updatePlayCountFromKodi(item, type, playcount)
|
WriteKodiVideoDB().updatePlayCountFromKodi(item, type, playcount)
|
||||||
|
@ -87,6 +88,7 @@ class Kodi_Monitor(xbmc.Monitor):
|
||||||
# triggers 3 times in a row.
|
# triggers 3 times in a row.
|
||||||
xbmc.sleep(100)
|
xbmc.sleep(100)
|
||||||
self.WINDOW.clearProperty("Played%s%s" % (type,id))
|
self.WINDOW.clearProperty("Played%s%s" % (type,id))
|
||||||
|
self.WINDOW.clearProperty('played_skipWatched')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -108,10 +108,17 @@ class PlaybackUtils():
|
||||||
resume_result = resumeScreen.select(self.language(30105), display_list)
|
resume_result = resumeScreen.select(self.language(30105), display_list)
|
||||||
if resume_result == 0:
|
if resume_result == 0:
|
||||||
WINDOW.setProperty(playurl+"seektime", str(seekTime))
|
WINDOW.setProperty(playurl+"seektime", str(seekTime))
|
||||||
|
elif resume_result < 0:
|
||||||
|
# User cancelled dialog
|
||||||
|
xbmc.log("Emby player -> User cancelled resume dialog.")
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
WINDOW.clearProperty(playurl+"seektime")
|
WINDOW.clearProperty(playurl+"seektime")
|
||||||
else:
|
else:
|
||||||
WINDOW.clearProperty(playurl+"seektime")
|
WINDOW.clearProperty(playurl+"seektime")
|
||||||
|
else:
|
||||||
|
# Playback started from library
|
||||||
|
WINDOW.setProperty(playurl+"seektime", str(seekTime))
|
||||||
|
|
||||||
if result.get("Type")=="Episode":
|
if result.get("Type")=="Episode":
|
||||||
WINDOW.setProperty(playurl+"refresh_id", result.get("SeriesId"))
|
WINDOW.setProperty(playurl+"refresh_id", result.get("SeriesId"))
|
||||||
|
|
|
@ -66,14 +66,13 @@ class Player( xbmc.Player ):
|
||||||
|
|
||||||
addonSettings = xbmcaddon.Addon(id='plugin.video.emby')
|
addonSettings = xbmcaddon.Addon(id='plugin.video.emby')
|
||||||
self.logMsg("emby Service -> played_information : " + str(self.played_information))
|
self.logMsg("emby Service -> played_information : " + str(self.played_information))
|
||||||
|
|
||||||
for item_url in self.played_information:
|
for item_url in self.played_information:
|
||||||
data = self.played_information.get(item_url)
|
data = self.played_information.get(item_url)
|
||||||
|
|
||||||
if (data is not None):
|
if (data is not None):
|
||||||
self.logMsg("emby Service -> item_url : " + item_url)
|
self.logMsg("emby Service -> item_url : " + item_url)
|
||||||
self.logMsg("emby Service -> item_data : " + str(data))
|
self.logMsg("emby Service -> item_data : " + str(data))
|
||||||
|
|
||||||
runtime = data.get("runtime")
|
runtime = data.get("runtime")
|
||||||
currentPosition = data.get("currentPosition")
|
currentPosition = data.get("currentPosition")
|
||||||
item_id = data.get("item_id")
|
item_id = data.get("item_id")
|
||||||
|
@ -81,6 +80,9 @@ class Player( xbmc.Player ):
|
||||||
currentFile = data.get("currentfile")
|
currentFile = data.get("currentfile")
|
||||||
type = data.get("Type")
|
type = data.get("Type")
|
||||||
|
|
||||||
|
# Prevent websocket feedback
|
||||||
|
self.WINDOW.setProperty("played_itemId", item_id)
|
||||||
|
|
||||||
if(currentPosition != None and self.hasData(runtime)):
|
if(currentPosition != None and self.hasData(runtime)):
|
||||||
runtimeTicks = int(runtime)
|
runtimeTicks = int(runtime)
|
||||||
self.logMsg("emby Service -> runtimeticks:" + str(runtimeTicks))
|
self.logMsg("emby Service -> runtimeticks:" + str(runtimeTicks))
|
||||||
|
@ -88,6 +90,10 @@ class Player( xbmc.Player ):
|
||||||
markPlayedAt = float(90) / 100
|
markPlayedAt = float(90) / 100
|
||||||
|
|
||||||
self.logMsg("emby Service -> Percent Complete:" + str(percentComplete) + " Mark Played At:" + str(markPlayedAt))
|
self.logMsg("emby Service -> Percent Complete:" + str(percentComplete) + " Mark Played At:" + str(markPlayedAt))
|
||||||
|
if percentComplete < markPlayedAt:
|
||||||
|
# Do not mark as watched
|
||||||
|
self.WINDOW.setProperty('played_skipWatched', 'true')
|
||||||
|
|
||||||
self.stopPlayback(data)
|
self.stopPlayback(data)
|
||||||
|
|
||||||
if percentComplete > .80 and data.get("Type") == "Episode" and addonSettings.getSetting("offerDelete")=="true":
|
if percentComplete > .80 and data.get("Type") == "Episode" and addonSettings.getSetting("offerDelete")=="true":
|
||||||
|
@ -116,28 +122,16 @@ class Player( xbmc.Player ):
|
||||||
self.logMsg("stopPlayback called", 2)
|
self.logMsg("stopPlayback called", 2)
|
||||||
|
|
||||||
item_id = data.get("item_id")
|
item_id = data.get("item_id")
|
||||||
audioindex = data.get("AudioStreamIndex")
|
|
||||||
subtitleindex = data.get("SubtitleStreamIndex")
|
|
||||||
playMethod = data.get("playmethod")
|
|
||||||
currentPosition = data.get("currentPosition")
|
currentPosition = data.get("currentPosition")
|
||||||
positionTicks = int(currentPosition * 10000000)
|
positionTicks = int(currentPosition * 10000000)
|
||||||
|
|
||||||
url = "{server}/mediabrowser/Sessions/Playing/Stopped"
|
url = "{server}/mediabrowser/Sessions/Playing/Stopped"
|
||||||
|
|
||||||
postdata = {
|
postdata = {
|
||||||
'QueueableMediaTypes': "Video",
|
|
||||||
'CanSeek': True,
|
|
||||||
'ItemId': item_id,
|
'ItemId': item_id,
|
||||||
'MediaSourceId': item_id,
|
'MediaSourceId': item_id,
|
||||||
'PlayMethod': playMethod,
|
|
||||||
'PositionTicks': positionTicks
|
'PositionTicks': positionTicks
|
||||||
}
|
}
|
||||||
|
|
||||||
if audioindex:
|
|
||||||
postdata['AudioStreamIndex'] = audioindex
|
|
||||||
|
|
||||||
if subtitleindex:
|
|
||||||
postdata['SubtitleStreamIndex'] = subtitleindex
|
|
||||||
|
|
||||||
self.doUtils.downloadUrl(url, postBody=postdata, type="POST")
|
self.doUtils.downloadUrl(url, postBody=postdata, type="POST")
|
||||||
|
|
||||||
|
@ -154,7 +148,7 @@ class Player( xbmc.Player ):
|
||||||
data = self.played_information.get(currentFile)
|
data = self.played_information.get(currentFile)
|
||||||
|
|
||||||
# only report playback if emby has initiated the playback (item_id has value)
|
# only report playback if emby has initiated the playback (item_id has value)
|
||||||
if (data is not None) and (data.get("item_id") is not None):
|
if data is not None and data.get("item_id") is not None:
|
||||||
|
|
||||||
# Get playback information
|
# Get playback information
|
||||||
item_id = data.get("item_id")
|
item_id = data.get("item_id")
|
||||||
|
@ -167,14 +161,22 @@ class Player( xbmc.Player ):
|
||||||
if paused is None:
|
if paused is None:
|
||||||
paused = False
|
paused = False
|
||||||
|
|
||||||
#url = "{server}/mediabrowser/Sessions/Playing/Progress"
|
# Get playback volume
|
||||||
|
volume_query = '{"jsonrpc": "2.0", "method": "Application.GetProperties", "params": {"properties": ["volume","muted"]}, "id": 1}'
|
||||||
|
result = xbmc.executeJSONRPC(volume_query)
|
||||||
|
result = json.loads(result)
|
||||||
|
volume = result.get(u'result').get(u'volume')
|
||||||
|
muted = result.get(u'result').get(u'muted')
|
||||||
|
|
||||||
postdata = {
|
postdata = {
|
||||||
'QueueableMediaTypes': "Video",
|
'QueueableMediaTypes': "Video",
|
||||||
'CanSeek': True,
|
'CanSeek': True,
|
||||||
'ItemId': item_id,
|
'ItemId': item_id,
|
||||||
'MediaSourceId': item_id,
|
'MediaSourceId': item_id,
|
||||||
|
'PlayMethod': playMethod,
|
||||||
'IsPaused': paused,
|
'IsPaused': paused,
|
||||||
'PlayMethod': playMethod
|
'VolumeLevel': volume,
|
||||||
|
'IsMuted': muted
|
||||||
}
|
}
|
||||||
|
|
||||||
if playTime:
|
if playTime:
|
||||||
|
@ -243,15 +245,17 @@ class Player( xbmc.Player ):
|
||||||
itemType = WINDOW.getProperty(currentFile + "type")
|
itemType = WINDOW.getProperty(currentFile + "type")
|
||||||
seekTime = WINDOW.getProperty(currentFile + "seektime")
|
seekTime = WINDOW.getProperty(currentFile + "seektime")
|
||||||
|
|
||||||
username = WINDOW.getProperty('currUser')
|
# Get playback volume
|
||||||
sessionId = WINDOW.getProperty('sessionId%s' % username)
|
volume_query = '{"jsonrpc": "2.0", "method": "Application.GetProperties", "params": {"properties": ["volume","muted"]}, "id": 1}'
|
||||||
|
result = xbmc.executeJSONRPC(volume_query)
|
||||||
if seekTime != "":
|
result = json.loads(result)
|
||||||
PlaybackUtils().seekToPosition(int(seekTime))
|
volume = result.get(u'result').get(u'volume')
|
||||||
|
muted = result.get(u'result').get(u'muted')
|
||||||
|
|
||||||
if (not item_id) or (len(item_id) == 0):
|
if seekTime:
|
||||||
self.logMsg("onPlayBackStarted: No info for current playing file", 0)
|
PlaybackUtils().seekToPosition(int(seekTime))
|
||||||
return
|
else:
|
||||||
|
seekTime = 0
|
||||||
|
|
||||||
url = "{server}/mediabrowser/Sessions/Playing"
|
url = "{server}/mediabrowser/Sessions/Playing"
|
||||||
postdata = {
|
postdata = {
|
||||||
|
@ -259,7 +263,10 @@ class Player( xbmc.Player ):
|
||||||
'CanSeek': True,
|
'CanSeek': True,
|
||||||
'ItemId': item_id,
|
'ItemId': item_id,
|
||||||
'MediaSourceId': item_id,
|
'MediaSourceId': item_id,
|
||||||
'PlayMethod': playMethod
|
'PlayMethod': playMethod,
|
||||||
|
'VolumeLevel': volume,
|
||||||
|
'PositionTicks': int(seekTime),
|
||||||
|
'IsMuted': muted
|
||||||
}
|
}
|
||||||
|
|
||||||
if audioindex:
|
if audioindex:
|
||||||
|
@ -268,24 +275,24 @@ class Player( xbmc.Player ):
|
||||||
if subtitleindex:
|
if subtitleindex:
|
||||||
postdata['SubtitleStreamIndex'] = subtitleindex
|
postdata['SubtitleStreamIndex'] = subtitleindex
|
||||||
|
|
||||||
|
# Post playback to server
|
||||||
self.logMsg("Sending POST play started.", 1)
|
self.logMsg("Sending POST play started.", 1)
|
||||||
#self.logMsg("emby Service -> Sending Post Play Started : " + url, 0)
|
self.doUtils.downloadUrl(url, postBody=postdata, type="POST")
|
||||||
self.doUtils.downloadUrl(url, postBody=postdata, type="POST")
|
|
||||||
|
|
||||||
# save data map for updates and position calls
|
# save data map for updates and position calls
|
||||||
data = {}
|
data = {
|
||||||
data["runtime"] = runtime
|
'runtime': runtime,
|
||||||
data["item_id"] = item_id
|
'item_id': item_id,
|
||||||
data["refresh_id"] = refresh_id
|
'refresh_id': refresh_id,
|
||||||
data["currentfile"] = currentFile
|
'currentfile': currentFile,
|
||||||
data["AudioStreamIndex"] = audioindex
|
'AudioStreamIndex': audioindex,
|
||||||
data["SubtitleStreamIndex"] = subtitleindex
|
'SubtitleStreamIndex': subtitleindex,
|
||||||
data["playmethod"] = playMethod
|
'playmethod': playMethod,
|
||||||
data["Type"] = itemType
|
'type': itemType,
|
||||||
|
'PositionTicks': int(seekTime)
|
||||||
|
}
|
||||||
self.played_information[currentFile] = data
|
self.played_information[currentFile] = data
|
||||||
|
self.logMsg("ADDING_FILE: %s" % self.played_information, 1)
|
||||||
self.logMsg("emby Service -> ADDING_FILE : " + currentFile, 0)
|
|
||||||
self.logMsg("emby Service -> ADDING_FILE : " + str(self.played_information), 0)
|
|
||||||
|
|
||||||
# log some playback stats
|
# log some playback stats
|
||||||
if(itemType != None):
|
if(itemType != None):
|
||||||
|
@ -303,7 +310,7 @@ class Player( xbmc.Player ):
|
||||||
self.playStats[playMethod] = 1
|
self.playStats[playMethod] = 1
|
||||||
|
|
||||||
# reset in progress position
|
# reset in progress position
|
||||||
self.reportPlayback()
|
#self.reportPlayback()
|
||||||
|
|
||||||
def GetPlayStats(self):
|
def GetPlayStats(self):
|
||||||
return self.playStats
|
return self.playStats
|
||||||
|
|
|
@ -99,8 +99,14 @@ class WebSocketThread(threading.Thread):
|
||||||
|
|
||||||
messageType = result.get("MessageType")
|
messageType = result.get("MessageType")
|
||||||
data = result.get("Data")
|
data = result.get("Data")
|
||||||
|
WINDOW = xbmcgui.Window( 10000 )
|
||||||
if(messageType != None and messageType == "Play" and data != None):
|
playedItemId = WINDOW.getProperty('played_itemId')
|
||||||
|
|
||||||
|
if (playedItemId != '') and (playedItemId in message):
|
||||||
|
# Prevent feedback for watched
|
||||||
|
WINDOW.clearProperty('played_itemId')
|
||||||
|
|
||||||
|
elif(messageType != None and messageType == "Play" and data != None):
|
||||||
itemIds = data.get("ItemIds")
|
itemIds = data.get("ItemIds")
|
||||||
playCommand = data.get("PlayCommand")
|
playCommand = data.get("PlayCommand")
|
||||||
|
|
||||||
|
@ -144,7 +150,6 @@ class WebSocketThread(threading.Thread):
|
||||||
|
|
||||||
elif(messageType != None and messageType == "UserDataChanged"):
|
elif(messageType != None and messageType == "UserDataChanged"):
|
||||||
# for now just do a full playcount sync
|
# for now just do a full playcount sync
|
||||||
WINDOW = xbmcgui.Window( 10000 )
|
|
||||||
self.logMsg("Message : Doing UserDataChanged", 0)
|
self.logMsg("Message : Doing UserDataChanged", 0)
|
||||||
userDataList = data.get("UserDataList")
|
userDataList = data.get("UserDataList")
|
||||||
self.logMsg("Message : Doing UserDataChanged : UserDataList : " + str(userDataList), 0)
|
self.logMsg("Message : Doing UserDataChanged : UserDataList : " + str(userDataList), 0)
|
||||||
|
@ -169,16 +174,77 @@ class WebSocketThread(threading.Thread):
|
||||||
|
|
||||||
elif messageType == "GeneralCommand":
|
elif messageType == "GeneralCommand":
|
||||||
|
|
||||||
if data.get("Name") == "DisplayMessage":
|
command = data.get("Name")
|
||||||
message = data.get("Arguments")
|
arguments = data.get("Arguments")
|
||||||
header = message[u'Header']
|
|
||||||
text = message[u'Text']
|
commandsPlayback = [
|
||||||
xbmcgui.Dialog().notification(header, text)
|
'Mute','Unmute','SetVolume',
|
||||||
|
'SetAudioStreamIndex'
|
||||||
|
]
|
||||||
|
|
||||||
elif data.get("Name") == "SendString":
|
if command in commandsPlayback:
|
||||||
message = data.get("Arguments")
|
# These commands need to be reported back
|
||||||
string = message[u'String']
|
if command == "Mute":
|
||||||
xbmcgui.Dialog().notification("Emby server", string)
|
xbmc.executebuiltin('Mute')
|
||||||
|
elif command == "Unmute":
|
||||||
|
xbmc.executebuiltin('Mute')
|
||||||
|
elif command == "SetVolume":
|
||||||
|
volume = arguments[u'Volume']
|
||||||
|
xbmc.executebuiltin('SetVolume(%s[,showvolumebar])' % volume)
|
||||||
|
# Report playback
|
||||||
|
WINDOW.setProperty('commandUpdate', 'true')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# GUI commands
|
||||||
|
if command == "ToggleFullscreen":
|
||||||
|
xbmc.executebuiltin('Action(FullScreen)')
|
||||||
|
elif command == "ToggleOsdMenu":
|
||||||
|
xbmc.executebuiltin('Action(OSD)')
|
||||||
|
elif command == "MoveUp":
|
||||||
|
xbmc.executebuiltin('Action(Up)')
|
||||||
|
elif command == "MoveDown":
|
||||||
|
xbmc.executebuiltin('Action(Down)')
|
||||||
|
elif command == "MoveLeft":
|
||||||
|
xbmc.executebuiltin('Action(Left)')
|
||||||
|
elif command == "MoveRight":
|
||||||
|
xbmc.executebuiltin('Action(Right)')
|
||||||
|
elif command == "Select":
|
||||||
|
xbmc.executebuiltin('Action(Select)')
|
||||||
|
elif command == "Back":
|
||||||
|
xbmc.executebuiltin('Action(back)')
|
||||||
|
elif command == "ToggleContextMenu":
|
||||||
|
xbmc.executebuiltin('Action(ContextMenu)')
|
||||||
|
elif command == "GoHome":
|
||||||
|
xbmc.executebuiltin('ActivateWindow(Home)')
|
||||||
|
elif command == "PageUp":
|
||||||
|
xbmc.executebuiltin('Action(PageUp)')
|
||||||
|
elif command == "NextLetter":
|
||||||
|
xbmc.executebuiltin('Action(NextLetter)')
|
||||||
|
elif command == "GoToSearch":
|
||||||
|
xbmc.executebuiltin('VideoLibrary.Search')
|
||||||
|
elif command == "GoToSettings":
|
||||||
|
xbmc.executebuiltin('ActivateWindow(Settings)')
|
||||||
|
elif command == "PageDown":
|
||||||
|
xbmc.executebuiltin('Action(PageDown)')
|
||||||
|
elif command == "PreviousLetter":
|
||||||
|
xbmc.executebuiltin('Action(PrevLetter)')
|
||||||
|
elif command == "TakeScreenshot":
|
||||||
|
xbmc.executebuiltin('TakeScreenshot')
|
||||||
|
elif command == "ToggleMute":
|
||||||
|
xbmc.executebuiltin('Mute')
|
||||||
|
elif command == "VolumeUp":
|
||||||
|
xbmc.executebuiltin('Action(VolumeUp)')
|
||||||
|
elif command == "VolumeDown":
|
||||||
|
xbmc.executebuiltin('Action(VolumeDown)')
|
||||||
|
elif command == "DisplayMessage":
|
||||||
|
header = arguments[u'Header']
|
||||||
|
text = arguments[u'Text']
|
||||||
|
xbmcgui.Dialog().notification(header, text)
|
||||||
|
elif command == "SendString":
|
||||||
|
string = arguments[u'String']
|
||||||
|
xbmcgui.Dialog().notification("Emby server", string)
|
||||||
|
else:
|
||||||
|
self.logMsg("Unknown command.", 1)
|
||||||
|
|
||||||
def remove_items(self, itemsRemoved):
|
def remove_items(self, itemsRemoved):
|
||||||
|
|
||||||
|
|
|
@ -98,6 +98,7 @@ class Service():
|
||||||
ws.start()
|
ws.start()
|
||||||
|
|
||||||
if xbmc.Player().isPlaying():
|
if xbmc.Player().isPlaying():
|
||||||
|
WINDOW.setProperty("Emby_Service_Timestamp", str(int(time.time())))
|
||||||
try:
|
try:
|
||||||
playTime = xbmc.Player().getTime()
|
playTime = xbmc.Player().getTime()
|
||||||
totalTime = xbmc.Player().getTotalTime()
|
totalTime = xbmc.Player().getTotalTime()
|
||||||
|
@ -131,6 +132,7 @@ class Service():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
WINDOW.setProperty("Emby_Service_Timestamp", str(int(time.time())))
|
||||||
#full sync
|
#full sync
|
||||||
if (startupComplete == False):
|
if (startupComplete == False):
|
||||||
self.logMsg("Doing_Db_Sync: syncDatabase (Started)")
|
self.logMsg("Doing_Db_Sync: syncDatabase (Started)")
|
||||||
|
|
Loading…
Reference in a new issue