From f0dae5e345133997397ea3b97f225de5c8bb4e6d Mon Sep 17 00:00:00 2001 From: Michele Giacomoli Date: Wed, 17 Dec 2025 01:56:32 +0100 Subject: [PATCH 01/11] Set scene title to filename as fallback if title is empty --- resources/lib/listing/listing.py | 4 +++- resources/lib/stash_interface.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/lib/listing/listing.py b/resources/lib/listing/listing.py index a93d503..0b7d12d 100644 --- a/resources/lib/listing/listing.py +++ b/resources/lib/listing/listing.py @@ -1,4 +1,5 @@ import json +import os.path from abc import ABC, abstractmethod from typing import Optional import xbmcgui @@ -74,7 +75,8 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: int, params: pass def _create_item(self, scene: dict, **kwargs): - title = kwargs['title'] if 'title' in kwargs else scene['title'] + title = kwargs['title'] if 'title' in kwargs else scene['title'] \ + if not scene['title'] == '' else os.path.split(scene['files'][0]['path'])[1] screenshot = kwargs['screenshot'] if 'screenshot' in kwargs else scene['paths']['screenshot'] file = scene['files'][0] # / 10 because rating is 1 to 100 and Kodi uses 1 to 10 diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index c28a177..40b0468 100644 --- a/resources/lib/stash_interface.py +++ b/resources/lib/stash_interface.py @@ -66,6 +66,7 @@ def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): audio_codec width height + path } studio { name @@ -124,6 +125,7 @@ def find_scene(self, id): audio_codec width height + path } studio { name @@ -155,6 +157,7 @@ def find_scene(self, id): audio_codec width height + path } studio { name @@ -305,6 +308,7 @@ def find_scene_markers(self, markers_filter=None, sort_field='title', sort_dir=0 audio_codec width height + path } studio { name From ef1bec94794b74b3eba0e92f91a76c472e85cbc3 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:16:17 +0200 Subject: [PATCH 02/11] Paging, home sections, sorting and faster loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large libraries (~9000 scenes) made every listing fetch everything at once, which made Kodi hang on low-end hardware. This change: - Pages all listings (scenes, markers, performers, tags, studios) with a "Next page" item; scene page size is configurable (20/40/60/100) - Adds home entries: Search, Recently added, Continue watching, Recently played, Random (seeded, so paging is stable), Favourite performers and Favourite tags - Adds a "Sort by…" context menu (title, date added, date, random, rating, play count, last played, duration, O-counter, reverse order) - Shows scene counts and favourites in performer/tag/studio listings, with images as thumb and poster - Replaces requests with urllib: importing requests took ~1.5 s per navigation on a Celeron N2840 (now ~0.25 s per listing in total) - Uses the InfoTagVideo API instead of the deprecated ListItem.setInfo (requires Kodi 20+, xbmc.python 3.0.1) - Uses sceneAddO, as sceneIncrementO no longer exists in current Stash - Uses the file's basename (without extension) as title fallback - Only lists the "Default" filter when Stash actually has one, and no longer sends "None" as sort field - Adds a Norwegian (nb_NO) translation; all labels are localizable Tested against Stash v0.31.1. --- addon.xml | 5 +- .../resource.language.en_gb/strings.po | 94 ++++- .../resource.language.nb_no/strings.po | 171 +++++++++ resources/lib/listing/listing.py | 109 +++--- resources/lib/listing/scene_listing.py | 27 +- resources/lib/listing/scene_marker_listing.py | 21 +- resources/lib/navigation/navigation_item.py | 45 ++- resources/lib/navigation/performer_item.py | 21 +- resources/lib/navigation/studio_item.py | 11 +- resources/lib/navigation/tag_item.py | 19 +- resources/lib/plugin.py | 72 +++- resources/lib/stash_interface.py | 329 ++++++++---------- resources/lib/utils/__init__.py | 20 +- resources/settings.xml | 3 + 14 files changed, 668 insertions(+), 279 deletions(-) create mode 100644 resources/language/resource.language.nb_no/strings.po diff --git a/addon.xml b/addon.xml index 3db2186..1a42429 100644 --- a/addon.xml +++ b/addon.xml @@ -1,8 +1,7 @@ - - + video @@ -10,6 +9,8 @@ View videos tracked in Stash Plugin to browse and view videos which are tracked in Stash (stashapp.io) + Se videoer som er organisert i Stash + Tillegg for å bla i og se videoer som er organisert i Stash (stashapp.cc) en all diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 060c49f..3c63797 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -76,4 +76,96 @@ msgstr "" msgctxt "#30015" msgid "Max Resolution" -msgstr "" \ No newline at end of file +msgstr "" + +msgctxt "#30016" +msgid "Search" +msgstr "" + +msgctxt "#30017" +msgid "Recently added" +msgstr "" + +msgctxt "#30018" +msgid "Continue watching" +msgstr "" + +msgctxt "#30019" +msgid "Recently played" +msgstr "" + +msgctxt "#30020" +msgid "Random" +msgstr "" + +msgctxt "#30021" +msgid "Favourite performers" +msgstr "" + +msgctxt "#30022" +msgid "Next page" +msgstr "" + +msgctxt "#30023" +msgid "page {} of {}" +msgstr "" + +msgctxt "#30024" +msgid "Sort by…" +msgstr "" + +msgctxt "#30025" +msgid "Title" +msgstr "" + +msgctxt "#30026" +msgid "Date added" +msgstr "" + +msgctxt "#30027" +msgid "Date" +msgstr "" + +msgctxt "#30028" +msgid "Rating" +msgstr "" + +msgctxt "#30029" +msgid "Play count" +msgstr "" + +msgctxt "#30030" +msgid "Last played" +msgstr "" + +msgctxt "#30031" +msgid "Duration" +msgstr "" + +msgctxt "#30032" +msgid "O-counter" +msgstr "" + +msgctxt "#30033" +msgid "Reverse order" +msgstr "" + +msgctxt "#30034" +msgid "Items per page" +msgstr "" + +msgctxt "#30035" +msgid "Interface" +msgstr "" + +msgctxt "#30036" +msgid "{} scenes" +msgstr "" + +msgctxt "#30037" +msgid "Search scenes" +msgstr "" + +msgctxt "#30038" +msgid "Favourite tags" +msgstr "" diff --git a/resources/language/resource.language.nb_no/strings.po b/resources/language/resource.language.nb_no/strings.po new file mode 100644 index 0000000..7682196 --- /dev/null +++ b/resources/language/resource.language.nb_no/strings.po @@ -0,0 +1,171 @@ +# Kodi Media Center language file +# Addon Name: Stash Kodi Plugin +# Addon id: plugin.video.stash +# Addon Provider: gitgiggety +msgid "" +msgstr "" +"Project-Id-Version: XBMC Addons\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: nb_NO\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#30000" +msgid "Base URL" +msgstr "Grunnadresse" + +msgctxt "#30001" +msgid "API Key" +msgstr "API-nøkkel" + +msgctxt "#30002" +msgid "All" +msgstr "Alle" + +msgctxt "#30003" +msgid "Performers" +msgstr "Utøvere" + +msgctxt "#30004" +msgid "Tags" +msgstr "Tagger" + +msgctxt "#30005" +msgid "Studios" +msgstr "Studioer" + +msgctxt "#30006" +msgid "Scenes" +msgstr "Scener" + +msgctxt "#30007" +msgid "Default" +msgstr "Standard" + +msgctxt "#30008" +msgid "Increment O-counter" +msgstr "Øk O-telleren" + +msgctxt "#30009" +msgid "Incremented O-counter to" +msgstr "O-telleren økt til" + +msgctxt "#30010" +msgid "Markers" +msgstr "Markører" + +msgctxt "#30011" +msgid "Markers of" +msgstr "Markører i" + +msgctxt "#30012" +msgid "Scene Tags" +msgstr "Scenetagger" + + +msgctxt "#30013" +msgid "Server Settings" +msgstr "Serverinnstillinger" + +msgctxt "#30014" +msgid "Playback Settings" +msgstr "Avspillingsinnstillinger" + +msgctxt "#30015" +msgid "Max Resolution" +msgstr "Maks oppløsning" + +msgctxt "#30016" +msgid "Search" +msgstr "Søk" + +msgctxt "#30017" +msgid "Recently added" +msgstr "Nylig lagt til" + +msgctxt "#30018" +msgid "Continue watching" +msgstr "Fortsett å se" + +msgctxt "#30019" +msgid "Recently played" +msgstr "Sist sett" + +msgctxt "#30020" +msgid "Random" +msgstr "Tilfeldig" + +msgctxt "#30021" +msgid "Favourite performers" +msgstr "Favoritt-utøvere" + +msgctxt "#30022" +msgid "Next page" +msgstr "Neste side" + +msgctxt "#30023" +msgid "page {} of {}" +msgstr "side {} av {}" + +msgctxt "#30024" +msgid "Sort by…" +msgstr "Sorter etter …" + +msgctxt "#30025" +msgid "Title" +msgstr "Tittel" + +msgctxt "#30026" +msgid "Date added" +msgstr "Lagt til" + +msgctxt "#30027" +msgid "Date" +msgstr "Dato" + +msgctxt "#30028" +msgid "Rating" +msgstr "Vurdering" + +msgctxt "#30029" +msgid "Play count" +msgstr "Antall avspillinger" + +msgctxt "#30030" +msgid "Last played" +msgstr "Sist avspilt" + +msgctxt "#30031" +msgid "Duration" +msgstr "Varighet" + +msgctxt "#30032" +msgid "O-counter" +msgstr "O-teller" + +msgctxt "#30033" +msgid "Reverse order" +msgstr "Snu rekkefølgen" + +msgctxt "#30034" +msgid "Items per page" +msgstr "Elementer per side" + +msgctxt "#30035" +msgid "Interface" +msgstr "Grensesnitt" + +msgctxt "#30036" +msgid "{} scenes" +msgstr "{} scener" + +msgctxt "#30037" +msgid "Search scenes" +msgstr "Søk i scener" + +msgctxt "#30038" +msgid "Favourite tags" +msgstr "Favoritt-tagger" diff --git a/resources/lib/listing/listing.py b/resources/lib/listing/listing.py index 0b7d12d..cf671bb 100644 --- a/resources/lib/listing/listing.py +++ b/resources/lib/listing/listing.py @@ -2,6 +2,7 @@ import os.path from abc import ABC, abstractmethod from typing import Optional +import xbmc import xbmcgui import xbmcplugin @@ -26,15 +27,28 @@ def list_items(self, params: dict): criterion = json.loads(params['criterion']) if 'criterion' in params else {} sort_field = params['sort_field'] if 'sort_field' in params else None sort_dir = params['sort_dir'] if 'sort_dir' in params else 'asc' + page = int(params.get('page', 1)) + per_page = utils.page_size() - xbmcplugin.setPluginCategory(self.handle, title) + (count, items) = self._create_items(criterion, sort_field, sort_dir, params, page=page, per_page=per_page) + pages = max(1, -(-count // per_page)) + + xbmcplugin.setPluginCategory(self.handle, title if pages == 1 else '{} ({})'.format( + title, local.get_localized(30023).format(page, pages))) xbmcplugin.setContent(self.handle, 'videos') - for (item, url) in self._create_items(criterion, sort_field, sort_dir, params): + for (item, url) in items: xbmcplugin.addDirectoryItem(self.handle, url, item, False) + if page < pages: + next_params = dict(params, page=page + 1) + item = xbmcgui.ListItem(label='{} ({})'.format(local.get_localized(30022), + local.get_localized(30023).format(page + 1, pages))) + item.setArt({'icon': 'DefaultFolder.png'}) + xbmcplugin.addDirectoryItem(self.handle, utils.get_url(**next_params), item, True) + xbmcplugin.addSortMethod(self.handle, xbmcplugin.SORT_METHOD_NONE) - xbmcplugin.endOfDirectory(self.handle) + xbmcplugin.endOfDirectory(self.handle, updateListing=page > 1) def get_root_item(self, override_title: str = "") -> (xbmcgui.ListItem, str): item = xbmcgui.ListItem(label=override_title if override_title != "" else self._label) @@ -50,18 +64,18 @@ def get_filters(self) -> [(xbmcgui.ListItem, str)]: default_filter = self._client.find_default_filter(self._filter_type) if default_filter is not None: items.append(self._create_item_from_filter(default_filter, local.get_localized(30007))) - else: - item = xbmcgui.ListItem(label=local.get_localized(30007)) - url = utils.get_url(list=self._type) - items.append((item, url)) saved_filters = self._client.find_saved_filters(self._filter_type) - for saved_filter in saved_filters: + for saved_filter in saved_filters or []: items.append(self._create_item_from_filter(saved_filter)) return items + def sort_options(self) -> [(str, str, str)]: + """(label, sort field, default direction) offered in the "Sort by" dialog""" + return [] + @abstractmethod def get_navigation(self) -> [NavigationItem]: pass @@ -71,49 +85,64 @@ def get_navigation_item(self, params: dict) -> Optional[NavigationItem]: pass @abstractmethod - def _create_items(self, criterion: dict, sort_field: str, sort_dir: int, params: dict) -> [(xbmcgui.ListItem, str)]: + def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: dict, + page: int = 1, per_page: int = 40) -> (int, [(xbmcgui.ListItem, str)]): pass + @staticmethod + def scene_title(scene: dict) -> str: + if scene.get('title'): + return scene['title'] + if scene.get('files'): + return os.path.splitext(scene['files'][0]['basename'])[0] + return scene['id'] + def _create_item(self, scene: dict, **kwargs): - title = kwargs['title'] if 'title' in kwargs else scene['title'] \ - if not scene['title'] == '' else os.path.split(scene['files'][0]['path'])[1] + title = kwargs['title'] if 'title' in kwargs else self.scene_title(scene) screenshot = kwargs['screenshot'] if 'screenshot' in kwargs else scene['paths']['screenshot'] - file = scene['files'][0] - # / 10 because rating is 1 to 100 and Kodi uses 1 to 10 - rating = round(scene['rating100'] / 10 if 'rating100' in scene and scene['rating100'] is not None else 0) - duration = int(file['duration']) + file = scene['files'][0] if scene['files'] else {} + duration = int(file.get('duration') or 0) + item = xbmcgui.ListItem(label=title) - item.setInfo('video', {'title': title, - 'mediatype': 'video', - 'plot': scene['details'], - 'cast': list(map(lambda p: p['name'], scene['performers'])), - 'duration': duration, - 'playcount': scene.get("play_count", 0), - 'studio': scene['studio']['name'] if scene['studio'] is not None else None, - 'userrating': rating, - 'premiered': scene['date'], - 'tag': list(map(lambda t: t['name'], scene['tags'])), - 'dateadded': scene['created_at'], - 'lastplayed': scene["last_played_at"] - }) - - item.addStreamInfo('video', {'codec': file['video_codec'], - 'width': file['width'], - 'height': file['height'], - 'duration': duration}) - - item.addStreamInfo('audio', {'codec': file['audio_codec']}) + info = item.getVideoInfoTag() + info.setMediaType('video') + info.setTitle(title) + info.setPlot(scene['details'] or '') + info.setCast([xbmc.Actor(p['name'], thumbnail=self._client.add_api_key(p.get('image_path')) or '') + for p in scene['performers']]) + info.setDuration(duration) + info.setPlaycount(scene.get('play_count') or 0) + if scene['studio'] is not None: + info.setStudios([scene['studio']['name']]) + # / 10 because rating is 1 to 100 and Kodi uses 1 to 10 + info.setUserRating(round((scene.get('rating100') or 0) / 10)) + if scene['date']: + info.setPremiered(scene['date']) + info.setYear(int(scene['date'][:4])) + info.setTags([t['name'] for t in scene['tags']]) + info.setDateAdded(utils.kodi_datetime(scene['created_at'])) + info.setLastPlayed(utils.kodi_datetime(scene.get('last_played_at'))) + info.setResumePoint(float(scene.get('resume_time') or 0), float(duration)) + if file: + info.addVideoStream(xbmc.VideoStreamDetail(width=file['width'], height=file['height'], + codec=file['video_codec'] or '', duration=duration)) + info.addAudioStream(xbmc.AudioStreamDetail(codec=file['audio_codec'] or '')) screenshot = self._client.add_api_key(screenshot) art_dict = {'thumb': screenshot, 'fanart': screenshot} - if scene['studio']: - art_dict["banner"] = scene["studio"]["image_path"] + if scene['studio'] and scene['studio'].get('image_path'): + art_dict["banner"] = self._client.add_api_key(scene["studio"]["image_path"]) item.setArt(art_dict) - item.setProperty('ResumeTime', str(0.0 if not scene["resume_time"] else float(scene["resume_time"]))) item.setProperty('IsPlayable', 'true') return item + def _context_menu(self, params: dict) -> [(str, str)]: + if not self.sort_options(): + return [] + sort_params = dict(params, choose_sort=self._type) + return [(local.get_localized(30024), 'RunPlugin({})'.format(utils.get_url(**sort_params)))] + @staticmethod def _create_play_url(scene_id: int, **kwargs): kwargs['play'] = scene_id @@ -125,8 +154,8 @@ def _set_title(self, title: str): def _create_item_from_filter(self, filter: dict, override_title=None) -> [(xbmcgui.ListItem, str)]: title = override_title if override_title is not None else filter['name'] item = xbmcgui.ListItem(label=title) - find_filter = filter['find_filter'] - object_filter = filter['object_filter'] + find_filter = filter['find_filter'] or {} + object_filter = filter['object_filter'] or {} criterion_json = json.dumps(criterion_parser.parse(object_filter)) url = utils.get_url(list=self._type, diff --git a/resources/lib/listing/scene_listing.py b/resources/lib/listing/scene_listing.py index 6fe0ca9..aed1878 100644 --- a/resources/lib/listing/scene_listing.py +++ b/resources/lib/listing/scene_listing.py @@ -22,13 +22,32 @@ def get_navigation_item(self, params: dict) -> Optional[NavigationItem]: browse = params['browse'] if browse == 'performers': return PerformerItem(self._client, 'scenes') + if browse == 'favorite_performers': + return PerformerItem(self._client, 'scenes', favorites=True) if browse == 'tags': return TagItem(self._client, 'scenes') + if browse == 'favorite_tags': + return TagItem(self._client, 'scenes', favorites=True) if browse == 'studios': return StudioItem(self._client, 'scenes') - def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: dict): - (count, scenes) = self._client.find_scenes(criterion, sort_field, sort_dir) + def sort_options(self): + return [ + (local.get_localized(30025), 'title', 'asc'), + (local.get_localized(30026), 'created_at', 'desc'), + (local.get_localized(30027), 'date', 'desc'), + (local.get_localized(30020), 'random', 'asc'), + (local.get_localized(30028), 'rating', 'desc'), + (local.get_localized(30029), 'play_count', 'desc'), + (local.get_localized(30030), 'last_played_at', 'desc'), + (local.get_localized(30031), 'duration', 'desc'), + (local.get_localized(30032), 'o_counter', 'desc'), + ] + + def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: dict, page=1, per_page=40): + (count, scenes) = self._client.find_scenes(criterion, sort_field, sort_dir, page=page, per_page=per_page, + q=params.get('q')) + sort_menu = self._context_menu(params) items = [] for scene in scenes: item = self._create_item(scene) @@ -40,8 +59,8 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: 'ActivateWindow(videos, {})'.format(get_url(list='scene_markers', scene=scene['id'])))) menu.append( (local.get_localized(30008), 'RunPlugin({})'.format(get_url(increment_o='', scene=scene['id'])))) - item.addContextMenuItems(menu) + item.addContextMenuItems(menu + sort_menu) items.append((item, url)) - return items + return count, items diff --git a/resources/lib/listing/scene_marker_listing.py b/resources/lib/listing/scene_marker_listing.py index b2b88d1..74756ac 100644 --- a/resources/lib/listing/scene_marker_listing.py +++ b/resources/lib/listing/scene_marker_listing.py @@ -26,24 +26,35 @@ def get_navigation_item(self, params: dict) -> Optional[NavigationItem]: if browse == 'scene_tags': return TagItem(self._client, 'scene_markers', type='scene_tags', label=local.get_localized(30012)) - def _create_items(self, criterion: dict, sort_field: str, sort_dir: int, params: dict): + def sort_options(self): + return [ + (local.get_localized(30025), 'title', 'asc'), + (local.get_localized(30026), 'created_at', 'desc'), + (local.get_localized(30020), 'random', 'asc'), + ] + + def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: dict, page=1, per_page=40): if 'scene' in params: scene = self._client.find_scene(params['scene']) - self._set_title('{} {}'.format(local.get_localized(30011), scene['title'])) + self._set_title('{} {}'.format(local.get_localized(30011), self.scene_title(scene))) markers = scene['scene_markers'] + count = len(markers) else: - (_, markers) = self._client.find_scene_markers(criterion, sort_field, sort_dir) + (count, markers) = self._client.find_scene_markers(criterion, sort_field, sort_dir, page=page, + per_page=per_page, q=params.get('q')) + sort_menu = [] if 'scene' in params else self._context_menu(params) items = [] for marker in markers: - title = '{} - {}'.format(marker['title'], marker['primary_tag']['name']) + title = '{} - {}'.format(marker['title'] or self.scene_title(marker['scene']), marker['primary_tag']['name']) item = self._create_item(marker['scene'], title=title, screenshot=marker['screenshot']) url = self._create_play_url(marker['scene']['id']) duration = marker['scene']['files'][0]['duration'] item.setProperty('StartPercent', str(round(marker['seconds'] / duration * 100, 2))) + item.addContextMenuItems(sort_menu) items.append((item, url)) - return items + return count, items diff --git a/resources/lib/navigation/navigation_item.py b/resources/lib/navigation/navigation_item.py index d14dff1..7ef379f 100644 --- a/resources/lib/navigation/navigation_item.py +++ b/resources/lib/navigation/navigation_item.py @@ -5,6 +5,7 @@ from resources.lib import utils from resources.lib.stash_interface import StashInterface +from resources.lib.utils import local class NavigationItem(ABC): @@ -22,28 +23,48 @@ def get_root_item(self) -> (xbmcgui.ListItem, str): return item, url - def list_items(self): - xbmcplugin.setPluginCategory(self.handle, self._label) + def list_items(self, params: dict = None): + params = params or {} + page = int(params.get('page', 1)) + per_page = 100 + + (count, items) = self._create_items(page=page, per_page=per_page) + pages = max(1, -(-count // per_page)) + + xbmcplugin.setPluginCategory(self.handle, self._label if pages == 1 else '{} ({})'.format( + self._label, local.get_localized(30023).format(page, pages))) xbmcplugin.setContent(self.handle, 'videos') - for (item, url) in self._create_items(): + for (item, url) in items: xbmcplugin.addDirectoryItem(self.handle, url, item, True) + if page < pages: + item = xbmcgui.ListItem(label='{} ({})'.format(local.get_localized(30022), + local.get_localized(30023).format(page + 1, pages))) + item.setArt({'icon': 'DefaultFolder.png'}) + xbmcplugin.addDirectoryItem(self.handle, utils.get_url(**dict(params, page=page + 1)), item, True) + xbmcplugin.addSortMethod(self.handle, xbmcplugin.SORT_METHOD_NONE) - xbmcplugin.endOfDirectory(self.handle) + xbmcplugin.endOfDirectory(self.handle, updateListing=page > 1) @abstractmethod - def _create_items(self) -> [(xbmcgui.ListItem, str)]: + def _create_items(self, page: int = 1, per_page: int = 100) -> (int, [(xbmcgui.ListItem, str)]): pass - def _create_item(self, title: str, description: str = '', image_path: str = '') -> xbmcgui.ListItem: - item = xbmcgui.ListItem(label=title) - item.setInfo('video', {'title': title, - 'mediatype': 'video', - 'plot': description}) + def _create_item(self, title: str, description: str = '', image_path: str = '', count: int = None, + favorite: bool = False) -> xbmcgui.ListItem: + label = '★ ' + title if favorite else title + item = xbmcgui.ListItem(label=label, label2=str(count) if count is not None else '') + info = item.getVideoInfoTag() + info.setMediaType('video') + info.setTitle(label) + info.setPlot(description or '') + if count is not None: + info.setTagLine(local.get_localized(30036).format(count)) - if image_path != '': - item.setArt({'thumb': self._client.add_api_key(image_path)}) + if image_path: + image = self._client.add_api_key(image_path) + item.setArt({'thumb': image, 'poster': image}) return item diff --git a/resources/lib/navigation/performer_item.py b/resources/lib/navigation/performer_item.py index 2e14c2b..3e30ab5 100644 --- a/resources/lib/navigation/performer_item.py +++ b/resources/lib/navigation/performer_item.py @@ -4,16 +4,23 @@ class PerformerItem(NavigationItem): - def __init__(self, client: StashInterface, browse_for: str): - NavigationItem.__init__(self, client, 'performers', local.get_localized(30003), browse_for) + def __init__(self, client: StashInterface, browse_for: str, favorites: bool = False): + NavigationItem.__init__(self, client, 'favorite_performers' if favorites else 'performers', + local.get_localized(30021 if favorites else 30003), browse_for) + self._favorites = favorites - def _create_items(self): - (count, performers) = self._client.find_performers() + def _create_items(self, page=1, per_page=100): + performer_filter = {'filter_favorites': True} if self._favorites else None + (count, performers) = self._client.find_performers(performer_filter, page=page, per_page=per_page) items = [] for performer in performers: + name = performer['name'] + if performer.get('disambiguation'): + name = '{} ({})'.format(name, performer['disambiguation']) criterion = {'performers': {'modifier': 'INCLUDES_ALL', 'value': [performer['id']]}} - item = self._create_item(performer['name'], performer['details'], performer['image_path']) - url = self._create_url(performer['name'], criterion) + item = self._create_item(name, performer['details'], performer['image_path'], + performer.get('scene_count'), performer.get('favorite', False)) + url = self._create_url(name, criterion, sort_field='date', sort_dir='desc') items.append((item, url)) - return items + return count, items diff --git a/resources/lib/navigation/studio_item.py b/resources/lib/navigation/studio_item.py index 6bef31f..3135eb5 100644 --- a/resources/lib/navigation/studio_item.py +++ b/resources/lib/navigation/studio_item.py @@ -7,13 +7,14 @@ class StudioItem(NavigationItem): def __init__(self, client: StashInterface, browse_for: str): NavigationItem.__init__(self, client, 'studios', local.get_localized(30005), browse_for) - def _create_items(self): - (count, studios) = self._client.find_studios() + def _create_items(self, page=1, per_page=100): + (count, studios) = self._client.find_studios(page=page, per_page=per_page) items = [] for studio in studios: criterion = {'studios': {'modifier': 'INCLUDES_ALL', 'value': [studio['id']], 'depth': 0}} - item = self._create_item(studio['name'], studio['details'], studio['image_path']) - url = self._create_url(studio['name'], criterion) + item = self._create_item(studio['name'], studio['details'], studio['image_path'], + studio.get('scene_count'), studio.get('favorite', False)) + url = self._create_url(studio['name'], criterion, sort_field='date', sort_dir='desc') items.append((item, url)) - return items + return count, items diff --git a/resources/lib/navigation/tag_item.py b/resources/lib/navigation/tag_item.py index 6fc602b..239842e 100644 --- a/resources/lib/navigation/tag_item.py +++ b/resources/lib/navigation/tag_item.py @@ -5,19 +5,24 @@ class TagItem(NavigationItem): def __init__(self, client: StashInterface, browse_for: str, **kwargs): - type = kwargs['type'] if 'type' in kwargs else 'tags' - label = kwargs['label'] if 'label' in kwargs else local.get_localized(30004) + self._favorites = kwargs.get('favorites', False) + type = kwargs['type'] if 'type' in kwargs else 'favorite_tags' if self._favorites else 'tags' + label = kwargs['label'] if 'label' in kwargs else local.get_localized(30038 if self._favorites else 30004) NavigationItem.__init__(self, client, type, label, browse_for) - self._filter_name = kwargs['filter_name'] if 'filter_name' in kwargs else type + self._filter_name = kwargs['filter_name'] if 'filter_name' in kwargs else 'tags' if self._favorites else type - def _create_items(self): - (count, tags) = self._client.find_tags(has_type='scenes' if self._type == 'scene_tags' else self._browse_for) + def _create_items(self, page=1, per_page=100): + has_type = 'scenes' if self._type == 'scene_tags' else self._browse_for + (count, tags) = self._client.find_tags(has_type=has_type, page=page, per_page=per_page, + favorites=self._favorites) items = [] for tag in tags: criterion = {self._filter_name: {'modifier': 'INCLUDES_ALL', 'value': [tag['id']]}} - item = self._create_item(tag['name'], image_path=tag['image_path']) + scenes = tag.get('scene_marker_count') if has_type == 'scene_markers' else tag.get('scene_count') + item = self._create_item(tag['name'], tag.get('description'), tag['image_path'], scenes, + tag.get('favorite', False)) url = self._create_url(tag['name'], criterion) items.append((item, url)) - return items + return count, items diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index f3602cf..ab2f074 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -1,3 +1,5 @@ +import json +import random import sys from urllib.parse import parse_qsl from typing import Optional @@ -7,7 +9,7 @@ import xbmcplugin from resources.lib import utils from resources.lib.listing import create_listing, Listing, SceneListing -from resources.lib.navigation import NavigationItem +from resources.lib.navigation import NavigationItem, PerformerItem, TagItem from resources.lib.stash_interface import StashInterface utils.BASE_URL = sys.argv[0] @@ -48,22 +50,43 @@ def run(): router(sys.argv[2][1:]) +def _folder(label: str, url: str, icon: str = 'DefaultFolder.png', is_folder: bool = True): + item = xbmcgui.ListItem(label=label) + item.setArt({'icon': icon}) + xbmcplugin.addDirectoryItem(_HANDLE, url, item, is_folder) + + def browse_root(): xbmcplugin.setPluginCategory(_HANDLE, 'Stash') xbmcplugin.setContent(_HANDLE, 'videos') + loc = utils.local.get_localized + _folder(loc(30016), utils.get_url(search='scenes'), 'DefaultAddonsSearch.png', is_folder=False) + _folder(loc(30017), utils.get_url(list='scenes', title=loc(30017), sort_field='created_at', sort_dir='desc'), + 'DefaultRecentlyAddedMovies.png') + _folder(loc(30018), utils.get_url(list='scenes', title=loc(30018), sort_field='last_played_at', sort_dir='desc', + criterion=json.dumps({'resume_time': {'modifier': 'GREATER_THAN', 'value': 0}})), + 'DefaultInProgressShows.png') + _folder(loc(30019), utils.get_url(list='scenes', title=loc(30019), sort_field='last_played_at', sort_dir='desc', + criterion=json.dumps({'play_count': {'modifier': 'GREATER_THAN', 'value': 0}})), + 'DefaultRecentlyAddedEpisodes.png') + _folder(loc(30020), utils.get_url(list='scenes', title=loc(30020), sort_field=random_sort())) + for favorites in (PerformerItem(client, 'scenes', favorites=True), TagItem(client, 'scenes', favorites=True)): + (item, url) = favorites.get_root_item() + xbmcplugin.addDirectoryItem(_HANDLE, url, item, True) + listing = SceneListing(client) for (item, url) in listing.get_filters(): xbmcplugin.addDirectoryItem(_HANDLE, url, item, True) - (item, url) = listing.get_root_item(utils.local.get_localized(30002)) + (item, url) = listing.get_root_item(loc(30002)) xbmcplugin.addDirectoryItem(_HANDLE, url, item, True) for navItem in listing.get_navigation(): (item, url) = navItem.get_root_item() xbmcplugin.addDirectoryItem(_HANDLE, url, item, True) - item = xbmcgui.ListItem(label=utils.local.get_localized(30010)) + item = xbmcgui.ListItem(label=loc(30010)) url = utils.get_url(browse='scene_markers') xbmcplugin.addDirectoryItem(_HANDLE, url, item, True) @@ -98,7 +121,40 @@ def list_items(params: dict): def browse_for(params: dict): listing = create_listing(params['browse_for'], client) navigation = listing.get_navigation_item(params) - navigation.list_items() + navigation.list_items(params) + + +def random_sort() -> str: + # A seed keeps the order stable while paging, like the random sort in Stash' UI + return 'random_{}'.format(random.randint(0, 99999999)) + + +def search(params: dict): + loc = utils.local.get_localized + query = xbmcgui.Dialog().input(loc(30037)) + if query: + url = utils.get_url(list=params['search'], q=query, title='{}: {}'.format(loc(30016), query), + sort_field='date', sort_dir='desc') + xbmc.executebuiltin('Container.Update({})'.format(url)) + + +def choose_sort(params: dict): + listing = create_listing(params.pop('choose_sort'), client) + options = listing.sort_options() + labels = [label for (label, _, _) in options] + [utils.local.get_localized(30033)] + choice = xbmcgui.Dialog().select(utils.local.get_localized(30024), labels) + if choice < 0: + return + + params.pop('page', None) + if choice == len(options): + params['sort_dir'] = 'asc' if params.get('sort_dir', 'asc').lower() == 'desc' else 'desc' + else: + (_, field, direction) = options[choice] + params['sort_field'] = random_sort() if field == 'random' else field + params['sort_dir'] = direction + + xbmc.executebuiltin('Container.Update({},replace)'.format(utils.get_url(**params))) def get_mp4_stream(sceneStreams: dict) -> str: @@ -132,7 +188,7 @@ def play(params: dict): def increment_o(params: dict): if 'scene' in params: - o_count = client.scene_increment_o(params['scene']) + o_count = client.scene_add_o(params['scene']) xbmc.executebuiltin('Notification(Stash, {} {})'.format(utils.local.get_localized(30009), o_count)) @@ -140,7 +196,11 @@ def router(param_string: str): params = dict(parse_qsl(param_string, keep_blank_values=True)) if params: - if 'browse_for' in params: + if 'choose_sort' in params: + choose_sort(params) + elif 'search' in params: + search(params) + elif 'browse_for' in params: browse_for(params) elif 'browse' in params: browse(params) diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index 40b0468..c16e78c 100644 --- a/resources/lib/stash_interface.py +++ b/resources/lib/stash_interface.py @@ -1,53 +1,10 @@ -import requests +import gzip +import json +import urllib.error import urllib.parse +import urllib.request - -class StashInterface: - def __init__(self, url, api_key): - if not url.endswith('/graphql'): - url = "{0}/graphql".format(url.rstrip("/")) - - self._headers = { - "Accept-Encoding": "gzip, deflate, br", - "Content-Type": "application/json", - "Accept": "application/json", - "Connection": "keep-alive", - "ApiKey": api_key - } - self._url = url - self._api_key = api_key - - def add_api_key(self, url: str): - if self._api_key: - url = "{}{}apikey={}".format(url, '&' if '?' in url else '?', urllib.parse.quote(self._api_key)) - - return url - - def __call_graphql(self, query, variables=None): - json = {'query': query} - if variables is not None: - json['variables'] = variables - - response = requests.post(self._url, json=json, headers=self._headers) - - if response.status_code == 200: - result = response.json() - if result.get("errors", None): - for error in result["errors"]: - raise Exception("GraphQL error: {}".format(error['message'])) - if result.get("data", None): - return result.get("data") - else: - raise Exception( - "GraphQL query failed:{} - {}. Query: {}. Variables: {}".format(response.status_code, response.content, - query, variables)) - - def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): - query = """ -query findScenes($scene_filter: SceneFilterType, $filter: FindFilterType!) { - findScenes(scene_filter: $scene_filter, filter: $filter) { - count - scenes { +SCENE_FIELDS = """ id title details @@ -57,16 +14,17 @@ def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): play_count resume_time last_played_at + o_counter paths { screenshot } files { + basename duration video_codec audio_codec width height - path } studio { name @@ -74,10 +32,95 @@ def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): } performers { name + image_path + } + tags { + name + } +""" + +MARKER_FIELDS = """ + id + title + seconds + screenshot + scene { +""" + SCENE_FIELDS + """ + } + primary_tag { + id + name } tags { + id name } +""" + + +class StashInterface: + def __init__(self, url, api_key): + if not url.endswith('/graphql'): + url = "{0}/graphql".format(url.rstrip("/")) + + self._headers = { + "Accept-Encoding": "gzip", + "Content-Type": "application/json", + "Accept": "application/json", + "ApiKey": api_key + } + self._url = url + self._api_key = api_key + + def add_api_key(self, url: str): + if url and self._api_key: + url = "{}{}apikey={}".format(url, '&' if '?' in url else '?', urllib.parse.quote(self._api_key)) + + return url + + def __call_graphql(self, query, variables=None): + body = {'query': query} + if variables is not None: + body['variables'] = variables + + # urllib instead of requests: importing requests takes over a second on low-end devices + request = urllib.request.Request(self._url, data=json.dumps(body).encode('utf-8'), headers=self._headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + content = response.read() + if response.headers.get('Content-Encoding') == 'gzip': + content = gzip.decompress(content) + except urllib.error.HTTPError as e: + raise Exception( + "GraphQL query failed:{} - {}. Query: {}. Variables: {}".format(e.code, e.read(), query, variables)) + + result = json.loads(content.decode('utf-8')) + if result.get("errors", None): + for error in result["errors"]: + raise Exception("GraphQL error: {}".format(error['message'])) + if result.get("data", None): + return result.get("data") + + @staticmethod + def _find_filter(sort_field, sort_dir, page, per_page, q=None): + find_filter = { + 'page': page, + 'per_page': per_page, + 'sort': sort_field, + 'direction': 'DESC' if str(sort_dir).lower() in ('desc', '1') else 'ASC' + } + if q: + find_filter['q'] = q + + return find_filter + + def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc', page=1, per_page=40, q=None): + query = """ +query findScenes($scene_filter: SceneFilterType, $filter: FindFilterType!) { + findScenes(scene_filter: $scene_filter, filter: $filter) { + count + scenes { +""" + SCENE_FIELDS + """ scene_markers { id } @@ -86,11 +129,7 @@ def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): } """ - variables = {'filter': { - 'per_page': -1, - 'sort': sort_field if sort_field is not None else 'title', - 'direction': 'DESC' if sort_dir.lower() == 'desc' else 'ASC' - }} + variables = {'filter': self._find_filter(sort_field or 'title', sort_dir, page, per_page, q)} if scene_filter is not None: variables['scene_filter'] = scene_filter @@ -120,12 +159,12 @@ def find_scene(self, id): screenshot } files { + basename duration video_codec audio_codec width height - path } studio { name @@ -137,46 +176,7 @@ def find_scene(self, id): name } scene_markers { - id - title - seconds - screenshot - scene { - id - title - details - rating100 - date - created_at - paths { - screenshot - } - files { - duration - video_codec - audio_codec - width - height - path - } - studio { - name - } - performers { - name - } - tags { - name - } - } - primary_tag { - id - name - } - tags { - id - name - } +""" + MARKER_FIELDS + """ } } } @@ -186,7 +186,7 @@ def find_scene(self, id): return self.__call_graphql(query, variables)['findScene'] - def find_performers(self, **kwargs): + def find_performers(self, performer_filter=None, sort_field='name', sort_dir='asc', page=1, per_page=100, q=None): query = """ query findPerformers($performer_filter: PerformerFilterType, $filter: FindFilterType!) { findPerformers(performer_filter: $performer_filter, filter: $filter) { @@ -194,30 +194,27 @@ def find_performers(self, **kwargs): performers { id name + disambiguation details image_path + favorite + scene_count } } } """ - variables = {'filter': { - 'per_page': -1, - 'sort': 'name' - }, - 'performer_filter': { - 'scene_count': { - 'modifier': 'GREATER_THAN', - 'value': 0, - } - } - } + performer_filter = dict(performer_filter or {}) + performer_filter.setdefault('scene_count', {'modifier': 'GREATER_THAN', 'value': 0}) + variables = {'filter': self._find_filter(sort_field, sort_dir, page, per_page, q), + 'performer_filter': performer_filter} result = self.__call_graphql(query, variables) return result["findPerformers"]["count"], result["findPerformers"]["performers"] - def find_tags(self, **kwargs): + def find_tags(self, has_type=None, sort_field='name', sort_dir='asc', page=1, per_page=100, q=None, + favorites=False): query = """ query findTags($tag_filter: TagFilterType, $filter: FindFilterType!) { findTags(tag_filter: $tag_filter, filter: $filter) { @@ -225,33 +222,31 @@ def find_tags(self, **kwargs): tags { id name + description image_path + favorite + scene_count + scene_marker_count } } } """ - filter = {} - if 'has_type' in kwargs: - count_type = 'marker_count' if kwargs['has_type'] == 'scene_markers' else 'scene_count' + tag_filter = {} + if has_type is not None: + count_type = 'marker_count' if has_type == 'scene_markers' else 'scene_count' + tag_filter[count_type] = {'modifier': 'GREATER_THAN', 'value': 0} + if favorites: + tag_filter['favorite'] = True - filter[count_type] = { - 'modifier': 'GREATER_THAN', - 'value': 0, - } - - variables = {'filter': { - 'per_page': -1, - 'sort': 'name' - }, - 'tag_filter': filter - } + variables = {'filter': self._find_filter(sort_field, sort_dir, page, per_page, q), + 'tag_filter': tag_filter} result = self.__call_graphql(query, variables) return result["findTags"]["count"], result["findTags"]["tags"] - def find_studios(self): + def find_studios(self, sort_field='name', sort_dir='asc', page=1, per_page=100, q=None): query = """ query findStudios($studio_filter: StudioFilterType, $filter: FindFilterType!) { findStudios(studio_filter: $studio_filter, filter: $filter) { @@ -261,83 +256,33 @@ def find_studios(self): name image_path details + favorite + scene_count } } } """ - variables = {'filter': { - 'per_page': -1, - 'sort': 'name' - }, - 'studio_filter': { - 'scene_count': { - 'modifier': 'GREATER_THAN', - 'value': 0, - } - } - } + variables = {'filter': self._find_filter(sort_field, sort_dir, page, per_page, q), + 'studio_filter': {'scene_count': {'modifier': 'GREATER_THAN', 'value': 0}}} result = self.__call_graphql(query, variables) return result["findStudios"]["count"], result["findStudios"]["studios"] - def find_scene_markers(self, markers_filter=None, sort_field='title', sort_dir=0): + def find_scene_markers(self, markers_filter=None, sort_field='title', sort_dir='asc', page=1, per_page=40, q=None): query = """ query findSceneMarkers($markers_filter: SceneMarkerFilterType, $filter: FindFilterType!) { findSceneMarkers(scene_marker_filter: $markers_filter, filter: $filter) { count scene_markers { - id - title - seconds - screenshot - scene { - id - title - details - rating100 - date - created_at - paths { - screenshot - } - files { - duration - video_codec - audio_codec - width - height - path - } - studio { - name - } - performers { - name - } - tags { - name - } - } - primary_tag { - id - name - } - tags { - id - name - } +""" + MARKER_FIELDS + """ } } } """ - variables = {'filter': { - 'per_page': -1, - 'sort': sort_field if sort_field is not None else 'title', - 'direction': 'DESC' if sort_dir == 1 else 'ASC' - }} + variables = {'filter': self._find_filter(sort_field or 'title', sort_dir, page, per_page, q)} if markers_filter is not None: variables['markers_filter'] = markers_filter @@ -392,15 +337,21 @@ def find_default_filter(self, mode): return result['findDefaultFilter'] - def scene_increment_o(self, id): - query = """ + def scene_add_o(self, id): + # sceneIncrementO was removed from Stash, sceneAddO replaces it + try: + result = self.__call_graphql(""" +mutation sceneAddO($id: ID!) { + sceneAddO(id: $id) { + count + } +} +""", {'id': id}) + return result["sceneAddO"]["count"] + except Exception: + result = self.__call_graphql(""" mutation sceneIncrementO($id: ID!) { sceneIncrementO(id: $id) } -""" - - variables = {'id': id} - - result = self.__call_graphql(query, variables) - - return result["sceneIncrementO"] +""", {'id': id}) + return result["sceneIncrementO"] diff --git a/resources/lib/utils/__init__.py b/resources/lib/utils/__init__.py index 2d1275c..2eb31c9 100644 --- a/resources/lib/utils/__init__.py +++ b/resources/lib/utils/__init__.py @@ -1,6 +1,24 @@ from urllib.parse import urlencode +import xbmcaddon BASE_URL = '' +PAGE_SIZES = (20, 40, 60, 100) + + def get_url(**kwargs): - return '{}?{}'.format(BASE_URL, urlencode(kwargs)) + return '{}?{}'.format(BASE_URL, urlencode({k: v for k, v in kwargs.items() if v is not None})) + + +def page_size() -> int: + try: + return PAGE_SIZES[int(xbmcaddon.Addon().getSetting('page_size'))] + except (ValueError, IndexError): + return 40 + + +def kodi_datetime(iso: str) -> str: + """Stash RFC 3339 timestamp (2024-06-29T12:34:56+02:00) to Kodi's 2024-06-29 12:34:56""" + if not iso: + return '' + return iso[:19].replace('T', ' ') diff --git a/resources/settings.xml b/resources/settings.xml index acb9a93..098ad0a 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -7,5 +7,8 @@ + + + From 8a66722bd785f467183b8f797b6dc77b60df825e Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:38:47 +0200 Subject: [PATCH 03/11] Report playback to Stash and allow O-counter during playback A service tracks playback of Stash streams in Kodi and reports it the way Stash' own player does (closes #24): - adds a play once the minimum play percent from Stash' interface settings is reached - saves the resume point and play duration every 30 seconds, on pause and on stop; playing to the end (98 %) clears the resume point - respects Stash' "track activity" setting increment_o without a scene id now applies to the scene currently playing, so it can be bound to a key: RunPlugin(plugin://plugin.video.stash/?increment_o) --- addon.xml | 1 + resources/lib/plugin.py | 8 +- resources/lib/service.py | 135 +++++++++++++++++++++++++++++++ resources/lib/stash_interface.py | 20 +++++ service.py | 4 + 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 resources/lib/service.py create mode 100644 service.py diff --git a/addon.xml b/addon.xml index 1a42429..0a7b4bd 100644 --- a/addon.xml +++ b/addon.xml @@ -6,6 +6,7 @@ video + View videos tracked in Stash Plugin to browse and view videos which are tracked in Stash (stashapp.io) diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index ab2f074..330f6f1 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -10,6 +10,7 @@ from resources.lib import utils from resources.lib.listing import create_listing, Listing, SceneListing from resources.lib.navigation import NavigationItem, PerformerItem, TagItem +from resources.lib.service import PLAYING_PROPERTY from resources.lib.stash_interface import StashInterface utils.BASE_URL = sys.argv[0] @@ -187,8 +188,11 @@ def play(params: dict): def increment_o(params: dict): - if 'scene' in params: - o_count = client.scene_add_o(params['scene']) + # Without a scene: the scene currently playing, so this can be bound to a key, e.g. in a keymap: + # RunPlugin(plugin://plugin.video.stash/?increment_o) + scene = params.get('scene') or xbmcgui.Window(10000).getProperty(PLAYING_PROPERTY) + if scene: + o_count = client.scene_add_o(scene) xbmc.executebuiltin('Notification(Stash, {} {})'.format(utils.local.get_localized(30009), o_count)) diff --git a/resources/lib/service.py b/resources/lib/service.py new file mode 100644 index 0000000..0318edf --- /dev/null +++ b/resources/lib/service.py @@ -0,0 +1,135 @@ +"""Reports playback in Kodi back to Stash, like Stash' own player does: +play count (after the minimum play percent set in Stash), resume point and +play duration.""" +import re +import time +import urllib.parse +import xbmc +import xbmcaddon +import xbmcgui + +from resources.lib.stash_interface import StashInterface + +SCENE_PATH = re.compile(r'/scene/(\d+)/stream') +SAVE_INTERVAL = 30 +# Like Stash' player: playing (almost) to the end clears the resume point +FINISHED_PERCENT = 98 +PLAYING_PROPERTY = 'plugin.video.stash.playing_scene' + + +def log(message: str): + xbmc.log('[plugin.video.stash] ' + message, xbmc.LOGINFO) + + +def client() -> (StashInterface, str): + addon = xbmcaddon.Addon() + base_url = addon.getSetting('base_url') + return StashInterface(base_url, addon.getSetting('api_key')), urllib.parse.urlparse(base_url).netloc + + +class Session: + def __init__(self, stash: StashInterface, scene_id: str, total: float, ui: dict): + self.stash = stash + self.scene_id = scene_id + self.total = total + self.track = ui.get('trackActivity', True) is not False + self.minimum_percent = float(ui.get('minimumPlayPercent') or 0) + self.position = 0.0 + self.unsaved = 0.0 + self.watched = 0.0 + self.last_save = time.monotonic() + self.counted = False + + def tick(self, position: float, elapsed: float): + self.position = position + self.unsaved += elapsed + self.watched += elapsed + if not self.counted and self.track and self.total > 0 \ + and self.watched / self.total * 100 >= self.minimum_percent: + self.counted = True + self.stash.scene_add_play(self.scene_id) + if time.monotonic() - self.last_save >= SAVE_INTERVAL: + self.save() + + def save(self, ended: bool = False): + self.last_save = time.monotonic() + if not self.track: + return + finished = ended or (self.total > 0 and self.position / self.total * 100 >= FINISHED_PERCENT) + self.stash.scene_save_activity(self.scene_id, 0.0 if finished else self.position, self.unsaved) + self.unsaved = 0.0 + + +class Player(xbmc.Player): + def __init__(self): + xbmc.Player.__init__(self) + self.session = None + self.ended = False + + def onAVStarted(self): + self.finish() + try: + playing = self.getPlayingFile() + (stash, netloc) = client() + url = urllib.parse.urlparse(playing) + match = SCENE_PATH.search(url.path) + if match is None or url.netloc != netloc: + return + self.session = Session(stash, match.group(1), self.getTotalTime(), stash.ui_configuration()) + self.ended = False + xbmcgui.Window(10000).setProperty(PLAYING_PROPERTY, self.session.scene_id) + except Exception as e: + log('could not start tracking: {}'.format(e)) + self.session = None + + def onPlayBackEnded(self): + self.ended = True + self.finish() + + def onPlayBackStopped(self): + self.finish() + + def onPlayBackError(self): + self.finish() + + def onPlayBackPaused(self): + self.save() + + def save(self): + if self.session is not None: + try: + self.session.save() + except Exception as e: + log('could not save activity: {}'.format(e)) + + def finish(self): + if self.session is None: + return + session, self.session = self.session, None + xbmcgui.Window(10000).clearProperty(PLAYING_PROPERTY) + try: + session.save(ended=self.ended) + except Exception as e: + log('could not save activity: {}'.format(e)) + + def poll(self, elapsed: float): + if self.session is None or not self.isPlayingVideo(): + return + try: + if xbmc.getCondVisibility('Player.Paused'): + self.session.position = self.getTime() + else: + self.session.tick(self.getTime(), elapsed) + except Exception as e: + log('could not report playback: {}'.format(e)) + + +def run(): + monitor = xbmc.Monitor() + player = Player() + last = time.monotonic() + while not monitor.waitForAbort(1): + now = time.monotonic() + player.poll(now - last) + last = now + player.finish() diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index c16e78c..cf2dc54 100644 --- a/resources/lib/stash_interface.py +++ b/resources/lib/stash_interface.py @@ -355,3 +355,23 @@ def scene_add_o(self, id): } """, {'id': id}) return result["sceneIncrementO"] + + def scene_add_play(self, id): + result = self.__call_graphql(""" +mutation sceneAddPlay($id: ID!) { + sceneAddPlay(id: $id) { + count + } +} +""", {'id': id}) + return result["sceneAddPlay"]["count"] + + def scene_save_activity(self, id, resume_time: float, play_duration: float): + self.__call_graphql(""" +mutation sceneSaveActivity($id: ID!, $resume_time: Float, $playDuration: Float) { + sceneSaveActivity(id: $id, resume_time: $resume_time, playDuration: $playDuration) +} +""", {'id': id, 'resume_time': resume_time, 'playDuration': play_duration}) + + def ui_configuration(self) -> dict: + return self.__call_graphql("{ configuration { ui } }")["configuration"]["ui"] or {} diff --git a/service.py b/service.py new file mode 100644 index 0000000..fe9fc48 --- /dev/null +++ b/service.py @@ -0,0 +1,4 @@ +from resources.lib.service import run + +if __name__ == '__main__': + run() From 22c6628af51e56c346a0295113986274ad1cedc9 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:39:40 +0200 Subject: [PATCH 04/11] Log when the playback reporting service starts --- resources/lib/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/lib/service.py b/resources/lib/service.py index 0318edf..19ea937 100644 --- a/resources/lib/service.py +++ b/resources/lib/service.py @@ -125,6 +125,7 @@ def poll(self, elapsed: float): def run(): + log('playback reporting started') monitor = xbmc.Monitor() player = Player() last = time.monotonic() From 6987966a29fbf64eac24b42d966cce988142a3a1 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:51:40 +0200 Subject: [PATCH 05/11] Document playback reporting and the O-counter key binding --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index ad0f3dd..18125aa 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,25 @@ support. As an alternative you can edit the settings file manually. 2. Open file `addon_data/plugin.video.stash/settings.xml` 3. Change line `` to `[your API Key]` + +## Playback reporting +Playback in Kodi is reported back to Stash the same way Stash' own player +does it: the play count is incremented once the *Minimum Play Percent* from +Stash' interface settings is reached, and the resume point and play duration +are saved while playing. Disable *Track Activity* in Stash to turn this off. + +## O-counter +Use *Increment O-counter* in a scene's context menu. To increment it for the +scene that is currently playing, bind a key in a +[keymap](https://kodi.wiki/view/Keymap), for example +`userdata/keymaps/stash.xml`: + +```xml + + + + RunPlugin(plugin://plugin.video.stash/?increment_o) + + + +``` From 6aac2e1bb861f228ea3a140710a1a3b9a976648e Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:56:04 +0200 Subject: [PATCH 06/11] Rate scenes and toggle favourites from Kodi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Rate…" in a scene's context menu sets 0–5 stars (rating100) - "Performers in scene…" toggles a performer of the scene as favourite - Performers, tags and studios get "Add to / Remove from favourites" in their context menu --- .../resource.language.en_gb/strings.po | 32 +++++++++++++++++++ .../resource.language.nb_no/strings.po | 32 +++++++++++++++++++ resources/lib/listing/scene_listing.py | 4 +++ resources/lib/navigation/navigation_item.py | 6 ++++ resources/lib/navigation/performer_item.py | 1 + resources/lib/navigation/studio_item.py | 1 + resources/lib/navigation/tag_item.py | 1 + resources/lib/plugin.py | 32 +++++++++++++++++++ resources/lib/stash_interface.py | 23 +++++++++++++ 9 files changed, 132 insertions(+) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 3c63797..936bf40 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -169,3 +169,35 @@ msgstr "" msgctxt "#30038" msgid "Favourite tags" msgstr "" + +msgctxt "#30039" +msgid "Add to favourites" +msgstr "" + +msgctxt "#30040" +msgid "Remove from favourites" +msgstr "" + +msgctxt "#30041" +msgid "Rate…" +msgstr "" + +msgctxt "#30042" +msgid "Performers in scene…" +msgstr "" + +msgctxt "#30043" +msgid "No rating" +msgstr "" + +msgctxt "#30044" +msgid "Favourite performer" +msgstr "" + +msgctxt "#30045" +msgid "added to favourites" +msgstr "" + +msgctxt "#30046" +msgid "removed from favourites" +msgstr "" diff --git a/resources/language/resource.language.nb_no/strings.po b/resources/language/resource.language.nb_no/strings.po index 7682196..a52475d 100644 --- a/resources/language/resource.language.nb_no/strings.po +++ b/resources/language/resource.language.nb_no/strings.po @@ -169,3 +169,35 @@ msgstr "Søk i scener" msgctxt "#30038" msgid "Favourite tags" msgstr "Favoritt-tagger" + +msgctxt "#30039" +msgid "Add to favourites" +msgstr "Legg til i favoritter" + +msgctxt "#30040" +msgid "Remove from favourites" +msgstr "Fjern fra favoritter" + +msgctxt "#30041" +msgid "Rate…" +msgstr "Vurder …" + +msgctxt "#30042" +msgid "Performers in scene…" +msgstr "Utøvere i scenen …" + +msgctxt "#30043" +msgid "No rating" +msgstr "Ingen vurdering" + +msgctxt "#30044" +msgid "Favourite performer" +msgstr "Favorittutøver" + +msgctxt "#30045" +msgid "added to favourites" +msgstr "lagt til i favoritter" + +msgctxt "#30046" +msgid "removed from favourites" +msgstr "fjernet fra favoritter" diff --git a/resources/lib/listing/scene_listing.py b/resources/lib/listing/scene_listing.py index aed1878..203e93c 100644 --- a/resources/lib/listing/scene_listing.py +++ b/resources/lib/listing/scene_listing.py @@ -59,6 +59,10 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: 'ActivateWindow(videos, {})'.format(get_url(list='scene_markers', scene=scene['id'])))) menu.append( (local.get_localized(30008), 'RunPlugin({})'.format(get_url(increment_o='', scene=scene['id'])))) + menu.append((local.get_localized(30041), 'RunPlugin({})'.format(get_url(rate_scene=scene['id'])))) + if scene['performers']: + menu.append((local.get_localized(30042), + 'RunPlugin({})'.format(get_url(scene_performers=scene['id'])))) item.addContextMenuItems(menu + sort_menu) items.append((item, url)) diff --git a/resources/lib/navigation/navigation_item.py b/resources/lib/navigation/navigation_item.py index 7ef379f..385cd34 100644 --- a/resources/lib/navigation/navigation_item.py +++ b/resources/lib/navigation/navigation_item.py @@ -68,6 +68,12 @@ def _create_item(self, title: str, description: str = '', image_path: str = '', return item + @staticmethod + def _favorite_menu(kind: str, id, favorite: bool) -> [(str, str)]: + label = local.get_localized(30040 if favorite else 30039) + url = utils.get_url(set_favorite=kind, id=id, value=0 if favorite else 1) + return [(label, 'RunPlugin({})'.format(url))] + def _create_url(self, title: str, criterion: dict, **kwargs) -> str: kwargs['criterion'] = json.dumps(criterion) kwargs['list'] = self._browse_for diff --git a/resources/lib/navigation/performer_item.py b/resources/lib/navigation/performer_item.py index 3e30ab5..280d753 100644 --- a/resources/lib/navigation/performer_item.py +++ b/resources/lib/navigation/performer_item.py @@ -21,6 +21,7 @@ def _create_items(self, page=1, per_page=100): item = self._create_item(name, performer['details'], performer['image_path'], performer.get('scene_count'), performer.get('favorite', False)) url = self._create_url(name, criterion, sort_field='date', sort_dir='desc') + item.addContextMenuItems(self._favorite_menu('performer', performer['id'], performer.get('favorite', False))) items.append((item, url)) return count, items diff --git a/resources/lib/navigation/studio_item.py b/resources/lib/navigation/studio_item.py index 3135eb5..56660a2 100644 --- a/resources/lib/navigation/studio_item.py +++ b/resources/lib/navigation/studio_item.py @@ -15,6 +15,7 @@ def _create_items(self, page=1, per_page=100): item = self._create_item(studio['name'], studio['details'], studio['image_path'], studio.get('scene_count'), studio.get('favorite', False)) url = self._create_url(studio['name'], criterion, sort_field='date', sort_dir='desc') + item.addContextMenuItems(self._favorite_menu('studio', studio['id'], studio.get('favorite', False))) items.append((item, url)) return count, items diff --git a/resources/lib/navigation/tag_item.py b/resources/lib/navigation/tag_item.py index 239842e..21efbd1 100644 --- a/resources/lib/navigation/tag_item.py +++ b/resources/lib/navigation/tag_item.py @@ -23,6 +23,7 @@ def _create_items(self, page=1, per_page=100): item = self._create_item(tag['name'], tag.get('description'), tag['image_path'], scenes, tag.get('favorite', False)) url = self._create_url(tag['name'], criterion) + item.addContextMenuItems(self._favorite_menu('tag', tag['id'], tag.get('favorite', False))) items.append((item, url)) return count, items diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index 330f6f1..af1e7bc 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -196,6 +196,32 @@ def increment_o(params: dict): xbmc.executebuiltin('Notification(Stash, {} {})'.format(utils.local.get_localized(30009), o_count)) +def set_favorite(params: dict): + client.set_favorite(params['set_favorite'], params['id'], params.get('value') == '1') + xbmc.executebuiltin('Container.Refresh') + + +def rate_scene(params: dict): + loc = utils.local.get_localized + labels = [loc(30043)] + ['★' * stars + '☆' * (5 - stars) for stars in range(1, 6)] + choice = xbmcgui.Dialog().select(loc(30041), labels) + if choice >= 0: + client.set_scene_rating(params['rate_scene'], choice * 20 if choice else None) + xbmc.executebuiltin('Container.Refresh') + + +def scene_performers(params: dict): + performers = client.find_scenes({'id': {'modifier': 'EQUALS', 'value': int(params['scene_performers'])}}, + per_page=1)[1][0]['performers'] + labels = [('★ ' if p['favorite'] else '☆ ') + p['name'] for p in performers] + choice = xbmcgui.Dialog().select(utils.local.get_localized(30044), labels) + if choice >= 0: + performer = performers[choice] + favorite = client.set_favorite('performer', performer['id'], not performer['favorite']) + xbmc.executebuiltin('Notification(Stash, {}: {})'.format( + performer['name'], utils.local.get_localized(30045 if favorite else 30046))) + + def router(param_string: str): params = dict(parse_qsl(param_string, keep_blank_values=True)) @@ -214,5 +240,11 @@ def router(param_string: str): play(params) elif 'increment_o' in params: increment_o(params) + elif 'set_favorite' in params: + set_favorite(params) + elif 'rate_scene' in params: + rate_scene(params) + elif 'scene_performers' in params: + scene_performers(params) else: browse_root() diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index cf2dc54..4703afd 100644 --- a/resources/lib/stash_interface.py +++ b/resources/lib/stash_interface.py @@ -31,8 +31,10 @@ image_path } performers { + id name image_path + favorite } tags { name @@ -375,3 +377,24 @@ def scene_save_activity(self, id, resume_time: float, play_duration: float): def ui_configuration(self) -> dict: return self.__call_graphql("{ configuration { ui } }")["configuration"]["ui"] or {} + + def set_favorite(self, kind: str, id, favorite: bool) -> bool: + """kind: performer, tag or studio""" + mutation = """ +mutation {0}Update($input: {1}UpdateInput!) {{ + {0}Update(input: $input) {{ + favorite + }} +}} +""".format(kind, kind.capitalize()) + result = self.__call_graphql(mutation, {'input': {'id': id, 'favorite': favorite}}) + return result[kind + 'Update']['favorite'] + + def set_scene_rating(self, id, rating100): + self.__call_graphql(""" +mutation sceneUpdate($input: SceneUpdateInput!) { + sceneUpdate(input: $input) { + id + } +} +""", {'input': {'id': id, 'rating100': rating100}}) From 591e29d1269dc1dd1d613fe82c3cab730568af1a Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 19:59:50 +0200 Subject: [PATCH 07/11] Optionally keep playing the following scenes With "Play next scene automatically" enabled, selecting a scene builds a Kodi video playlist of it and the scenes after it in the listing (current and next page). Kodi then continues with the next scene and its next/previous controls work during playback. As Kodi does not offer to resume playlist items, a resume/play from beginning choice is shown when Stash has a resume point; the service seeks once playback has started. --- .../resource.language.en_gb/strings.po | 12 ++++++++ .../resource.language.nb_no/strings.po | 12 ++++++++ resources/lib/listing/listing.py | 6 ++++ resources/lib/listing/scene_listing.py | 23 +++++++++++++-- resources/lib/plugin.py | 29 +++++++++++++++++-- resources/lib/service.py | 9 +++++- resources/lib/utils/__init__.py | 11 +++++++ resources/settings.xml | 1 + 8 files changed, 98 insertions(+), 5 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 936bf40..bccf7da 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -201,3 +201,15 @@ msgstr "" msgctxt "#30046" msgid "removed from favourites" msgstr "" + +msgctxt "#30047" +msgid "Resume from" +msgstr "" + +msgctxt "#30048" +msgid "Play from beginning" +msgstr "" + +msgctxt "#30049" +msgid "Play next scene automatically" +msgstr "" diff --git a/resources/language/resource.language.nb_no/strings.po b/resources/language/resource.language.nb_no/strings.po index a52475d..db39d78 100644 --- a/resources/language/resource.language.nb_no/strings.po +++ b/resources/language/resource.language.nb_no/strings.po @@ -201,3 +201,15 @@ msgstr "lagt til i favoritter" msgctxt "#30046" msgid "removed from favourites" msgstr "fjernet fra favoritter" + +msgctxt "#30047" +msgid "Resume from" +msgstr "Fortsett fra" + +msgctxt "#30048" +msgid "Play from beginning" +msgstr "Spill fra starten" + +msgctxt "#30049" +msgid "Play next scene automatically" +msgstr "Spill neste scene automatisk" diff --git a/resources/lib/listing/listing.py b/resources/lib/listing/listing.py index cf671bb..a5da153 100644 --- a/resources/lib/listing/listing.py +++ b/resources/lib/listing/listing.py @@ -137,6 +137,12 @@ def _create_item(self, scene: dict, **kwargs): return item + def create_queue_item(self, scene: dict) -> xbmcgui.ListItem: + item = self._create_item(scene) + # The resume point is applied by the service when chosen in play_from + item.getVideoInfoTag().setResumePoint(0.0) + return item + def _context_menu(self, params: dict) -> [(str, str)]: if not self.sort_options(): return [] diff --git a/resources/lib/listing/scene_listing.py b/resources/lib/listing/scene_listing.py index 203e93c..664f9a9 100644 --- a/resources/lib/listing/scene_listing.py +++ b/resources/lib/listing/scene_listing.py @@ -1,9 +1,10 @@ +import json from typing import Optional import xbmcgui from .listing import Listing from resources.lib.stash_interface import StashInterface -from resources.lib.utils import local, get_url +from resources.lib.utils import local, get_url, autoplay from resources.lib.navigation import NavigationItem, StudioItem, TagItem, PerformerItem @@ -48,10 +49,16 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: (count, scenes) = self._client.find_scenes(criterion, sort_field, sort_dir, page=page, per_page=per_page, q=params.get('q')) sort_menu = self._context_menu(params) + play_next = autoplay() items = [] for scene in scenes: item = self._create_item(scene) - url = self._create_play_url(scene['id']) + if play_next: + # Not playable itself: play_from builds a playlist starting at this scene + url = get_url(**dict(params, play_from=scene['id'])) + item.setProperty('IsPlayable', 'false') + else: + url = self._create_play_url(scene['id']) menu = [] if len(scene['scene_markers']) > 0: @@ -68,3 +75,15 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: items.append((item, url)) return count, items + + def queue(self, params: dict, scene_id: str, per_page: int) -> [dict]: + """The scene and the ones after it in the listing it was started from (this page and the next)""" + criterion = json.loads(params['criterion']) if 'criterion' in params else {} + page = int(params.get('page', 1)) + args = dict(sort_field=params.get('sort_field'), sort_dir=params.get('sort_dir', 'asc'), per_page=per_page, + q=params.get('q')) + (count, scenes) = self._client.find_scenes(criterion, page=page, **args) + if page * per_page < count: + scenes = scenes + self._client.find_scenes(criterion, page=page + 1, **args)[1] + ids = [scene['id'] for scene in scenes] + return scenes[ids.index(scene_id):] if scene_id in ids else [self._client.find_scene(scene_id)] diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index af1e7bc..9e312ce 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -10,7 +10,7 @@ from resources.lib import utils from resources.lib.listing import create_listing, Listing, SceneListing from resources.lib.navigation import NavigationItem, PerformerItem, TagItem -from resources.lib.service import PLAYING_PROPERTY +from resources.lib.service import PLAYING_PROPERTY, START_OFFSET_PROPERTY from resources.lib.stash_interface import StashInterface utils.BASE_URL = sys.argv[0] @@ -158,6 +158,29 @@ def choose_sort(params: dict): xbmc.executebuiltin('Container.Update({},replace)'.format(utils.get_url(**params))) +def play_from(params: dict): + loc = utils.local.get_localized + scene_id = params.pop('play_from') + listing = SceneListing(client) + scenes = listing.queue(params, scene_id, utils.page_size()) + + resume = float(scenes[0].get('resume_time') or 0) + if resume > 0: + choice = xbmcgui.Dialog().contextmenu( + ['{} {}'.format(loc(30047), utils.duration(resume)), loc(30048)]) + if choice < 0: + return + if choice == 0: + xbmcgui.Window(10000).setProperty(START_OFFSET_PROPERTY, '{}:{}'.format(scene_id, resume)) + + playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + playlist.clear() + for scene in scenes: + item = listing.create_queue_item(scene) + playlist.add(Listing._create_play_url(scene['id']), item) + xbmc.Player().play(playlist) + + def get_mp4_stream(sceneStreams: dict) -> str: for stream in sceneStreams: if stream["label"] == "DASH": @@ -226,7 +249,9 @@ def router(param_string: str): params = dict(parse_qsl(param_string, keep_blank_values=True)) if params: - if 'choose_sort' in params: + if 'play_from' in params: + play_from(params) + elif 'choose_sort' in params: choose_sort(params) elif 'search' in params: search(params) diff --git a/resources/lib/service.py b/resources/lib/service.py index 19ea937..affc4d9 100644 --- a/resources/lib/service.py +++ b/resources/lib/service.py @@ -15,6 +15,8 @@ # Like Stash' player: playing (almost) to the end clears the resume point FINISHED_PERCENT = 98 PLAYING_PROPERTY = 'plugin.video.stash.playing_scene' +# ":", set by play_from when the user chooses to resume +START_OFFSET_PROPERTY = 'plugin.video.stash.start_offset' def log(message: str): @@ -77,7 +79,12 @@ def onAVStarted(self): return self.session = Session(stash, match.group(1), self.getTotalTime(), stash.ui_configuration()) self.ended = False - xbmcgui.Window(10000).setProperty(PLAYING_PROPERTY, self.session.scene_id) + window = xbmcgui.Window(10000) + window.setProperty(PLAYING_PROPERTY, self.session.scene_id) + (offset_scene, _, offset) = window.getProperty(START_OFFSET_PROPERTY).partition(':') + window.clearProperty(START_OFFSET_PROPERTY) + if offset_scene == self.session.scene_id: + self.seekTime(float(offset)) except Exception as e: log('could not start tracking: {}'.format(e)) self.session = None diff --git a/resources/lib/utils/__init__.py b/resources/lib/utils/__init__.py index 2eb31c9..bf9b966 100644 --- a/resources/lib/utils/__init__.py +++ b/resources/lib/utils/__init__.py @@ -17,6 +17,17 @@ def page_size() -> int: return 40 +def autoplay() -> bool: + return xbmcaddon.Addon().getSetting('autoplay') == 'true' + + +def duration(seconds: float) -> str: + seconds = int(seconds) + if seconds >= 3600: + return '{}:{:02d}:{:02d}'.format(seconds // 3600, seconds // 60 % 60, seconds % 60) + return '{}:{:02d}'.format(seconds // 60, seconds % 60) + + def kodi_datetime(iso: str) -> str: """Stash RFC 3339 timestamp (2024-06-29T12:34:56+02:00) to Kodi's 2024-06-29 12:34:56""" if not iso: diff --git a/resources/settings.xml b/resources/settings.xml index 098ad0a..649cec7 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -6,6 +6,7 @@ + From b22b61d130543e4d00e58498d7308b2edd6b190a Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 20:04:13 +0200 Subject: [PATCH 08/11] Stop orphaned or duplicate service instances Kodi can start the service just after the add-on was disabled, or twice when it is disabled and enabled in quick succession. Such an instance never got an abort request, so it blocked Kodi's shutdown and reported playback twice. The service now stops when the add-on is disabled or a newer instance runs. --- resources/lib/service.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/resources/lib/service.py b/resources/lib/service.py index affc4d9..e51c262 100644 --- a/resources/lib/service.py +++ b/resources/lib/service.py @@ -17,6 +17,7 @@ PLAYING_PROPERTY = 'plugin.video.stash.playing_scene' # ":", set by play_from when the user chooses to resume START_OFFSET_PROPERTY = 'plugin.video.stash.start_offset' +SERVICE_PROPERTY = 'plugin.video.stash.service' def log(message: str): @@ -132,12 +133,22 @@ def poll(self, elapsed: float): def run(): + # Kodi can start the service just after the add-on was disabled (or start it twice when it is + # disabled and enabled quickly); such an instance never gets an abort request and would keep + # Kodi from shutting down and report playback twice. The newest instance wins. + token = str(time.time()) + window = xbmcgui.Window(10000) + window.setProperty(SERVICE_PROPERTY, token) log('playback reporting started') monitor = xbmc.Monitor() player = Player() last = time.monotonic() while not monitor.waitForAbort(1): + if window.getProperty(SERVICE_PROPERTY) != token \ + or not xbmc.getCondVisibility('System.AddonIsEnabled(plugin.video.stash)'): + break now = time.monotonic() player.poll(now - last) last = now player.finish() + log('playback reporting stopped') From 5e3f6dd79568847b99296aa42370c6cd68ad3391 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 20:21:21 +0200 Subject: [PATCH 09/11] Avoid star glyphs that Kodi's default fonts lack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rating dialog showed boxes instead of ★/☆. Ratings are now shown as "1 star" … "5 stars" with the current rating preselected, and favourites are shown in gold instead of with a star prefix. --- .../language/resource.language.en_gb/strings.po | 14 +++++++++++++- .../language/resource.language.nb_no/strings.po | 16 ++++++++++++++-- resources/lib/listing/scene_listing.py | 2 +- resources/lib/navigation/navigation_item.py | 2 +- resources/lib/plugin.py | 8 +++++--- resources/lib/utils/__init__.py | 5 +++++ 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index bccf7da..0596d07 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -191,7 +191,7 @@ msgid "No rating" msgstr "" msgctxt "#30044" -msgid "Favourite performer" +msgid "Add or remove favourite performer" msgstr "" msgctxt "#30045" @@ -213,3 +213,15 @@ msgstr "" msgctxt "#30049" msgid "Play next scene automatically" msgstr "" + +msgctxt "#30050" +msgid "1 star" +msgstr "" + +msgctxt "#30051" +msgid "{} stars" +msgstr "" + +msgctxt "#30052" +msgid "favourite" +msgstr "" diff --git a/resources/language/resource.language.nb_no/strings.po b/resources/language/resource.language.nb_no/strings.po index db39d78..42fce28 100644 --- a/resources/language/resource.language.nb_no/strings.po +++ b/resources/language/resource.language.nb_no/strings.po @@ -191,8 +191,8 @@ msgid "No rating" msgstr "Ingen vurdering" msgctxt "#30044" -msgid "Favourite performer" -msgstr "Favorittutøver" +msgid "Add or remove favourite performer" +msgstr "Legg til eller fjern favorittutøver" msgctxt "#30045" msgid "added to favourites" @@ -213,3 +213,15 @@ msgstr "Spill fra starten" msgctxt "#30049" msgid "Play next scene automatically" msgstr "Spill neste scene automatisk" + +msgctxt "#30050" +msgid "1 star" +msgstr "1 stjerne" + +msgctxt "#30051" +msgid "{} stars" +msgstr "{} stjerner" + +msgctxt "#30052" +msgid "favourite" +msgstr "favoritt" diff --git a/resources/lib/listing/scene_listing.py b/resources/lib/listing/scene_listing.py index 664f9a9..d8937ba 100644 --- a/resources/lib/listing/scene_listing.py +++ b/resources/lib/listing/scene_listing.py @@ -66,7 +66,7 @@ def _create_items(self, criterion: dict, sort_field: str, sort_dir: str, params: 'ActivateWindow(videos, {})'.format(get_url(list='scene_markers', scene=scene['id'])))) menu.append( (local.get_localized(30008), 'RunPlugin({})'.format(get_url(increment_o='', scene=scene['id'])))) - menu.append((local.get_localized(30041), 'RunPlugin({})'.format(get_url(rate_scene=scene['id'])))) + menu.append((local.get_localized(30041), 'RunPlugin({})'.format(get_url(rate_scene=scene['id'], rating=scene.get('rating100') or 0)))) if scene['performers']: menu.append((local.get_localized(30042), 'RunPlugin({})'.format(get_url(scene_performers=scene['id'])))) diff --git a/resources/lib/navigation/navigation_item.py b/resources/lib/navigation/navigation_item.py index 385cd34..f11e2f8 100644 --- a/resources/lib/navigation/navigation_item.py +++ b/resources/lib/navigation/navigation_item.py @@ -53,7 +53,7 @@ def _create_items(self, page: int = 1, per_page: int = 100) -> (int, [(xbmcgui.L def _create_item(self, title: str, description: str = '', image_path: str = '', count: int = None, favorite: bool = False) -> xbmcgui.ListItem: - label = '★ ' + title if favorite else title + label = utils.favorite_label(title) if favorite else title item = xbmcgui.ListItem(label=label, label2=str(count) if count is not None else '') info = item.getVideoInfoTag() info.setMediaType('video') diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index 9e312ce..9813a70 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -226,8 +226,9 @@ def set_favorite(params: dict): def rate_scene(params: dict): loc = utils.local.get_localized - labels = [loc(30043)] + ['★' * stars + '☆' * (5 - stars) for stars in range(1, 6)] - choice = xbmcgui.Dialog().select(loc(30041), labels) + labels = [loc(30043), loc(30050)] + [loc(30051).format(stars) for stars in range(2, 6)] + current = round(int(params.get('rating') or 0) / 20) + choice = xbmcgui.Dialog().select(loc(30041), labels, preselect=current) if choice >= 0: client.set_scene_rating(params['rate_scene'], choice * 20 if choice else None) xbmc.executebuiltin('Container.Refresh') @@ -236,7 +237,8 @@ def rate_scene(params: dict): def scene_performers(params: dict): performers = client.find_scenes({'id': {'modifier': 'EQUALS', 'value': int(params['scene_performers'])}}, per_page=1)[1][0]['performers'] - labels = [('★ ' if p['favorite'] else '☆ ') + p['name'] for p in performers] + labels = ['{} ({})'.format(utils.favorite_label(p['name']), utils.local.get_localized(30052)) if p['favorite'] + else p['name'] for p in performers] choice = xbmcgui.Dialog().select(utils.local.get_localized(30044), labels) if choice >= 0: performer = performers[choice] diff --git a/resources/lib/utils/__init__.py b/resources/lib/utils/__init__.py index bf9b966..9f0c97b 100644 --- a/resources/lib/utils/__init__.py +++ b/resources/lib/utils/__init__.py @@ -21,6 +21,11 @@ def autoplay() -> bool: return xbmcaddon.Addon().getSetting('autoplay') == 'true' +def favorite_label(label: str) -> str: + # Kodi's default fonts have no star glyphs, so favourites are shown in colour + return '[COLOR gold]{}[/COLOR]'.format(label) + + def duration(seconds: float) -> str: seconds = int(seconds) if seconds >= 3600: From c91da8992bad88c2a0b97d89e5312c846265c155 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 20:28:08 +0200 Subject: [PATCH 10/11] Performer information and actions from a scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kodi's info dialog searches its own library when a cast member is selected, which finds nothing for add-on items. "Performers in scene…" now offers to show the performer's scenes, show information (aliases, age, country, measurements, career, … and the details from Stash, in Kodi's info dialog) or toggle the favourite. Performer listings get "Information" in their context menu. --- .../resource.language.en_gb/strings.po | 56 +++++++++++++ .../resource.language.nb_no/strings.po | 56 +++++++++++++ resources/lib/navigation/performer_item.py | 5 +- resources/lib/plugin.py | 78 +++++++++++++++++-- resources/lib/stash_interface.py | 31 ++++++++ 5 files changed, 218 insertions(+), 8 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 0596d07..b10db98 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -225,3 +225,59 @@ msgstr "" msgctxt "#30052" msgid "favourite" msgstr "" + +msgctxt "#30053" +msgid "Show scenes" +msgstr "" + +msgctxt "#30054" +msgid "Information" +msgstr "" + +msgctxt "#30055" +msgid "Aliases" +msgstr "" + +msgctxt "#30056" +msgid "Born" +msgstr "" + +msgctxt "#30057" +msgid "Died" +msgstr "" + +msgctxt "#30058" +msgid "Country" +msgstr "" + +msgctxt "#30059" +msgid "Ethnicity" +msgstr "" + +msgctxt "#30060" +msgid "Hair colour" +msgstr "" + +msgctxt "#30061" +msgid "Eye colour" +msgstr "" + +msgctxt "#30062" +msgid "Height" +msgstr "" + +msgctxt "#30063" +msgid "Measurements" +msgstr "" + +msgctxt "#30064" +msgid "Career" +msgstr "" + +msgctxt "#30065" +msgid "Tattoos" +msgstr "" + +msgctxt "#30066" +msgid "Piercings" +msgstr "" diff --git a/resources/language/resource.language.nb_no/strings.po b/resources/language/resource.language.nb_no/strings.po index 42fce28..5b0ab46 100644 --- a/resources/language/resource.language.nb_no/strings.po +++ b/resources/language/resource.language.nb_no/strings.po @@ -225,3 +225,59 @@ msgstr "{} stjerner" msgctxt "#30052" msgid "favourite" msgstr "favoritt" + +msgctxt "#30053" +msgid "Show scenes" +msgstr "Vis scener" + +msgctxt "#30054" +msgid "Information" +msgstr "Informasjon" + +msgctxt "#30055" +msgid "Aliases" +msgstr "Alias" + +msgctxt "#30056" +msgid "Born" +msgstr "Født" + +msgctxt "#30057" +msgid "Died" +msgstr "Død" + +msgctxt "#30058" +msgid "Country" +msgstr "Land" + +msgctxt "#30059" +msgid "Ethnicity" +msgstr "Etnisitet" + +msgctxt "#30060" +msgid "Hair colour" +msgstr "Hårfarge" + +msgctxt "#30061" +msgid "Eye colour" +msgstr "Øyenfarge" + +msgctxt "#30062" +msgid "Height" +msgstr "Høyde" + +msgctxt "#30063" +msgid "Measurements" +msgstr "Mål" + +msgctxt "#30064" +msgid "Career" +msgstr "Karriere" + +msgctxt "#30065" +msgid "Tattoos" +msgstr "Tatoveringer" + +msgctxt "#30066" +msgid "Piercings" +msgstr "Piercinger" diff --git a/resources/lib/navigation/performer_item.py b/resources/lib/navigation/performer_item.py index 280d753..ea2d619 100644 --- a/resources/lib/navigation/performer_item.py +++ b/resources/lib/navigation/performer_item.py @@ -1,5 +1,6 @@ from .navigation_item import NavigationItem from resources.lib.stash_interface import StashInterface +from resources.lib import utils from resources.lib.utils import local @@ -21,7 +22,9 @@ def _create_items(self, page=1, per_page=100): item = self._create_item(name, performer['details'], performer['image_path'], performer.get('scene_count'), performer.get('favorite', False)) url = self._create_url(name, criterion, sort_field='date', sort_dir='desc') - item.addContextMenuItems(self._favorite_menu('performer', performer['id'], performer.get('favorite', False))) + item.addContextMenuItems( + [(local.get_localized(30054), 'RunPlugin({})'.format(utils.get_url(performer_info=performer['id'])))] + + self._favorite_menu('performer', performer['id'], performer.get('favorite', False))) items.append((item, url)) return count, items diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index 9813a70..38f1b37 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -1,3 +1,4 @@ +import datetime import json import random import sys @@ -235,16 +236,77 @@ def rate_scene(params: dict): def scene_performers(params: dict): + loc = utils.local.get_localized performers = client.find_scenes({'id': {'modifier': 'EQUALS', 'value': int(params['scene_performers'])}}, per_page=1)[1][0]['performers'] - labels = ['{} ({})'.format(utils.favorite_label(p['name']), utils.local.get_localized(30052)) if p['favorite'] - else p['name'] for p in performers] - choice = xbmcgui.Dialog().select(utils.local.get_localized(30044), labels) - if choice >= 0: - performer = performers[choice] + labels = ['{} ({})'.format(utils.favorite_label(p['name']), loc(30052)) if p['favorite'] else p['name'] + for p in performers] + choice = xbmcgui.Dialog().select(loc(30042), labels) + if choice < 0: + return + performer = performers[choice] + actions = [loc(30053), loc(30054), loc(30040 if performer['favorite'] else 30039)] + action = xbmcgui.Dialog().contextmenu(actions) + if action == 0: + criterion = {'performers': {'modifier': 'INCLUDES_ALL', 'value': [performer['id']]}} + url = utils.get_url(list='scenes', title=performer['name'], criterion=json.dumps(criterion), + sort_field='date', sort_dir='desc') + xbmc.executebuiltin('Container.Update({})'.format(url)) + elif action == 1: + performer_info({'performer_info': performer['id']}) + elif action == 2: favorite = client.set_favorite('performer', performer['id'], not performer['favorite']) - xbmc.executebuiltin('Notification(Stash, {}: {})'.format( - performer['name'], utils.local.get_localized(30045 if favorite else 30046))) + xbmc.executebuiltin('Notification(Stash, {}: {})'.format(performer['name'], loc(30045 if favorite else 30046))) + + +def performer_info(params: dict): + loc = utils.local.get_localized + performer = client.find_performer(params['performer_info']) + birthdate = performer.get('birthdate') + if birthdate: + born = birthdate + if not performer.get('death_date'): + today = datetime.date.today() + (year, month, day) = (int(part) for part in birthdate.split('-')) + born = '{} ({})'.format(birthdate, today.year - year - ((today.month, today.day) < (month, day))) + else: + born = None + facts = [ + (30055, ', '.join(performer.get('alias_list') or [])), + (30056, born), + (30057, performer.get('death_date')), + (30058, performer.get('country')), + (30059, performer.get('ethnicity')), + (30060, performer.get('hair_color')), + (30061, performer.get('eye_color')), + (30062, '{} cm'.format(performer['height_cm']) if performer.get('height_cm') else None), + (30063, performer.get('measurements')), + (30064, performer.get('career_length')), + (30065, performer.get('tattoos')), + (30066, performer.get('piercings')), + (30006, performer.get('scene_count')), + ] + lines = ['[B]{}:[/B] {}'.format(loc(label), value) for (label, value) in facts if value] + plot = '\n'.join(lines) + if performer.get('details'): + plot = '{}\n\n{}'.format(plot, performer['details']) + + name = performer['name'] + if performer.get('disambiguation'): + name = '{} ({})'.format(name, performer['disambiguation']) + item = xbmcgui.ListItem(label=name) + info = item.getVideoInfoTag() + info.setMediaType('video') + info.setTitle(name) + info.setPlot(plot) + info.setGenres([tag['name'] for tag in performer.get('tags') or []]) + info.setUserRating(round((performer.get('rating100') or 0) / 10)) + if performer.get('country'): + info.setCountries([performer['country']]) + image = client.add_api_key(performer.get('image_path')) + if image: + item.setArt({'thumb': image, 'poster': image}) + xbmcgui.Dialog().info(item) def router(param_string: str): @@ -273,5 +335,7 @@ def router(param_string: str): rate_scene(params) elif 'scene_performers' in params: scene_performers(params) + elif 'performer_info' in params: + performer_info(params) else: browse_root() diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index 4703afd..c03e654 100644 --- a/resources/lib/stash_interface.py +++ b/resources/lib/stash_interface.py @@ -398,3 +398,34 @@ def set_scene_rating(self, id, rating100): } } """, {'input': {'id': id, 'rating100': rating100}}) + + def find_performer(self, id): + return self.__call_graphql(""" +query findPerformer($id: ID!) { + findPerformer(id: $id) { + id + name + disambiguation + alias_list + birthdate + death_date + country + ethnicity + hair_color + eye_color + height_cm + measurements + career_length + tattoos + piercings + details + favorite + rating100 + scene_count + image_path + tags { + name + } + } +} +""", {'id': id})['findPerformer'] From 2eca5483211e97b5fcae9aabf5f78b968b18e4a7 Mon Sep 17 00:00:00 2001 From: Andreas Noteng Date: Tue, 13 Oct 2026 20:36:28 +0200 Subject: [PATCH 11/11] Document performer actions and info dialog limitations Also share the URL of a performer's scenes between "Show scenes" and other callers. --- README.md | 14 ++++++++++++++ resources/lib/plugin.py | 11 +++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 18125aa..d642d9f 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,17 @@ scene that is currently playing, bind a key in a ``` + +## Performers +Use *Performers in scene…* in a scene's context menu to show a performer's +scenes, show information about the performer or add/remove the performer as +favourite. Performer listings have *Information* in their context menu. + +Known limitations, caused by how Kodi's information dialog works for add-on +items: + +- Selecting a performer in the cast list of a scene's information dialog + searches Kodi's own video library, which does not contain Stash scenes, so + nothing is found. Use *Performers in scene…* instead. +- The play button in a performer's information dialog does nothing useful; + use *Show scenes* instead. diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index 38f1b37..92a9793 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -248,10 +248,7 @@ def scene_performers(params: dict): actions = [loc(30053), loc(30054), loc(30040 if performer['favorite'] else 30039)] action = xbmcgui.Dialog().contextmenu(actions) if action == 0: - criterion = {'performers': {'modifier': 'INCLUDES_ALL', 'value': [performer['id']]}} - url = utils.get_url(list='scenes', title=performer['name'], criterion=json.dumps(criterion), - sort_field='date', sort_dir='desc') - xbmc.executebuiltin('Container.Update({})'.format(url)) + xbmc.executebuiltin('Container.Update({})'.format(performer_scenes_url(performer['id'], performer['name']))) elif action == 1: performer_info({'performer_info': performer['id']}) elif action == 2: @@ -309,6 +306,12 @@ def performer_info(params: dict): xbmcgui.Dialog().info(item) +def performer_scenes_url(performer_id, name: str) -> str: + criterion = {'performers': {'modifier': 'INCLUDES_ALL', 'value': [performer_id]}} + return utils.get_url(list='scenes', title=name, criterion=json.dumps(criterion), sort_field='date', + sort_dir='desc') + + def router(param_string: str): params = dict(parse_qsl(param_string, keep_blank_values=True))