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()