diff --git a/README.md b/README.md index ad0f3dd..d642d9f 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,39 @@ 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) + + + +``` + +## 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/addon.xml b/addon.xml index 3db2186..0a7b4bd 100644 --- a/addon.xml +++ b/addon.xml @@ -1,15 +1,17 @@ - - + video + 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..b10db98 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -76,4 +76,208 @@ 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 "" + +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 "Add or remove favourite performer" +msgstr "" + +msgctxt "#30045" +msgid "added to favourites" +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 "" + +msgctxt "#30050" +msgid "1 star" +msgstr "" + +msgctxt "#30051" +msgid "{} stars" +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 new file mode 100644 index 0000000..5b0ab46 --- /dev/null +++ b/resources/language/resource.language.nb_no/strings.po @@ -0,0 +1,283 @@ +# 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" + +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 "Add or remove favourite performer" +msgstr "Legg til eller fjern favorittutøver" + +msgctxt "#30045" +msgid "added to favourites" +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" + +msgctxt "#30050" +msgid "1 star" +msgstr "1 stjerne" + +msgctxt "#30051" +msgid "{} stars" +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/listing/listing.py b/resources/lib/listing/listing.py index a93d503..a5da153 100644 --- a/resources/lib/listing/listing.py +++ b/resources/lib/listing/listing.py @@ -1,6 +1,8 @@ import json +import os.path from abc import ABC, abstractmethod from typing import Optional +import xbmc import xbmcgui import xbmcplugin @@ -25,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) @@ -49,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 @@ -70,48 +85,70 @@ 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'] + 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 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 [] + 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 @@ -123,8 +160,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..d8937ba 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 @@ -22,17 +23,42 @@ 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) + 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: @@ -40,8 +66,24 @@ 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) + 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'])))) + item.addContextMenuItems(menu + sort_menu) items.append((item, url)) - return items + 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/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..f11e2f8 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,31 +23,57 @@ 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 = 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') + 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 + @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 2e14c2b..ea2d619 100644 --- a/resources/lib/navigation/performer_item.py +++ b/resources/lib/navigation/performer_item.py @@ -1,19 +1,30 @@ from .navigation_item import NavigationItem from resources.lib.stash_interface import StashInterface +from resources.lib import utils from resources.lib.utils import local 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') + 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 items + return count, items diff --git a/resources/lib/navigation/studio_item.py b/resources/lib/navigation/studio_item.py index 6bef31f..56660a2 100644 --- a/resources/lib/navigation/studio_item.py +++ b/resources/lib/navigation/studio_item.py @@ -7,13 +7,15 @@ 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') + item.addContextMenuItems(self._favorite_menu('studio', studio['id'], studio.get('favorite', False))) 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..21efbd1 100644 --- a/resources/lib/navigation/tag_item.py +++ b/resources/lib/navigation/tag_item.py @@ -5,19 +5,25 @@ 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) + item.addContextMenuItems(self._favorite_menu('tag', tag['id'], tag.get('favorite', False))) items.append((item, url)) - return items + return count, items diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index f3602cf..92a9793 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -1,3 +1,6 @@ +import datetime +import json +import random import sys from urllib.parse import parse_qsl from typing import Optional @@ -7,7 +10,8 @@ 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.service import PLAYING_PROPERTY, START_OFFSET_PROPERTY from resources.lib.stash_interface import StashInterface utils.BASE_URL = sys.argv[0] @@ -48,22 +52,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 +123,63 @@ 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 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: @@ -131,16 +212,117 @@ def play(params: dict): def increment_o(params: dict): - if 'scene' in params: - o_count = client.scene_increment_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)) +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), 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') + + +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']), 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: + xbmc.executebuiltin('Container.Update({})'.format(performer_scenes_url(performer['id'], performer['name']))) + 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'], 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 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)) if params: - if 'browse_for' in params: + if 'play_from' in params: + play_from(params) + elif '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) @@ -150,5 +332,13 @@ 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) + elif 'performer_info' in params: + performer_info(params) else: browse_root() diff --git a/resources/lib/service.py b/resources/lib/service.py new file mode 100644 index 0000000..e51c262 --- /dev/null +++ b/resources/lib/service.py @@ -0,0 +1,154 @@ +"""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' +# ":", 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): + 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 + 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 + + 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(): + # 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') diff --git a/resources/lib/stash_interface.py b/resources/lib/stash_interface.py index c28a177..c03e654 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,10 +14,12 @@ 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 @@ -72,11 +31,98 @@ def find_scenes(self, scene_filter=None, sort_field='title', sort_dir='asc'): image_path } performers { + id + name + image_path + favorite + } + 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 } @@ -85,11 +131,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 @@ -119,6 +161,7 @@ def find_scene(self, id): screenshot } files { + basename duration video_codec audio_codec @@ -135,45 +178,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 - } - studio { - name - } - performers { - name - } - tags { - name - } - } - primary_tag { - id - name - } - tags { - id - name - } +""" + MARKER_FIELDS + """ } } } @@ -183,7 +188,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) { @@ -191,30 +196,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) { @@ -222,33 +224,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) { @@ -258,82 +258,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 - } - 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 @@ -388,15 +339,93 @@ 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) } -""" +""", {'id': id}) + return result["sceneIncrementO"] - variables = {'id': id} + def scene_add_play(self, id): + result = self.__call_graphql(""" +mutation sceneAddPlay($id: ID!) { + sceneAddPlay(id: $id) { + count + } +} +""", {'id': id}) + return result["sceneAddPlay"]["count"] - result = self.__call_graphql(query, variables) + 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 {} + + 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}}) - return result["sceneIncrementO"] + 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'] diff --git a/resources/lib/utils/__init__.py b/resources/lib/utils/__init__.py index 2d1275c..9f0c97b 100644 --- a/resources/lib/utils/__init__.py +++ b/resources/lib/utils/__init__.py @@ -1,6 +1,40 @@ 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 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: + 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: + return '' + return iso[:19].replace('T', ' ') diff --git a/resources/settings.xml b/resources/settings.xml index acb9a93..649cec7 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -6,6 +6,10 @@ + + + + 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()