From 4b1b5c814c11f778d3ff81ac26aa9008137687ea Mon Sep 17 00:00:00 2001 From: pannal Date: Mon, 20 May 2024 14:42:06 +0200 Subject: [PATCH] [script.plexmod] 0.7.8 --- script.plexmod/LICENSE.txt | 62 + script.plexmod/README.md | 20 +- script.plexmod/addon.xml | 10 +- script.plexmod/changelog.txt | 121 +- script.plexmod/fanart.jpg | Bin 71213 -> 0 bytes script.plexmod/fanart.png | Bin 0 -> 22613 bytes script.plexmod/icon.png | Bin 25724 -> 0 bytes script.plexmod/icon2.png | Bin 0 -> 5721 bytes .../lib/_included_packages/_ipaddress.py | 2420 +++++++++++++++++ .../plexnet/asyncadapter.py | 7 + .../lib/_included_packages/plexnet/audio.py | 4 +- .../lib/_included_packages/plexnet/http.py | 10 +- .../lib/_included_packages/plexnet/locks.py | 2 + .../lib/_included_packages/plexnet/media.py | 16 +- .../plexnet/mediadecisionengine.py | 3 +- .../plexnet/myplexaccount.py | 63 +- .../plexnet/myplexmanager.py | 9 + .../plexnet/nowplayingmanager.py | 81 +- .../lib/_included_packages/plexnet/photo.py | 2 +- .../_included_packages/plexnet/playqueue.py | 8 + .../lib/_included_packages/plexnet/plexapp.py | 9 +- .../plexnet/plexconnection.py | 25 +- .../_included_packages/plexnet/plexlibrary.py | 53 +- .../_included_packages/plexnet/plexmedia.py | 2 + .../_included_packages/plexnet/plexobjects.py | 18 +- .../_included_packages/plexnet/plexpart.py | 39 +- .../_included_packages/plexnet/plexplayer.py | 246 +- .../_included_packages/plexnet/plexserver.py | 32 +- .../plexnet/plexservermanager.py | 11 + .../plexnet/signalsmixin.py | 8 +- .../lib/_included_packages/plexnet/util.py | 51 +- .../lib/_included_packages/plexnet/video.py | 75 +- .../plexnet/videosession.py | 22 +- script.plexmod/lib/advancedsettings.py | 42 + script.plexmod/lib/backgroundthread.py | 11 +- script.plexmod/lib/cache.py | 172 ++ script.plexmod/lib/data_cache.py | 121 + script.plexmod/lib/main.py | 12 +- script.plexmod/lib/path_mapping.py | 123 + script.plexmod/lib/playback_utils.py | 5 + script.plexmod/lib/player.py | 208 +- script.plexmod/lib/plex.py | 16 +- script.plexmod/lib/plex_hosts.py | 112 + script.plexmod/lib/util.py | 357 ++- script.plexmod/lib/windows/__init__.py | 3 +- script.plexmod/lib/windows/background.py | 3 +- script.plexmod/lib/windows/busy.py | 9 +- script.plexmod/lib/windows/currentplaylist.py | 49 +- script.plexmod/lib/windows/dropdown.py | 5 +- script.plexmod/lib/windows/episodes.py | 354 ++- script.plexmod/lib/windows/home.py | 725 ++++- script.plexmod/lib/windows/info.py | 16 +- script.plexmod/lib/windows/kodigui.py | 84 +- script.plexmod/lib/windows/library.py | 70 +- script.plexmod/lib/windows/mixins.py | 99 +- script.plexmod/lib/windows/musicplayer.py | 24 +- script.plexmod/lib/windows/opener.py | 9 +- script.plexmod/lib/windows/optionsdialog.py | 2 +- script.plexmod/lib/windows/pagination.py | 5 +- script.plexmod/lib/windows/photos.py | 41 +- .../lib/windows/playbacksettings.py | 5 +- .../lib/windows/playerbackground.py | 4 +- script.plexmod/lib/windows/playersettings.py | 8 +- script.plexmod/lib/windows/playlist.py | 74 +- script.plexmod/lib/windows/playlists.py | 12 +- script.plexmod/lib/windows/preplay.py | 32 +- script.plexmod/lib/windows/preplayutils.py | 2 +- script.plexmod/lib/windows/search.py | 10 +- script.plexmod/lib/windows/seekdialog.py | 288 +- script.plexmod/lib/windows/settings.py | 102 +- script.plexmod/lib/windows/signin.py | 4 +- script.plexmod/lib/windows/slidehshow.py | 9 +- script.plexmod/lib/windows/subitems.py | 36 +- script.plexmod/lib/windows/tracks.py | 15 +- script.plexmod/lib/windows/userselect.py | 19 +- script.plexmod/lib/windows/videoplayer.py | 99 +- script.plexmod/lib/windows/windowutils.py | 76 +- .../resource.language.de_de/strings.po | 815 ++++-- .../resource.language.en_gb/strings.po | 256 +- .../resource.language.es_es/strings.po | 1053 ++++++- .../resource.language.it_it/strings.po | 1471 +++++++++- .../resource.language.zh_cn/strings.po | 8 +- script.plexmod/resources/settings.xml | 61 +- .../skins/Main/1080i/script-plex-album.xml | 12 +- .../skins/Main/1080i/script-plex-artist.xml | 12 +- .../Main/1080i/script-plex-background.xml | 4 +- .../skins/Main/1080i/script-plex-dropdown.xml | 8 + .../1080i/script-plex-dropdown_header.xml | 12 + .../skins/Main/1080i/script-plex-episodes.xml | 229 +- .../skins/Main/1080i/script-plex-home.xml | 1003 ++++++- .../skins/Main/1080i/script-plex-info.xml | 12 +- .../Main/1080i/script-plex-listview-16x9.xml | 98 +- .../1080i/script-plex-listview-square.xml | 18 +- .../Main/1080i/script-plex-options_dialog.xml | 2 + .../skins/Main/1080i/script-plex-playlist.xml | 18 +- .../Main/1080i/script-plex-playlists.xml | 12 +- .../Main/1080i/script-plex-posters-small.xml | 96 +- .../skins/Main/1080i/script-plex-posters.xml | 86 +- .../skins/Main/1080i/script-plex-pre_play.xml | 97 +- .../skins/Main/1080i/script-plex-seasons.xml | 152 +- .../script-plex-seek_dialog_skeleton.xml | 786 ++++++ .../skins/Main/1080i/script-plex-settings.xml | 9 +- .../skins/Main/1080i/script-plex-squares.xml | 12 +- .../Main/1080i/script-plex-user_select.xml | 4 +- .../script-plex-video_current_playlist.xml | 52 +- .../Main/1080i/script-plex-video_player.xml | 110 +- .../templates/seek_dialog_buttons_classic.xml | 334 +++ .../seek_dialog_buttons_modern-colored.xml | 310 +++ .../seek_dialog_buttons_modern-dotted.xml | 310 +++ .../templates/seek_dialog_buttons_modern.xml | 312 +++ .../player/modern-dotted/next-focus.png | Bin 0 -> 713 bytes .../buttons/player/modern-dotted/next.png | Bin 0 -> 693 bytes .../player/modern-dotted/pause-focus.png | Bin 0 -> 281 bytes .../buttons/player/modern-dotted/pause.png | Bin 0 -> 240 bytes .../player/modern-dotted/play-focus.png | Bin 0 -> 823 bytes .../buttons/player/modern-dotted/play.png | Bin 0 -> 789 bytes .../player/modern-dotted/pqueue-focus.png | Bin 0 -> 717 bytes .../buttons/player/modern-dotted/pqueue.png | Bin 0 -> 688 bytes .../player/modern-dotted/repeat-focus.png | Bin 0 -> 611 bytes .../player/modern-dotted/repeat-one-focus.png | Bin 0 -> 687 bytes .../player/modern-dotted/repeat-one.png | Bin 0 -> 654 bytes .../buttons/player/modern-dotted/repeat.png | Bin 0 -> 576 bytes .../player/modern-dotted/settings-focus.png | Bin 0 -> 761 bytes .../buttons/player/modern-dotted/settings.png | Bin 0 -> 744 bytes .../player/modern-dotted/shuffle-focus.png | Bin 0 -> 1370 bytes .../buttons/player/modern-dotted/shuffle.png | Bin 0 -> 1349 bytes .../modern-dotted/skip-forward-focus.png | Bin 0 -> 1046 bytes .../player/modern-dotted/skip-forward.png | Bin 0 -> 1017 bytes .../player/modern-dotted/stop-focus.png | Bin 0 -> 244 bytes .../buttons/player/modern-dotted/stop.png | Bin 0 -> 205 bytes .../player/modern-dotted/subtitle-focus.png | Bin 0 -> 560 bytes .../buttons/player/modern-dotted/subtitle.png | Bin 0 -> 524 bytes .../buttons/player/modern/next.png | Bin 0 -> 693 bytes .../buttons/player/modern/pause.png | Bin 0 -> 240 bytes .../buttons/player/modern/play.png | Bin 0 -> 789 bytes .../buttons/player/modern/pqueue.png | Bin 0 -> 688 bytes .../buttons/player/modern/repeat-one.png | Bin 0 -> 654 bytes .../buttons/player/modern/repeat.png | Bin 0 -> 576 bytes .../buttons/player/modern/settings.png | Bin 0 -> 744 bytes .../buttons/player/modern/shuffle.png | Bin 0 -> 1349 bytes .../buttons/player/modern/skip-forward.png | Bin 0 -> 1017 bytes .../buttons/player/modern/stop.png | Bin 0 -> 205 bytes .../buttons/player/modern/subtitle.png | Bin 0 -> 524 bytes .../Main/media/script.plex/home/plex.png | Bin 1579 -> 2046 bytes .../media/script.plex/indicators/watched.png | Bin 0 -> 596 bytes .../skins/Main/media/script.plex/splash.png | Bin 9464 -> 15885 bytes .../media/script.plex/user_select/plex.png | Bin 5374 -> 4767 bytes .../script.plex/white-square-bl-rounded.png | Bin 0 -> 203 bytes .../script.plex/white-square-bl-rounded_w.png | Bin 0 -> 488 bytes .../white-square-tr-bl-rounded.png | Bin 0 -> 222 bytes 150 files changed, 12755 insertions(+), 1920 deletions(-) delete mode 100644 script.plexmod/fanart.jpg create mode 100644 script.plexmod/fanart.png delete mode 100644 script.plexmod/icon.png create mode 100644 script.plexmod/icon2.png create mode 100644 script.plexmod/lib/_included_packages/_ipaddress.py create mode 100644 script.plexmod/lib/advancedsettings.py create mode 100644 script.plexmod/lib/cache.py create mode 100644 script.plexmod/lib/data_cache.py create mode 100644 script.plexmod/lib/path_mapping.py create mode 100644 script.plexmod/lib/plex_hosts.py create mode 100644 script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog_skeleton.xml create mode 100644 script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_classic.xml create mode 100644 script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-colored.xml create mode 100644 script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-dotted.xml create mode 100644 script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern.xml create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pause-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pause.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/settings-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/settings.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/skip-forward-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/skip-forward.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/subtitle-focus.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/subtitle.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/next.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/pause.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/play.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/pqueue.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat-one.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/settings.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/shuffle.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/skip-forward.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/stop.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/subtitle.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/indicators/watched.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded_w.png create mode 100644 script.plexmod/resources/skins/Main/media/script.plex/white-square-tr-bl-rounded.png diff --git a/script.plexmod/LICENSE.txt b/script.plexmod/LICENSE.txt index 6bcffb4610..48d882dcd4 100644 --- a/script.plexmod/LICENSE.txt +++ b/script.plexmod/LICENSE.txt @@ -485,8 +485,70 @@ apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. +------------------------------------------------------------------------- + +https://github.com/phihag/ipaddress + +This package is a modified version of cpython's ipaddress module. +It is therefore distributed under the PSF license, as follows: + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014 Python Software Foundation; All Rights Reserved" are +retained in Python alone or in any derivative version prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + ------------------------------------------------------------------------- Fontawesome https://fontawesome.com/license/free CC BY 4.0 License: https://creativecommons.org/licenses/by/4.0/# + +------------------------------------------------------------------------- +Play queue by Sébastien Robaszkiewicz from Noun Project (CC BY 3.0) +fast forward by Adiyogi from Noun Project (CC BY 3.0) +subtitle by YANDI RS from Noun Project (CC BY 3.0) + +Other unlisted icons under CC BY 3.0, https://creativecommons.org/licenses/by/3.0/ \ No newline at end of file diff --git a/script.plexmod/README.md b/script.plexmod/README.md index 317bd7cab1..50b922dcd2 100644 --- a/script.plexmod/README.md +++ b/script.plexmod/README.md @@ -1,4 +1,6 @@ -# PlexMod (for Kodi) +# PM4K / PlexMod for Kodi + +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Z8Z8X6P9T) This is a modification of the official open-source Plex client for Kodi "plex-for-kodi" (Plex4Kodi) semi-maintained by me (pannal). @@ -6,7 +8,7 @@ Contrary to how this repository was handled before, this client does _not_ claim It implements features that are not implemented in other official Plex clients and may implement others in non-conform ways. -It is still based off of the original P4K source and critical bugfixes will be PR'd back. +It is still based off of the original P4K source and critical bugfixes might be PR'd back. ## Active branches * [develop-kodi21](https://github.com/pannal/plex-for-kodi/tree/develop_kodi21) (Kodi 19, 20, 21 cross-compatible) @@ -16,15 +18,21 @@ Master branch is based off of the official plex-for-kodi master branch. ## Installation -### Via repository +### Via repository (recommended) * Add `https://pannal.github.io/dontpanickodi/` to your Kodi installation as a file source -* Go back to addons, choose zip file, choose the file source you added and install the repository -* Install Plex via Addons->Install from repository->Don’t Panic->Video add-ons->Plex -* Optional, recommended: Install Plextuary via Addons->Install from repository->Don’t Panic->Look and Feel->Skin->Plextuary +* Go to Settings->Addons, choose "Install from zip file", choose the file source you added and install the repository +* Install Plex via Settings->Addons->Install from repository->Don't Panic->Video add-ons->Plex +* Optional, recommended: Install Plextuary via Settings->Addons->Install from repository->Don't Panic->Look and Feel->Skin->Plextuary + +### Installation (stable only, not optimized, possibly outdated) +* Install "PM4K for Kodi" from the official Kodi repository +* Optional, recommended: Install Plextuary skin using the above ### Manual * Checkout any branch of this GitHub repository, rename to `script.plexmod` and use as an addon +## Translation +You can help! Join the translation effort at [POEditor](https://poeditor.com/join/project/ASOl50YAXg) (thanks for the free open source license, guys). ## Help/Bug Reports https://forums.plex.tv/t/plexmod-for-kodi-18-19-20-21/481208 diff --git a/script.plexmod/addon.xml b/script.plexmod/addon.xml index 2e911137be..d18174ecf8 100644 --- a/script.plexmod/addon.xml +++ b/script.plexmod/addon.xml @@ -1,7 +1,7 @@ @@ -33,11 +33,11 @@ https://github.com/pannal/plex-for-kodi all -- Based on 0.7.6-rev2 +- 0.7.8 - icon.png - fanart.jpg + icon2.png + fanart.png - \ No newline at end of file + diff --git a/script.plexmod/changelog.txt b/script.plexmod/changelog.txt index 9f552aa800..d0a8537d5c 100644 --- a/script.plexmod/changelog.txt +++ b/script.plexmod/changelog.txt @@ -1,6 +1,122 @@ -[-0.7.6-rev2 -] -- Core: Avoid DNS rebind protection issues for plex.direct +[-0.7.8-] +- Global: Use watched markers instead of unwatched markers (green checkmark vs. yellow triangle) +- Home: Blur in-progress episode thumbnails if Addon Setting “Use episode thumbnails in continue hub” is enabled and titles if requested +- Home: Refresh hubs when no episode spoiler setting changed +- Home/Settings: After changing home-relevant settings, reload Home on revisit, not immediately +- Home: Dynamically reload home when relevant settings have changed +- Home/Sections: Improve handling for sections that errored out once, and retry loading them just like any stale section every 5 minutes +- Home: Fix hubs not coming back after disconnect +- Home: Harden disconnect handling in general; PM4K should be able to "live" forever now +- Home: Refresh last section on: screensaver deactivated, DPMS deactivated, system wakeup after sleep +- Home/PowerEvents: Disable updates when system goes to sleep, enable them when it wakes up; force a hubs update when waking up +- Home: Probably fix long-running section change issue (partially reverting a previous change and being a little smarter about the current selection state); add debug logging for when we detect the "anomaly" +- Home: Don't let any tasks remain in a crashed state +- Core/Home: Add easy path mapping +- Core/Home: Add library context option to hide them for current user/server; Add context option for "Home" to unhide hidden libraries +- Home: Hide hidden library content from home hubs as well +- Home: Add library reordering functionality +- Home: Remove Hub round-robining altogether +- Home: Use ACTION_NAV_BACK/ACTION_PREVIOUS_MENU in home hubs to select the first item when any item other than the first item is selected +- Home: After refreshing stale section hubs (every 5m), re-select the last selected position in every hub, possibly re-extending the hubs to match the last position +- Episodes: Blur unwatched/in-progress episode thumbnails and redact spoiler texts, and episode titles if requested +- Episodes: Possibly use correct episode when playing TV Show from TV Show overview; always play latest in progress, unwatched, deprioritize specials +- Episodes: after watching an episode, remove chevron immediately +- Episodes: After watching multiple episodes in a row that span more than one season (e.g. watched S01E10, S02E01), properly redirect to the latest correct season view of the just watched episodes +- Episodes: Inject watched/progress state into listitem datasource (fixes wrong "Mark as (Un) Played" menu item behaviour immediately after watching an episode) +- Episodes: Don't play theme music when coming back from Home-Direct-Play (pressed P or ACTION_PLAYER_PLAY) +- Episodes: Select the correct episode after returning from direct playback from home (P/PLAY pressed) +- SeekDialog: Hide episode title if wanted +- SeekDialog: Properly update VideoPlaylist when using next/prev +- SeekDialog: Improve chapter visibility (selected and deselected (current)) +- SeekDialog: Throw away intro markers with an unreasonably late start offset (>10m) +- SeekDialog: Throw invalid markers away once, not every tick +- SeekDialog/Settings/Video Playlists: change the setting options for showing the prev/next and playlist buttons from "Only for Episodes" to "Only for Episodes/Playlists"; Show next/prev/playlist buttons in player for video playlists if wanted +- Core/Players: Remove all Kodi media-loading spinners when using Plextuary skin +- Core/Player: Always set infolabel "year" and remove it when downloading subtitles; don't set infolabels "episode" and "season" at all for non-TV-shows (fixes scrobbling issues with trakt plugin) +- Core/Player: Report all known Guids to script.trakt if it's installed; generate slug for movies +- Player/SeekDialog: Properly handle a manual stop action on episodes when OSD was visible (possibly other occasions) +- Player/PostPlay: Hide spoilers as well, if configured +- Postplay: Don't show the same episode on deck which is going to be played next +- TV Shows/Seasons: Try reloading instead of exiting to home when deleting a season if possible +- Libraries: Add movie/show year to label (thanks @bowlingbeeg) +- Libraries: Fix year display, fix Art display in listview 16x9; make small posters view a little less cramped +- VideoPlaylists: Show playback menu when an item can be resumed +- VideoPlaylists: Show playback menu when CONTEXT_MENU action is detected +- VideoPlaylists: Allow resuming, or, if possible, Start from Beginning +- Core: Ignore local IPv4 docker plex.direct hosts when checking for host mapping +- Core: Unify spoiler handling across multiple windows +- Core: API requests: Don't generally include markers, only for PlayableVideos +- Core: Open up translations to everyone, using POEditor +- Core/AddonSettings: Remove old compatibility profile code and setting +- Core/AddonSettings: Make caching home users optional; add setting (default: on) +- Core/Home: When home user caching is disabled, refresh home users when opening the user dropdown (once) +- Core: Add support for Guids in new library agents +- Core/Episodes/Player: Inject Show reference into episodes to speed up playback start and avoid additional API hits; Don't initiate a playlist if only one episode is being played +- Core: Compatibility with script.trakt +- Core: Add automated generic JSON data cache, stored as addon_data/script.plexmod/data_cache.json +- Core/TV: Store and use "once seen" genres for a show in the data cache to speed up certain views (such as postplay and continue watching, with no spoiler mode active) +- Core: Automatically clean up old unused data cache entries which haven't been accessed for 30 days +- Core/Settings: Add option to use watched markers instead of unwatched markers (default: on) +- Core/Settings: Add option to hide the black background on inverted watched markers (default: off) +- Core: Fix dialogs reverting the underlying window's watched marker setting temporarily +- Core: Memory usage and performance optimizations (possibly >10% in py3 and >30% in py2 by using __slots__ on massively used classes) +- Core: Allow for custom watched.png, unwatched.png, unwatched-rounded.png in addon_data/script.plexmod/media/ +- Core: Improve delete media clarity +- Core/Playlists: Fix context menu "play" action on playlist items when addonSetting playlist visit media is on +- Core/Windows: Optimize Imports +- Settings: Add setting to show indicators for libraries with active path mapping (default: on) +- Settings: Add setting to blur episode thumbnails and preview images, as well as redact spoilers for unwatched or unwatched+in progress episodes +- Settings: Add setting to also hide episode titles based on the above +- AddonSettings: Add setting to configure the blur amount for unwatched episodes (default: 16/255) +- AddonSettings: Add setting for ignoring local docker IPv4 hosts when checking for host mapping (default: enabled) +- AddonSettings: Change default of "Visit media in video playlist instead of playing it" to False +- AddonSettings: Add setting to define the maximum start offset of an intro marker to consider it (default: 600s/10m) +- Theme: Bump theme version to 3 + + +[-0.7.7-rev2-] +- UserSelect: When not switching user (startup), close the addon on cancel actions +- Libraries/Photos: Do autoplay when Play or Shuffle buttons are pressed in photo library view +- Libraries/Photos: Fix wonky thumbnail display on photodirectories +- SeekDialog: Fix final credits marker skipping wrongly on manual marker skip +- SeekDialog: Fix several marker issues (over-jumping, invalid markers) +- SeekDialog: Show stream transport type in video session info (smb, nfs, path mapped, http(s)) +- SeekDialog: Fix empty chapters list shown when watching an episode without markers via NEXT after watching one with markers +- SeekDialog/Settings: Add option to hide all time-related information from the user when the OSD isn't open +- SeekDialog: Transcode Session Only: Don't show subtitle download option in subtitle quick actions; properly show subtitles when toggling them via subtitle quick actions +- Core: Advanced/Addon settings: Set default Plex requests timeout to 10 (was 5) +- Core/Mainloop: Make sure we were able to open the Home/UserSelect window after our BACKGROUND successfully opened, even if another modal dialog opened in the meantime - Core: Firstrun: Refresh resources after signin and/or home user switch, otherwise the first time the addon's run the user sees no servers +- Core: UI: Fall back to black background image when we have backgrounds set but they didn't load +- Core: Finally fix and handle plex.direct mappings via advancedsettings.xml +- Core: Fix issues resulting in new devices being registered with plex.tv on every plugin start +- Core: Only use mapped file path if mapped file exists or verification is off +- Core: Backgrounds: More concise fallback handling +- Core/Player: Add optional DirectPlay path mapping via addon_data/script.plexmod/path_mapping.json (path_mapping.example.json included in addon directory). Allows for arbitrary replacements of HTTP playback with SMB, NFS, local mounts etc. +- Home: Rework and simplify section/library change logic +- Home: Never update hubs while playing a video to avoid hickups in high bitrate scenarios +- Home: Properly cache user thumbnail (by removing the ?c timestamp from the URL), increasing performance +- Home: Fix round robining on hubs (going left once before the last item, then right falsely round-robined to the start, early) +- Home: Add virtual hub 'home.VIRTUAL.movies.recentlyreleased' on index 3 if we encounter 'movie.recentlyreleased' on the home hubs +- Home/Settings: Add setting to use the modern Continue Watching hub on Home instead of the separated In Progress/On Deck hubs +- Episodes: Complete rework of the watch-state handler for TV; instantly update progress while watching +- Theme: Update assets, icon, splash +- Music: Rework music player and handler; remove plugin:// path and handle tracks directly; fixed all stability issues; massively improve performance +- Player: Add button theme support, use new modern colored theme; support custom themes +- Player: Improve handling when postplay screen is not wanted and we're at the end of a show; harden progressEvent handler +- Player: Remove all /file.xxx instances instead of just .mkv and .mp4 from non mapped stream URLs +- Musicplayer: Partially fix "dangling" playing tracks in Plex Dashboard after stopping playback +- Music: Hide spinner on prev/next button clicks as well (only with Plextuary skin 4.0.0-pm4k0.9 (omega), 3.0.10-pm4k0.8 (nexus and older)) +- Settings: Add setting to toggle path mapping dynamically +- Settings: Adjust cache recommendations down from 100MB to 50MB +- AddonSettings: Add advanced setting to verify mapped files before playing them (default: on) +- Account/HomeUsers: Use new API to determine the PlexPass subscription status of the Plex Home +- Account/HomeUsers: Refresh home users once a week if we've never seen a plex home +- Home/RefreshUsers/UserSelect: Refresh subscription state + + +[-0.7.6-] +- Core: Avoid DNS rebind protection issues for plex.direct - Core: Support ipv6 plex.direct hosts when checking for locality/LAN - Core: Network: Massively speed up local connection checks - Core: Network: Skip local connection checks for plex.tv @@ -25,7 +141,6 @@ - SeekDialog: Autoscroll episode/movie title lines for too long titles - SeekDialog: Hide non-autoskipping marker into the OSD using NAV_BACK/PREVIOUS_MENU - SeekDialog: Apply positive marker endtime offset to manually skipping markers as well (unifying with the autoskip handling), to avoid re-showing the marker occasionally after seeking -- SeekDialog: Fix final credits marker skipping wrongly on manual marker skip - Library: Show current total item count in title - Settings: Add description for Direct Stream diff --git a/script.plexmod/fanart.jpg b/script.plexmod/fanart.jpg deleted file mode 100644 index 7548349f6a1dd3633c2dc7262c386ec461f361cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71213 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 diff --git a/script.plexmod/fanart.png b/script.plexmod/fanart.png new file mode 100644 index 0000000000000000000000000000000000000000..851411a141147fbd238c610b541c903a109328ad GIT binary patch literal 22613 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 2 ** 32: + raise struct.error("integer out of range for 'I' format code") + return struct.pack(b'!I', intval) + elif length == 16: + if intval < 0 or intval >= 2 ** 128: + raise struct.error("integer out of range for 'QQ' format code") + return struct.pack(b'!QQ', intval >> 64, intval & 0xffffffffffffffff) + else: + raise NotImplementedError() + + +if hasattr(int, 'bit_length'): + # Not int.bit_length , since that won't work in 2.7 where long exists + def _compat_bit_length(i): + return i.bit_length() +else: + def _compat_bit_length(i): + for res in itertools.count(): + if i >> res == 0: + return res + + +def _compat_range(start, end, step=1): + assert step > 0 + i = start + while i < end: + yield i + i += step + + +class _TotalOrderingMixin(object): + __slots__ = () + + # Helper that derives the other comparison operations from + # __lt__ and __eq__ + # We avoid functools.total_ordering because it doesn't handle + # NotImplemented correctly yet (http://bugs.python.org/issue10042) + def __eq__(self, other): + raise NotImplementedError + + def __ne__(self, other): + equal = self.__eq__(other) + if equal is NotImplemented: + return NotImplemented + return not equal + + def __lt__(self, other): + raise NotImplementedError + + def __le__(self, other): + less = self.__lt__(other) + if less is NotImplemented or not less: + return self.__eq__(other) + return less + + def __gt__(self, other): + less = self.__lt__(other) + if less is NotImplemented: + return NotImplemented + equal = self.__eq__(other) + if equal is NotImplemented: + return NotImplemented + return not (less or equal) + + def __ge__(self, other): + less = self.__lt__(other) + if less is NotImplemented: + return NotImplemented + return not less + + +IPV4LENGTH = 32 +IPV6LENGTH = 128 + + +class AddressValueError(ValueError): + """A Value Error related to the address.""" + + +class NetmaskValueError(ValueError): + """A Value Error related to the netmask.""" + + +def ip_address(address): + """Take an IP string/int and return an object of the correct type. + + Args: + address: A string or integer, the IP address. Either IPv4 or + IPv6 addresses may be supplied; integers less than 2**32 will + be considered to be IPv4 by default. + + Returns: + An IPv4Address or IPv6Address object. + + Raises: + ValueError: if the *address* passed isn't either a v4 or a v6 + address + + """ + try: + return IPv4Address(address) + except (AddressValueError, NetmaskValueError): + pass + + try: + return IPv6Address(address) + except (AddressValueError, NetmaskValueError): + pass + + if isinstance(address, bytes): + raise AddressValueError( + '%r does not appear to be an IPv4 or IPv6 address. ' + 'Did you pass in a bytes (str in Python 2) instead of' + ' a unicode object?' % address) + + raise ValueError('%r does not appear to be an IPv4 or IPv6 address' % + address) + + +def ip_network(address, strict=True): + """Take an IP string/int and return an object of the correct type. + + Args: + address: A string or integer, the IP network. Either IPv4 or + IPv6 networks may be supplied; integers less than 2**32 will + be considered to be IPv4 by default. + + Returns: + An IPv4Network or IPv6Network object. + + Raises: + ValueError: if the string passed isn't either a v4 or a v6 + address. Or if the network has host bits set. + + """ + try: + return IPv4Network(address, strict) + except (AddressValueError, NetmaskValueError): + pass + + try: + return IPv6Network(address, strict) + except (AddressValueError, NetmaskValueError): + pass + + if isinstance(address, bytes): + raise AddressValueError( + '%r does not appear to be an IPv4 or IPv6 network. ' + 'Did you pass in a bytes (str in Python 2) instead of' + ' a unicode object?' % address) + + raise ValueError('%r does not appear to be an IPv4 or IPv6 network' % + address) + + +def ip_interface(address): + """Take an IP string/int and return an object of the correct type. + + Args: + address: A string or integer, the IP address. Either IPv4 or + IPv6 addresses may be supplied; integers less than 2**32 will + be considered to be IPv4 by default. + + Returns: + An IPv4Interface or IPv6Interface object. + + Raises: + ValueError: if the string passed isn't either a v4 or a v6 + address. + + Notes: + The IPv?Interface classes describe an Address on a particular + Network, so they're basically a combination of both the Address + and Network classes. + + """ + try: + return IPv4Interface(address) + except (AddressValueError, NetmaskValueError): + pass + + try: + return IPv6Interface(address) + except (AddressValueError, NetmaskValueError): + pass + + raise ValueError('%r does not appear to be an IPv4 or IPv6 interface' % + address) + + +def v4_int_to_packed(address): + """Represent an address as 4 packed bytes in network (big-endian) order. + + Args: + address: An integer representation of an IPv4 IP address. + + Returns: + The integer address packed as 4 bytes in network (big-endian) order. + + Raises: + ValueError: If the integer is negative or too large to be an + IPv4 IP address. + + """ + try: + return _compat_to_bytes(address, 4, 'big') + except (struct.error, OverflowError): + raise ValueError("Address negative or too large for IPv4") + + +def v6_int_to_packed(address): + """Represent an address as 16 packed bytes in network (big-endian) order. + + Args: + address: An integer representation of an IPv6 IP address. + + Returns: + The integer address packed as 16 bytes in network (big-endian) order. + + """ + try: + return _compat_to_bytes(address, 16, 'big') + except (struct.error, OverflowError): + raise ValueError("Address negative or too large for IPv6") + + +def _split_optional_netmask(address): + """Helper to split the netmask and raise AddressValueError if needed""" + addr = _compat_str(address).split('/') + if len(addr) > 2: + raise AddressValueError("Only one '/' permitted in %r" % address) + return addr + + +def _find_address_range(addresses): + """Find a sequence of sorted deduplicated IPv#Address. + + Args: + addresses: a list of IPv#Address objects. + + Yields: + A tuple containing the first and last IP addresses in the sequence. + + """ + it = iter(addresses) + first = last = next(it) + for ip in it: + if ip._ip != last._ip + 1: + yield first, last + first = ip + last = ip + yield first, last + + +def _count_righthand_zero_bits(number, bits): + """Count the number of zero bits on the right hand side. + + Args: + number: an integer. + bits: maximum number of bits to count. + + Returns: + The number of zero bits on the right hand side of the number. + + """ + if number == 0: + return bits + return min(bits, _compat_bit_length(~number & (number - 1))) + + +def summarize_address_range(first, last): + """Summarize a network range given the first and last IP addresses. + + Example: + >>> list(summarize_address_range(IPv4Address('192.0.2.0'), + ... IPv4Address('192.0.2.130'))) + ... #doctest: +NORMALIZE_WHITESPACE + [IPv4Network('192.0.2.0/25'), IPv4Network('192.0.2.128/31'), + IPv4Network('192.0.2.130/32')] + + Args: + first: the first IPv4Address or IPv6Address in the range. + last: the last IPv4Address or IPv6Address in the range. + + Returns: + An iterator of the summarized IPv(4|6) network objects. + + Raise: + TypeError: + If the first and last objects are not IP addresses. + If the first and last objects are not the same version. + ValueError: + If the last object is not greater than the first. + If the version of the first address is not 4 or 6. + + """ + if (not (isinstance(first, _BaseAddress) and + isinstance(last, _BaseAddress))): + raise TypeError('first and last must be IP addresses, not networks') + if first.version != last.version: + raise TypeError("%s and %s are not of the same version" % ( + first, last)) + if first > last: + raise ValueError('last IP address must be greater than first') + + if first.version == 4: + ip = IPv4Network + elif first.version == 6: + ip = IPv6Network + else: + raise ValueError('unknown IP version') + + ip_bits = first._max_prefixlen + first_int = first._ip + last_int = last._ip + while first_int <= last_int: + nbits = min(_count_righthand_zero_bits(first_int, ip_bits), + _compat_bit_length(last_int - first_int + 1) - 1) + net = ip((first_int, ip_bits - nbits)) + yield net + first_int += 1 << nbits + if first_int - 1 == ip._ALL_ONES: + break + + +def _collapse_addresses_internal(addresses): + """Loops through the addresses, collapsing concurrent netblocks. + + Example: + + ip1 = IPv4Network('192.0.2.0/26') + ip2 = IPv4Network('192.0.2.64/26') + ip3 = IPv4Network('192.0.2.128/26') + ip4 = IPv4Network('192.0.2.192/26') + + _collapse_addresses_internal([ip1, ip2, ip3, ip4]) -> + [IPv4Network('192.0.2.0/24')] + + This shouldn't be called directly; it is called via + collapse_addresses([]). + + Args: + addresses: A list of IPv4Network's or IPv6Network's + + Returns: + A list of IPv4Network's or IPv6Network's depending on what we were + passed. + + """ + # First merge + to_merge = list(addresses) + subnets = {} + while to_merge: + net = to_merge.pop() + supernet = net.supernet() + existing = subnets.get(supernet) + if existing is None: + subnets[supernet] = net + elif existing != net: + # Merge consecutive subnets + del subnets[supernet] + to_merge.append(supernet) + # Then iterate over resulting networks, skipping subsumed subnets + last = None + for net in sorted(subnets.values()): + if last is not None: + # Since they are sorted, + # last.network_address <= net.network_address is a given. + if last.broadcast_address >= net.broadcast_address: + continue + yield net + last = net + + +def collapse_addresses(addresses): + """Collapse a list of IP objects. + + Example: + collapse_addresses([IPv4Network('192.0.2.0/25'), + IPv4Network('192.0.2.128/25')]) -> + [IPv4Network('192.0.2.0/24')] + + Args: + addresses: An iterator of IPv4Network or IPv6Network objects. + + Returns: + An iterator of the collapsed IPv(4|6)Network objects. + + Raises: + TypeError: If passed a list of mixed version objects. + + """ + addrs = [] + ips = [] + nets = [] + + # split IP addresses and networks + for ip in addresses: + if isinstance(ip, _BaseAddress): + if ips and ips[-1]._version != ip._version: + raise TypeError("%s and %s are not of the same version" % ( + ip, ips[-1])) + ips.append(ip) + elif ip._prefixlen == ip._max_prefixlen: + if ips and ips[-1]._version != ip._version: + raise TypeError("%s and %s are not of the same version" % ( + ip, ips[-1])) + try: + ips.append(ip.ip) + except AttributeError: + ips.append(ip.network_address) + else: + if nets and nets[-1]._version != ip._version: + raise TypeError("%s and %s are not of the same version" % ( + ip, nets[-1])) + nets.append(ip) + + # sort and dedup + ips = sorted(set(ips)) + + # find consecutive address ranges in the sorted sequence and summarize them + if ips: + for first, last in _find_address_range(ips): + addrs.extend(summarize_address_range(first, last)) + + return _collapse_addresses_internal(addrs + nets) + + +def get_mixed_type_key(obj): + """Return a key suitable for sorting between networks and addresses. + + Address and Network objects are not sortable by default; they're + fundamentally different so the expression + + IPv4Address('192.0.2.0') <= IPv4Network('192.0.2.0/24') + + doesn't make any sense. There are some times however, where you may wish + to have ipaddress sort these for you anyway. If you need to do this, you + can use this function as the key= argument to sorted(). + + Args: + obj: either a Network or Address object. + Returns: + appropriate key. + + """ + if isinstance(obj, _BaseNetwork): + return obj._get_networks_key() + elif isinstance(obj, _BaseAddress): + return obj._get_address_key() + return NotImplemented + + +class _IPAddressBase(_TotalOrderingMixin): + + """The mother class.""" + + __slots__ = () + + @property + def exploded(self): + """Return the longhand version of the IP address as a string.""" + return self._explode_shorthand_ip_string() + + @property + def compressed(self): + """Return the shorthand version of the IP address as a string.""" + return _compat_str(self) + + @property + def reverse_pointer(self): + """The name of the reverse DNS pointer for the IP address, e.g.: + >>> ipaddress.ip_address("127.0.0.1").reverse_pointer + '1.0.0.127.in-addr.arpa' + >>> ipaddress.ip_address("2001:db8::1").reverse_pointer + '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa' + + """ + return self._reverse_pointer() + + @property + def version(self): + msg = '%200s has no version specified' % (type(self),) + raise NotImplementedError(msg) + + def _check_int_address(self, address): + if address < 0: + msg = "%d (< 0) is not permitted as an IPv%d address" + raise AddressValueError(msg % (address, self._version)) + if address > self._ALL_ONES: + msg = "%d (>= 2**%d) is not permitted as an IPv%d address" + raise AddressValueError(msg % (address, self._max_prefixlen, + self._version)) + + def _check_packed_address(self, address, expected_len): + address_len = len(address) + if address_len != expected_len: + msg = ( + '%r (len %d != %d) is not permitted as an IPv%d address. ' + 'Did you pass in a bytes (str in Python 2) instead of' + ' a unicode object?') + raise AddressValueError(msg % (address, address_len, + expected_len, self._version)) + + @classmethod + def _ip_int_from_prefix(cls, prefixlen): + """Turn the prefix length into a bitwise netmask + + Args: + prefixlen: An integer, the prefix length. + + Returns: + An integer. + + """ + return cls._ALL_ONES ^ (cls._ALL_ONES >> prefixlen) + + @classmethod + def _prefix_from_ip_int(cls, ip_int): + """Return prefix length from the bitwise netmask. + + Args: + ip_int: An integer, the netmask in expanded bitwise format + + Returns: + An integer, the prefix length. + + Raises: + ValueError: If the input intermingles zeroes & ones + """ + trailing_zeroes = _count_righthand_zero_bits(ip_int, + cls._max_prefixlen) + prefixlen = cls._max_prefixlen - trailing_zeroes + leading_ones = ip_int >> trailing_zeroes + all_ones = (1 << prefixlen) - 1 + if leading_ones != all_ones: + byteslen = cls._max_prefixlen // 8 + details = _compat_to_bytes(ip_int, byteslen, 'big') + msg = 'Netmask pattern %r mixes zeroes & ones' + raise ValueError(msg % details) + return prefixlen + + @classmethod + def _report_invalid_netmask(cls, netmask_str): + msg = '%r is not a valid netmask' % netmask_str + raise NetmaskValueError(msg) + + @classmethod + def _prefix_from_prefix_string(cls, prefixlen_str): + """Return prefix length from a numeric string + + Args: + prefixlen_str: The string to be converted + + Returns: + An integer, the prefix length. + + Raises: + NetmaskValueError: If the input is not a valid netmask + """ + # int allows a leading +/- as well as surrounding whitespace, + # so we ensure that isn't the case + if not _BaseV4._DECIMAL_DIGITS.issuperset(prefixlen_str): + cls._report_invalid_netmask(prefixlen_str) + try: + prefixlen = int(prefixlen_str) + except ValueError: + cls._report_invalid_netmask(prefixlen_str) + if not (0 <= prefixlen <= cls._max_prefixlen): + cls._report_invalid_netmask(prefixlen_str) + return prefixlen + + @classmethod + def _prefix_from_ip_string(cls, ip_str): + """Turn a netmask/hostmask string into a prefix length + + Args: + ip_str: The netmask/hostmask to be converted + + Returns: + An integer, the prefix length. + + Raises: + NetmaskValueError: If the input is not a valid netmask/hostmask + """ + # Parse the netmask/hostmask like an IP address. + try: + ip_int = cls._ip_int_from_string(ip_str) + except AddressValueError: + cls._report_invalid_netmask(ip_str) + + # Try matching a netmask (this would be /1*0*/ as a bitwise regexp). + # Note that the two ambiguous cases (all-ones and all-zeroes) are + # treated as netmasks. + try: + return cls._prefix_from_ip_int(ip_int) + except ValueError: + pass + + # Invert the bits, and try matching a /0+1+/ hostmask instead. + ip_int ^= cls._ALL_ONES + try: + return cls._prefix_from_ip_int(ip_int) + except ValueError: + cls._report_invalid_netmask(ip_str) + + def __reduce__(self): + return self.__class__, (_compat_str(self),) + + +class _BaseAddress(_IPAddressBase): + + """A generic IP object. + + This IP class contains the version independent methods which are + used by single IP addresses. + """ + + __slots__ = () + + def __int__(self): + return self._ip + + def __eq__(self, other): + try: + return (self._ip == other._ip and + self._version == other._version) + except AttributeError: + return NotImplemented + + def __lt__(self, other): + if not isinstance(other, _IPAddressBase): + return NotImplemented + if not isinstance(other, _BaseAddress): + raise TypeError('%s and %s are not of the same type' % ( + self, other)) + if self._version != other._version: + raise TypeError('%s and %s are not of the same version' % ( + self, other)) + if self._ip != other._ip: + return self._ip < other._ip + return False + + # Shorthand for Integer addition and subtraction. This is not + # meant to ever support addition/subtraction of addresses. + def __add__(self, other): + if not isinstance(other, _compat_int_types): + return NotImplemented + return self.__class__(int(self) + other) + + def __sub__(self, other): + if not isinstance(other, _compat_int_types): + return NotImplemented + return self.__class__(int(self) - other) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, _compat_str(self)) + + def __str__(self): + return _compat_str(self._string_from_ip_int(self._ip)) + + def __hash__(self): + return hash(hex(int(self._ip))) + + def _get_address_key(self): + return (self._version, self) + + def __reduce__(self): + return self.__class__, (self._ip,) + + +class _BaseNetwork(_IPAddressBase): + + """A generic IP network object. + + This IP class contains the version independent methods which are + used by networks. + + """ + def __init__(self, address): + self._cache = {} + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, _compat_str(self)) + + def __str__(self): + return '%s/%d' % (self.network_address, self.prefixlen) + + def hosts(self): + """Generate Iterator over usable hosts in a network. + + This is like __iter__ except it doesn't return the network + or broadcast addresses. + + """ + network = int(self.network_address) + broadcast = int(self.broadcast_address) + for x in _compat_range(network + 1, broadcast): + yield self._address_class(x) + + def __iter__(self): + network = int(self.network_address) + broadcast = int(self.broadcast_address) + for x in _compat_range(network, broadcast + 1): + yield self._address_class(x) + + def __getitem__(self, n): + network = int(self.network_address) + broadcast = int(self.broadcast_address) + if n >= 0: + if network + n > broadcast: + raise IndexError('address out of range') + return self._address_class(network + n) + else: + n += 1 + if broadcast + n < network: + raise IndexError('address out of range') + return self._address_class(broadcast + n) + + def __lt__(self, other): + if not isinstance(other, _IPAddressBase): + return NotImplemented + if not isinstance(other, _BaseNetwork): + raise TypeError('%s and %s are not of the same type' % ( + self, other)) + if self._version != other._version: + raise TypeError('%s and %s are not of the same version' % ( + self, other)) + if self.network_address != other.network_address: + return self.network_address < other.network_address + if self.netmask != other.netmask: + return self.netmask < other.netmask + return False + + def __eq__(self, other): + try: + return (self._version == other._version and + self.network_address == other.network_address and + int(self.netmask) == int(other.netmask)) + except AttributeError: + return NotImplemented + + def __hash__(self): + return hash(int(self.network_address) ^ int(self.netmask)) + + def __contains__(self, other): + # always false if one is v4 and the other is v6. + if self._version != other._version: + return False + # dealing with another network. + if isinstance(other, _BaseNetwork): + return False + # dealing with another address + else: + # address + return (int(self.network_address) <= int(other._ip) <= + int(self.broadcast_address)) + + def overlaps(self, other): + """Tell if self is partly contained in other.""" + return self.network_address in other or ( + self.broadcast_address in other or ( + other.network_address in self or ( + other.broadcast_address in self))) + + @property + def broadcast_address(self): + x = self._cache.get('broadcast_address') + if x is None: + x = self._address_class(int(self.network_address) | + int(self.hostmask)) + self._cache['broadcast_address'] = x + return x + + @property + def hostmask(self): + x = self._cache.get('hostmask') + if x is None: + x = self._address_class(int(self.netmask) ^ self._ALL_ONES) + self._cache['hostmask'] = x + return x + + @property + def with_prefixlen(self): + return '%s/%d' % (self.network_address, self._prefixlen) + + @property + def with_netmask(self): + return '%s/%s' % (self.network_address, self.netmask) + + @property + def with_hostmask(self): + return '%s/%s' % (self.network_address, self.hostmask) + + @property + def num_addresses(self): + """Number of hosts in the current subnet.""" + return int(self.broadcast_address) - int(self.network_address) + 1 + + @property + def _address_class(self): + # Returning bare address objects (rather than interfaces) allows for + # more consistent behaviour across the network address, broadcast + # address and individual host addresses. + msg = '%200s has no associated address class' % (type(self),) + raise NotImplementedError(msg) + + @property + def prefixlen(self): + return self._prefixlen + + def address_exclude(self, other): + """Remove an address from a larger block. + + For example: + + addr1 = ip_network('192.0.2.0/28') + addr2 = ip_network('192.0.2.1/32') + list(addr1.address_exclude(addr2)) = + [IPv4Network('192.0.2.0/32'), IPv4Network('192.0.2.2/31'), + IPv4Network('192.0.2.4/30'), IPv4Network('192.0.2.8/29')] + + or IPv6: + + addr1 = ip_network('2001:db8::1/32') + addr2 = ip_network('2001:db8::1/128') + list(addr1.address_exclude(addr2)) = + [ip_network('2001:db8::1/128'), + ip_network('2001:db8::2/127'), + ip_network('2001:db8::4/126'), + ip_network('2001:db8::8/125'), + ... + ip_network('2001:db8:8000::/33')] + + Args: + other: An IPv4Network or IPv6Network object of the same type. + + Returns: + An iterator of the IPv(4|6)Network objects which is self + minus other. + + Raises: + TypeError: If self and other are of differing address + versions, or if other is not a network object. + ValueError: If other is not completely contained by self. + + """ + if not self._version == other._version: + raise TypeError("%s and %s are not of the same version" % ( + self, other)) + + if not isinstance(other, _BaseNetwork): + raise TypeError("%s is not a network object" % other) + + if not other.subnet_of(self): + raise ValueError('%s not contained in %s' % (other, self)) + if other == self: + return + + # Make sure we're comparing the network of other. + other = other.__class__('%s/%s' % (other.network_address, + other.prefixlen)) + + s1, s2 = self.subnets() + while s1 != other and s2 != other: + if other.subnet_of(s1): + yield s2 + s1, s2 = s1.subnets() + elif other.subnet_of(s2): + yield s1 + s1, s2 = s2.subnets() + else: + # If we got here, there's a bug somewhere. + raise AssertionError('Error performing exclusion: ' + 's1: %s s2: %s other: %s' % + (s1, s2, other)) + if s1 == other: + yield s2 + elif s2 == other: + yield s1 + else: + # If we got here, there's a bug somewhere. + raise AssertionError('Error performing exclusion: ' + 's1: %s s2: %s other: %s' % + (s1, s2, other)) + + def compare_networks(self, other): + """Compare two IP objects. + + This is only concerned about the comparison of the integer + representation of the network addresses. This means that the + host bits aren't considered at all in this method. If you want + to compare host bits, you can easily enough do a + 'HostA._ip < HostB._ip' + + Args: + other: An IP object. + + Returns: + If the IP versions of self and other are the same, returns: + + -1 if self < other: + eg: IPv4Network('192.0.2.0/25') < IPv4Network('192.0.2.128/25') + IPv6Network('2001:db8::1000/124') < + IPv6Network('2001:db8::2000/124') + 0 if self == other + eg: IPv4Network('192.0.2.0/24') == IPv4Network('192.0.2.0/24') + IPv6Network('2001:db8::1000/124') == + IPv6Network('2001:db8::1000/124') + 1 if self > other + eg: IPv4Network('192.0.2.128/25') > IPv4Network('192.0.2.0/25') + IPv6Network('2001:db8::2000/124') > + IPv6Network('2001:db8::1000/124') + + Raises: + TypeError if the IP versions are different. + + """ + # does this need to raise a ValueError? + if self._version != other._version: + raise TypeError('%s and %s are not of the same type' % ( + self, other)) + # self._version == other._version below here: + if self.network_address < other.network_address: + return -1 + if self.network_address > other.network_address: + return 1 + # self.network_address == other.network_address below here: + if self.netmask < other.netmask: + return -1 + if self.netmask > other.netmask: + return 1 + return 0 + + def _get_networks_key(self): + """Network-only key function. + + Returns an object that identifies this address' network and + netmask. This function is a suitable "key" argument for sorted() + and list.sort(). + + """ + return (self._version, self.network_address, self.netmask) + + def subnets(self, prefixlen_diff=1, new_prefix=None): + """The subnets which join to make the current subnet. + + In the case that self contains only one IP + (self._prefixlen == 32 for IPv4 or self._prefixlen == 128 + for IPv6), yield an iterator with just ourself. + + Args: + prefixlen_diff: An integer, the amount the prefix length + should be increased by. This should not be set if + new_prefix is also set. + new_prefix: The desired new prefix length. This must be a + larger number (smaller prefix) than the existing prefix. + This should not be set if prefixlen_diff is also set. + + Returns: + An iterator of IPv(4|6) objects. + + Raises: + ValueError: The prefixlen_diff is too small or too large. + OR + prefixlen_diff and new_prefix are both set or new_prefix + is a smaller number than the current prefix (smaller + number means a larger network) + + """ + if self._prefixlen == self._max_prefixlen: + yield self + return + + if new_prefix is not None: + if new_prefix < self._prefixlen: + raise ValueError('new prefix must be longer') + if prefixlen_diff != 1: + raise ValueError('cannot set prefixlen_diff and new_prefix') + prefixlen_diff = new_prefix - self._prefixlen + + if prefixlen_diff < 0: + raise ValueError('prefix length diff must be > 0') + new_prefixlen = self._prefixlen + prefixlen_diff + + if new_prefixlen > self._max_prefixlen: + raise ValueError( + 'prefix length diff %d is invalid for netblock %s' % ( + new_prefixlen, self)) + + start = int(self.network_address) + end = int(self.broadcast_address) + 1 + step = (int(self.hostmask) + 1) >> prefixlen_diff + for new_addr in _compat_range(start, end, step): + current = self.__class__((new_addr, new_prefixlen)) + yield current + + def supernet(self, prefixlen_diff=1, new_prefix=None): + """The supernet containing the current network. + + Args: + prefixlen_diff: An integer, the amount the prefix length of + the network should be decreased by. For example, given a + /24 network and a prefixlen_diff of 3, a supernet with a + /21 netmask is returned. + + Returns: + An IPv4 network object. + + Raises: + ValueError: If self.prefixlen - prefixlen_diff < 0. I.e., you have + a negative prefix length. + OR + If prefixlen_diff and new_prefix are both set or new_prefix is a + larger number than the current prefix (larger number means a + smaller network) + + """ + if self._prefixlen == 0: + return self + + if new_prefix is not None: + if new_prefix > self._prefixlen: + raise ValueError('new prefix must be shorter') + if prefixlen_diff != 1: + raise ValueError('cannot set prefixlen_diff and new_prefix') + prefixlen_diff = self._prefixlen - new_prefix + + new_prefixlen = self.prefixlen - prefixlen_diff + if new_prefixlen < 0: + raise ValueError( + 'current prefixlen is %d, cannot have a prefixlen_diff of %d' % + (self.prefixlen, prefixlen_diff)) + return self.__class__(( + int(self.network_address) & (int(self.netmask) << prefixlen_diff), + new_prefixlen)) + + @property + def is_multicast(self): + """Test if the address is reserved for multicast use. + + Returns: + A boolean, True if the address is a multicast address. + See RFC 2373 2.7 for details. + + """ + return (self.network_address.is_multicast and + self.broadcast_address.is_multicast) + + @staticmethod + def _is_subnet_of(a, b): + try: + # Always false if one is v4 and the other is v6. + if a._version != b._version: + raise TypeError( + "%s and %s are not of the same version" % (a, b)) + return (b.network_address <= a.network_address and + b.broadcast_address >= a.broadcast_address) + except AttributeError: + raise TypeError("Unable to test subnet containment " + "between %s and %s" % (a, b)) + + def subnet_of(self, other): + """Return True if this network is a subnet of other.""" + return self._is_subnet_of(self, other) + + def supernet_of(self, other): + """Return True if this network is a supernet of other.""" + return self._is_subnet_of(other, self) + + @property + def is_reserved(self): + """Test if the address is otherwise IETF reserved. + + Returns: + A boolean, True if the address is within one of the + reserved IPv6 Network ranges. + + """ + return (self.network_address.is_reserved and + self.broadcast_address.is_reserved) + + @property + def is_link_local(self): + """Test if the address is reserved for link-local. + + Returns: + A boolean, True if the address is reserved per RFC 4291. + + """ + return (self.network_address.is_link_local and + self.broadcast_address.is_link_local) + + @property + def is_private(self): + """Test if this address is allocated for private networks. + + Returns: + A boolean, True if the address is reserved per + iana-ipv4-special-registry or iana-ipv6-special-registry. + + """ + return (self.network_address.is_private and + self.broadcast_address.is_private) + + @property + def is_global(self): + """Test if this address is allocated for public networks. + + Returns: + A boolean, True if the address is not reserved per + iana-ipv4-special-registry or iana-ipv6-special-registry. + + """ + return not self.is_private + + @property + def is_unspecified(self): + """Test if the address is unspecified. + + Returns: + A boolean, True if this is the unspecified address as defined in + RFC 2373 2.5.2. + + """ + return (self.network_address.is_unspecified and + self.broadcast_address.is_unspecified) + + @property + def is_loopback(self): + """Test if the address is a loopback address. + + Returns: + A boolean, True if the address is a loopback address as defined in + RFC 2373 2.5.3. + + """ + return (self.network_address.is_loopback and + self.broadcast_address.is_loopback) + + +class _BaseV4(object): + + """Base IPv4 object. + + The following methods are used by IPv4 objects in both single IP + addresses and networks. + + """ + + __slots__ = () + _version = 4 + # Equivalent to 255.255.255.255 or 32 bits of 1's. + _ALL_ONES = (2 ** IPV4LENGTH) - 1 + _DECIMAL_DIGITS = frozenset('0123456789') + + # the valid octets for host and netmasks. only useful for IPv4. + _valid_mask_octets = frozenset([255, 254, 252, 248, 240, 224, 192, 128, 0]) + + _max_prefixlen = IPV4LENGTH + # There are only a handful of valid v4 netmasks, so we cache them all + # when constructed (see _make_netmask()). + _netmask_cache = {} + + def _explode_shorthand_ip_string(self): + return _compat_str(self) + + @classmethod + def _make_netmask(cls, arg): + """Make a (netmask, prefix_len) tuple from the given argument. + + Argument can be: + - an integer (the prefix length) + - a string representing the prefix length (e.g. "24") + - a string representing the prefix netmask (e.g. "255.255.255.0") + """ + if arg not in cls._netmask_cache: + if isinstance(arg, _compat_int_types): + prefixlen = arg + else: + try: + # Check for a netmask in prefix length form + prefixlen = cls._prefix_from_prefix_string(arg) + except NetmaskValueError: + # Check for a netmask or hostmask in dotted-quad form. + # This may raise NetmaskValueError. + prefixlen = cls._prefix_from_ip_string(arg) + netmask = IPv4Address(cls._ip_int_from_prefix(prefixlen)) + cls._netmask_cache[arg] = netmask, prefixlen + return cls._netmask_cache[arg] + + @classmethod + def _ip_int_from_string(cls, ip_str): + """Turn the given IP string into an integer for comparison. + + Args: + ip_str: A string, the IP ip_str. + + Returns: + The IP ip_str as an integer. + + Raises: + AddressValueError: if ip_str isn't a valid IPv4 Address. + + """ + if not ip_str: + raise AddressValueError('Address cannot be empty') + + octets = ip_str.split('.') + if len(octets) != 4: + raise AddressValueError("Expected 4 octets in %r" % ip_str) + + try: + return _compat_int_from_byte_vals( + map(cls._parse_octet, octets), 'big') + except ValueError as exc: + raise AddressValueError("%s in %r" % (exc, ip_str)) + + @classmethod + def _parse_octet(cls, octet_str): + """Convert a decimal octet into an integer. + + Args: + octet_str: A string, the number to parse. + + Returns: + The octet as an integer. + + Raises: + ValueError: if the octet isn't strictly a decimal from [0..255]. + + """ + if not octet_str: + raise ValueError("Empty octet not permitted") + # Whitelist the characters, since int() allows a lot of bizarre stuff. + if not cls._DECIMAL_DIGITS.issuperset(octet_str): + msg = "Only decimal digits permitted in %r" + raise ValueError(msg % octet_str) + # We do the length check second, since the invalid character error + # is likely to be more informative for the user + if len(octet_str) > 3: + msg = "At most 3 characters permitted in %r" + raise ValueError(msg % octet_str) + # Convert to integer (we know digits are legal) + octet_int = int(octet_str, 10) + # Any octets that look like they *might* be written in octal, + # and which don't look exactly the same in both octal and + # decimal are rejected as ambiguous + if octet_int > 7 and octet_str[0] == '0': + msg = "Ambiguous (octal/decimal) value in %r not permitted" + raise ValueError(msg % octet_str) + if octet_int > 255: + raise ValueError("Octet %d (> 255) not permitted" % octet_int) + return octet_int + + @classmethod + def _string_from_ip_int(cls, ip_int): + """Turns a 32-bit integer into dotted decimal notation. + + Args: + ip_int: An integer, the IP address. + + Returns: + The IP address as a string in dotted decimal notation. + + """ + return '.'.join(_compat_str(struct.unpack(b'!B', b)[0] + if isinstance(b, bytes) + else b) + for b in _compat_to_bytes(ip_int, 4, 'big')) + + def _is_hostmask(self, ip_str): + """Test if the IP string is a hostmask (rather than a netmask). + + Args: + ip_str: A string, the potential hostmask. + + Returns: + A boolean, True if the IP string is a hostmask. + + """ + bits = ip_str.split('.') + try: + parts = [x for x in map(int, bits) if x in self._valid_mask_octets] + except ValueError: + return False + if len(parts) != len(bits): + return False + if parts[0] < parts[-1]: + return True + return False + + def _reverse_pointer(self): + """Return the reverse DNS pointer name for the IPv4 address. + + This implements the method described in RFC1035 3.5. + + """ + reverse_octets = _compat_str(self).split('.')[::-1] + return '.'.join(reverse_octets) + '.in-addr.arpa' + + @property + def max_prefixlen(self): + return self._max_prefixlen + + @property + def version(self): + return self._version + + +class IPv4Address(_BaseV4, _BaseAddress): + + """Represent and manipulate single IPv4 Addresses.""" + + __slots__ = ('_ip', '__weakref__') + + def __init__(self, address): + + """ + Args: + address: A string or integer representing the IP + + Additionally, an integer can be passed, so + IPv4Address('192.0.2.1') == IPv4Address(3221225985). + or, more generally + IPv4Address(int(IPv4Address('192.0.2.1'))) == + IPv4Address('192.0.2.1') + + Raises: + AddressValueError: If ipaddress isn't a valid IPv4 address. + + """ + # Efficient constructor from integer. + if isinstance(address, _compat_int_types): + self._check_int_address(address) + self._ip = address + return + + # Constructing from a packed address + if isinstance(address, bytes): + self._check_packed_address(address, 4) + bvs = _compat_bytes_to_byte_vals(address) + self._ip = _compat_int_from_byte_vals(bvs, 'big') + return + + # Assume input argument to be string or any object representation + # which converts into a formatted IP string. + addr_str = _compat_str(address) + if '/' in addr_str: + raise AddressValueError("Unexpected '/' in %r" % address) + self._ip = self._ip_int_from_string(addr_str) + + @property + def packed(self): + """The binary representation of this address.""" + return v4_int_to_packed(self._ip) + + @property + def is_reserved(self): + """Test if the address is otherwise IETF reserved. + + Returns: + A boolean, True if the address is within the + reserved IPv4 Network range. + + """ + return self in self._constants._reserved_network + + @property + def is_private(self): + """Test if this address is allocated for private networks. + + Returns: + A boolean, True if the address is reserved per + iana-ipv4-special-registry. + + """ + return any(self in net for net in self._constants._private_networks) + + @property + def is_global(self): + return ( + self not in self._constants._public_network and + not self.is_private) + + @property + def is_multicast(self): + """Test if the address is reserved for multicast use. + + Returns: + A boolean, True if the address is multicast. + See RFC 3171 for details. + + """ + return self in self._constants._multicast_network + + @property + def is_unspecified(self): + """Test if the address is unspecified. + + Returns: + A boolean, True if this is the unspecified address as defined in + RFC 5735 3. + + """ + return self == self._constants._unspecified_address + + @property + def is_loopback(self): + """Test if the address is a loopback address. + + Returns: + A boolean, True if the address is a loopback per RFC 3330. + + """ + return self in self._constants._loopback_network + + @property + def is_link_local(self): + """Test if the address is reserved for link-local. + + Returns: + A boolean, True if the address is link-local per RFC 3927. + + """ + return self in self._constants._linklocal_network + + +class IPv4Interface(IPv4Address): + + def __init__(self, address): + if isinstance(address, (bytes, _compat_int_types)): + IPv4Address.__init__(self, address) + self.network = IPv4Network(self._ip) + self._prefixlen = self._max_prefixlen + return + + if isinstance(address, tuple): + IPv4Address.__init__(self, address[0]) + if len(address) > 1: + self._prefixlen = int(address[1]) + else: + self._prefixlen = self._max_prefixlen + + self.network = IPv4Network(address, strict=False) + self.netmask = self.network.netmask + self.hostmask = self.network.hostmask + return + + addr = _split_optional_netmask(address) + IPv4Address.__init__(self, addr[0]) + + self.network = IPv4Network(address, strict=False) + self._prefixlen = self.network._prefixlen + + self.netmask = self.network.netmask + self.hostmask = self.network.hostmask + + def __str__(self): + return '%s/%d' % (self._string_from_ip_int(self._ip), + self.network.prefixlen) + + def __eq__(self, other): + address_equal = IPv4Address.__eq__(self, other) + if not address_equal or address_equal is NotImplemented: + return address_equal + try: + return self.network == other.network + except AttributeError: + # An interface with an associated network is NOT the + # same as an unassociated address. That's why the hash + # takes the extra info into account. + return False + + def __lt__(self, other): + address_less = IPv4Address.__lt__(self, other) + if address_less is NotImplemented: + return NotImplemented + try: + return (self.network < other.network or + self.network == other.network and address_less) + except AttributeError: + # We *do* allow addresses and interfaces to be sorted. The + # unassociated address is considered less than all interfaces. + return False + + def __hash__(self): + return self._ip ^ self._prefixlen ^ int(self.network.network_address) + + __reduce__ = _IPAddressBase.__reduce__ + + @property + def ip(self): + return IPv4Address(self._ip) + + @property + def with_prefixlen(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self._prefixlen) + + @property + def with_netmask(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self.netmask) + + @property + def with_hostmask(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self.hostmask) + + +class IPv4Network(_BaseV4, _BaseNetwork): + + """This class represents and manipulates 32-bit IPv4 network + addresses.. + + Attributes: [examples for IPv4Network('192.0.2.0/27')] + .network_address: IPv4Address('192.0.2.0') + .hostmask: IPv4Address('0.0.0.31') + .broadcast_address: IPv4Address('192.0.2.32') + .netmask: IPv4Address('255.255.255.224') + .prefixlen: 27 + + """ + # Class to use when creating address objects + _address_class = IPv4Address + + def __init__(self, address, strict=True): + + """Instantiate a new IPv4 network object. + + Args: + address: A string or integer representing the IP [& network]. + '192.0.2.0/24' + '192.0.2.0/255.255.255.0' + '192.0.0.2/0.0.0.255' + are all functionally the same in IPv4. Similarly, + '192.0.2.1' + '192.0.2.1/255.255.255.255' + '192.0.2.1/32' + are also functionally equivalent. That is to say, failing to + provide a subnetmask will create an object with a mask of /32. + + If the mask (portion after the / in the argument) is given in + dotted quad form, it is treated as a netmask if it starts with a + non-zero field (e.g. /255.0.0.0 == /8) and as a hostmask if it + starts with a zero field (e.g. 0.255.255.255 == /8), with the + single exception of an all-zero mask which is treated as a + netmask == /0. If no mask is given, a default of /32 is used. + + Additionally, an integer can be passed, so + IPv4Network('192.0.2.1') == IPv4Network(3221225985) + or, more generally + IPv4Interface(int(IPv4Interface('192.0.2.1'))) == + IPv4Interface('192.0.2.1') + + Raises: + AddressValueError: If ipaddress isn't a valid IPv4 address. + NetmaskValueError: If the netmask isn't valid for + an IPv4 address. + ValueError: If strict is True and a network address is not + supplied. + + """ + _BaseNetwork.__init__(self, address) + + # Constructing from a packed address or integer + if isinstance(address, (_compat_int_types, bytes)): + self.network_address = IPv4Address(address) + self.netmask, self._prefixlen = self._make_netmask( + self._max_prefixlen) + # fixme: address/network test here. + return + + if isinstance(address, tuple): + if len(address) > 1: + arg = address[1] + else: + # We weren't given an address[1] + arg = self._max_prefixlen + self.network_address = IPv4Address(address[0]) + self.netmask, self._prefixlen = self._make_netmask(arg) + packed = int(self.network_address) + if packed & int(self.netmask) != packed: + if strict: + raise ValueError('%s has host bits set' % self) + else: + self.network_address = IPv4Address(packed & + int(self.netmask)) + return + + # Assume input argument to be string or any object representation + # which converts into a formatted IP prefix string. + addr = _split_optional_netmask(address) + self.network_address = IPv4Address(self._ip_int_from_string(addr[0])) + + if len(addr) == 2: + arg = addr[1] + else: + arg = self._max_prefixlen + self.netmask, self._prefixlen = self._make_netmask(arg) + + if strict: + if (IPv4Address(int(self.network_address) & int(self.netmask)) != + self.network_address): + raise ValueError('%s has host bits set' % self) + self.network_address = IPv4Address(int(self.network_address) & + int(self.netmask)) + + if self._prefixlen == (self._max_prefixlen - 1): + self.hosts = self.__iter__ + + @property + def is_global(self): + """Test if this address is allocated for public networks. + + Returns: + A boolean, True if the address is not reserved per + iana-ipv4-special-registry. + + """ + return (not (self.network_address in IPv4Network('100.64.0.0/10') and + self.broadcast_address in IPv4Network('100.64.0.0/10')) and + not self.is_private) + + +class _IPv4Constants(object): + + _linklocal_network = IPv4Network('169.254.0.0/16') + + _loopback_network = IPv4Network('127.0.0.0/8') + + _multicast_network = IPv4Network('224.0.0.0/4') + + _public_network = IPv4Network('100.64.0.0/10') + + _private_networks = [ + IPv4Network('0.0.0.0/8'), + IPv4Network('10.0.0.0/8'), + IPv4Network('127.0.0.0/8'), + IPv4Network('169.254.0.0/16'), + IPv4Network('172.16.0.0/12'), + IPv4Network('192.0.0.0/29'), + IPv4Network('192.0.0.170/31'), + IPv4Network('192.0.2.0/24'), + IPv4Network('192.168.0.0/16'), + IPv4Network('198.18.0.0/15'), + IPv4Network('198.51.100.0/24'), + IPv4Network('203.0.113.0/24'), + IPv4Network('240.0.0.0/4'), + IPv4Network('255.255.255.255/32'), + ] + + _reserved_network = IPv4Network('240.0.0.0/4') + + _unspecified_address = IPv4Address('0.0.0.0') + + +IPv4Address._constants = _IPv4Constants + + +class _BaseV6(object): + + """Base IPv6 object. + + The following methods are used by IPv6 objects in both single IP + addresses and networks. + + """ + + __slots__ = () + _version = 6 + _ALL_ONES = (2 ** IPV6LENGTH) - 1 + _HEXTET_COUNT = 8 + _HEX_DIGITS = frozenset('0123456789ABCDEFabcdef') + _max_prefixlen = IPV6LENGTH + + # There are only a bunch of valid v6 netmasks, so we cache them all + # when constructed (see _make_netmask()). + _netmask_cache = {} + + @classmethod + def _make_netmask(cls, arg): + """Make a (netmask, prefix_len) tuple from the given argument. + + Argument can be: + - an integer (the prefix length) + - a string representing the prefix length (e.g. "24") + - a string representing the prefix netmask (e.g. "255.255.255.0") + """ + if arg not in cls._netmask_cache: + if isinstance(arg, _compat_int_types): + prefixlen = arg + else: + prefixlen = cls._prefix_from_prefix_string(arg) + netmask = IPv6Address(cls._ip_int_from_prefix(prefixlen)) + cls._netmask_cache[arg] = netmask, prefixlen + return cls._netmask_cache[arg] + + @classmethod + def _ip_int_from_string(cls, ip_str): + """Turn an IPv6 ip_str into an integer. + + Args: + ip_str: A string, the IPv6 ip_str. + + Returns: + An int, the IPv6 address + + Raises: + AddressValueError: if ip_str isn't a valid IPv6 Address. + + """ + if not ip_str: + raise AddressValueError('Address cannot be empty') + + parts = ip_str.split(':') + + # An IPv6 address needs at least 2 colons (3 parts). + _min_parts = 3 + if len(parts) < _min_parts: + msg = "At least %d parts expected in %r" % (_min_parts, ip_str) + raise AddressValueError(msg) + + # If the address has an IPv4-style suffix, convert it to hexadecimal. + if '.' in parts[-1]: + try: + ipv4_int = IPv4Address(parts.pop())._ip + except AddressValueError as exc: + raise AddressValueError("%s in %r" % (exc, ip_str)) + parts.append('%x' % ((ipv4_int >> 16) & 0xFFFF)) + parts.append('%x' % (ipv4_int & 0xFFFF)) + + # An IPv6 address can't have more than 8 colons (9 parts). + # The extra colon comes from using the "::" notation for a single + # leading or trailing zero part. + _max_parts = cls._HEXTET_COUNT + 1 + if len(parts) > _max_parts: + msg = "At most %d colons permitted in %r" % ( + _max_parts - 1, ip_str) + raise AddressValueError(msg) + + # Disregarding the endpoints, find '::' with nothing in between. + # This indicates that a run of zeroes has been skipped. + skip_index = None + for i in _compat_range(1, len(parts) - 1): + if not parts[i]: + if skip_index is not None: + # Can't have more than one '::' + msg = "At most one '::' permitted in %r" % ip_str + raise AddressValueError(msg) + skip_index = i + + # parts_hi is the number of parts to copy from above/before the '::' + # parts_lo is the number of parts to copy from below/after the '::' + if skip_index is not None: + # If we found a '::', then check if it also covers the endpoints. + parts_hi = skip_index + parts_lo = len(parts) - skip_index - 1 + if not parts[0]: + parts_hi -= 1 + if parts_hi: + msg = "Leading ':' only permitted as part of '::' in %r" + raise AddressValueError(msg % ip_str) # ^: requires ^:: + if not parts[-1]: + parts_lo -= 1 + if parts_lo: + msg = "Trailing ':' only permitted as part of '::' in %r" + raise AddressValueError(msg % ip_str) # :$ requires ::$ + parts_skipped = cls._HEXTET_COUNT - (parts_hi + parts_lo) + if parts_skipped < 1: + msg = "Expected at most %d other parts with '::' in %r" + raise AddressValueError(msg % (cls._HEXTET_COUNT - 1, ip_str)) + else: + # Otherwise, allocate the entire address to parts_hi. The + # endpoints could still be empty, but _parse_hextet() will check + # for that. + if len(parts) != cls._HEXTET_COUNT: + msg = "Exactly %d parts expected without '::' in %r" + raise AddressValueError(msg % (cls._HEXTET_COUNT, ip_str)) + if not parts[0]: + msg = "Leading ':' only permitted as part of '::' in %r" + raise AddressValueError(msg % ip_str) # ^: requires ^:: + if not parts[-1]: + msg = "Trailing ':' only permitted as part of '::' in %r" + raise AddressValueError(msg % ip_str) # :$ requires ::$ + parts_hi = len(parts) + parts_lo = 0 + parts_skipped = 0 + + try: + # Now, parse the hextets into a 128-bit integer. + ip_int = 0 + for i in range(parts_hi): + ip_int <<= 16 + ip_int |= cls._parse_hextet(parts[i]) + ip_int <<= 16 * parts_skipped + for i in range(-parts_lo, 0): + ip_int <<= 16 + ip_int |= cls._parse_hextet(parts[i]) + return ip_int + except ValueError as exc: + raise AddressValueError("%s in %r" % (exc, ip_str)) + + @classmethod + def _parse_hextet(cls, hextet_str): + """Convert an IPv6 hextet string into an integer. + + Args: + hextet_str: A string, the number to parse. + + Returns: + The hextet as an integer. + + Raises: + ValueError: if the input isn't strictly a hex number from + [0..FFFF]. + + """ + # Whitelist the characters, since int() allows a lot of bizarre stuff. + if not cls._HEX_DIGITS.issuperset(hextet_str): + raise ValueError("Only hex digits permitted in %r" % hextet_str) + # We do the length check second, since the invalid character error + # is likely to be more informative for the user + if len(hextet_str) > 4: + msg = "At most 4 characters permitted in %r" + raise ValueError(msg % hextet_str) + # Length check means we can skip checking the integer value + return int(hextet_str, 16) + + @classmethod + def _compress_hextets(cls, hextets): + """Compresses a list of hextets. + + Compresses a list of strings, replacing the longest continuous + sequence of "0" in the list with "" and adding empty strings at + the beginning or at the end of the string such that subsequently + calling ":".join(hextets) will produce the compressed version of + the IPv6 address. + + Args: + hextets: A list of strings, the hextets to compress. + + Returns: + A list of strings. + + """ + best_doublecolon_start = -1 + best_doublecolon_len = 0 + doublecolon_start = -1 + doublecolon_len = 0 + for index, hextet in enumerate(hextets): + if hextet == '0': + doublecolon_len += 1 + if doublecolon_start == -1: + # Start of a sequence of zeros. + doublecolon_start = index + if doublecolon_len > best_doublecolon_len: + # This is the longest sequence of zeros so far. + best_doublecolon_len = doublecolon_len + best_doublecolon_start = doublecolon_start + else: + doublecolon_len = 0 + doublecolon_start = -1 + + if best_doublecolon_len > 1: + best_doublecolon_end = (best_doublecolon_start + + best_doublecolon_len) + # For zeros at the end of the address. + if best_doublecolon_end == len(hextets): + hextets += [''] + hextets[best_doublecolon_start:best_doublecolon_end] = [''] + # For zeros at the beginning of the address. + if best_doublecolon_start == 0: + hextets = [''] + hextets + + return hextets + + @classmethod + def _string_from_ip_int(cls, ip_int=None): + """Turns a 128-bit integer into hexadecimal notation. + + Args: + ip_int: An integer, the IP address. + + Returns: + A string, the hexadecimal representation of the address. + + Raises: + ValueError: The address is bigger than 128 bits of all ones. + + """ + if ip_int is None: + ip_int = int(cls._ip) + + if ip_int > cls._ALL_ONES: + raise ValueError('IPv6 address is too large') + + hex_str = '%032x' % ip_int + hextets = ['%x' % int(hex_str[x:x + 4], 16) for x in range(0, 32, 4)] + + hextets = cls._compress_hextets(hextets) + return ':'.join(hextets) + + def _explode_shorthand_ip_string(self): + """Expand a shortened IPv6 address. + + Args: + ip_str: A string, the IPv6 address. + + Returns: + A string, the expanded IPv6 address. + + """ + if isinstance(self, IPv6Network): + ip_str = _compat_str(self.network_address) + elif isinstance(self, IPv6Interface): + ip_str = _compat_str(self.ip) + else: + ip_str = _compat_str(self) + + ip_int = self._ip_int_from_string(ip_str) + hex_str = '%032x' % ip_int + parts = [hex_str[x:x + 4] for x in range(0, 32, 4)] + if isinstance(self, (_BaseNetwork, IPv6Interface)): + return '%s/%d' % (':'.join(parts), self._prefixlen) + return ':'.join(parts) + + def _reverse_pointer(self): + """Return the reverse DNS pointer name for the IPv6 address. + + This implements the method described in RFC3596 2.5. + + """ + reverse_chars = self.exploded[::-1].replace(':', '') + return '.'.join(reverse_chars) + '.ip6.arpa' + + @property + def max_prefixlen(self): + return self._max_prefixlen + + @property + def version(self): + return self._version + + +class IPv6Address(_BaseV6, _BaseAddress): + + """Represent and manipulate single IPv6 Addresses.""" + + __slots__ = ('_ip', '__weakref__') + + def __init__(self, address): + """Instantiate a new IPv6 address object. + + Args: + address: A string or integer representing the IP + + Additionally, an integer can be passed, so + IPv6Address('2001:db8::') == + IPv6Address(42540766411282592856903984951653826560) + or, more generally + IPv6Address(int(IPv6Address('2001:db8::'))) == + IPv6Address('2001:db8::') + + Raises: + AddressValueError: If address isn't a valid IPv6 address. + + """ + # Efficient constructor from integer. + if isinstance(address, _compat_int_types): + self._check_int_address(address) + self._ip = address + return + + # Constructing from a packed address + if isinstance(address, bytes): + self._check_packed_address(address, 16) + bvs = _compat_bytes_to_byte_vals(address) + self._ip = _compat_int_from_byte_vals(bvs, 'big') + return + + # Assume input argument to be string or any object representation + # which converts into a formatted IP string. + addr_str = _compat_str(address) + if '/' in addr_str: + raise AddressValueError("Unexpected '/' in %r" % address) + self._ip = self._ip_int_from_string(addr_str) + + @property + def packed(self): + """The binary representation of this address.""" + return v6_int_to_packed(self._ip) + + @property + def is_multicast(self): + """Test if the address is reserved for multicast use. + + Returns: + A boolean, True if the address is a multicast address. + See RFC 2373 2.7 for details. + + """ + return self in self._constants._multicast_network + + @property + def is_reserved(self): + """Test if the address is otherwise IETF reserved. + + Returns: + A boolean, True if the address is within one of the + reserved IPv6 Network ranges. + + """ + return any(self in x for x in self._constants._reserved_networks) + + @property + def is_link_local(self): + """Test if the address is reserved for link-local. + + Returns: + A boolean, True if the address is reserved per RFC 4291. + + """ + return self in self._constants._linklocal_network + + @property + def is_site_local(self): + """Test if the address is reserved for site-local. + + Note that the site-local address space has been deprecated by RFC 3879. + Use is_private to test if this address is in the space of unique local + addresses as defined by RFC 4193. + + Returns: + A boolean, True if the address is reserved per RFC 3513 2.5.6. + + """ + return self in self._constants._sitelocal_network + + @property + def is_private(self): + """Test if this address is allocated for private networks. + + Returns: + A boolean, True if the address is reserved per + iana-ipv6-special-registry. + + """ + return any(self in net for net in self._constants._private_networks) + + @property + def is_global(self): + """Test if this address is allocated for public networks. + + Returns: + A boolean, true if the address is not reserved per + iana-ipv6-special-registry. + + """ + return not self.is_private + + @property + def is_unspecified(self): + """Test if the address is unspecified. + + Returns: + A boolean, True if this is the unspecified address as defined in + RFC 2373 2.5.2. + + """ + return self._ip == 0 + + @property + def is_loopback(self): + """Test if the address is a loopback address. + + Returns: + A boolean, True if the address is a loopback address as defined in + RFC 2373 2.5.3. + + """ + return self._ip == 1 + + @property + def ipv4_mapped(self): + """Return the IPv4 mapped address. + + Returns: + If the IPv6 address is a v4 mapped address, return the + IPv4 mapped address. Return None otherwise. + + """ + if (self._ip >> 32) != 0xFFFF: + return None + return IPv4Address(self._ip & 0xFFFFFFFF) + + @property + def teredo(self): + """Tuple of embedded teredo IPs. + + Returns: + Tuple of the (server, client) IPs or None if the address + doesn't appear to be a teredo address (doesn't start with + 2001::/32) + + """ + if (self._ip >> 96) != 0x20010000: + return None + return (IPv4Address((self._ip >> 64) & 0xFFFFFFFF), + IPv4Address(~self._ip & 0xFFFFFFFF)) + + @property + def sixtofour(self): + """Return the IPv4 6to4 embedded address. + + Returns: + The IPv4 6to4-embedded address if present or None if the + address doesn't appear to contain a 6to4 embedded address. + + """ + if (self._ip >> 112) != 0x2002: + return None + return IPv4Address((self._ip >> 80) & 0xFFFFFFFF) + + +class IPv6Interface(IPv6Address): + + def __init__(self, address): + if isinstance(address, (bytes, _compat_int_types)): + IPv6Address.__init__(self, address) + self.network = IPv6Network(self._ip) + self._prefixlen = self._max_prefixlen + return + if isinstance(address, tuple): + IPv6Address.__init__(self, address[0]) + if len(address) > 1: + self._prefixlen = int(address[1]) + else: + self._prefixlen = self._max_prefixlen + self.network = IPv6Network(address, strict=False) + self.netmask = self.network.netmask + self.hostmask = self.network.hostmask + return + + addr = _split_optional_netmask(address) + IPv6Address.__init__(self, addr[0]) + self.network = IPv6Network(address, strict=False) + self.netmask = self.network.netmask + self._prefixlen = self.network._prefixlen + self.hostmask = self.network.hostmask + + def __str__(self): + return '%s/%d' % (self._string_from_ip_int(self._ip), + self.network.prefixlen) + + def __eq__(self, other): + address_equal = IPv6Address.__eq__(self, other) + if not address_equal or address_equal is NotImplemented: + return address_equal + try: + return self.network == other.network + except AttributeError: + # An interface with an associated network is NOT the + # same as an unassociated address. That's why the hash + # takes the extra info into account. + return False + + def __lt__(self, other): + address_less = IPv6Address.__lt__(self, other) + if address_less is NotImplemented: + return NotImplemented + try: + return (self.network < other.network or + self.network == other.network and address_less) + except AttributeError: + # We *do* allow addresses and interfaces to be sorted. The + # unassociated address is considered less than all interfaces. + return False + + def __hash__(self): + return self._ip ^ self._prefixlen ^ int(self.network.network_address) + + __reduce__ = _IPAddressBase.__reduce__ + + @property + def ip(self): + return IPv6Address(self._ip) + + @property + def with_prefixlen(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self._prefixlen) + + @property + def with_netmask(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self.netmask) + + @property + def with_hostmask(self): + return '%s/%s' % (self._string_from_ip_int(self._ip), + self.hostmask) + + @property + def is_unspecified(self): + return self._ip == 0 and self.network.is_unspecified + + @property + def is_loopback(self): + return self._ip == 1 and self.network.is_loopback + + +class IPv6Network(_BaseV6, _BaseNetwork): + + """This class represents and manipulates 128-bit IPv6 networks. + + Attributes: [examples for IPv6('2001:db8::1000/124')] + .network_address: IPv6Address('2001:db8::1000') + .hostmask: IPv6Address('::f') + .broadcast_address: IPv6Address('2001:db8::100f') + .netmask: IPv6Address('ffff:ffff:ffff:ffff:ffff:ffff:ffff:fff0') + .prefixlen: 124 + + """ + + # Class to use when creating address objects + _address_class = IPv6Address + + def __init__(self, address, strict=True): + """Instantiate a new IPv6 Network object. + + Args: + address: A string or integer representing the IPv6 network or the + IP and prefix/netmask. + '2001:db8::/128' + '2001:db8:0000:0000:0000:0000:0000:0000/128' + '2001:db8::' + are all functionally the same in IPv6. That is to say, + failing to provide a subnetmask will create an object with + a mask of /128. + + Additionally, an integer can be passed, so + IPv6Network('2001:db8::') == + IPv6Network(42540766411282592856903984951653826560) + or, more generally + IPv6Network(int(IPv6Network('2001:db8::'))) == + IPv6Network('2001:db8::') + + strict: A boolean. If true, ensure that we have been passed + A true network address, eg, 2001:db8::1000/124 and not an + IP address on a network, eg, 2001:db8::1/124. + + Raises: + AddressValueError: If address isn't a valid IPv6 address. + NetmaskValueError: If the netmask isn't valid for + an IPv6 address. + ValueError: If strict was True and a network address was not + supplied. + + """ + _BaseNetwork.__init__(self, address) + + # Efficient constructor from integer or packed address + if isinstance(address, (bytes, _compat_int_types)): + self.network_address = IPv6Address(address) + self.netmask, self._prefixlen = self._make_netmask( + self._max_prefixlen) + return + + if isinstance(address, tuple): + if len(address) > 1: + arg = address[1] + else: + arg = self._max_prefixlen + self.netmask, self._prefixlen = self._make_netmask(arg) + self.network_address = IPv6Address(address[0]) + packed = int(self.network_address) + if packed & int(self.netmask) != packed: + if strict: + raise ValueError('%s has host bits set' % self) + else: + self.network_address = IPv6Address(packed & + int(self.netmask)) + return + + # Assume input argument to be string or any object representation + # which converts into a formatted IP prefix string. + addr = _split_optional_netmask(address) + + self.network_address = IPv6Address(self._ip_int_from_string(addr[0])) + + if len(addr) == 2: + arg = addr[1] + else: + arg = self._max_prefixlen + self.netmask, self._prefixlen = self._make_netmask(arg) + + if strict: + if (IPv6Address(int(self.network_address) & int(self.netmask)) != + self.network_address): + raise ValueError('%s has host bits set' % self) + self.network_address = IPv6Address(int(self.network_address) & + int(self.netmask)) + + if self._prefixlen == (self._max_prefixlen - 1): + self.hosts = self.__iter__ + + def hosts(self): + """Generate Iterator over usable hosts in a network. + + This is like __iter__ except it doesn't return the + Subnet-Router anycast address. + + """ + network = int(self.network_address) + broadcast = int(self.broadcast_address) + for x in _compat_range(network + 1, broadcast + 1): + yield self._address_class(x) + + @property + def is_site_local(self): + """Test if the address is reserved for site-local. + + Note that the site-local address space has been deprecated by RFC 3879. + Use is_private to test if this address is in the space of unique local + addresses as defined by RFC 4193. + + Returns: + A boolean, True if the address is reserved per RFC 3513 2.5.6. + + """ + return (self.network_address.is_site_local and + self.broadcast_address.is_site_local) + + +class _IPv6Constants(object): + + _linklocal_network = IPv6Network('fe80::/10') + + _multicast_network = IPv6Network('ff00::/8') + + _private_networks = [ + IPv6Network('::1/128'), + IPv6Network('::/128'), + IPv6Network('::ffff:0:0/96'), + IPv6Network('100::/64'), + IPv6Network('2001::/23'), + IPv6Network('2001:2::/48'), + IPv6Network('2001:db8::/32'), + IPv6Network('2001:10::/28'), + IPv6Network('fc00::/7'), + IPv6Network('fe80::/10'), + ] + + _reserved_networks = [ + IPv6Network('::/8'), IPv6Network('100::/8'), + IPv6Network('200::/7'), IPv6Network('400::/6'), + IPv6Network('800::/5'), IPv6Network('1000::/4'), + IPv6Network('4000::/3'), IPv6Network('6000::/3'), + IPv6Network('8000::/3'), IPv6Network('A000::/3'), + IPv6Network('C000::/3'), IPv6Network('E000::/4'), + IPv6Network('F000::/5'), IPv6Network('F800::/6'), + IPv6Network('FE00::/9'), + ] + + _sitelocal_network = IPv6Network('fec0::/10') + + +IPv6Address._constants = _IPv6Constants diff --git a/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py b/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py index 97fbcd741b..2b427fb065 100644 --- a/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py +++ b/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py @@ -77,6 +77,8 @@ def getConnectTimeout(self): class AsyncVerifiedHTTPSConnection(VerifiedHTTPSConnection): + __slots__ = ("_canceled", "deadline", "_timeout") + def __init__(self, *args, **kwargs): VerifiedHTTPSConnection.__init__(self, *args, **kwargs) self._canceled = False @@ -166,6 +168,7 @@ def cancel(self): class AsyncHTTPConnection(HTTPConnection): + __slots__ = ("_canceled", "deadline") def __init__(self, *args, **kwargs): HTTPConnection.__init__(self, *args, **kwargs) self._canceled = False @@ -176,6 +179,8 @@ def cancel(self): class AsyncHTTPConnectionPool(HTTPConnectionPool): + __slots__ = ("connections",) + def __init__(self, *args, **kwargs): HTTPConnectionPool.__init__(self, *args, **kwargs) self.connections = [] @@ -209,6 +214,8 @@ def cancel(self): class AsyncHTTPSConnectionPool(HTTPSConnectionPool): + __slots__ = ("connections",) + def __init__(self, *args, **kwargs): HTTPSConnectionPool.__init__(self, *args, **kwargs) self.connections = [] diff --git a/script.plexmod/lib/_included_packages/plexnet/audio.py b/script.plexmod/lib/_included_packages/plexnet/audio.py index 5c06a31ad3..95e349f3f4 100644 --- a/script.plexmod/lib/_included_packages/plexnet/audio.py +++ b/script.plexmod/lib/_included_packages/plexnet/audio.py @@ -56,7 +56,7 @@ def tracks(self, watched=None): leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey return plexobjects.listItems(self.server, leavesKey, watched=watched) - def all(self): + def all(self, *args, **kwargs): return self.tracks() def track(self, title): @@ -93,7 +93,7 @@ def track(self, title): path = '%s/children' % self.key return plexobjects.findItem(self.server, path, title) - def all(self): + def all(self, *args, **kwargs): return self.tracks() def isFullObject(self): diff --git a/script.plexmod/lib/_included_packages/plexnet/http.py b/script.plexmod/lib/_included_packages/plexnet/http.py index d79f8fbef2..f94de43dff 100644 --- a/script.plexmod/lib/_included_packages/plexnet/http.py +++ b/script.plexmod/lib/_included_packages/plexnet/http.py @@ -23,7 +23,7 @@ DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(util.TIMEOUT).setConnectTimeout(util.TIMEOUT) -KNOWN_HOSTS = {} +RESOLVED_PD_HOSTS = {} _getaddrinfo = socket.getaddrinfo @@ -34,11 +34,11 @@ def pgetaddrinfo(host, port, *args, **kwargs): """ if host.endswith("plex.direct"): v6 = host.count("-") > 3 - if host in KNOWN_HOSTS: - ip = KNOWN_HOSTS[host] + if host in RESOLVED_PD_HOSTS: + ip = RESOLVED_PD_HOSTS[host] else: base = host.split(".", 1)[0] - ip = KNOWN_HOSTS[host] = v6 and base.replace("-", ":") or base.replace("-", ".") + ip = RESOLVED_PD_HOSTS[host] = v6 and base.replace("-", ":") or base.replace("-", ".") util.DEBUG_LOG("Dynamically resolving {} to {}".format(host, ip)) fam = v6 and socket.AF_INET6 or socket.AF_INET @@ -77,6 +77,8 @@ def __setattr__(self, attr, value): class HttpRequest(object): + __slots__ = ("server", "path", "hasParams", "ignoreResponse", "session", "currentResponse", "method", "url", + "thread", "__dict__") _cancel = False def __init__(self, url, method=None, forceCertificate=False): diff --git a/script.plexmod/lib/_included_packages/plexnet/locks.py b/script.plexmod/lib/_included_packages/plexnet/locks.py index 5e309f4ee4..a7ab8d242d 100644 --- a/script.plexmod/lib/_included_packages/plexnet/locks.py +++ b/script.plexmod/lib/_included_packages/plexnet/locks.py @@ -8,6 +8,8 @@ class Locks(object): + __slots__ = ("locks", "oneTimeLocks") + def __init__(self): self.locks = {} self.oneTimeLocks = {} diff --git a/script.plexmod/lib/_included_packages/plexnet/media.py b/script.plexmod/lib/_included_packages/plexnet/media.py index 802da02c92..d98f9e6b4f 100644 --- a/script.plexmod/lib/_included_packages/plexnet/media.py +++ b/script.plexmod/lib/_included_packages/plexnet/media.py @@ -176,10 +176,11 @@ class TranscodeSession(plexobjects.PlexObject): class MediaTag(plexobjects.PlexObject): TYPE = None ID = 'None' + virtual = False def __repr__(self): tag = self.tag.replace(' ', '.')[0:20] - return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag) + return '<%s:%s:%s:%s>' % (self.__class__.__name__, self.id, tag, self.virtual) def __eq__(self, other): if other.__class__ != self.__class__: @@ -196,6 +197,11 @@ class Collection(MediaTag): FILTER = 'collection' +class Location(MediaTag): + TYPE = 'Location' + FILTER = 'location' + + class Country(MediaTag): TYPE = 'Country' FILTER = 'country' @@ -255,6 +261,11 @@ class Writer(MediaTag): FILTER = 'writer' +class Guid(MediaTag): + TYPE = 'Guid' + FILTER = 'guid' + + class Chapter(MediaTag): TYPE = 'Chapter' @@ -270,6 +281,9 @@ class Marker(MediaTag): TYPE = 'Marker' FILTER = 'Marker' + def __repr__(self): + return '<%s:%s:%s:%s>' % (self.__class__.__name__, self.id, self.type, self.final and "final" or "") + class Review(MediaTag): TYPE = 'Review' diff --git a/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py b/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py index 9dea3e0860..c54c7ac028 100644 --- a/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py +++ b/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py @@ -24,7 +24,8 @@ def __init__(self): def chooseMedia(self, item, forceUpdate=False): # If we've already evaluated this item, use our previous choice. - if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and not item.mediaChoice.media.isIndirect(): + if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and \ + not item.mediaChoice.media.isIndirect(): return item.mediaChoice # See if we're missing media/stream details for this item. diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py b/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py index d9add92d79..953e686dc7 100644 --- a/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py +++ b/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py @@ -16,7 +16,9 @@ class HomeUser(util.AttributeDict): - pass + def __repr__(self): + return '<{0}:{1}:{2} (admin: {3})>'.format(self.__class__.__name__, self.id, + self.get('title', 'None').encode('utf8'), self.get('admin', 0)) class MyPlexAccount(object): @@ -33,6 +35,7 @@ def __init__(self): # Booleans self.isAuthenticated = util.INTERFACE.getPreference('auto_signin', False) + self.cacheHomeUsers = util.INTERFACE.getPreference('cache_home_users', True) self.isSignedIn = False self.isOffline = False self.isExpired = False @@ -48,6 +51,7 @@ def __init__(self): self.adminHasPlexPass = False self.lastHomeUserUpdate = None + self.revalidatePlexPass = False self.homeUsers = [] def init(self): @@ -67,10 +71,12 @@ def saveState(self): 'isSecure': self.isSecure, 'adminHasPlexPass': self.adminHasPlexPass, 'thumb': self.thumb, - 'homeUsers': self.homeUsers, 'lastHomeUserUpdate': self.lastHomeUserUpdate } + if self.cacheHomeUsers: + obj["homeUsers"] = self.homeUsers + util.INTERFACE.setRegistry("MyPlexAccount", json.dumps(obj), "myplex") def loadState(self): @@ -103,7 +109,8 @@ def loadState(self): self.adminHasPlexPass = obj.get('adminHasPlexPass') or self.adminHasPlexPass self.thumb = obj.get('thumb') self.lastHomeUserUpdate = obj.get('lastHomeUserUpdate') - self.homeUsers = [HomeUser(data) for data in obj.get('homeUsers', [])] + if self.cacheHomeUsers: + self.homeUsers = [HomeUser(data) for data in obj.get('homeUsers', [])] if self.homeUsers: util.LOG("cached home users: {0} (last update: {1})".format(self.homeUsers, self.lastHomeUserUpdate)) @@ -128,6 +135,26 @@ def logState(self): util.LOG("Admin: {0}".format(self.isAdmin)) util.LOG("AdminPlexPass: {0}".format(self.adminHasPlexPass)) + def getHomeSubscription(self): + """ + This gets the state of the plex home subscription, which is easier to determine than using a combination of + isAdmin and adminHasPlexPass, especially when caching home users. + """ + try: + req = myplexrequest.MyPlexRequest("/api/v2/home") + xml = req.getToStringWithTimeout(seconds=util.LONG_TIMEOUT) + data = ElementTree.fromstring(xml) + return data.attrib.get('subscription') == '1' + except: + util.LOG("Couldn't get Plex Home info") + return + return False + + def refreshSubscription(self): + ret = self.getHomeSubscription() + if isinstance(ret, bool): + self.isPlexPass = ret + def onAccountResponse(self, request, response, context): oldId = self.ID @@ -141,9 +168,11 @@ def onAccountResponse(self, request, response, context): self.title = data.attrib.get('title') self.username = data.attrib.get('username') self.email = data.attrib.get('email') - self.thumb = data.attrib.get('thumb') + self.thumb = data.attrib.get('thumb').split("?")[0] self.authToken = data.attrib.get('authenticationToken') - self.isPlexPass = (data.find('subscription') is not None and data.find('subscription').attrib.get('active') == '1') + self.isPlexPass = self.isPlexPass or \ + (data.find('subscription') is not None and + data.find('subscription').attrib.get('active') == '1') self.isManaged = data.attrib.get('restricted') == '1' self.isSecure = data.attrib.get('secure') == '1' self.hasQueue = bool(data.attrib.get('queueEmail')) @@ -159,12 +188,20 @@ def onAccountResponse(self, request, response, context): # Cache home users forever epoch = time.time() - if self.lastHomeUserUpdate: + # never automatically update home users if we have some. + # if we've never seen any, check once a week + if (self.lastHomeUserUpdate and self.homeUsers) or \ + (self.lastHomeUserUpdate and not self.homeUsers and epoch - self.lastHomeUserUpdate < 604800): util.DEBUG_LOG( "Skipping home user update (updated {0} seconds ago)".format(epoch - self.lastHomeUserUpdate)) else: self.updateHomeUsers(use_async=bool(self.homeUsers)) + # revalidate plex home subscription state after switching home user + if self.revalidatePlexPass and self.homeUsers: + self.refreshSubscription() + self.revalidatePlexPass = False + if self.isAdmin and self.isPlexPass: self.adminHasPlexPass = True @@ -221,8 +258,8 @@ def signOut(self, expired=False): # Booleans self.isSignedIn = False - self.isPlexPass = False - self.adminHasPlexPass = False + #self.isPlexPass = False + #self.adminHasPlexPass = False self.isManaged = False self.isSecure = False self.isExpired = expired @@ -260,7 +297,7 @@ def refreshAccount(self): return self.validateToken(self.authToken, False) - def updateHomeUsers(self, use_async=False): + def updateHomeUsers(self, use_async=False, refreshSubscription=False): # Ignore request and clear any home users we are not signed in if not self.isSignedIn: self.homeUsers = [] @@ -280,6 +317,11 @@ def updateHomeUsers(self, use_async=False): else: self.onHomeUsersUpdateResponse(req, None, None) + if refreshSubscription: + self.refreshSubscription() + self.logState() + self.saveState() + def onHomeUsersUpdateResponse(self, request, response, context): """ this can either be called with a given request, which will lead to a synchronous request, or as a @@ -331,7 +373,7 @@ def switchHomeUser(self, userId, pin=''): self.validateToken(self.authToken, True) return True else: - # build path and post to myplex to swith the user + # build path and post to myplex to switch the user path = '/api/home/users/{0}/switch'.format(userId) req = myplexrequest.MyPlexRequest(path) xml = req.postToStringWithTimeout({'pin': pin}, seconds=util.LONG_TIMEOUT) @@ -344,6 +386,7 @@ def switchHomeUser(self, userId, pin=''): self.isAuthenticated = True # validate the token (trigger change:user) on user change or channel startup if userId != self.ID or not locks.LOCKS.isLocked("idleLock"): + self.revalidatePlexPass = True self.validateToken(data.attrib.get('authenticationToken'), True, force_resource_refresh=plexapp.SERVERMANAGER.reachabilityNeverTested) return True diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py b/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py index 72c2ebb9ce..7856db5f4b 100644 --- a/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py +++ b/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py @@ -11,6 +11,11 @@ class MyPlexManager(object): + gotResources = False + + def __init__(self): + self.gotResources = False + def publish(self): util.LOG('MyPlexManager().publish() - NOT IMPLEMENTED') return # TODO: ----------------------------------------------------------------------------------------------------------------------------- IMPLEMENT? @@ -53,6 +58,7 @@ def onResourcesResponse(self, request, response, context): response.parseFakeXMLResponse(data) util.DEBUG_LOG("Using cached resources") + hosts = [] if response.container: for resource in response.container: util.DEBUG_LOG( @@ -67,13 +73,16 @@ def onResourcesResponse(self, request, response, context): for conn in resource.connections: util.DEBUG_LOG(' {0}'.format(conn)) + hosts.append(conn.address) if 'server' in resource.provides: server = plexserver.createPlexServerForResource(resource) util.DEBUG_LOG(' {0}'.format(server)) servers.append(server) + self.gotResources = True plexapp.SERVERMANAGER.updateFromConnectionType(servers, plexconnection.PlexConnection.SOURCE_MYPLEX) + util.APP.trigger("loaded:myplex_servers", hosts=hosts, source="myplex") MANAGER = MyPlexManager() diff --git a/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py b/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py index 454516be7f..9ef4b288e5 100644 --- a/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py +++ b/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py @@ -25,8 +25,7 @@ def __init__(self, timelineType, *args, **kwargs): util.AttributeDict.__init__(self, *args, **kwargs) self.type = timelineType self.state = "stopped" - self.item = None - self.choice = None + self.itemData = None self.playQueue = None self.controllable = util.AttributeDict() @@ -61,51 +60,6 @@ def updateControllableStr(self): prependComma = True self.controllableStr += name - def toXmlAttributes(self, elem): - self.updateControllableStr() - elem.attrib["type"] = self.type - elem.attrib["start"] = self.state - elem.attrib["controllable"] = self.controllableStr - - if self.item: - if self.item.duration: - elem.attrib['duration'] = self.item.duration - if self.item.ratingKey: - elem.attrib['ratingKey'] = self.item.ratingKey - if self.item.key: - elem.attrib['key'] = self.item.key - if self.item.container.address: - elem.attrib['containerKey'] = self.item.container.address - - # Send the audio, video and subtitle choice if it's available - if self.choice: - for stream in ("audioStream", "videoStream", "subtitleStream"): - if self.choice.get(stream) and self.choice[stream].id: - elem.attrib[stream + "ID"] = self.choice[stream].id - - server = self.item.getServer() - if server: - elem.attrib["machineIdentifier"] = server.uuid - - if server.activeConnection: - parts = six.moves.urllib.parse.uslparse(server.activeConnection.address) - elem.attrib["protocol"] = parts.scheme - elem.attrib["address"] = parts.netloc.split(':', 1)[0] - if ':' in parts.netloc: - elem.attrib["port"] = parts.netloc.split(':', 1)[-1] - elif parts.scheme == 'https': - elem.attrib["port"] = '443' - else: - elem.attrib["port"] = '80' - - if self.playQueue: - elem.attrib["playQueueID"] = str(self.playQueue.id) - elem.attrib["playQueueItemID"] = str(self.playQueue.selectedId) - elem.attrib["playQueueVersion"] = str(self.playQueue.version) - - for key, val in self.attrs.items(): - elem.attrib[key] = val - class NowPlayingManager(object): def __init__(self): @@ -131,11 +85,10 @@ def __init__(self): for timelineType in self.TIMELINE_TYPES: self.timelines[timelineType] = TimelineData(timelineType) - def updatePlaybackState(self, timelineType, playerObject, state, t, playQueue=None, duration=0, force=False): + def updatePlaybackState(self, timelineType, itemData, state, t, playQueue=None, duration=0, force=False): timeline = self.timelines[timelineType] timeline.state = state - timeline.item = playerObject.item - timeline.choice = playerObject.choice + timeline.itemData = itemData timeline.playQueue = playQueue timeline.attrs["time"] = str(t) timeline.duration = duration @@ -145,29 +98,25 @@ def updatePlaybackState(self, timelineType, playerObject, state, t, playQueue=No self.sendTimelineToServer(timelineType, timeline, t, force=force) def sendTimelineToServer(self, timelineType, timeline, t, force=False): - if not hasattr(timeline.item, 'getServer') or not timeline.item.getServer(): + server = util.APP.serverManager.selectedServer + if not server: return serverTimeline = self.getServerTimeline(timelineType) # Only send timeline if it's the first, item changes, playstate changes or timer pops - itemsEqual = timeline.item and serverTimeline.item and timeline.item.ratingKey == serverTimeline.item.ratingKey + itemsEqual = timeline.itemData and serverTimeline.itemData \ + and timeline.itemData.ratingKey == serverTimeline.itemData.ratingKey if itemsEqual and timeline.state == serverTimeline.state and not serverTimeline.isExpired() and not force: return serverTimeline.reset() - serverTimeline.item = timeline.item + serverTimeline.itemData = timeline.itemData serverTimeline.state = timeline.state - # Ignore sending timelines for multi part media with no duration - obj = timeline.choice - if obj and obj.part and obj.part.duration.asInt() == 0 and obj.media.parts and len(obj.media.parts) > 1: - util.WARN_LOG("Timeline not supported: the current part doesn't have a valid duration") - return - # It's possible with timers and in player seeking for the time to be greater than the # duration, which causes a 400, so in that case we'll set the time to the duration. - duration = timeline.item.duration.asInt() or timeline.duration + duration = timeline.itemData.duration or timeline.duration if t > duration: t = duration @@ -175,11 +124,11 @@ def sendTimelineToServer(self, timelineType, timeline, t, force=False): params["time"] = t params["duration"] = duration params["state"] = timeline.state - params["guid"] = timeline.item.guid - params["ratingKey"] = timeline.item.ratingKey - params["url"] = timeline.item.url - params["key"] = timeline.item.key - params["containerKey"] = timeline.item.container.address + params["guid"] = timeline.itemData.guid + params["ratingKey"] = timeline.itemData.ratingKey + params["url"] = timeline.itemData.url + params["key"] = timeline.itemData.key + params["containerKey"] = timeline.itemData.containerKey if timeline.playQueue: params["playQueueItemID"] = timeline.playQueue.selectedId @@ -188,7 +137,7 @@ def sendTimelineToServer(self, timelineType, timeline, t, force=False): if params[paramKey]: path = http.addUrlParam(path, paramKey + "=" + six.moves.urllib.parse.quote(str(params[paramKey]))) - request = plexrequest.PlexRequest(timeline.item.getServer(), path) + request = plexrequest.PlexRequest(server, path) context = request.createRequestContext("timelineUpdate", callback.Callable(self.onTimelineResponse)) context.playQueue = timeline.playQueue diff --git a/script.plexmod/lib/_included_packages/plexnet/photo.py b/script.plexmod/lib/_included_packages/plexnet/photo.py index d3a66ca681..b611a0238d 100644 --- a/script.plexmod/lib/_included_packages/plexnet/photo.py +++ b/script.plexmod/lib/_included_packages/plexnet/photo.py @@ -46,7 +46,7 @@ def isPhotoOrDirectoryItem(self): class PhotoDirectory(media.MediaItem): TYPE = 'photodirectory' - def all(self): + def all(self, *args, **kwargs): path = self.key return plexobjects.listItems(self.server, path) diff --git a/script.plexmod/lib/_included_packages/plexnet/playqueue.py b/script.plexmod/lib/_included_packages/plexnet/playqueue.py index 63a9ba2284..88a8b9b620 100644 --- a/script.plexmod/lib/_included_packages/plexnet/playqueue.py +++ b/script.plexmod/lib/_included_packages/plexnet/playqueue.py @@ -68,10 +68,18 @@ def createUsage(cls, playQueue): if obj.type: if obj.type == "audio": return obj.createAudioUsage() + elif obj.type == "photo": + return obj.createPhotoUsage() util.DEBUG_LOG("Don't know how to usage for " + str(obj.type)) return None + def createPhotoUsage(self): + if not self.usage or self.usage.playQueueId != self.playQueue.id: + self.usage = self.playQueue.usage + + return self.usage + def createAudioUsage(self): skips = self.playQueue.container.stationSkipsPerHour.asInt(-1) if skips == -1: diff --git a/script.plexmod/lib/_included_packages/plexnet/plexapp.py b/script.plexmod/lib/_included_packages/plexnet/plexapp.py index 931799be4d..f07d1b2004 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexapp.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexapp.py @@ -1,10 +1,7 @@ from __future__ import print_function, absolute_import -import threading import platform import uuid -import sys -from . import callback from . import signalsmixin from . import simpleobjects from . import util @@ -28,6 +25,8 @@ def init(): SERVERMANAGER = plexservermanager.MANAGER from . import myplexmanager util.MANAGER = MANAGER = myplexmanager.MANAGER + util.ACCOUNT = ACCOUNT + util.SERVERMANAGER = SERVERMANAGER util.DEBUG_LOG("Verifying account...") ACCOUNT.verifyAccount() @@ -44,6 +43,10 @@ def __init__(self): def addTimer(self, timer): self.timers.append(timer) + @property + def serverManager(self): + return SERVERMANAGER + def startRequest(self, request, context, body=None, contentType=None): context.request = request diff --git a/script.plexmod/lib/_included_packages/plexnet/plexconnection.py b/script.plexmod/lib/_included_packages/plexnet/plexconnection.py index c9e9c41667..db495d1930 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexconnection.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexconnection.py @@ -6,6 +6,11 @@ from . import callback from . import util +try: + from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network +except ImportError: + from _ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network + HAS_ICMPLIB = False try: from icmplib import ping, resolve, ICMPLibError @@ -14,16 +19,16 @@ else: HAS_ICMPLIB = True from urllib.parse import urlparse - from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network - # local networks - LOCAL_NETWORKS = { - 4: [IPv4Network('10.0.0.0/8'), IPv4Network('192.168.0.0/16'), IPv4Network('172.16.0.0/12'), - IPv4Network('127.0.0.0/8')], - 6: [IPv6Network('fd00::/8')] - } +# local networks +DOCKER_NETWORK = IPv4Network(u'172.16.0.0/12') +LOCAL_NETWORKS = { + 4: [IPv4Network(u'10.0.0.0/8'), IPv4Network(u'192.168.0.0/16'), DOCKER_NETWORK, + IPv4Network(u'127.0.0.0/8')], + 6: [IPv6Network(u'fd00::/8')] +} - LOCALS_SEEN = {} +LOCALS_SEEN = {} class ConnectionSource(int): @@ -128,9 +133,7 @@ def checkLocal(self): if hostname.endswith("plex.direct"): util.DEBUG_LOG("Using shortcut for hostname IP detection due to plex.direct host: {}".format(hostname)) - v6 = hostname.count("-") > 3 - base = hostname.split(".", 1)[0] - ips = [v6 and base.replace("-", ":") or base.replace("-", ".")] + ips = [util.parsePlexDirectHost(hostname)] else: try: diff --git a/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py b/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py index c9d0c4fc4b..d04cd082b9 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py @@ -10,6 +10,8 @@ from . import exceptions from . import util from . import signalsmixin +from lib.path_mapping import pmm, norm_sep +from lib.exceptions import NoDataException from six.moves import map @@ -34,7 +36,7 @@ def section(self, title=None): return item raise exceptions.NotFound('Invalid library section: %s' % title) - def all(self): + def all(self, *args, **kwargs): return plexobjects.listItems(self.server, '/library/all') def onDeck(self): @@ -90,10 +92,21 @@ class LibrarySection(plexobjects.PlexObject): isLibraryPQ = True + def __init__(self, data, initpath=None, server=None, container=None): + self.locations = [] + self._isMapped = None + super(LibrarySection, self).__init__(data, initpath=initpath, server=server, container=container) + def __repr__(self): title = self.title.replace(' ', '.')[0:20] return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8')) + def _setData(self, data): + super(LibrarySection, self)._setData(data) + for loc in plexobjects.PlexItemList(data, media.Location, media.Location.TYPE, server=self.server): + sep = norm_sep(loc.path) + self.locations.append(loc.path if loc.path.endswith(sep) else loc.path + sep) + @staticmethod def fromFilter(filter_): cls = SECTION_IDS.get(filter_.getLibrarySectionType()) @@ -127,6 +140,30 @@ def isDirectory(self): def isLibraryItem(self): return True + def getMappedPath(self, loc=None): + if not self.locations: + return None, None + + return pmm.getMappedPathFor(loc or self.locations[0], self.server) + + def deleteMapping(self, target): + pmm.deletePathMapping(target, server=self.getServer()) + self._isMapped = None + + @property + def isMapped(self): + if self._isMapped is not None: + return self._isMapped + elif self._isMapped is False: + return False + + for loc in self.locations: + if all(self.getMappedPath(loc)): + self._isMapped = True + return True + self._isMapped = False + return self._isMapped + def getAbsolutePath(self, key): if key == 'key': return '/library/sections/{0}/all'.format(self.key) @@ -524,9 +561,11 @@ def reset(self): (self.items[0].container.offset.asInt() + self.items[0].container.size.asInt() < totalSize) and '1' or '' ) - def getCleanHubIdentifier(self): + def getCleanHubIdentifier(self, is_home=False): if not self._identifier: self._identifier = re.sub(r'\.\d+$', '', re.sub(r'\.\d+$', '', self.hubIdentifier)) + if is_home and self._identifier == 'movie.recentlyreleased': + self._identifier = 'home.VIRTUAL.movies.recentlyreleased' return self._identifier @@ -566,10 +605,13 @@ def reload(self, **kwargs): return self.initpath = self.key - self._setData(data) + try: + self._setData(data) + except: + raise NoDataException self.init(data) - def extend(self, start=None, size=None): + def extend(self, start=None, size=None, **kwargs): path = self.key args = {} @@ -578,6 +620,9 @@ def extend(self, start=None, size=None): args['X-Plex-Container-Start'] = start args['X-Plex-Container-Size'] = size + if kwargs: + args.update(kwargs) + if args: path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?') diff --git a/script.plexmod/lib/_included_packages/plexnet/plexmedia.py b/script.plexmod/lib/_included_packages/plexnet/plexmedia.py index 76434d78a6..882dd2044c 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexmedia.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexmedia.py @@ -10,6 +10,8 @@ class PlexMedia(plexobjects.PlexObject): + __slots__ = ("_data", "container_", "container", "indirectHeaders", "parts") + def __init__(self, data, initpath=None, server=None, container=None): self._data = data.attrib plexobjects.PlexObject.__init__(self, data, initpath, server) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexobjects.py b/script.plexmod/lib/_included_packages/plexnet/plexobjects.py index cba919e90e..5406dca92b 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexobjects.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexobjects.py @@ -34,6 +34,8 @@ def wrap(func): class PlexValue(six.text_type): + __slots__ = ("parent", "NA") + def __new__(cls, value, parent=None): self = super(PlexValue, cls).__new__(cls, value) self.parent = parent @@ -153,6 +155,8 @@ def isSettings(self): class PlexObject(Checks): + __slots__ = ("initpath", "key", "server", "container", "mediaChoice", "titleSort", "deleted", "_reloaded", "data") + def __init__(self, data, initpath=None, server=None, container=None): self.initpath = initpath self.key = None @@ -198,7 +202,7 @@ def exists(self, *args, **kwargs): return True def get(self, attr, default=''): - ret = self.__dict__.get(attr) + ret = self.__dict__.get(attr, getattr(self, attr) if attr in self.__slots__ else None) return ret is not None and ret or PlexValue(default, self) def set(self, attr, value): @@ -234,8 +238,6 @@ def reload(self, _soft=False, **kwargs): if _soft and self._reloaded: return self - kwargs["includeMarkers"] = 1 - try: if self.get('ratingKey'): data = self.server.query('/library/metadata/{0}'.format(self.ratingKey), params=kwargs) @@ -422,6 +424,8 @@ def serialize(self, full=False): class PlexContainer(PlexObject): + __slots__ = ("address",) + def __init__(self, data, initpath=None, server=None, address=None): PlexObject.__init__(self, data, initpath, server) self.setAddress(address) @@ -446,6 +450,8 @@ def getAbsolutePath(self, path): class PlexServerContainer(PlexContainer): + __slots__ = ("resources",) + def __init__(self, data, initpath=None, server=None, address=None): PlexContainer.__init__(self, data, initpath, server, address) from . import plexserver @@ -463,6 +469,8 @@ def __len__(self): class PlexItemList(object): + __slots__ = ("_data", "_itemClass", "_itemTag", "_server", "_container", "_items") + def __init__(self, data, item_cls, tag, server=None, container=None): self._data = data self._itemClass = item_cls @@ -502,6 +510,8 @@ def append(self, item): class PlexMediaItemList(PlexItemList): + __slots__ = ("_initpath", "_media", "_items") + def __init__(self, data, item_cls, tag, initpath=None, server=None, media=None): PlexItemList.__init__(self, data, item_cls, tag, server) self._initpath = initpath @@ -538,6 +548,8 @@ def buildItem(server, elem, initpath, bytag=False, container=None, tag_fallback= class ItemContainer(list): + __slots__ = ("container", "totalSize") + def __getattr__(self, attr): return getattr(self.container, attr) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexpart.py b/script.plexmod/lib/_included_packages/plexnet/plexpart.py index 7fd0c138cd..1931d6b1f7 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexpart.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexpart.py @@ -1,9 +1,13 @@ from __future__ import absolute_import +from kodi_six import xbmcvfs from . import plexobjects from . import plexstream from . import plexrequest from . import util +from lib.util import addonSettings +from lib.path_mapping import pmm, norm_sep + class PlexPart(plexobjects.PlexObject): def reload(self): @@ -120,7 +124,6 @@ def setSelectedStream(self, streamType, streamId, _async): if _async: context = request.createRequestContext("ignored") - from . import plexapp util.APP.startRequest(request, context, "") else: request.postToStringWithTimeout() @@ -157,6 +160,40 @@ def getIndexPath(self, indexKey, interval=None): def hasStreams(self): return bool(self.streams) + def getPathMappedUrl(self, return_only_folder=False): + verify = addonSettings.verifyMappedFiles + + map_path, pms_path = pmm.getMappedPathFor(self.file, self.getServer()) + if map_path and pms_path: + if return_only_folder: + return map_path + + sep = norm_sep(map_path) + + # replace match and normalize path separator to separator style of map_path + url = self.file.replace(pms_path, map_path, 1).replace(sep == "/" and "\\" or "/", sep) + + if (verify and xbmcvfs.exists(url)) or not verify: + util.DEBUG_LOG("File {} found in path map, mapping to {}".format(self.file, pms_path)) + return url + util.LOG("Mapped file {} doesn't exist".format(url)) + return "" + + @property + def isPathMapped(self): + return bool(self.getPathMappedUrl()) + + def getPathMappedProto(self): + url = self.getPathMappedUrl() + if url: + prot = url.split("://")[0] + if prot == url: + ret = "mnt://" + else: + ret = "{}://".format(prot) + return ret + return "" + def __str__(self): return "PlexPart {0} {1}".format(self.id("NaN"), self.key) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexplayer.py b/script.plexmod/lib/_included_packages/plexnet/plexplayer.py index 4c3b0c1c1b..da2c755d98 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexplayer.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexplayer.py @@ -6,14 +6,34 @@ from . import plexrequest from . import mediadecisionengine from . import serverdecision -from lib.util import CACHE_SIZE, advancedSettings, KODI_VERSION_MAJOR +from lib.util import addonSettings, KODI_VERSION_MAJOR +from lib.cache import CACHE_SIZE from six.moves import range DecisionFailure = serverdecision.DecisionFailure -class PlexPlayer(object): +class BasePlayer(object): + item = None + + def setupObj(self, obj, part, server, force_request_to_server=False): + # check for path mapping + url = part.getPathMappedUrl() + + if not url: + url = server.buildUrl(part.getAbsolutePath("key")) + # Check if we should include our token or not for this request + obj.isRequestToServer = force_request_to_server or server.isRequestToServer(url) + obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)] + obj.isMapped = False + else: + obj.isRequestToServer = False + obj.streamUrls = [url] + obj.isMapped = True + + +class PlexPlayer(BasePlayer): DECISION_ENDPOINT = "/video/:/transcode/universal/decision" def __init__(self, item, seekValue=0, forceUpdate=False): @@ -271,10 +291,7 @@ def getDecisionPath(self, directPlay=False): "mediaBufferSize={}".format(str(CACHE_SIZE * 1024))) decisionPath = http.addUrlParam(decisionPath, "hasMDE=1") - if not advancedSettings.oldprofile: - decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Client-Profile-Name=Generic') - else: - decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Client-Profile-Name=Chrome') + decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Client-Profile-Name=Generic') return decisionPath @@ -302,11 +319,7 @@ def buildTranscodeHls(self, obj): builder.extras = [] builder.addParam("protocol", "hls") - # TODO: This should be Generic, but will need to re-evaluate the augmentations with that change - if not advancedSettings.oldprofile: - builder.addParam("X-Plex-Client-Profile-Name", "Generic") - else: - builder.addParam("X-Plex-Client-Profile-Name", "Chrome") + builder.addParam("X-Plex-Client-Profile-Name", "Generic") if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY: builder.addParam("skipSubtitles", "1") @@ -318,11 +331,7 @@ def buildTranscodeHls(self, obj): # Augment the server's profile for things that depend on the Roku's configuration. if self.item.settings.supportsAudioStream("ac3", 6): builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") - if not advancedSettings.oldprofile: - builder.extras.append("add-direct-play-profile(type=videoProfile&container=mkv&videoCodec=*&audioCodec=ac3)") - else: - builder.extras.append( - "add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=ac3)") + builder.extras.append("add-direct-play-profile(type=videoProfile&container=mkv&videoCodec=*&audioCodec=ac3)") return builder @@ -336,10 +345,7 @@ def buildTranscodeMkv(self, obj, directStream=True): builder.extras = [] builder.addParam("protocol", "http") builder.addParam("copyts", "1") - if not advancedSettings.oldprofile: - builder.addParam("X-Plex-Client-Profile-Name", "Generic") - else: - builder.addParam("X-Plex-Client-Profile-Name", "Chrome") + builder.addParam("X-Plex-Client-Profile-Name", "Generic") obj.subtitleUrl = None @@ -535,159 +541,15 @@ def buildTranscodeMkv(self, obj, directStream=True): return builder - def buildTranscodeMkvLegacy(self, obj, directStream=True): - util.DEBUG_LOG('buildTranscodeMkvLegacy()') - obj.streamFormat = "mkv" - obj.streamBitrates = [0] - obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv" - - builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) - builder.extras = [] - builder.addParam("protocol", "http") - builder.addParam("copyts", "1") - builder.addParam("X-Plex-Client-Profile-Name", "Generic") - - obj.subtitleUrl = None - - # fixme: still necessary? - if True: # if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: # Must burn transcoded because we can't set offset - builder.addParam("subtitles", "burn") - captionSize = captions.CAPTIONS.getBurnedSize() - if captionSize is not None: - builder.addParam("subtitleSize", captionSize) - - else: - # TODO(rob): can we safely assume the id will also be 3 (one based index). - # If not, we will have to get tricky and select the subtitle stream after - # video playback starts via roCaptionRenderer: GetSubtitleTracks() and - # ChangeSubtitleTrack() - - obj.subtitleConfig = {'TrackName': "mkv/3"} - - # Allow text conversion of subtitles if we only burn image formats - if self.item.settings.getPreference("burn_subtitles") == "image": - builder.addParam("advancedSubtitles", "text") - - builder.addParam("subtitles", "auto") - - if directStream: - audioCodecs = "eac3,ac3,dca,aac,mp3,mp2,pcm,flac,alac,wmav2,wmapro,wmavoice,opus,vorbis,truehd" - else: - audioCodecs = "mp3,ac3,aac,opus" - - # Allow virtually anything in Kodi playback. - - # DP might not do anything here - # builder.extras.append( - # "add-direct-play-profile(type=videoProfile&videoCodec=" - # "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,vc1,wmv3&container=*&" - # "audioCodec="+audioCodecs+"&protocol=http)") - - builder.extras.append( - "add-transcode-target(type=videoProfile&videoCodec=" - "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,wmv3&container=mkv&" - "audioCodec="+audioCodecs+"&protocol=http&context=streaming)") - - # builder.extras.append( - # "append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + - # audioCodecs + ")") - - # if self.item.settings.supportsSurroundSound(): - # if self.choice.audioStream is not None: - # numChannels = self.choice.audioStream.channels.asInt(8) - # else: - # numChannels = 8 - # - # for codec in ("ac3", "eac3", "dca"): - # if self.item.settings.supportsAudioStream(codec, numChannels): - # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") - # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") - # if codec == "dca": - # builder.extras.append( - # "add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=8&isRequired=false)" - # ) - # - # for codec in ("ac3", "eac3", "dca"): - # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") - # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") - - # limit OPUS to 334kbit - numChannels = self.choice.audioStream.channels.asInt(8) if self.choice.audioStream else 8 - - if numChannels == 8: - # 7.1 - opusBitrate = 334 - elif numChannels >= 6: - # 5.1 - opusBitrate = 256 - else: - # 2 - opusBitrate = 128 - - builder.extras.append( - "add-limitation(scope=videoAudioCodec&scopeName=opus&type=upperBound&name=audio.bitrate&" - "value={}&isRequired=false)".format(opusBitrate) - ) - - # limit AC3 - builder.extras.append( - "add-limitation(scope=videoAudioCodec&scopeName=ac3&type=upperBound&name=audio.bitrate&value=640)" - ) - - # limit audio to Kodi audio channels - builder.extras.append( - "add-limitation(scope=videoAudioCodec&scopeName=*&type=upperBound&" - "name=audio.channels&value={})".format(self.audioChannels) - ) - - # AAC sample rate cannot be less than 22050hz (HLS is capable). - if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050: - builder.extras.append( - "add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&" - "name=audio.samplingRate&value=22050&isRequired=false)") - - # HEVC - if self.item.settings.getPreference("allow_hevc", True): - builder.extras.append( - "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" - "protocol=http&videoCodec=hevc)") - # builder.extras.append( - # "add-direct-play-profile(type=videoProfile&videoCodec=hevc&container=*&audioCodec=*)") - - # VP9 - if self.item.settings.getGlobal("vp9Support"): - builder.extras.append( - "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" - "protocol=http&videoCodec=vp9)") - # builder.extras.append( - # "add-direct-play-profile(type=videoProfile&videoCodec=vp9&container=*&audioCodec=*)") - - # AV1 - if self.item.settings.getPreference("allow_av1", False): - builder.extras.append( - "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" - "protocol=http&videoCodec=av1)") - # builder.extras.append( - # "add-direct-play-profile(type=videoProfile&videoCodec=av1&container=*&audioCodec=*)") - - # VC1 - if self.item.settings.getPreference("allow_vc1", True): - builder.extras.append( - "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" - "protocol=http&videoCodec=vc1)") - - return builder - def buildDirectPlay(self, obj, partIndex): util.DEBUG_LOG('buildDirectPlay()') part = self.media.parts[partIndex] server = self.item.getServer() - # Check if we should include our token or not for this request - obj.isRequestToServer = server.isRequestToServer(server.buildUrl(part.getAbsolutePath("key"))) - obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)] + self.setupObj(obj, part, server) obj.token = obj.isRequestToServer and server.getToken() or None + if self.media.protocol == "hls": obj.streamFormat = "hls" obj.switchingStrategy = "full-adaptation" @@ -748,10 +610,7 @@ def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart): # if server.supportsFeature("mkvTranscode") and self.item.settings.getPreference("transcode_format", 'mkv') != "hls": if server.supportsFeature("mkvTranscode"): - if not advancedSettings.oldprofile: - builder = self.buildTranscodeMkv(obj, directStream=directStream) - else: - builder = self.buildTranscodeMkvLegacy(obj, directStream=directStream) + builder = self.buildTranscodeMkv(obj, directStream=directStream) else: builder = self.buildTranscodeHls(obj) @@ -840,38 +699,34 @@ def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart): return obj -class PlexAudioPlayer(object): - def __init__(self, item): +class PlexAudioPlayer(BasePlayer): + def __init__(self, item=None): + self.item = item + self.choice = None self.containerFormats = { 'aac': "es.aac-adts" } - self.item = item - self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item) - if self.choice: - self.media = self.choice.media self.lyrics = None # createLyrics(item, self.media) - def build(self, directPlay=None): - directPlay = directPlay or self.choice.isDirectPlayable + def build(self, item, directPlay=None): + item = item or self.item + self.choice = choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item) + directPlay = directPlay or choice.isDirectPlayable obj = util.AttributeDict() - # TODO(schuyler): Do we want/need to add anything generic here? Title? Duration? - if directPlay: - obj = self.buildDirectPlay(obj) + obj = self.buildDirectPlay(item, choice, obj) else: - obj = self.buildTranscode(obj) - - self.metadata = obj + obj = self.buildTranscode(item, choice, obj) util.LOG("Constructed audio item for playback: {0}".format(util.cleanObjTokens(dict(obj)))) - return self.metadata + return obj - def buildTranscode(self, obj): - transcodeServer = self.item.getTranscodeServer(True, "audio") + def buildTranscode(self, item, choice, obj): + transcodeServer = item.getTranscodeServer(True, "audio") if not transcodeServer: return None @@ -882,8 +737,8 @@ def buildTranscode(self, obj): builder = http.HttpRequest(transcodeServer.buildUrl(obj.transcodeEndpoint, True)) # builder.addParam("protocol", "http") - builder.addParam("path", self.item.getAbsolutePath("key")) - builder.addParam("session", self.item.getGlobal("clientIdentifier")) + builder.addParam("path", item.getAbsolutePath("key")) + builder.addParam("session", item.getGlobal("clientIdentifier")) builder.addParam("directPlay", "0") builder.addParam("directStream", "0") @@ -891,26 +746,27 @@ def buildTranscode(self, obj): return obj - def buildDirectPlay(self, obj): - if self.choice.part: - obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True) + def buildDirectPlay(self, item, choice, obj): + if choice.part: + self.setupObj(obj, choice.part, item.getServer(), force_request_to_server=True) + obj.url = obj.streamUrls[0] # Set and override the stream format if applicable - obj.streamFormat = self.choice.media.get('container', 'mp3') + obj.streamFormat = choice.media.get('container', 'mp3') if self.containerFormats.get(obj.streamFormat): obj.streamFormat = self.containerFormats[obj.streamFormat] # If we're direct playing a FLAC, bitrate can be required, and supposedly # this is the only way to do it. plexinc/roku-client#48 # - bitrate = self.choice.media.bitrate.asInt() + bitrate = choice.media.bitrate.asInt() if bitrate > 0: obj.streams = [{'url': obj.url, 'bitrate': bitrate}] return obj # We may as well fallback to transcoding if we could not direct play - return self.buildTranscode(obj) + return self.buildTranscode(item, choice, obj) def getLyrics(self): return self.lyrics diff --git a/script.plexmod/lib/_included_packages/plexnet/plexserver.py b/script.plexmod/lib/_included_packages/plexnet/plexserver.py index 7857260a84..3685640de1 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexserver.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexserver.py @@ -118,7 +118,7 @@ def getObject(self, key): data = self.query(key) return plexobjects.buildItem(self, data[0], key, container=self) - def hubs(self, section=None, count=None, search_query=None): + def hubs(self, section=None, count=None, search_query=None, section_ids=None): hubs = [] params = {"includeMarkers": 1} @@ -143,6 +143,10 @@ def hubs(self, section=None, count=None, search_query=None): return hubs else: q = '/hubs/sections/%s' % section + else: + # home hub + if section_ids: + params['pinnedContentDirectoryID'] = ",".join(section_ids) if count is not None: params['count'] = count @@ -150,8 +154,34 @@ def hubs(self, section=None, count=None, search_query=None): data = self.query(q, params=params) container = plexobjects.PlexContainer(data, initpath=q, server=self, address=q) + newCW = util.INTERFACE.getPreference('hubs_use_new_continue_watching', False) and not search_query \ + and not section + + if newCW: + # home, add continueWatching + cq = '/hubs/continueWatching' + if section_ids: + cq += util.joinArgs(params) + + cdata = self.query(cq, params=params) + ccontainer = plexobjects.PlexContainer(cdata, initpath=cq, server=self, address=cq) + hubs.append(plexlibrary.Hub(cdata[0], server=self, container=ccontainer)) + for elem in data: + hubIdent = elem.attrib.get('hubIdentifier') + # if we've added continueWatching, which combines continue and ondeck, skip those two hubs + if newCW and hubIdent and \ + (hubIdent.startswith('home.continue') or hubIdent.startswith('home.ondeck')): + continue + hubs.append(plexlibrary.Hub(elem, server=self, container=container)) + + if section_ids: + # when we have hidden sections, apply the filter to the hubs keys for subsequent queries + for hub in hubs: + if "pinnedContentDirectoryID" not in hub.key: + hub.key += util.joinArgs(params, '?' not in hub.key) + return hubs def playlists(self, start=0, size=10, hub=None): diff --git a/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py b/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py index d6e968b364..d8782b8d01 100644 --- a/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py +++ b/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py @@ -42,6 +42,10 @@ def __init__(self): def getSelectedServer(self): return self.selectedServer + @property + def allConnections(self): + return [c.address for s in list(self.serversByUuid.values()) for c in s.connections if s.connections] + def setSelectedServer(self, server, force=False): # Don't do anything if the server is already selected. if self.selectedServer and self.selectedServer == server: @@ -329,6 +333,7 @@ def loadState(self): util.ERROR_LOG("Failed to parse PlexServerManager JSON") return + hosts = [] for serverObj in obj['servers']: server = plexserver.createPlexServerForName(serverObj['uuid'], serverObj['name']) server.owned = bool(serverObj.get('owned')) @@ -343,6 +348,7 @@ def loadState(self): for i in range(len(serverObj.get('connections', []))): conn = serverObj['connections'][i] + hosts.append(conn['address']) isFallback = hasSecureConn and conn['address'][:5] != "https" and not util.LOCAL_OVER_SECURE sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']] connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback) @@ -358,6 +364,7 @@ def loadState(self): self.serversByUuid[server.uuid] = server util.LOG("Loaded {0} servers from registry".format(len(obj['servers']))) + util.APP.trigger("loaded:server_connections", hosts=hosts, source="stored") self.updateReachability(False, True) def saveState(self, setPreferred=False): @@ -370,6 +377,8 @@ def saveState(self, setPreferred=False): servers = self.getServers() obj['servers'] = [] + hosts = [] + for server in servers: # Don't save secondary servers. They should be discovered through GDM or myPlex. if not server.isSecondary(): @@ -383,6 +392,7 @@ def saveState(self, setPreferred=False): for i in range(len(server.connections)): conn = server.connections[i] + hosts.append(conn.address) serverObj['connections'].append({ 'sources': conn.sources, 'address': conn.address, @@ -397,6 +407,7 @@ def saveState(self, setPreferred=False): and setPreferred: util.INTERFACE.setPreference("lastServerId.{}".format(plexapp.ACCOUNT.ID), self.selectedServer.uuid) + util.APP.trigger("loaded:server_connections", hosts=hosts, source="myplex") util.INTERFACE.setRegistry("PlexServerManager", json.dumps(obj)) def clearState(self): diff --git a/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py b/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py index 2efe9d6e63..cfe09f764e 100644 --- a/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py +++ b/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py @@ -3,7 +3,7 @@ class SignalsMixin(object): - def __init__(self): + def __init__(self, *args, **kwargs): self._signals = {} def on(self, signalName, callback): @@ -14,6 +14,12 @@ def on(self, signalName, callback): signal.connect(callback) + def has_signal(self, signalName, callback): + if not self._signals: + return + + return signalName in self._signals and self._signals[signalName].is_connected(callback) + def off(self, signalName, callback): if not self._signals: return diff --git a/script.plexmod/lib/_included_packages/plexnet/util.py b/script.plexmod/lib/_included_packages/plexnet/util.py index d29232fb16..bece856d1e 100644 --- a/script.plexmod/lib/_included_packages/plexnet/util.py +++ b/script.plexmod/lib/_included_packages/plexnet/util.py @@ -68,6 +68,8 @@ def resetBaseHeaders(): TIMER = None APP = None MANAGER = None +ACCOUNT = None +SERVERMANAGER = None try: _platform = platform.system() @@ -78,7 +80,10 @@ def resetBaseHeaders(): _platform = sys.platform X_PLEX_DEVICE = _platform # Device name and model number, eg iPhone3,2, Motorola XOOM, LG5200TV -X_PLEX_IDENTIFIER = str(hex(uuid.getnode())) # UUID, serial number, or other number unique per device +X_PLEX_IDENTIFIER = ADDON.getSetting('client.ID') +if not X_PLEX_IDENTIFIER: + X_PLEX_IDENTIFIER = str(uuid.uuid4()) + ADDON.setSetting('client.ID', X_PLEX_IDENTIFIER) BASE_HEADERS = resetBaseHeaders() @@ -159,21 +164,21 @@ def cleanToken(url): return re.sub(r'X-Plex-Token=[^&]+', 'X-Plex-Token=****', url) -def cleanObjTokens(dorig, flistkeys=("streamUrls",), fstrkeys=("url", "token")): +def cleanObjTokens(dorig, flistkeys=("streamUrls", "streams",), fstrkeys=("url", "token")): d = {} dcopy = copy(dorig) # filter lists for k in flistkeys: - if k not in d: + if k not in dcopy: continue - d[k] = list(map(lambda x: cleanToken(x), d[k][:])) + d[k] = list(map(lambda x: cleanObjTokens(x) if isinstance(x, dict) else cleanToken(x), dcopy[k][:])) # filter strings for k in fstrkeys: - if k not in d: + if k not in dcopy: continue - d[k] = "****" if k == "token" else cleanToken(d[k]) + d[k] = "****" if k == "token" else cleanToken(dcopy[k]) dcopy.update(d) return dcopy @@ -198,17 +203,21 @@ def joinArgs(args, includeQuestion=True): return '{0}{1}'.format(includeQuestion and '?' or '&', '&'.join(arglist)) +def getPlexHeaders(): + return {"X-Plex-Platform": INTERFACE.getGlobal("platform"), + "X-Plex-Version": INTERFACE.getGlobal("appVersionStr"), + "X-Plex-Client-Identifier": INTERFACE.getGlobal("clientIdentifier"), + "X-Plex-Platform-Version": INTERFACE.getGlobal("platformVersion", "unknown"), + "X-Plex-Product": INTERFACE.getGlobal("product"), + "X-Plex-Provides": not INTERFACE.getPreference("remotecontrol", False) and 'player' or '', + "X-Plex-Device": INTERFACE.getGlobal("device"), + "X-Plex-Model": INTERFACE.getGlobal("model"), + "X-Plex-Device-Name": INTERFACE.getGlobal("friendlyName"), + } + + def addPlexHeaders(transferObj, token=None): - headers = {"X-Plex-Platform": INTERFACE.getGlobal("platform"), - "X-Plex-Version": INTERFACE.getGlobal("appVersionStr"), - "X-Plex-Client-Identifier": INTERFACE.getGlobal("clientIdentifier"), - "X-Plex-Platform-Version": INTERFACE.getGlobal("platformVersion", "unknown"), - "X-Plex-Product": INTERFACE.getGlobal("product"), - "X-Plex-Provides": not INTERFACE.getPreference("remotecontrol", False) and 'player' or '', - "X-Plex-Device": INTERFACE.getGlobal("device"), - "X-Plex-Model": INTERFACE.getGlobal("model"), - "X-Plex-Device-Name": INTERFACE.getGlobal("friendlyName"), - } + headers = getPlexHeaders() transferObj.session.headers.update(headers) @@ -255,6 +264,12 @@ def normalizedVersion(ver): return verlib.NormalizedVersion(verlib.suggest_normalized_version('0.0.0')) +def parsePlexDirectHost(hostname): + v6 = hostname.count("-") > 3 + base = hostname.split(".", 1)[0] + return v6 and base.replace("-", ":") or base.replace("-", ".") + + class CompatEvent(Event): def wait(self, timeout): Event.wait(self, timeout) @@ -315,9 +330,9 @@ def is_alive(self): def shouldAbort(self): return False - def join(self): + def join(self, timeout=None): if self.thread.is_alive(): - self.thread.join() + self.thread.join(timeout=timeout) def isExpired(self): return self.event.isSet() diff --git a/script.plexmod/lib/_included_packages/plexnet/video.py b/script.plexmod/lib/_included_packages/plexnet/video.py index 8fe100a503..6e6995a877 100644 --- a/script.plexmod/lib/_included_packages/plexnet/video.py +++ b/script.plexmod/lib/_included_packages/plexnet/video.py @@ -12,6 +12,9 @@ from . import mediachoice from .mixins import AudioCodecMixin +from lib.data_cache import dcm +from lib.util import T + class PlexVideoItemList(plexobjects.PlexItemList): def __init__(self, data, initpath=None, server=None, container=None): @@ -45,15 +48,19 @@ def _impl(self, *method_args, **method_kwargs): class Video(media.MediaItem, AudioCodecMixin): + __slots__ = ("_settings",) + TYPE = None manually_selected_sub_stream = False current_subtitle_is_embedded = False _current_subtitle_idx = None + _noSpoilers = False def __init__(self, *args, **kwargs): self._settings = None media.MediaItem.__init__(self, *args, **kwargs) AudioCodecMixin.__init__(self) + self._noSpoilers = False def __eq__(self, other): return other and self.ratingKey == other.ratingKey @@ -331,15 +338,23 @@ def audioChannelsString(self, translate_func=util.dummyTranslate): @property def remainingTime(self): - if not self.viewOffset.asInt(): + return self._remainingTime() + + def _remainingTime(self, view_offset=None): + view_offset = view_offset if view_offset is not None else self.viewOffset.asInt() + if not view_offset: return - return (self.duration.asInt() - self.viewOffset.asInt()) // 1000 + return (self.duration.asInt() - view_offset) // 1000 @property def remainingTimeString(self): - if not self.remainingTime: + return self._remainingTimeString() + + def _remainingTimeString(self, view_offset=None): + remt = self._remainingTime(view_offset=view_offset) + if not remt: return '' - seconds = self.remainingTime + seconds = remt hours = seconds // 3600 minutes = (seconds - hours * 3600) // 60 return (hours and "{}h ".format(hours) or '') + (minutes and "{}m".format(minutes) or "0m") @@ -364,6 +379,7 @@ def sectionOnDeckCount(self): class PlayableVideo(Video, media.RelatedMixin): + __slots__ = ("extras", "guids", "chapters") TYPE = None _videoStreams = None _audioStreams = None @@ -374,6 +390,7 @@ def _setData(self, data): Video._setData(self, data) if self.isFullObject(): self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self) + self.guids = plexobjects.PlexItemList(data, media.Guid, media.Guid.TYPE, server=self.server) # the PMS Extras API can return protocol=mp4 when it doesn't make sense, mark this as an extra so the MDE # knows what to do @@ -404,6 +421,8 @@ def reload(self, *args, **kwargs): fromMediaChoice = kwargs.get("fromMediaChoice", False) + kwargs["includeMarkers"] = 1 + # capture current IDs mediaID = None partID = None @@ -461,6 +480,8 @@ def postPlay(self, **params): @plexobjects.registerLibType class Movie(PlayableVideo): + __slots__ = ("collections", "countries", "directors", "genres", "media", "producers", "roles", "reviews", + "writers", "markers", "sessionKey", "user", "player", "session", "transcodeSession") TYPE = 'movie' def _setData(self, data): @@ -534,19 +555,25 @@ def actors(self): def isWatched(self): return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + @property + def isFullyWatched(self): + return self.get('viewCount').asInt() > 0 and not self.get('viewOffset').asInt() + def getStreamURL(self, **params): return self._getStreamURL(**params) @plexobjects.registerLibType class Show(Video, media.RelatedMixin, SectionOnDeckMixin): + __slots__ = ("_genres", "guids", "onDeck") TYPE = 'show' def _setData(self, data): Video._setData(self, data) if self.isFullObject(): - self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) + self._genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container) + self.guids = plexobjects.PlexItemList(data, media.Guid, media.Guid.TYPE, server=self.server) #self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self) self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self) self.onDeck = PlexVideoItemList(data.find('OnDeck'), initpath=self.initpath, server=self.server, @@ -560,6 +587,10 @@ def unViewedLeafCount(self): def isWatched(self): return self.viewedLeafCount == self.leafCount + @property + def isFullyWatched(self): + return self.isWatched + @property def playbackSettings(self): return util.INTERFACE.playbackManager(self) @@ -580,7 +611,7 @@ def episode(self, title): path = '/library/metadata/%s/allLeaves' % self.ratingKey return plexobjects.findItem(self.server, path, title) - def all(self): + def all(self, *args, **kwargs): return self.episodes() def watched(self): @@ -592,6 +623,17 @@ def unwatched(self): def refresh(self): self.server.query('/library/metadata/%s/refresh' % self.ratingKey) + def genres(self): + genres = dcm.getCacheData("show_genres", self.ratingKey) + if genres: + return [media.Genre(util.AttributeDict(tag="genre", attrib={"tag": g}, virtual=True)) for g in genres] + + if not self.isFullObject(): + self.reload(soft=True) + + dcm.setCacheData("show_genres", self.ratingKey, [g.tag for g in self._genres]) + return self._genres + @plexobjects.registerLibType class Season(Video): @@ -604,7 +646,7 @@ def _setData(self, data): @property def defaultTitle(self): - return self.parentTitle or self.title + return T(32303, "Season {}").format(self.index) @property def unViewedLeafCount(self): @@ -614,6 +656,10 @@ def unViewedLeafCount(self): def isWatched(self): return self.viewedLeafCount == self.leafCount + @property + def isFullyWatched(self): + return self.isWatched + def episodes(self, watched=None, offset=None, limit=None): path = self.key return plexobjects.listItems(self.server, path, watched=watched, offset=offset, limit=limit) @@ -622,7 +668,7 @@ def episode(self, title): path = self.key return plexobjects.findItem(self.server, path, title) - def all(self): + def all(self, *args, **kwargs): return self.episodes() def show(self): @@ -637,6 +683,7 @@ def unwatched(self): @plexobjects.registerLibType class Episode(PlayableVideo, SectionOnDeckMixin): + __slots__ = ("_show", "_season") TYPE = 'episode' def init(self, data): @@ -692,6 +739,14 @@ def subtitleStreams(self): def isWatched(self): return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + @property + def isFullyWatched(self): + return self.get('viewCount').asInt() > 0 and not self.get('viewOffset').asInt() + + @property + def inProgress(self): + return bool(self.get('viewOffset').asInt()) + @property def playbackSettings(self): return self.show().playbackSettings @@ -742,6 +797,10 @@ def _setData(self, data): def isWatched(self): return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + @property + def isFullyWatched(self): + return self.get('viewCount').asInt() > 0 and not self.get('viewOffset').asInt() + def getStreamURL(self, **params): return self._getStreamURL(**params) diff --git a/script.plexmod/lib/_included_packages/plexnet/videosession.py b/script.plexmod/lib/_included_packages/plexnet/videosession.py index 933dac0edc..e77f993333 100644 --- a/script.plexmod/lib/_included_packages/plexnet/videosession.py +++ b/script.plexmod/lib/_included_packages/plexnet/videosession.py @@ -2,6 +2,7 @@ import six from collections import OrderedDict from plexnet import plexapp +from kodi_six import xbmc class MediaDetails: @@ -289,6 +290,24 @@ def value(self, obj): return self.resolve(self.retVal, obj) +class DPAttributeMapped(DPAttribute): + def __init__(self): + pass + + def value(self, obj): + p = xbmc.Player() + if p.isPlaying(): + f = p.getPlayingFile() + prot = f.split("://")[0] + if prot == f: + ret = "path mapped" + elif prot.startswith("http"): + ret = prot + else: + ret = "mapped ({})".format(prot) + return ret + + class ComputedPPIValue: """ Holds the final computed attribute data for display @@ -318,7 +337,8 @@ class ModePPI(ComputedPPIValue): name = "Mode" dataPoints = [ DPAttributeSession("partDecision"), - DPAttributeExists("local", source="session.player", returnValue="local") + DPAttributeExists("local", source="session.player", returnValue="local"), + DPAttributeMapped() ] diff --git a/script.plexmod/lib/advancedsettings.py b/script.plexmod/lib/advancedsettings.py new file mode 100644 index 0000000000..b4fdbd725c --- /dev/null +++ b/script.plexmod/lib/advancedsettings.py @@ -0,0 +1,42 @@ +# coding=utf-8 + +from kodi_six import xbmcvfs + +from lib.util import LOG, ERROR + + +class AdvancedSettings(object): + _data = None + + def __init__(self): + self.load() + + def __bool__(self): + return bool(self._data) + + def getData(self): + return self._data + + def load(self): + if xbmcvfs.exists("special://profile/advancedsettings.xml"): + try: + f = xbmcvfs.File("special://profile/advancedsettings.xml") + self._data = f.read() + f.close() + except: + LOG('script.plex: No advancedsettings.xml found') + + def write(self, data=None): + self._data = data = data or self._data + if not data: + return + + try: + f = xbmcvfs.File("special://profile/advancedsettings.xml", "w") + f.write(data) + f.close() + except: + ERROR("Couldn't write advancedsettings.xml") + + +adv = AdvancedSettings() diff --git a/script.plexmod/lib/backgroundthread.py b/script.plexmod/lib/backgroundthread.py index 7b69cd09d8..66837cbf49 100644 --- a/script.plexmod/lib/backgroundthread.py +++ b/script.plexmod/lib/backgroundthread.py @@ -42,6 +42,9 @@ def __le__(self, other): def __gt__(self, other): return self._priority > other._priority + def __bool__(self): + return self.isValid() + def start(self): BGThreader.addTask(self) @@ -146,7 +149,7 @@ def kill(self): class BackgroundThreader: - def __init__(self, name=None, worker_count=3): + def __init__(self, name=None, worker_count=5): self.name = name self._queue = MutablePriorityQueue() self._abort = False @@ -227,10 +230,10 @@ def kill(self): class ThreaderManager: - def __init__(self): + def __init__(self, worker_count=5): self.index = 0 self.abandoned = [] - self.threader = BackgroundThreader(str(self.index)) + self.threader = BackgroundThreader(str(self.index), worker_count=worker_count) def __getattr__(self, name): return getattr(self.threader, name) @@ -252,4 +255,4 @@ def kill(self): self.threader.kill() -BGThreader = ThreaderManager() +BGThreader = ThreaderManager(worker_count=util.getSetting('worker_count', 5)) diff --git a/script.plexmod/lib/cache.py b/script.plexmod/lib/cache.py new file mode 100644 index 0000000000..aa51cf1142 --- /dev/null +++ b/script.plexmod/lib/cache.py @@ -0,0 +1,172 @@ +# coding=utf-8 +import os +import re + +from kodi_six import xbmc +from kodi_six import xbmcvfs + +from plexnet import plexapp + +from lib.kodijsonrpc import rpc +from lib.util import ADDON, translatePath, KODI_BUILD_NUMBER, DEBUG_LOG, LOG, ERROR +from lib.advancedsettings import adv + + +ADV_MSIZE_RE = re.compile(r'(\d+)') +ADV_RFACT_RE = re.compile(r'(\d+)') +ADV_CACHE_RE = re.compile(r'\s*.*', re.S | re.I) + + +class KodiCacheManager(object): + """ + A pretty cheap approach at managing the section of advancedsettings.xml + + Starting with build 20.90.821 (Kodi 21.0-BETA2) a lot of caching issues have been fixed and + readfactor behaves better. We need to adjust for that. + """ + useModernAPI = False + memorySize = 20 # in MB + readFactor = 4 + defRF = 4 + defRFSM = 20 + recRFRange = "4-10" + template = None + orig_tpl_path = os.path.join(ADDON.getAddonInfo('path'), "pm4k_cache_template.xml") + custom_tpl_path = "special://profile/pm4k_cache_template.xml" + translated_ctpl_path = translatePath(custom_tpl_path) + + # give Android a little more leeway with its sometimes weird memory management; otherwise stick with 23% of free mem + safeFactor = .20 if xbmc.getCondVisibility('System.Platform.Android') else .23 + + def __init__(self): + if KODI_BUILD_NUMBER >= 2090821: + self.memorySize = rpc.Settings.GetSettingValue(setting='filecache.memorysize')['value'] + self.readFactor = rpc.Settings.GetSettingValue(setting='filecache.readfactor')['value'] / 100.0 + if self.readFactor % 1 == 0: + self.readFactor = int(self.readFactor) + DEBUG_LOG("Not using advancedsettings.xml for cache/buffer management, we're at least Kodi 21 non-alpha") + self.useModernAPI = True + self.defRFSM = 7 + self.recRFRange = "1.5-4" + + if KODI_BUILD_NUMBER >= 2090830: + self.recRFRange = ADDON.getLocalizedString(32976) + + else: + self.load() + self.template = self.getTemplate() + + plexapp.util.APP.on('change:slow_connection', + lambda value=None, **kwargs: self.write(readFactor=value and self.defRFSM or self.defRF)) + + def getTemplate(self): + if xbmcvfs.exists(self.custom_tpl_path): + try: + f = xbmcvfs.File(self.custom_tpl_path) + data = f.read() + f.close() + if data: + return data + except: + pass + + DEBUG_LOG("Custom pm4k_cache_template.xml not found, using default") + f = xbmcvfs.File(self.orig_tpl_path) + data = f.read() + f.close() + return data + + def load(self): + data = adv.getData() + if not data: + return + + cachexml_match = ADV_CACHE_RE.search(data) + if cachexml_match: + cachexml = cachexml_match.group(0) + + try: + self.memorySize = int(ADV_MSIZE_RE.search(cachexml).group(1)) // 1024 // 1024 + except: + DEBUG_LOG("script.plex: invalid or not found memorysize in advancedsettings.xml") + + try: + self.readFactor = int(ADV_RFACT_RE.search(cachexml).group(1)) + except: + DEBUG_LOG("script.plex: invalid or not found readfactor in advancedsettings.xml") + + # self._cleanData = data.replace(cachexml, "") + #else: + # self._cleanData = data + + def write(self, memorySize=None, readFactor=None): + memorySize = self.memorySize = memorySize if memorySize is not None else self.memorySize + readFactor = self.readFactor = readFactor if readFactor is not None else self.readFactor + + if self.useModernAPI: + # kodi cache settings have moved to Services>Caching + try: + rpc.Settings.SetSettingValue(setting='filecache.memorysize', value=self.memorySize) + rpc.Settings.SetSettingValue(setting='filecache.readfactor', value=int(self.readFactor * 100)) + except: + pass + return + + data = adv.getData() + cd = "\n" + if data: + cachexml_match = ADV_CACHE_RE.search(data) + if cachexml_match: + cachexml = cachexml_match.group(0) + cd = data.replace(cachexml, "") + else: + cd = data + + finalxml = "{}\n".format( + cd.replace("", self.template.format(memorysize=memorySize * 1024 * 1024, + readfactor=readFactor)) + ) + + adv.write(finalxml) + + def clamp16(self, x): + return x - x % 16 + + @property + def viableOptions(self): + default = list(filter(lambda x: x < self.recMax, + [16, 20, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024])) + + # add option to overcommit slightly + overcommit = [] + if xbmc.getCondVisibility('System.Platform.Android'): + overcommit.append(min(self.clamp16(int(self.free * 0.23)), 2048)) + + overcommit.append(min(self.clamp16(int(self.free * 0.26)), 2048)) + overcommit.append(min(self.clamp16(int(self.free * 0.3)), 2048)) + + # re-append current memorySize here, as recommended max might have changed + return list(sorted(list(set(default + [self.memorySize, self.recMax] + overcommit)))) + + @property + def readFactorOpts(self): + ret = list(sorted(list(set([1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 7, 10, 15, 20, 30, 50] + [self.readFactor])))) + if KODI_BUILD_NUMBER >= 2090830 and self.readFactor > 0: + # support for adaptive read factor from build 2090822 onwards + ret.insert(0, 0) + return ret + + @property + def free(self): + return float(xbmc.getInfoLabel('System.Memory(free)')[:-2]) + + @property + def recMax(self): + freeMem = self.free + recMem = min(int(freeMem * self.safeFactor), 2048) + LOG("Free memory: {} MB, recommended max: {} MB".format(freeMem, recMem)) + return recMem + + +kcm = KodiCacheManager() +CACHE_SIZE = kcm.memorySize diff --git a/script.plexmod/lib/data_cache.py b/script.plexmod/lib/data_cache.py new file mode 100644 index 0000000000..9766cbdea6 --- /dev/null +++ b/script.plexmod/lib/data_cache.py @@ -0,0 +1,121 @@ +# coding=utf-8 + +import os +import json +import time +import copy +import zlib + +from kodi_six import xbmcvfs + +from plexnet import plexapp + +from . util import translatePath, ADDON, ERROR, DEBUG_LOG, LOG + + +class DataCacheManager(object): + # store arbitrary data in JSON on disk + DATA_CACHES_VERSION = 2 + DATA_CACHES = { + "general": {}, + "cache": {} + } + DC_LAST_UPDATE = None + DC_PATH = os.path.join(translatePath(ADDON.getAddonInfo("profile")), "data_cache.json") + DC_LRU_TIMEOUT = 30 + DC_LRUP_TIMEOUT = 90 + USE_GZ = False + + def __init__(self): + self._currentServerUUID = None + plexapp.util.APP.on('change:selectedServer', self.setServerUUID) + if self.USE_GZ: + self.DC_PATH += "z" + if xbmcvfs.exists(self.DC_PATH): + try: + f = xbmcvfs.File(self.DC_PATH) + d = f.readBytes() if self.USE_GZ else f.read() + f.close() + + tdc = json.loads(zlib.decompress(d).decode("utf-8") if self.USE_GZ else d) + old_ver = tdc["general"].get("version", 0) + if old_ver < self.DATA_CACHES_VERSION: + # this is where we migrate + if old_ver == 1: + tdc = self.DATA_CACHES.copy() + tdc["general"]["version"] = self.DATA_CACHES_VERSION + tdc["general"]["updated"] = time.time() + self.DATA_CACHES = tdc + self.storeDataCache() + else: + tdc["general"]["version"] = self.DATA_CACHES_VERSION + self.DATA_CACHES.update(tdc) + self.dataCacheCleanup() + self.DC_LAST_UPDATE = self.DATA_CACHES["general"]["updated"] + except: + ERROR("Couldn't read data_cache.json") + self.DATA_CACHES["general"]["updated"] = time.time() + self.storeDataCache() + + def deinit(self): + plexapp.util.APP.off('change:selectedServer', self.setServerUUID) + + def getCacheData(self, context, identifier): + ret = self.DATA_CACHES["cache"].get(self._currentServerUUID, {}).get(context, {}).get(identifier, {}) + if "data" in ret and ret["data"]: + # purge old data (> X days last updated) + if ret["updated"] < time.time() - self.DC_LRUP_TIMEOUT * 3600 * 24: + del self.DATA_CACHES["cache"][self._currentServerUUID][context][identifier] + return None + + self.DATA_CACHES["cache"][self._currentServerUUID][context][identifier]["last_access"] = time.time() + return ret["data"] + + def setCacheData(self, context, identifier, value): + if self._currentServerUUID not in self.DATA_CACHES["cache"]: + self.DATA_CACHES["cache"][self._currentServerUUID] = {} + if context not in self.DATA_CACHES["cache"][self._currentServerUUID]: + self.DATA_CACHES["cache"][self._currentServerUUID][context] = {} + if identifier not in self.DATA_CACHES["cache"][self._currentServerUUID][context]: + self.DATA_CACHES["cache"][self._currentServerUUID][context][identifier] = {} + t = time.time() + self.DATA_CACHES["general"]["updated"] = t + self.DATA_CACHES["cache"][self._currentServerUUID][context][identifier] = { + "updated": t, + "last_access": t, + "data": value + } + + def setServerUUID(self, server=None, **kwargs): + if not server and not plexapp.SERVERMANAGER.selectedServer: + return + self._currentServerUUID = (server if server is not None else plexapp.SERVERMANAGER.selectedServer).uuid[-8:] + + def dataCacheCleanup(self): + d = copy.deepcopy(self.DATA_CACHES) + t = time.time() + for k, contexts in d["cache"].items(): + for context, identifiers in contexts.items(): + for identifier, iddata in identifiers.items(): + # clean up anything not accessed during the last X days + if iddata["last_access"] < t - self.DC_LRU_TIMEOUT * 3600 * 24: + DEBUG_LOG("Clearing cached data for: {}: {}".format(context, identifier)) + del self.DATA_CACHES["cache"][k][context][identifier] + + def storeDataCache(self): + lu = self.DATA_CACHES["general"].get("updated") + if self.DATA_CACHES and lu and self.DC_LAST_UPDATE != lu: + try: + dcf = xbmcvfs.File(self.DC_PATH, "w") + self.dataCacheCleanup() + d = json.dumps(self.DATA_CACHES) + if self.USE_GZ: + d = zlib.compress(d.encode("utf-8")) + dcf.write(d) + dcf.close() + LOG("Data cache written to: addon_data/script.plexmod/data_cache.json") + except: + ERROR("Couldn't write data_cache.json") + + +dcm = DataCacheManager() diff --git a/script.plexmod/lib/main.py b/script.plexmod/lib/main.py index 88c347111f..3a79cf751e 100644 --- a/script.plexmod/lib/main.py +++ b/script.plexmod/lib/main.py @@ -19,6 +19,7 @@ from . import player from . import backgroundthread from . import util +from .data_cache import dcm BACKGROUND = None quitKodi = False @@ -77,6 +78,7 @@ def main(): try: util.setGlobalProperty('running', '') util.setGlobalProperty('stop_running', '') + util.setGlobalProperty('ignore_spinner', '') except: pass @@ -99,7 +101,7 @@ def _main(): (len(plexapp.ACCOUNT.homeUsers) > 1 or plexapp.ACCOUNT.isProtected) ): - result = userselect.start() + result = userselect.start(BACKGROUND._winID) if not result: return elif result == 'signout': @@ -110,7 +112,8 @@ def _main(): elif result == 'cancel' and fromSwitch: util.DEBUG_LOG('Main: User selection canceled, reusing previous user') plexapp.ACCOUNT.isAuthenticated = True - + elif result == 'cancel': + return if not fromSwitch: util.DEBUG_LOG('Main: User selected') @@ -135,7 +138,7 @@ def _main(): util.DEBUG_LOG('Main: STARTING WITH SERVER: {0}'.format(selectedServer)) windowutils.HOME = home.HomeWindow.create() - if windowutils.HOME.waitForOpen(): + if windowutils.HOME.waitForOpen(base_win_id=BACKGROUND._winID): windowutils.HOME.modal() else: util.LOG("Couldn't open home window, exiting") @@ -168,6 +171,9 @@ def _main(): util.ERROR() finally: util.DEBUG_LOG('Main: SHUTTING DOWN...') + dcm.storeDataCache() + dcm.deinit() + plexapp.util.INTERFACE.playbackManager.deinit() background.setShutdown() player.shutdown() plexapp.util.APP.preShutdown() diff --git a/script.plexmod/lib/path_mapping.py b/script.plexmod/lib/path_mapping.py new file mode 100644 index 0000000000..e6fe30919a --- /dev/null +++ b/script.plexmod/lib/path_mapping.py @@ -0,0 +1,123 @@ +# coding=utf-8 + +import os +import copy +import re +import json + +import plexnet.util + +from kodi_six import xbmcvfs + +from .util import translatePath, ADDON, ERROR, LOG, getSetting + + +PM_MCMT_RE = re.compile(r'/\*.+\*/\s?', re.IGNORECASE | re.MULTILINE | re.DOTALL) +PM_CMT_RE = re.compile(r'[\t ]+//.+\n?') +PM_COMMA_RE = re.compile(r',\s*}\s*}') + + +def norm_sep(s): + return "\\" in s and "\\" or "/" + + +class PathMappingManager(object): + mapfile = os.path.join(translatePath(ADDON.getAddonInfo("profile")), "path_mapping.json") + PATH_MAP = {} + + def __init__(self): + self.load() + + def load(self): + if xbmcvfs.exists(self.mapfile): + try: + f = xbmcvfs.File(self.mapfile) + # sanitize json + + # remove multiline comments + data = PM_MCMT_RE.sub("", f.read()) + # remove comments + data = PM_CMT_RE.sub("", data) + # remove invalid trailing comma + + data = PM_COMMA_RE.sub("}}", data) + self.PATH_MAP = json.loads(data) + f.close() + except: + ERROR("Couldn't read path_mapping.json") + else: + LOG("Path mapping: {}".format(repr(self.PATH_MAP))) + + @property + def mapping(self): + return self.PATH_MAP and getSetting("path_mapping", True) + + def getMappedPathFor(self, path, server): + if self.mapping: + match = ("", "") + + for map_path, pms_path in self.PATH_MAP.get(server.name, {}).items(): + # the longest matching path wins + if path.startswith(pms_path) and len(pms_path) > len(match[1]): + match = (map_path, pms_path) + + if all(match): + map_path, pms_path = match + return map_path, pms_path + return None, None + + def deletePathMapping(self, target, server=None, save=True): + server = server or plexnet.util.SERVERMANAGER.selectedServer + if not server: + ERROR("Delete path mapping: Something went wrong") + return + + if server.name not in self.PATH_MAP: + return + + pm = copy.deepcopy(self.PATH_MAP) + + deleted = None + for s, t in pm[server.name].items(): + if target == t: + deleted = s + del self.PATH_MAP[server.name][s] + break + if save and deleted and self.save(): + LOG("Path mapping stored after deletion of {}:{}".format(deleted, target)) + + def addPathMapping(self, source, target, server=None, save=True): + server = server or plexnet.util.SERVERMANAGER.selectedServer + if not server: + ERROR("Add path mapping: Something went wrong") + return + + if server.name not in self.PATH_MAP: + self.PATH_MAP[server.name] = {} + + sep = norm_sep(source) + + if not source.endswith(sep): + source += sep + + sep = norm_sep(target) + + if not target.endswith(sep): + target += sep + + self.PATH_MAP[server.name][source] = target + if save and self.save(): + LOG("Path mapping stored for {}:{}".format(source, target)) + + def save(self): + try: + f = xbmcvfs.File(self.mapfile, "w") + f.write(json.dumps(self.PATH_MAP)) + f.close() + except: + ERROR("Couldn't write path_mapping.json") + else: + return True + + +pmm = PathMappingManager() diff --git a/script.plexmod/lib/playback_utils.py b/script.plexmod/lib/playback_utils.py index 86b20a4b33..9cbf195f53 100644 --- a/script.plexmod/lib/playback_utils.py +++ b/script.plexmod/lib/playback_utils.py @@ -62,6 +62,11 @@ def __init__(self): plexapp.util.APP.on("change:user", lambda **kwargs: self.setUserID(**kwargs)) plexapp.util.APP.on('init', lambda **kwargs: self.setUserID(**kwargs)) + def deinit(self): + plexapp.util.APP.off('change:selectedServer', lambda **kwargs: self.setServerUUID(**kwargs)) + plexapp.util.APP.off("change:user", lambda **kwargs: self.setUserID(**kwargs)) + plexapp.util.APP.off('init', lambda **kwargs: self.setUserID(**kwargs)) + def __call__(self, obj, key=None, value=None, kv_dict=None): # shouldn't happen if not self._currentServerUUID or not self._currentUserID: diff --git a/script.plexmod/lib/player.py b/script.plexmod/lib/player.py index b2dd54bb56..c7fb8e5c81 100644 --- a/script.plexmod/lib/player.py +++ b/script.plexmod/lib/player.py @@ -1,5 +1,6 @@ from __future__ import absolute_import import base64 +import json import threading import six import re @@ -28,6 +29,7 @@ def __init__(self, player, session_id=None): self.media = None self.baseOffset = 0 self._lastDuration = 0 + self._progressHld = {} self.timelineType = None self.lastTimelineState = None self.ignoreTimelines = False @@ -118,9 +120,6 @@ def currentDuration(self): return self._lastDuration def updateNowPlaying(self, force=False, refreshQueue=False, t=None, state=None, overrideChecks=False): - util.DEBUG_LOG("UpdateNowPlaying: force: {0} refreshQueue: " - "{1} state: {2} overrideChecks: {3} time: {4}".format(force, refreshQueue, state, overrideChecks, - t)) if self.ignoreTimelines: util.DEBUG_LOG("UpdateNowPlaying: ignoring timeline as requested") return @@ -132,23 +131,45 @@ def updateNowPlaying(self, force=False, refreshQueue=False, t=None, state=None, if not self.shouldSendTimeline(item): return + util.DEBUG_LOG("UpdateNowPlaying: {0}, force: {1} refreshQueue: " + "{2} state: {3} overrideChecks: {4} time: {5}".format(item.ratingKey, + force, refreshQueue, state, overrideChecks, + t)) + state = state or self.player.playState # Avoid duplicates if state == self.lastTimelineState and not force: return + obj = item.choice + + # Ignore sending timelines for multi part media with no duration + if obj and obj.part and obj.part.duration.asInt() == 0 and obj.media.parts and len(obj.media.parts) > 1: + util.LOG("Timeline not supported: the current part doesn't have a valid duration") + return + self.lastTimelineState = state # self.timelineTimer.reset() _time = t or int(self.trueTime * 1000) + self._progressHld[str(item.ratingKey)] = _time # self.trigger("progress", [m, item, time]) if refreshQueue and self.playQueue: self.playQueue.refreshOnTimeline = True + data = plexnetUtil.AttributeDict({ + "key": str(item.key), + "ratingKey": str(item.ratingKey), + "guid": str(item.guid), + "url": str(item.url), + "duration": item.duration.asInt(), + "containerKey": str(item.container.address) + }) + plexapp.util.APP.nowplayingmanager.updatePlaybackState( - self.timelineType, self.player.playerObject, state, _time, self.playQueue, duration=self.currentDuration(), + self.timelineType, data, state, _time, self.playQueue, duration=self.currentDuration(), force=overrideChecks ) @@ -195,6 +216,7 @@ def reset(self): self.seeking = self.NO_SEEK self.seekOnStart = 0 self._lastDuration = 0 + self._progressHld = {} self.mode = self.MODE_RELATIVE self.ended = False self.stoppedManually = False @@ -207,6 +229,7 @@ def setup(self, duration, meta, offset, bif_url, title='', title2='', seeking=NO self.seeking = seeking self.duration = duration self._lastDuration = duration + self._progressHld = {} self.bifURL = bif_url self.title = title self.title2 = title2 @@ -255,8 +278,8 @@ def shouldShowPostPlay(self): if not self.stoppedManually and self.skipPostPlay: return False - if (not util.advancedSettings.postplayAlways and self._lastDuration <= FIVE_MINUTES_MILLIS)\ - or util.advancedSettings.postplayTimeout <= 0: + if (not util.addonSettings.postplayAlways and self._lastDuration <= FIVE_MINUTES_MILLIS)\ + or util.addonSettings.postplayTimeout <= 0: return False return True @@ -281,16 +304,21 @@ def getIntroOffset(self, offset=None, setSkipped=False): return self.getDialog().displayMarkers(onlyReturnIntroMD=True, offset=offset, setSkipped=setSkipped) def next(self, on_end=False): - if self.playlist and next(self.playlist): - self.seeking = self.SEEK_PLAYLIST + hasNext = False + if self.playlist: + hasNext = bool(next(self.playlist)) + if hasNext: + self.seeking = self.SEEK_PLAYLIST if on_end: if self.showPostPlay(): return True - if not self.playlist or self.stoppedManually: + if not self.playlist or self.stoppedManually or (self.playlist and not hasNext): return False + self.triggerProgressEvent() + self.player.playVideoPlaylist(self.playlist, handler=self, resume=False) return True @@ -299,6 +327,7 @@ def prev(self): if not self.playlist or not self.playlist.prev(): return False + self.triggerProgressEvent() self.seeking = self.SEEK_PLAYLIST self.player.playVideoPlaylist(self.playlist, handler=self, resume=False) @@ -391,11 +420,13 @@ def seekAbsolute(self, seek=None): def onAVChange(self): util.DEBUG_LOG('SeekHandler: onAVChange') + self.player.trigger('changed.video') if self.dialog: self.dialog.onAVChange() def onAVStarted(self): util.DEBUG_LOG('SeekHandler: onAVStarted') + self.player.trigger('started.video') if self.isDirectPlay: self.seekAbsolute() @@ -443,6 +474,26 @@ def onPlayBackResumed(self): util.CRON.forceTick() # self.hideOSD() + @property + def videoPlayedFac(self): + return self.trueTime * 1000 / float(self.duration) + + @property + def videoWatched(self): + return self.videoPlayedFac >= self.playedThreshold + + def triggerProgressEvent(self): + if not self.player.video: + return + + rk = str(self.player.video.ratingKey) + if rk not in self._progressHld: + # progress already consumed + return + + self.player.trigger('video.progress', data=(rk, self._progressHld[rk] if not self.videoWatched else True)) + self._progressHld = {} + def onPlayBackStopped(self): util.DEBUG_LOG('SeekHandler: onPlayBackStopped - ' 'Seeking={0}, QueueingNext={1}, BingeMode={2}, StoppedManually={3}, SkipPostPlay={4}' @@ -461,10 +512,11 @@ def onPlayBackStopped(self): if self.seeking not in (self.SEEK_IN_PROGRESS, self.SEEK_REWIND): self.updateNowPlaying() + self.triggerProgressEvent() # show post play if possible, if an item has been watched (90% by Plex standards) if self.seeking != self.SEEK_PLAYLIST and self.duration: - playedFac = self.trueTime * 1000 / float(self.duration) + playedFac = self.videoPlayedFac util.DEBUG_LOG("Player - played-threshold: {}/{}".format(playedFac, self.playedThreshold)) if playedFac >= self.playedThreshold and self.next(on_end=True): return @@ -486,6 +538,7 @@ def onPlayBackEnded(self): return self.updateNowPlaying() + self.triggerProgressEvent() if self.queuingNext: util.DEBUG_LOG('SeekHandler: onPlayBackEnded - event ignored') @@ -578,7 +631,7 @@ def setAudioTrack(self): except: util.ERROR() - xbmc.sleep(100) + util.MONITOR.waitForAbort(0.1) util.DEBUG_LOG('Switching audio track - index: {0}'.format(track.typeIndex)) self.player.setAudioStream(track.typeIndex) @@ -628,8 +681,13 @@ def onVideoWindowClosed(self): self.hideOSD() util.DEBUG_LOG('SeekHandler: onVideoWindowClosed - Seeking={0}'.format(self.seeking)) if not self.seeking: + # send events as we might not have seen onPlayBackEnded and/or onPlayBackStopped in certain cases, + # especially when postplay isn't wanted and we're at the end of a show + #self.updateNowPlaying() + #if self._progressHld: + # self.triggerProgressEvent() if self.player.isPlaying(): - self.player.stop() + self.player.stopAndWait() if not self.playlist or not self.playlist.hasNext(): if not self.shouldShowPostPlay(): self.sessionEnded() @@ -681,7 +739,7 @@ def extractTrackInfo(self): if plexID: break - xbmc.sleep(100) + util.MONITOR.waitForAbort(0.1) if not plexID: return @@ -775,14 +833,22 @@ def stampCurrentTime(self): def onMonitorInit(self): self.extractTrackInfo() + self.ignoreTimelines = False self.updateNowPlaying(state='playing') def onPlayBackStarted(self): self.player.lastPlayWasBGM = False self.updatePlayQueue(delay=True) self.extractTrackInfo() + self.ignoreTimelines = False self.updateNowPlaying(state='playing') + def onAVStarted(self): + self.player.trigger('started.audio') + + def onAVChange(self): + self.player.trigger('changed.audio') + def onPlayBackResumed(self): self.updateNowPlaying(state='playing') @@ -792,11 +858,13 @@ def onPlayBackPaused(self): def onPlayBackStopped(self): self.updatePlayQueue() self.updateNowPlaying(state='stopped') + self.ignoreTimelines = True self.finish() def onPlayBackEnded(self): self.updatePlayQueue() self.updateNowPlaying(state='stopped') + self.ignoreTimelines = True self.finish() def onPlayBackFailed(self): @@ -1086,25 +1154,23 @@ def _playVideo(self, offset=0, seeking=0, force_update=False, playerObject=None, return meta = self.playerObject.metadata - - # Kodi 19 will try to look for subtitles in the directory containing the file. '/' and `/file.mkv` both point - # to the file, and Kodi will happily try to read the whole file without recognizing it isn't a directory. - # To get around that, we omit the filename here since it is unnecessary. - url = meta.streamUrls[0].replace("file.mkv", "").replace("file.mp4", "") + url = meta.streamUrls[0] bifURL = self.playerObject.getBifUrl() util.DEBUG_LOG('Playing URL(+{1}ms): {0}{2}'.format(plexnetUtil.cleanToken(url), offset, bifURL and ' - indexed' or '')) self.ignoreStopEvents = True self.stopAndWait() # Stop before setting up the handler to prevent player events from causing havoc - if self.handler and self.handler.queuingNext and util.advancedSettings.consecutiveVideoPbWait: + if self.handler and self.handler.queuingNext and util.addonSettings.consecutiveVideoPbWait: util.DEBUG_LOG( - "Waiting for {}s until playing back next item".format(util.advancedSettings.consecutiveVideoPbWait)) - util.MONITOR.waitForAbort(util.advancedSettings.consecutiveVideoPbWait) + "Waiting for {}s until playing back next item".format(util.addonSettings.consecutiveVideoPbWait)) + util.MONITOR.waitForAbort(util.addonSettings.consecutiveVideoPbWait) self.ignoreStopEvents = False self.sessionID = session_id or self.sessionID + # fixme: this handler might be accessing a new playerObject, not the one it's expecting to access, + # especially when .next() is used self.handler.setup(self.video.duration.asInt(), meta, offset, bifURL, title=self.video.grandparentTitle, title2=self.video.title, seeking=seeking, chapters=self.video.chapters) @@ -1138,10 +1204,18 @@ def _playVideo(self, offset=0, seeking=0, force_update=False, playerObject=None, self.handler.mode = self.handler.MODE_ABSOLUTE - url = util.addURLParams(url, { - 'X-Plex-Client-Profile-Name': 'Generic', - 'X-Plex-Client-Identifier': plexapp.util.INTERFACE.getGlobal('clientIdentifier') - }) + if not meta.isMapped: + # Kodi 19 will try to look for subtitles in the directory containing the file. '/' and `/file.mkv` both + # point to the file, and Kodi will happily try to read the whole file without recognizing it isn't a + # directory. To get around that, we omit the filename here since it is unnecessary. + omit, fname = url.rsplit("/", 1) + if fname.startswith("file."): + url = "{}/{}".format(omit, "?" + fname.split("?")[1] if "?" in fname else "") + + url = util.addURLParams(url, { + 'X-Plex-Client-Profile-Name': 'Generic', + 'X-Plex-Client-Identifier': plexapp.util.INTERFACE.getGlobal('clientIdentifier') + }) li = xbmcgui.ListItem(self.video.title, path=url) vtype = self.video.type if self.video.type in ('movie', 'episode', 'musicvideo') else 'video' @@ -1149,28 +1223,86 @@ def _playVideo(self, offset=0, seeking=0, force_update=False, playerObject=None, util.setGlobalProperty("current_size", str(meta.size), base='videoinfo.{0}') imdbNum = None - if "com.plexapp.agents.imdb" in self.video.guid: - a = self.video.guid + + fill_trakt_ids = False + trakt_ids = {} + + # generate guids when script.trakt is installed + if "script.trakt" in util.USER_ADDONS: + fill_trakt_ids = True + + a = self.video.guid + if "com.plexapp.agents.imdb" in a: imdbNum = a.split("?lang=")[0][a.index("com.plexapp.agents.imdb://")+len("com.plexapp.agents.imdb://"):] - li.setInfo('video', { + if fill_trakt_ids: + if imdbNum: + trakt_ids["imdb"] = imdbNum + + elif fill_trakt_ids and "com.plexapp.agents.themoviedb" in a: + trakt_ids["tmdb"] = a.split("?lang=")[0][ + a.index("com.plexapp.agents.themoviedb://") + len("com.plexapp.agents.themoviedb://"):] + + elif fill_trakt_ids and "com.plexapp.agents.thetvdb" in a: + trakt_ids["tvdb"] = a.split("?lang=")[0][ + a.index("com.plexapp.agents.thetvdb://") + + len("com.plexapp.agents.thetvdb://"):].split("/", 1)[0] + + elif "plex://movie" in a or "plex://episode" in a: + ref = self.video + if fill_trakt_ids and "plex://episode" in a: + ref = self.video.show() + if not ref.isFullObject(): + ref.reload() + + for guid in ref.guids: + if not imdbNum and guid.id.startswith('imdb://'): + imdbNum = guid.id.split('imdb://')[1] + + if fill_trakt_ids: + sabbr, gid = guid.id.split("://") + try: + gid = int(gid) + except: + pass + + trakt_ids[sabbr] = gid + if fill_trakt_ids: + # generate trakt slug + if vtype == "movie": + year = self.video.year.asInt() + trakt_ids['slug'] = util.slugify("{}{}".format(self.video.title, year and " {}".format(year) or "")) + + util.DEBUG_LOG("Setting Trakt IDs: {}".format(trakt_ids)) + # report IDs to trakt + xbmcgui.Window(10000).setProperty('script.trakt.ids', json.dumps(trakt_ids)) + + info = { 'mediatype': vtype, 'title': self.video.title, 'originaltitle': self.video.title, 'tvshowtitle': self.video.grandparentTitle, - 'episode': vtype == "episode" and self.video.index.asInt() or '', - 'season': vtype == "episode" and self.video.parentIndex.asInt() or '', - #'year': self.video.year.asInt(), + 'year': self.video.year.asInt(), 'plot': self.video.summary, 'path': meta.path, 'size': meta.size, 'imdbnumber': imdbNum - }) + } + if vtype == "episode": + info.update({ + 'episode': self.video.index.asInt(), + 'season': self.video.parentIndex.asInt(), + }) + util.DEBUG_LOG("Setting VideoInfo: {}".format( + plexnetUtil.cleanObjTokens(info, flistkeys=[], fstrkeys=("path",)) + )) + li.setInfo('video', info) li.setArt({ 'poster': self.video.defaultThumb.asTranscodedImageURL(347, 518), 'fanart': self.video.defaultArt.asTranscodedImageURL(1920, 1080), 'thumb': self.video.defaultThumb.asTranscodedImageURL(256, 256), }) + self.trigger('starting.video') self.play(url, li) def playVideoPlaylist(self, playlist, resume=False, handler=None, session_id=None): @@ -1219,12 +1351,14 @@ def playAudio(self, track, fanart=None, **kwargs): self.ignoreStopEvents = True self.handler = AudioPlayerHandler(self) + self.playerObject = plexplayer.PlexAudioPlayer(track) url, li = self.createTrackListItem(track, fanart) self.stopAndWait() self.ignoreStopEvents = False # maybe fixme: once started, self.sessionID will never be None for Audio self.sessionID = "AUD%s" % track.ratingKey + self.trigger('starting.audio') self.play(url, li, **kwargs) def playAlbum(self, album, startpos=-1, fanart=None, **kwargs): @@ -1233,6 +1367,7 @@ def playAlbum(self, album, startpos=-1, fanart=None, **kwargs): self.ignoreStopEvents = True self.handler = AudioPlayerHandler(self) + self.playerObject = plexplayer.PlexAudioPlayer() plist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) plist.clear() index = 1 @@ -1244,6 +1379,7 @@ def playAlbum(self, album, startpos=-1, fanart=None, **kwargs): self.stopAndWait() self.ignoreStopEvents = False self.sessionID = "ALB%s" % album.ratingKey + self.trigger('starting.audio') self.play(plist, startpos=startpos, **kwargs) def playAudioPlaylist(self, playlist, startpos=-1, fanart=None, **kwargs): @@ -1252,6 +1388,7 @@ def playAudioPlaylist(self, playlist, startpos=-1, fanart=None, **kwargs): self.ignoreStopEvents = True self.handler = AudioPlayerHandler(self) + self.playerObject = plexplayer.PlexAudioPlayer() plist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) plist.clear() index = 1 @@ -1271,11 +1408,14 @@ def playAudioPlaylist(self, playlist, startpos=-1, fanart=None, **kwargs): self.stopAndWait() self.ignoreStopEvents = False self.sessionID = "PLS%s" % getattr(playlist, "ratingKey", getattr(playlist, "id", random.randint(0, 1000))) + self.trigger('starting.audio') self.play(plist, startpos=startpos, **kwargs) def createTrackListItem(self, track, fanart=None, index=0): data = base64.urlsafe_b64encode(track.serialize().encode("utf8")).decode("utf8") - url = 'plugin://script.plexmod/play?{0}'.format(data) + if not track.isFullObject(): + track = track.reload() + url = self.playerObject.build(track)['url'] li = xbmcgui.ListItem(track.title, path=url) li.setInfo('music', { 'artist': six.text_type(track.originalTitle or track.grandparentTitle), @@ -1285,6 +1425,8 @@ def createTrackListItem(self, track, fanart=None, index=0): 'tracknumber': track.get('index').asInt(), 'duration': int(track.duration.asInt() / 1000), 'playcount': index, + # fixme: this is not really necessary, as we don't go the plugin:// route anymore. + # changing the track identification style would mean a bigger rewrite, though, so let's keep it. 'comment': 'PLEX-{0}:{1}'.format(track.ratingKey, data) }) art = fanart or track.defaultArt diff --git a/script.plexmod/lib/plex.py b/script.plexmod/lib/plex.py index 9a6506dcf2..c3ffecf707 100644 --- a/script.plexmod/lib/plex.py +++ b/script.plexmod/lib/plex.py @@ -76,6 +76,13 @@ def defaultUserAgent(): '%s/%s' % (p_system, p_release)]) +def getFriendlyName(): + fn = util.rpc.Settings.GetSettingValue(setting='services.devicename').get('value', 'Kodi') + if fn: + fn = fn.strip() + return fn or 'Kodi' + + class PlexInterface(plexapp.AppInterface): _regs = { None: {}, @@ -89,7 +96,7 @@ class PlexInterface(plexapp.AppInterface): 'provides': 'player', 'device': util.getPlatform() or plexapp.PLATFORM, 'model': 'Unknown', - 'friendlyName': util.rpc.Settings.GetSettingValue(setting='services.devicename').get('value') or 'Kodi', + 'friendlyName': getFriendlyName(), 'supports1080p60': True, 'vp9Support': True, 'audioChannels': '2.0', @@ -284,15 +291,16 @@ def onManualIPChange(**kwargs): plexapp.util.LOCAL_OVER_SECURE = util.getSetting('prefer_local', False) # set requests timeout -TIMEOUT = float(util.advancedSettings.requestsTimeout) -CONNCHECK_TIMEOUT = float(util.advancedSettings.connCheckTimeout) +TIMEOUT = float(util.addonSettings.requestsTimeout) +CONNCHECK_TIMEOUT = float(util.addonSettings.connCheckTimeout) plexapp.util.TIMEOUT = TIMEOUT plexapp.util.CONN_CHECK_TIMEOUT = asyncadapter.AsyncTimeout(CONNCHECK_TIMEOUT).setConnectTimeout(CONNCHECK_TIMEOUT) -plexapp.util.LAN_REACHABILITY_TIMEOUT = util.advancedSettings.localReachTimeout / 1000.0 +plexapp.util.LAN_REACHABILITY_TIMEOUT = util.addonSettings.localReachTimeout / 1000.0 pnhttp.DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(TIMEOUT).setConnectTimeout(TIMEOUT) asyncadapter.DEFAULT_TIMEOUT = pnhttp.DEFAULT_TIMEOUT plexapp.util.ACCEPT_LANGUAGE = util.ACCEPT_LANGUAGE_CODE plexapp.setUserAgent(defaultUserAgent()) +plexnet_util.BASE_HEADERS = plexnet_util.getPlexHeaders() class CallbackEvent(plexapp.util.CompatEvent): diff --git a/script.plexmod/lib/plex_hosts.py b/script.plexmod/lib/plex_hosts.py new file mode 100644 index 0000000000..758bfb7d7a --- /dev/null +++ b/script.plexmod/lib/plex_hosts.py @@ -0,0 +1,112 @@ +# coding=utf-8 +import re +try: + from urllib.parse import urlparse +except ImportError: + from requests.compat import urlparse + +import plexnet.http + +from six import text_type + +from lib import util +from lib.advancedsettings import adv + +from plexnet.util import parsePlexDirectHost +from plexnet.plexconnection import DOCKER_NETWORK, IPv4Address + +HOSTS_RE = re.compile(r'\s*.*', re.S | re.I) +HOST_RE = re.compile(r'(?P.+)') + + +class PlexHostsManager(object): + _hosts = None + _orig_hosts = None + + HOSTS_TPL = """\ + +{} + """ + ENTRY_TPL = ' {}' + + def __init__(self): + self.load() + + def __bool__(self): + return bool(self._hosts) + + def __len__(self): + return self and len(self._hosts) or 0 + + def getHosts(self): + return self._hosts or {} + + @property + def hadHosts(self): + return bool(self._orig_hosts) + + def newHosts(self, hosts, source="stored"): + """ + hosts should be a list of plex.direct connection uri's + """ + for address in hosts: + parsed = urlparse(address) + ip = parsePlexDirectHost(parsed.hostname) + # ignore docker V4 hosts + if util.addonSettings.ignoreDockerV4 and ":" not in ip and IPv4Address(text_type(ip)) in DOCKER_NETWORK: + util.DEBUG_LOG("Ignoring plex.direct local Docker IPv4 address: {}".format(source, parsed.hostname)) + continue + + if parsed.hostname not in self._hosts: + self._hosts[parsed.hostname] = plexnet.http.RESOLVED_PD_HOSTS.get(parsed.hostname, ip) + util.LOG("Found new unmapped {} plex.direct host: {}".format(source, parsed.hostname)) + + @property + def differs(self): + return self._hosts != self._orig_hosts + + @property + def diff(self): + return set(self._hosts) - set(self._orig_hosts) + + def load(self): + data = adv.getData() + self._hosts = {} + self._orig_hosts = {} + if not data: + return + + hosts_match = HOSTS_RE.search(data) + if hosts_match: + hosts_xml = hosts_match.group(0) + + hosts = HOST_RE.findall(hosts_xml) + if hosts: + self._hosts = dict(hosts) + self._orig_hosts = dict(hosts) + util.DEBUG_LOG("Found {} hosts in advancedsettings.xml".format(len(self._hosts))) + + def write(self, hosts=None): + self._hosts = hosts or self._hosts + if not self._hosts: + return + data = adv.getData() + cd = "\n" + if data: + hosts_match = HOSTS_RE.search(data) + if hosts_match: + hosts_xml = hosts_match.group(0) + cd = data.replace(hosts_xml, "") + else: + cd = data + + finalxml = "{}\n".format( + cd.replace("", self.HOSTS_TPL.format("\n".join(self.ENTRY_TPL.format(hostname, ip) + for hostname, ip in self._hosts.items()))) + ) + + adv.write(finalxml) + self._orig_hosts = dict(self._hosts) + + +pdm = PlexHostsManager() diff --git a/script.plexmod/lib/util.py b/script.plexmod/lib/util.py index 025ae4b734..6c5ea156dc 100644 --- a/script.plexmod/lib/util.py +++ b/script.plexmod/lib/util.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import + import gc import sys import re @@ -10,12 +11,16 @@ import time import datetime import contextlib +import unicodedata + import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error import six import os import struct import requests +import plexnet.util + from .kodijsonrpc import rpc from kodi_six import xbmc from kodi_six import xbmcgui @@ -25,7 +30,7 @@ from . import colors # noinspection PyUnresolvedReferences from .exceptions import NoDataException -from plexnet import signalsmixin, plexapp +from plexnet import signalsmixin DEBUG = True _SHUTDOWN = False @@ -115,10 +120,10 @@ def getSetting(key, default=None): def getUserSetting(key, default=None): - if not plexapp.ACCOUNT: + if not plexnet.util.ACCOUNT: return default - key = '{}.{}'.format(key, plexapp.ACCOUNT.ID) + key = '{}.{}'.format(key, plexnet.util.ACCOUNT.ID) with SETTINGS_LOCK: setting = ADDON.getSetting(key) return _processSetting(setting, default) @@ -142,7 +147,7 @@ def _processSetting(setting, default): return setting -class AdvancedSettings(object): +class AddonSettings(object): """ @DynamicAttrs """ @@ -162,12 +167,11 @@ class AdvancedSettings(object): ("postplay_timeout", 16), ("skip_intro_button_timeout", 10), ("skip_credits_button_timeout", 10), - ("playlist_visit_media", True), + ("playlist_visit_media", False), ("intro_skip_early", False), ("show_media_ends_info", True), ("show_media_ends_label", True), ("background_colour", None), - ("oldprofile", False), ("skip_intro_button_show_early_threshold1", 60), ("requests_timeout", 5.0), ("local_reach_timeout", 10), @@ -188,6 +192,11 @@ class AdvancedSettings(object): ("consecutive_video_pb_wait", 0.0), ("retrieve_all_media_up_front", False), ("library_chunk_size", 240), + ("verify_mapped_files", True), + ("episode_no_spoiler_blur", 16), + ("ignore_docker_v4", True), + ("cache_home_users", True), + ("intro_marker_max_offset", 600), ) def __init__(self): @@ -198,7 +207,7 @@ def __init__(self): getSetting(setting, default)) -advancedSettings = AdvancedSettings() +addonSettings = AddonSettings() def LOG(msg, level=xbmc.LOGINFO): @@ -209,7 +218,7 @@ def DEBUG_LOG(msg): if _SHUTDOWN: return - if not advancedSettings.debug and not xbmc.getCondVisibility('System.GetBool(debug.showloginfo)'): + if not addonSettings.debug and not xbmc.getCondVisibility('System.GetBool(debug.showloginfo)'): return LOG(msg) @@ -293,8 +302,13 @@ def onNotification(self, sender, method, data): setGlobalProperty('stop_running', '1') return - elif sender == "xbmc" and method == "System.OnSleep" and getSetting('action_on_sleep', "none") != "none": - getattr(self, "action{}".format(getSetting('action_on_sleep', "none").capitalize()))() + elif sender == "xbmc" and method == "System.OnSleep": + if getSetting('action_on_sleep', "none") != "none": + getattr(self, "action{}".format(getSetting('action_on_sleep', "none").capitalize()))() + self.trigger('system.sleep') + + elif sender == "xbmc" and method == "System.OnWake": + self.trigger('system.wakeup') def stopPlayback(self): LOG('Monitor: Stopping media playback') @@ -302,11 +316,22 @@ def stopPlayback(self): def onScreensaverActivated(self): DEBUG_LOG("Monitor: OnScreensaverActivated") + self.trigger('screensaver.activated') if getSetting('player_stop_on_screensaver', True) and xbmc.Player().isPlayingVideo(): self.stopPlayback() + def onScreensaverDeactivated(self): + DEBUG_LOG("Monitor: OnScreensaverDeactivated") + self.trigger('screensaver.deactivated') + def onDPMSActivated(self): DEBUG_LOG("Monitor: OnDPMSActivated") + self.trigger('dpms.activated') + #self.stopPlayback() + + def onDPMSDeactivated(self): + DEBUG_LOG("Monitor: OnDPMSDeactivated") + self.trigger('dpms.deactivated') #self.stopPlayback() def onSettingsChanged(self): @@ -316,169 +341,6 @@ def onSettingsChanged(self): MONITOR = UtilityMonitor() -ADV_MSIZE_RE = re.compile(r'(\d+)') -ADV_RFACT_RE = re.compile(r'(\d+)') -ADV_CACHE_RE = re.compile(r'\s*.*', re.S | re.I) - - -class KodiCacheManager(object): - """ - A pretty cheap approach at managing the section of advancedsettings.xml - - Starting with build 20.90.821 (Kodi 21.0-BETA2) a lot of caching issues have been fixed and - readfactor behaves better. We need to adjust for that. - """ - _cleanData = None - useModernAPI = False - memorySize = 20 # in MB - readFactor = 4 - defRF = 4 - defRFSM = 20 - recRFRange = "4-10" - template = None - orig_tpl_path = os.path.join(ADDON.getAddonInfo('path'), "pm4k_cache_template.xml") - custom_tpl_path = "special://profile/pm4k_cache_template.xml" - translated_ctpl_path = translatePath(custom_tpl_path) - - # give Android a little more leeway with its sometimes weird memory management; otherwise stick with 23% of free mem - safeFactor = .20 if xbmc.getCondVisibility('System.Platform.Android') else .23 - - def __init__(self): - if KODI_BUILD_NUMBER >= 2090821: - self.memorySize = rpc.Settings.GetSettingValue(setting='filecache.memorysize')['value'] - self.readFactor = rpc.Settings.GetSettingValue(setting='filecache.readfactor')['value'] / 100.0 - if self.readFactor % 1 == 0: - self.readFactor = int(self.readFactor) - DEBUG_LOG("Not using advancedsettings.xml for cache/buffer management, we're at least Kodi 21 non-alpha") - self.useModernAPI = True - self.defRFSM = 7 - self.recRFRange = "1.5-4" - - if KODI_BUILD_NUMBER >= 2090830: - self.recRFRange = ADDON.getLocalizedString(32976) - - else: - self.load() - self.template = self.getTemplate() - - plexapp.util.APP.on('change:slow_connection', - lambda value=None, **kwargs: self.write(readFactor=value and self.defRFSM or self.defRF)) - - def getTemplate(self): - if xbmcvfs.exists(self.custom_tpl_path): - try: - f = xbmcvfs.File(self.custom_tpl_path) - data = f.read() - f.close() - if data: - return data - except: - pass - - DEBUG_LOG("Custom pm4k_cache_template.xml not found, using default") - f = xbmcvfs.File(self.orig_tpl_path) - data = f.read() - f.close() - return data - - def load(self): - try: - f = xbmcvfs.File("special://profile/advancedsettings.xml") - data = f.read() - f.close() - except: - LOG('script.plex: No advancedsettings.xml found') - else: - cachexml_match = ADV_CACHE_RE.search(data) - if cachexml_match: - cachexml = cachexml_match.group(0) - - try: - self.memorySize = int(ADV_MSIZE_RE.search(cachexml).group(1)) // 1024 // 1024 - except: - DEBUG_LOG("script.plex: invalid or not found memorysize in advancedsettings.xml") - - try: - self.readFactor = int(ADV_RFACT_RE.search(cachexml).group(1)) - except: - DEBUG_LOG("script.plex: invalid or not found readfactor in advancedsettings.xml") - - self._cleanData = data.replace(cachexml, "") - else: - self._cleanData = data - - def write(self, memorySize=None, readFactor=None): - memorySize = self.memorySize = memorySize if memorySize is not None else self.memorySize - readFactor = self.readFactor = readFactor if readFactor is not None else self.readFactor - - if self.useModernAPI: - # kodi cache settings have moved to Services>Caching - try: - rpc.Settings.SetSettingValue(setting='filecache.memorysize', value=self.memorySize) - rpc.Settings.SetSettingValue(setting='filecache.readfactor', value=int(self.readFactor * 100)) - except: - pass - return - - cd = self._cleanData - if not cd: - cd = "\n" - - finalxml = "{}\n".format( - cd.replace("", self.template.format(memorysize=memorySize * 1024 * 1024, - readfactor=readFactor)) - ) - - try: - f = xbmcvfs.File("special://profile/advancedsettings.xml", "w") - f.write(finalxml) - f.close() - except: - ERROR("Couldn't write advancedsettings.xml") - - def clamp16(self, x): - return x - x % 16 - - @property - def viableOptions(self): - default = list(filter(lambda x: x < self.recMax, - [16, 20, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024])) - - # add option to overcommit slightly - overcommit = [] - if xbmc.getCondVisibility('System.Platform.Android'): - overcommit.append(min(self.clamp16(int(self.free * 0.23)), 2048)) - - overcommit.append(min(self.clamp16(int(self.free * 0.26)), 2048)) - overcommit.append(min(self.clamp16(int(self.free * 0.3)), 2048)) - - # re-append current memorySize here, as recommended max might have changed - return list(sorted(list(set(default + [self.memorySize, self.recMax] + overcommit)))) - - @property - def readFactorOpts(self): - ret = list(sorted(list(set([1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 7, 10, 15, 20, 30, 50] + [self.readFactor])))) - if KODI_BUILD_NUMBER >= 2090830 and self.readFactor > 0: - # support for adaptive read factor from build 2090822 onwards - ret.insert(0, 0) - return ret - - @property - def free(self): - return float(xbmc.getInfoLabel('System.Memory(free)')[:-2]) - - @property - def recMax(self): - freeMem = self.free - recMem = min(int(freeMem * self.safeFactor), 2048) - LOG("Free memory: {} MB, recommended max: {} MB".format(freeMem, recMem)) - return recMem - - -kcm = KodiCacheManager() - -CACHE_SIZE = kcm.memorySize - def T(ID, eng=''): return ADDON.getLocalizedString(ID) @@ -486,14 +348,14 @@ def T(ID, eng=''): hasCustomBGColour = False if KODI_VERSION_MAJOR > 18: - hasCustomBGColour = not advancedSettings.dynamicBackgrounds and advancedSettings.backgroundColour and \ - advancedSettings.backgroundColour != "-" + hasCustomBGColour = not addonSettings.dynamicBackgrounds and addonSettings.backgroundColour and \ + addonSettings.backgroundColour != "-" def getAdvancedSettings(): # yes, global, hang me! - global advancedSettings - advancedSettings = AdvancedSettings() + global addonSettings + addonSettings = AddonSettings() def reInitAddon(): @@ -665,7 +527,7 @@ def shortenText(text, size): def scaleResolution(w, h, by=None): if by is None: - by = advancedSettings.posterResolutionScalePerc + by = addonSettings.posterResolutionScalePerc if 0 < by != 100.0: px = w * h * (by / 100.0) @@ -675,6 +537,9 @@ def scaleResolution(w, h, by=None): return w, h +SPOILER_ALLOWED_GENRES = ("Reality", "Game Show", "Documentary", "Sport") + + class TextBox: # constants WINDOW = 10147 @@ -951,6 +816,98 @@ def getTimeFormat(): timeFormat, timeFormatKN, padHour = getTimeFormat() +DEF_THEME = "modern-colored" +THEME_VERSION = 3 + + +def applyTheme(theme=None): + """ + Dynamically build script-plex-seek_dialog.xml by combining a player button template with + script-plex-seek_dialog_skeleton.xml + """ + theme = theme or getSetting('theme', DEF_THEME) + skel = os.path.join(ADDON.getAddonInfo('path'), "resources", "skins", "Main", "1080i", + "script-plex-seek_dialog_skeleton.xml") + if theme == "custom": + btnTheme = os.path.join(ADDON.getAddonInfo("profile"), "templates", + "seek_dialog_buttons_custom.xml") + customSkel = os.path.join(ADDON.getAddonInfo("profile"), "templates", + "script-plex-seek_dialog_skeleton_custom.xml") + if xbmcvfs.exists(customSkel): + skel = customSkel + else: + btnTheme = os.path.join(ADDON.getAddonInfo('path'), "resources", "skins", "Main", "1080i", "templates", + "seek_dialog_buttons_{}.xml".format(theme)) + + if not xbmcvfs.exists(btnTheme): + LOG("Theme {} doesn't exist, falling back to modern".format(theme)) + setSetting('theme', DEF_THEME) + return applyTheme(DEF_THEME) + + try: + # read skeleton + f = xbmcvfs.File(skel) + skelData = f.read() + f.close() + except: + ERROR("Couldn't find {}".format("script-plex-seek_dialog_skeleton.xml")) + else: + try: + # read button theme + f = xbmcvfs.File(btnTheme) + btnData = f.read() + f.close() + except: + ERROR("Couldn't find {}".format("seek_dialog_buttons_{}.xml".format(theme))) + else: + # combine both + finalXML = skelData.replace('', btnData) + try: + # write final file + f = xbmcvfs.File(os.path.join(ADDON.getAddonInfo('path'), "resources", "skins", "Main", "1080i", + "script-plex-seek_dialog.xml"), "w") + f.write(finalXML) + f.close() + except: + ERROR("Couldn't write script-plex-seek_dialog.xml") + else: + LOG('Using theme: {}'.format(theme)) + + +# apply theme if version changed +theme = getSetting('theme', DEF_THEME) +curThemeVer = getSetting('theme_version', 0) +if curThemeVer < THEME_VERSION: + setSetting('theme_version', THEME_VERSION) + # apply seekdialog button theme + applyTheme(theme) + +# apply theme if seek_dialog xml missing +if not xbmcvfs.exists(os.path.join(ADDON.getAddonInfo('path'), "resources", "skins", "Main", "1080i", + "script-plex-seek_dialog.xml")): + applyTheme(theme) + + +# get mounts +KODI_SOURCES = [] + + +def getKodiSources(): + try: + data = rpc.Files.GetSources(media="files")["sources"] + except: + LOG("Couldn't parse Kodi sources") + else: + for d in data: + f = d["file"] + if f.startswith("smb://") or f.startswith("nfs://") or f.startswith("/") or ':\\\\' in f: + KODI_SOURCES.append(d) + LOG("Parsed {} Kodi sources: {}".format(len(KODI_SOURCES), KODI_SOURCES)) + + +if getSetting('path_mapping', True): + getKodiSources() + def populateTimeFormat(): global timeFormat, timeFormatKN, padHour @@ -972,14 +929,48 @@ def getPlatform(): return key.rsplit('.', 1)[-1] -def getProgressImage(obj, perc=None): +def getRunningAddons(): + try: + return xbmcvfs.listdir('addons://running/')[1] + except: + return [] + + +def getUserAddons(): + try: + return xbmcvfs.listdir('addons://user/all')[1] + except: + return [] + + +USER_ADDONS = getUserAddons() + + +SLUGIFY_RE1 = re.compile(r'[^\w\s-]') +SLUGIFY_RE2 = re.compile(r'[-\s]+') + + +def slugify(value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + """ + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') + value = SLUGIFY_RE1.sub('', value).strip().lower() + return SLUGIFY_RE2.sub('-', value) + + +def getProgressImage(obj, perc=None, view_offset=None): if not obj and not perc: return '' if obj: - if not obj.get('viewOffset') or not obj.get('duration'): + if not view_offset: + view_offset = obj.get('viewOffset') and obj.viewOffset.asInt() + if not view_offset or not obj.get('duration'): return '' - pct = int((obj.viewOffset.asInt() / obj.duration.asFloat()) * 100) + pct = int((view_offset / obj.duration.asFloat()) * 100) else: pct = perc pct = pct - pct % 2 # Round to even number - we have even numbered progress only @@ -992,8 +983,8 @@ def backgroundFromArt(art, width=1920, height=1080, background=colors.noAlpha.Ba return return art.asTranscodedImageURL( width, height, - blur=advancedSettings.backgroundArtBlurAmount2, - opacity=advancedSettings.backgroundArtOpacityAmount2, + blur=addonSettings.backgroundArtBlurAmount2, + opacity=addonSettings.backgroundArtOpacityAmount2, background=background ) diff --git a/script.plexmod/lib/windows/__init__.py b/script.plexmod/lib/windows/__init__.py index f96b690080..90112572d8 100644 --- a/script.plexmod/lib/windows/__init__.py +++ b/script.plexmod/lib/windows/__init__.py @@ -1,5 +1,6 @@ from __future__ import absolute_import -from . import kodigui + from lib import util +from . import kodigui kodigui.MONITOR = util.MONITOR diff --git a/script.plexmod/lib/windows/background.py b/script.plexmod/lib/windows/background.py index 8940d1c6bd..58ad8cbb05 100644 --- a/script.plexmod/lib/windows/background.py +++ b/script.plexmod/lib/windows/background.py @@ -1,6 +1,7 @@ from __future__ import absolute_import -from . import kodigui + from lib import util +from . import kodigui util.setGlobalProperty('background.busy', '') util.setGlobalProperty('background.shutdown', '') diff --git a/script.plexmod/lib/windows/busy.py b/script.plexmod/lib/windows/busy.py index dd8e3ef060..edc992199b 100644 --- a/script.plexmod/lib/windows/busy.py +++ b/script.plexmod/lib/windows/busy.py @@ -1,9 +1,12 @@ from __future__ import absolute_import -from . import kodigui -from lib import util -from kodi_six import xbmcgui + import threading +from kodi_six import xbmcgui + +from lib import util +from . import kodigui + class BusyWindow(kodigui.BaseDialog): xmlFile = 'script-plex-busy.xml' diff --git a/script.plexmod/lib/windows/currentplaylist.py b/script.plexmod/lib/windows/currentplaylist.py index 91dffd23a1..9f9d239581 100644 --- a/script.plexmod/lib/windows/currentplaylist.py +++ b/script.plexmod/lib/windows/currentplaylist.py @@ -1,18 +1,17 @@ from __future__ import absolute_import + from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from lib import kodijsonrpc +from lib import player +from lib import util +from lib.util import T from . import busy -from . import windowutils from . import dropdown +from . import kodigui from . import opener - -from lib import util -from lib import player -from lib import kodijsonrpc - -from lib.util import T +from . import windowutils class CurrentPlaylistWindow(kodigui.ControlledWindow, windowutils.UtilMixin): @@ -61,12 +60,23 @@ def __init__(self, *args, **kwargs): self.musicPlayerWinID = kwargs.get('winID') def doClose(self, **kwargs): - player.PLAYER.off('playback.started', self.onPlayBackStarted) + player.PLAYER.off('av.started', self.onPlayBackStarted) player.PLAYER.off('playlist.changed', self.playQueueCallback) if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: player.PLAYER.handler.playQueue.off('change', self.updateProperties) + self.commonDeinit() kodigui.ControlledWindow.doClose(self) + def commonInit(self): + player.PLAYER.on('starting.audio', self.onAudioStarting) + player.PLAYER.on('started.audio', self.onAudioStarted) + player.PLAYER.on('changed.audio', self.onAudioChanged) + + def commonDeinit(self): + player.PLAYER.off('starting.audio', self.onAudioStarting) + player.PLAYER.off('started.audio', self.onAudioStarted) + player.PLAYER.off('changed.audio', self.onAudioChanged) + def onFirstInit(self): self.playlistListControl = kodigui.ManagedControlList(self, self.PLAYLIST_LIST_ID, 9) self.setupSeekbar() @@ -74,7 +84,7 @@ def onFirstInit(self): self.fillPlaylist() self.selectPlayingItem() self.setFocusId(self.PLAYLIST_LIST_ID) - + self.commonInit() self.updateProperties() if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: player.PLAYER.handler.playQueue.on('change', self.updateProperties) @@ -123,9 +133,20 @@ def onFocus(self, controlID): self.updateSelectedProgress() def onPlayBackStarted(self, **kwargs): - xbmc.sleep(2000) self.setDuration() + def onAudioStarting(self, *args, **kwargs): + util.setGlobalProperty('ignore_spinner', '1') + self.ignoreStopCommands = True + + def onAudioStarted(self, *args, **kwargs): + util.setGlobalProperty('ignore_spinner', '') + self.ignoreStopCommands = False + + def onAudioChanged(self, *args, **kwargs): + util.setGlobalProperty('ignore_spinner', '') + self.ignoreStopCommands = False + def repeatButtonClicked(self): if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: if xbmc.getCondVisibility('Playlist.IsRepeatOne'): @@ -180,7 +201,8 @@ def optionsButtonClicked(self, pos=(670, 1060)): def stopButtonClicked(self): xbmc.executebuiltin('Action(Back, {})'.format(self.musicPlayerWinID)) - xbmc.sleep(500) + util.MONITOR.waitForAbort(0.5) + player.PLAYER.stopAndWait() self.doClose() def selectPlayingItem(self): @@ -217,6 +239,7 @@ def playlistListClicked(self): mli = self.playlistListControl.getSelectedItem() if not mli: return + self.onAudioStarting() player.PLAYER.playselected(mli.pos()) def createListItem(self, pi, idx): @@ -257,7 +280,7 @@ def setupSeekbar(self): self.selectionBox = self.getControl(self.SELECTION_BOX) self.selectionBoxHalf = self.SELECTION_BOX_WIDTH // 2 self.selectionBoxMax = self.SEEK_IMAGE_WIDTH - player.PLAYER.on('playback.started', self.onPlayBackStarted) + player.PLAYER.on('av.started', self.onPlayBackStarted) def checkSeekActions(self, action, controlID): if controlID == self.SEEK_BUTTON_ID: diff --git a/script.plexmod/lib/windows/dropdown.py b/script.plexmod/lib/windows/dropdown.py index aefb7f0e1b..0bcae6e03f 100644 --- a/script.plexmod/lib/windows/dropdown.py +++ b/script.plexmod/lib/windows/dropdown.py @@ -1,8 +1,9 @@ from __future__ import absolute_import -from kodi_six import xbmc, xbmcgui -from . import kodigui + +from kodi_six import xbmcgui from lib import util +from . import kodigui SEPARATOR = None diff --git a/script.plexmod/lib/windows/episodes.py b/script.plexmod/lib/windows/episodes.py index d6c6ed3913..c1c594f384 100644 --- a/script.plexmod/lib/windows/episodes.py +++ b/script.plexmod/lib/windows/episodes.py @@ -3,31 +3,26 @@ import requests.exceptions from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from plexnet import plexapp, playlist, plexplayer -from lib import util from lib import backgroundthread from lib import metadata from lib import player - -from plexnet import plexapp, playlist, plexplayer -from plexnet.util import INTERFACE - +from lib import util +from lib.util import T from . import busy -from . import videoplayer from . import dropdown -from . import windowutils -from . import opener -from . import search -from . import playersettings from . import info +from . import kodigui +from . import opener from . import optionsdialog -from . import preplayutils from . import pagination from . import playbacksettings - -from lib.util import T -from .mixins import SeasonsMixin, RatingsMixin +from . import playersettings +from . import search +from . import videoplayer +from . import windowutils +from .mixins import SeasonsMixin, RatingsMixin, SpoilersMixin VIDEO_RELOAD_KW = dict(includeExtras=1, includeExtrasCount=10, includeChapters=1) @@ -75,8 +70,10 @@ def createListItem(self, data): return mli def prepareListItem(self, data, mli): + mli.setBoolProperty('watched', mli.dataSource.isFullyWatched) if not mli.dataSource.isWatched: mli.setProperty('unwatched.count', str(mli.dataSource.unViewedLeafCount)) + mli.setProperty('unwatched', '1') mli.setProperty('progress', util.getProgressImage(mli.dataSource)) def setEpisode(self, ep): @@ -174,7 +171,7 @@ def getData(self, offset, amount): return self.parentWindow.show_.getRelated(offset=offset, limit=amount) -class EpisodesWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SeasonsMixin, RatingsMixin, +class EpisodesWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SeasonsMixin, RatingsMixin, SpoilersMixin, playbacksettings.PlaybackSettingsMixin): xmlFile = 'script-plex-episodes.xml' path = util.ADDON.getAddonInfo('path') @@ -218,24 +215,16 @@ class EpisodesWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SeasonsMix def __init__(self, *args, **kwargs): kodigui.ControlledWindow.__init__(self, *args, **kwargs) windowutils.UtilMixin.__init__(self) + SpoilersMixin.__init__(self, *args, **kwargs) self.episode = None self.reset(kwargs.get('episode'), kwargs.get('season'), kwargs.get('show')) - self.initialEpisode = kwargs.get('episode') self.parentList = kwargs.get('parentList') - self.lastItem = None - self.lastFocusID = None - self.lastNonOptionsFocusID = None - self.episodesPaginator = None - self.relatedPaginator = None self.cameFrom = kwargs.get('came_from') self.tasks = backgroundthread.Tasks() - self.initialized = False - self.currentItemLoaded = False - self.closing = False - self._reloadVideos = [] def reset(self, episode, season=None, show=None): self.episode = episode + self.initialEpisode = episode self.season = season if season is not None else self.episode.season() try: self.show_ = show or (self.episode or self.season).show().reload(includeExtras=1, includeExtrasCount=10, @@ -243,10 +232,19 @@ def reset(self, episode, season=None, show=None): except IndexError: raise util.NoDataException + self.initialized = False + self.closing = False self.parentList = None + self.episodesPaginator = None + self.relatedPaginator = None self.seasons = None - self._reloadVideos = [] - #self.initialized = False + self.manuallySelected = False + self.currentItemLoaded = False + self.lastItem = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self._videoProgress = None + self.openedWithAutoPlay = False def doClose(self): self.closing = True @@ -258,6 +256,7 @@ def doClose(self): self.tasks = None try: player.PLAYER.off('new.video', self.onNewVideo) + player.PLAYER.off('video.progress', self.onVideoProgress) except KeyError: pass @@ -271,58 +270,91 @@ def _onFirstInit(self): self.extraListControl = kodigui.ManagedControlList(self, self.EXTRA_LIST_ID, 5) self.relatedListControl = kodigui.ManagedControlList(self, self.RELATED_LIST_ID, 5) + if not self.openedWithAutoPlay: + # we may have set up the hooks before + self._setup_hooks() self._setup() self.postSetup() def doAutoPlay(self): # First reload the video to get all the other info self.initialEpisode.reload(checkFiles=1, **VIDEO_RELOAD_KW) + + # We're not hitting onFirstInit when autoplaying from home, setup hooks here, so we can grab video progress + self._setup_hooks() + self.openedWithAutoPlay = True return self.playButtonClicked(force_episode=self.initialEpisode, from_auto_play=True) def onFirstInit(self): self._onFirstInit() if self.show_ and self.show_.theme and not util.getSetting("slow_connection", False) and \ - (not self.cameFrom or self.cameFrom != self.show_.ratingKey): + (not self.cameFrom or self.cameFrom != self.show_.ratingKey) and not self.openedWithAutoPlay: volume = self.show_.settings.getThemeMusicValue() if volume > 0: player.PLAYER.playBackgroundMusic(self.show_.theme.asURL(True), volume, self.show_.ratingKey) + self.openedWithAutoPlay = False @busy.dialog() def onReInit(self): if not self.tasks: self.tasks = backgroundthread.Tasks() + if self.manuallySelected and not self._videoProgress: + util.DEBUG_LOG("Episodes: ReInit: Not doing anything, as we've previously manually selected " + "this item and don't have progress") + return + + self.manuallySelected = False + util.DEBUG_LOG("Episodes: {}: Got progress info: {}".format( + self.episode and self.episode.ratingKey or None, self._videoProgress)) try: - self.selectEpisode() + redirect = self.selectEpisode(progress_data=self._videoProgress) except AttributeError: raise util.NoDataException + if redirect: + util.DEBUG_LOG("Got episode progress for a different season, redirecting") + self.episodeListControl.reset() + self.relatedListControl.reset() + self.reset(episode=redirect) + self._setup() + self.postSetup() + return + mli = self.episodeListControl.getSelectedItem() if not mli or not self.episodesPaginator: return - reloadItems = [mli] - for v in self._reloadVideos: + reload_items = [mli] + skip_progress_for = None + if self._videoProgress: + skip_progress_for = [] for m in self.episodeListControl: - if m.dataSource == v: - reloadItems.append(m) - self.episodesPaginator.prepareListItem(v, m) + # pagination boundary + if not m.dataSource: + continue - # re-set current item's progress to a loading state - if util.getSetting("slow_connection", False): - self.progressImageControl.setWidth(1) - mli.setProperty('remainingTime', T(32914, "Loading")) + if m.dataSource.ratingKey in self._videoProgress: + reload_items.append(m) + skip_progress_for.append(m.dataSource.ratingKey) + del self._videoProgress[m.dataSource.ratingKey] + if not self._videoProgress: + break + + self._videoProgress = None + + reload_items = list(set(reload_items)) + select_episode = reload_items and reload_items[-1] or mli + + self.episodesPaginator.setEpisode(select_episode.dataSource) + self.reloadItems(items=reload_items, with_progress=True, skip_progress_for=skip_progress_for) - self.reloadItems(items=reloadItems, with_progress=True) - self.episodesPaginator.setEpisode(self._reloadVideos and self._reloadVideos[-1] or mli) - self._reloadVideos = [] self.fillRelated() - def postSetup(self, from_select_episode=False): - self.selectEpisode(from_select_episode=from_select_episode) - self.checkForHeaderFocus(xbmcgui.ACTION_MOVE_DOWN) + def postSetup(self): + self.checkForHeaderFocus(xbmcgui.ACTION_MOVE_DOWN, initial=True) selected = self.episodeListControl.getSelectedItem() if selected: @@ -336,16 +368,19 @@ def postSetup(self, from_select_episode=False): def setup(self): self._setup() - def _setup(self, from_select_episode=False): + def _setup_hooks(self): player.PLAYER.on('new.video', self.onNewVideo) + player.PLAYER.on('video.progress', self.onVideoProgress) + + def _setup(self): (self.season or self.show_).reload(checkFiles=1, **VIDEO_RELOAD_KW) - if not from_select_episode or not self.episodesPaginator: + if not self.episodesPaginator: self.episodesPaginator = EpisodesPaginator(self.episodeListControl, leaf_count=int(self.season.leafCount) if self.season else 0, parent_window=self) - if not from_select_episode or not self.episodesPaginator: + if not self.episodesPaginator: self.relatedPaginator = RelatedPaginator(self.relatedListControl, leaf_count=int(self.show_.relatedCount), parent_window=self) @@ -361,20 +396,79 @@ def _setup(self, from_select_episode=False): hasPrev = self.fillRelated(hasPrev) self.fillRoles(hasPrev) - def selectEpisode(self, from_select_episode=False): - if not self.episode or not self.episodesPaginator: + def selectEpisode(self, progress_data=None): + if not self.episodesPaginator: return + had_progress_data = bool(progress_data) + progress_data_left = None + if had_progress_data: + progress_data_left = progress_data.copy() + + set_main_progress_to = None + for mli in self.episodeListControl: - if mli.dataSource == self.episode: - self.episodeListControl.selectItem(mli.pos()) - self.episodesPaginator.setEpisode(self.episode) + # pagination boundary + if not mli.dataSource: + continue + + just_fully_watched = False + if progress_data_left and mli.dataSource: + progress = progress_data_left.pop(mli.dataSource.ratingKey, False) + # progress can be False (no entry), a number (progress), or True (fully watched just now) + # select it if it's not watched or in progress + if progress is True: + # ep was just watched + just_fully_watched = True + mli.setProperty('unwatched', '') + mli.setProperty('watched', '1') + mli.setProperty('progress', '') + mli.setProperty('unwatched.count', '') + mli.dataSource.set('viewCount', 1) + self.setUserItemInfo(mli, fully_watched=True) + + elif progress and progress > 60000: + # ep has progress + mli.setProperty('watched', '') + mli.setProperty('progress', util.getProgressImage(mli.dataSource, view_offset=progress)) + mli.dataSource.set('viewOffset', progress) + self.setUserItemInfo(mli, watched=True) + set_main_progress_to = progress + + # after immediately updating the watched state, if we still have data left, continue + if progress is True and progress_data_left: + continue + + # first condition: we select self.episode if we've got no progress data or we haven't watched it just now. + # second condition: we've just come from playback with progress upon reinit. select the next available + # episode that's either unwatched or in progress. + # third condition: select the next unwatched episode if we don't have self.episode and didn't have any + # player progress, which happens when being called without an episode (season view, show view). + if (mli.dataSource == self.episode and not just_fully_watched and not progress_data_left) or \ + (had_progress_data and not progress_data_left and not just_fully_watched + and not mli.dataSource.isFullyWatched) or \ + (not had_progress_data and not self.episode and not mli.dataSource.isFullyWatched): + if self.episodeListControl.getSelectedPosition() < mli.pos(): + self.episodeListControl.selectItem(mli.pos()) + self.episodesPaginator.setEpisode(self.episode or mli.dataSource) + if just_fully_watched: + set_main_progress_to = 0 + + # this is a little counter intuitive - None is actually valid here, and if set to None, setProgress will + # use the actual item progress, not ours + self.setProgress(mli, view_offset=set_main_progress_to) break else: - if not from_select_episode: - self.reset(self.episode) - self._setup(from_select_episode=True) - self.postSetup(from_select_episode=True) + # no matching episode found + mli = self.episodeListControl.getSelectedItem() + self.setProgress(mli, view_offset=0) + + if progress_data_left: + # we've probably watched something in the next season + key = '/library/metadata/{0}'.format(list(progress_data_left.keys())[-1]) + ep = plexapp.SERVERMANAGER.selectedServer.getObject(key) + if ep.parentIndex != self.show_.parentIndex: + return ep self.episode = None @@ -427,7 +521,7 @@ def onAction(self, action): elif action == xbmcgui.ACTION_NAV_BACK: if (not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( self.OPTIONS_GROUP_ID)) or not controlID) and \ - not util.advancedSettings.fastBack: + not util.addonSettings.fastBack: if self.getProperty('on.extras'): self.setFocusId(self.OPTIONS_GROUP_ID) return @@ -448,10 +542,18 @@ def onNewVideo(self, video=None, **kwargs): util.DEBUG_LOG('Updating selected episode: {0}'.format(video)) self.episode = video - self._reloadVideos.append(video) return True + def onVideoProgress(self, data=None, **kwargs): + if not data: + return + + util.DEBUG_LOG("Storing video progress data: {}".format(data)) + if not self._videoProgress: + self._videoProgress = {} + self._videoProgress[data[0]] = data[1] + def checkOptionsAction(self, action): if action == xbmcgui.ACTION_MOVE_UP: mli = self.episodeListControl.getSelectedItem() @@ -704,17 +806,21 @@ def infoButtonClicked(self): episode = mli.dataSource if episode.index: - subtitle = u'{0} {1} {2} {3}'.format(T(32303, 'Season'), episode.parentIndex, T(32304, 'Episode'), episode.index) + subtitle = u'{0} {1}'.format(T(32303, 'Season').format(episode.parentIndex), + T(32304, 'Episode').format(episode.index)) else: subtitle = episode.originallyAvailableAt.asDatetime('%B %d, %Y') + hide_spoilers = self.hideSpoilers(episode) + opener.handleOpen( info.InfoWindow, - title=episode.title, + title=hide_spoilers and self.noTitles and T(33008, '') or episode.title, sub_title=subtitle, thumb=episode.thumb, + thumb_opts=self.getThumbnailOpts(episode, hide_spoilers=hide_spoilers), thumb_fallback='script.plex/thumb_fallbacks/show.png', - info=episode.summary, + info=hide_spoilers and T(33008, '') or episode.summary, background=self.getProperty('background'), is_16x9=True, video=episode @@ -757,11 +863,14 @@ def episodeListClicked(self, force_episode=None, from_auto_play=False): if choice['key'] == 'resume': resume = True - self._reloadVideos.append(episode) - pl = playlist.LocalPlaylist(self.show_.all(), self.show_.getServer()) try: - if len(pl): # Don't use playlist if it's only this video + # inject our show in case we need to access show metadata from the player + episode._show = self.show_ + if len(pl) > 1: # Don't use playlist if it's only this video + for ep in pl: + ep._show = self.show_ + pl.setCurrent(episode) self.processCommand(videoplayer.play(play_queue=pl, resume=resume)) return True @@ -855,7 +964,7 @@ def optionsButtonClicked(self, from_item=False): elif choice['key'] == 'to_section': self.goHome(self.show_.getLibrarySectionId()) elif choice['key'] == 'delete': - self.delete() + self.delete(mli.dataSource) elif choice['key'] == 'playback_settings': self.playbackSettings(self.show_, pos, bottom) @@ -879,10 +988,11 @@ def mediaButtonClicked(self): choice['key'].set('selected', 1) self.setPostReloadItemInfo(ds, mli) - def delete(self): + def delete(self, item): button = optionsdialog.show( T(32326, 'Really delete?'), - T(32327, 'Are you sure you really want to delete this media?'), + T(33036, "Delete episode S{0:02d}E{1:02d} from {2}?").format(item.parentIndex.asInt(), + item.index.asInt(), item.defaultTitle), T(32328, 'Yes'), T(32329, 'No') ) @@ -910,10 +1020,10 @@ def _delete(self): (self.season or self.show_).reload() return success - def checkForHeaderFocus(self, action): + def checkForHeaderFocus(self, action, initial=False): # don't continue if we're still waiting for tasks if self.tasks or not self.episodesPaginator: - if self.tasks: + if self.tasks and not initial: util.DEBUG_LOG("Episodes: Moving too fast through paginator, throttling.") return @@ -943,7 +1053,10 @@ def checkForHeaderFocus(self, action): if action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_PAGE_UP): if mli.getProperty('is.header'): xbmc.executebuiltin('Action(up)') - if action in (xbmcgui.ACTION_MOVE_DOWN, xbmcgui.ACTION_PAGE_DOWN, xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + if action in (xbmcgui.ACTION_MOVE_DOWN, xbmcgui.ACTION_PAGE_DOWN, xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_MOVE_RIGHT): + if not initial and action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + self.manuallySelected = True if mli.getProperty('is.header'): xbmc.executebuiltin('Action(down)') @@ -957,8 +1070,10 @@ def updateProperties(self): self.setProperty('season.title', (self.season or self.show_).title) if self.season: - self.setProperty('episodes.header', u'{0} \u2022 {1} {2}'.format(showTitle, T(32303, 'Season'), self.season.index)) - self.setProperty('extras.header', u'{0} \u2022 {1} {2}'.format(T(32305, 'Extras'), T(32303, 'Season'), self.season.index)) + self.setProperty('episodes.header', u'{0} \u2022 {1}'.format(showTitle, + T(32303, 'Season').format(self.season.index))) + self.setProperty('extras.header', u'{0} \u2022 {1}'.format(T(32305, 'Extras'), + T(32303, 'Season').format(self.season.index))) else: self.setProperty('episodes.header', u'Episodes') self.setProperty('extras.header', u'Extras') @@ -972,27 +1087,73 @@ def updateProperties(self): def updateItems(self, item=None): if item: item.setProperty('unwatched', not item.dataSource.isWatched and '1' or '') + item.setProperty('watched', item.dataSource.isFullyWatched and '1' or '') self.setProgress(item) item.setProperty('progress', util.getProgressImage(item.dataSource)) (self.season or self.show_).reload() + + self.setUserItemInfo(item) else: self.fillEpisodes(update=True) if self.episode: self.episode.reload() + def setUserItemInfo(self, mli, video=None, types=("title", "thumbnail", "summary"), watched=None, + fully_watched=None, hide_spoilers=None): + video = video or mli.dataSource + + properties = {} + methods = [] + if self.noSpoilers == "off" and not hide_spoilers: + # no special handling + if "title" in types: + properties["title"] = video.title + methods.append(("setLabel", video.title)) + if "summary" in types: + properties["summary"] = video.summary.strip().replace('\t', ' ') + + if "thumbnail" in types: + methods.append(("setThumbnailImage", video.thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM))) + + else: + hide_spoilers = hide_spoilers if hide_spoilers is not None else \ + self.hideSpoilers(video, fully_watched=fully_watched, watched=watched) + hide_title = hide_spoilers and self.noTitles + if "title" in types: + tit = hide_title and T(33008, '') or video.title + properties["title"] = tit + methods.append(("setLabel", tit)) + + if "summary" in types: + properties["summary"] = hide_spoilers and T(33008, '') or video.summary.strip().replace('\t', ' ') + + if "thumbnail" in types: + methods.append(("setThumbnailImage", + video.thumb.asTranscodedImageURL( + *self.THUMB_AR16X9_DIM, + **self.getThumbnailOpts(video, fully_watched=fully_watched, watched=watched, + hide_spoilers=hide_spoilers) + ) + )) + + for property, value in properties.items(): + mli.setProperty(property, value) + + for method, value in methods: + getattr(mli, method)(value) + def setItemInfo(self, video, mli): # video.reload(checkFiles=1) mli.setProperty('background', util.backgroundFromArt(video.art, width=self.width, height=self.height)) - mli.setProperty('title', video.title) mli.setProperty('show.title', video.grandparentTitle or (self.show_.title if self.show_ else '')) mli.setProperty('duration', util.durationToText(video.duration.asInt())) - mli.setProperty('summary', video.summary.strip().replace('\t', ' ')) mli.setProperty('video.rendering', video.videoCodecRendering) + self.setUserItemInfo(mli, video, types=("title", "summary")) if video.index: - mli.setProperty('season', u'{0} {1}'.format(T(32303, 'Season'), video.parentIndex)) - mli.setProperty('episode', u'{0} {1}'.format(T(32304, 'Episode'), video.index)) + mli.setProperty('season', T(32303, 'Season').format(video.parentIndex)) + mli.setProperty('episode', T(32304, 'Episode').format(video.index)) else: mli.setProperty('season', '') mli.setProperty('episode', '') @@ -1009,6 +1170,7 @@ def setItemInfo(self, video, mli): def setPostReloadItemInfo(self, video, mli): self.setItemAudioAndSubtitleInfo(video, mli) mli.setProperty('unwatched', not video.isWatched and '1' or '') + mli.setProperty('watched', video.isFullyWatched and '1' or '') mli.setProperty('video.res', video.resolutionString()) mli.setProperty('audio.codec', video.audioCodecString()) mli.setProperty('video.codec', video.videoCodecString()) @@ -1054,48 +1216,58 @@ def setItemAudioAndSubtitleInfo(self, video, mli): else: mli.setProperty('subtitles', T(32309, 'None')) - def setProgress(self, mli): + def setProgress(self, mli, view_offset=None): video = mli.dataSource - if video.viewOffset.asInt(): - width = video.viewOffset.asInt() and (1 + int((video.viewOffset.asInt() / video.duration.asFloat()) * self.width)) or 1 + view_offset = view_offset if view_offset is not None else video.viewOffset.asInt() + if view_offset: + width = view_offset and (1 + int((view_offset / video.duration.asFloat()) * self.width)) or 1 self.progressImageControl.setWidth(width) else: self.progressImageControl.setWidth(1) - if video.viewOffset.asInt(): - mli.setProperty('remainingTime', T(33615, "{time} left").format(time=video.remainingTimeString)) + if view_offset: + mli.setProperty('remainingTime', T(33615, + "{time} left").format(time=video._remainingTimeString(view_offset))) else: mli.setProperty('remainingTime', '') def createListItem(self, episode): if episode.index: - subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + subtitle = u'{0} \u2022 {1}'.format(T(32310, 'S').format(episode.parentIndex), + T(32311, 'E').format(episode.index)) else: subtitle = episode.originallyAvailableAt.asDatetime('%m/%d/%y') mli = kodigui.ManagedListItem( - episode.title, + '', subtitle, - thumbnailImage=episode.thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM), data_source=episode ) + self.setUserItemInfo(mli, types=("title", "thumbnail")) mli.setProperty('episode.number', str(episode.index) or '') mli.setProperty('episode.duration', util.durationToText(episode.duration.asInt())) mli.setProperty('unwatched', not episode.isWatched and '1' or '') + mli.setProperty('watched', episode.isFullyWatched and '1' or '') # mli.setProperty('progress', util.getProgressImage(obj)) return mli def fillEpisodes(self, update=False): items = self.episodesPaginator.paginate() - self.reloadItems(items) + if not update: + self.selectEpisode(progress_data=self._videoProgress) + self.reloadItems(items, with_progress=True) - def reloadItems(self, items, with_progress=False): + def reloadItems(self, items, with_progress=False, skip_progress_for=None): tasks = [] for mli in items: if not mli.dataSource: continue - task = EpisodeReloadTask().setup(mli.dataSource, self.reloadItemCallback, with_progress=with_progress) + item_progress = with_progress + if skip_progress_for: + item_progress = False if mli.dataSource.ratingKey in skip_progress_for else with_progress + + task = EpisodeReloadTask().setup(mli.dataSource, self.reloadItemCallback, with_progress=item_progress) self.tasks.add(task) tasks.append(task) @@ -1128,7 +1300,8 @@ def reloadItemCallback(self, task, episode, with_progress=False): self.episodesPaginator.prepareListItem(None, mli) if mli == selected: self.lastItem = mli - self.setProgress(mli) + if with_progress: + self.setProgress(mli) if not self.currentItemLoaded and ( mli == selected or (self.episode and self.episode == mli.dataSource)): @@ -1145,7 +1318,8 @@ def reloadItemCallback(self, task, episode, with_progress=False): tries += 1 if xbmc.getCondVisibility('Control.IsVisible({})'.format(PBID)): self.setFocusId(PBID) - return + + break def fillExtras(self, has_prev=False): items = [] diff --git a/script.plexmod/lib/windows/home.py b/script.plexmod/lib/windows/home.py index 3777707c67..95008f2015 100644 --- a/script.plexmod/lib/windows/home.py +++ b/script.plexmod/lib/windows/home.py @@ -1,27 +1,31 @@ from __future__ import absolute_import -import time + +import json import threading +import time +import math +import plexnet from kodi_six import xbmc from kodi_six import xbmcgui +from plexnet import plexapp +from six.moves import range -from . import kodigui -from lib import util from lib import backgroundthread from lib import player - -import plexnet -from plexnet import plexapp - -from . import windowutils -from . import playlists +from lib import util +from lib.path_mapping import pmm +from lib.plex_hosts import pdm +from lib.util import T from . import busy +from . import dropdown +from . import kodigui from . import opener -from . import search from . import optionsdialog - -from lib.util import T -from six.moves import range +from . import playlists +from . import search +from . import windowutils +from .mixins import SpoilersMixin HUBS_REFRESH_INTERVAL = 300 # 5 Minutes HUB_PAGE_SIZE = 10 @@ -46,13 +50,16 @@ class HubsList(list): def init(self): self.lastUpdated = time.time() + self.invalid = False return self class SectionHubsTask(backgroundthread.Task): - def setup(self, section, callback): + def setup(self, section, callback, section_keys=None, reselect_pos_dict=None): self.section = section self.callback = callback + self.section_keys = section_keys + self.reselect_pos_dict = reselect_pos_dict return self def run(self): @@ -64,16 +71,22 @@ def run(self): return try: - hubs = HubsList(plexapp.SERVERMANAGER.selectedServer.hubs(self.section.key, count=HUB_PAGE_SIZE)).init() + hubs = HubsList(plexapp.SERVERMANAGER.selectedServer.hubs(self.section.key, count=HUB_PAGE_SIZE, + section_ids=self.section_keys)).init() if self.isCanceled(): return - self.callback(self.section, hubs) + self.callback(self.section, hubs, reselect_pos_dict=self.reselect_pos_dict) except plexnet.exceptions.BadRequest: util.DEBUG_LOG('404 on section: {0}'.format(repr(self.section.title))) - self.callback(self.section, False) - except TypeError: + hubs = HubsList().init() + hubs.invalid = True + self.callback(self.section, hubs) + except: util.ERROR("No data - disconnected?", notify=True, time_ms=5000) - self.cancel() + util.DEBUG_LOG('Generic exception when fetching section: {0}'.format(repr(self.section.title))) + hubs = HubsList().init() + hubs.invalid = True + self.callback(self.section, hubs) class UpdateHubTask(backgroundthread.Task): @@ -96,14 +109,20 @@ def run(self): return self.callback(self.hub) except plexnet.exceptions.BadRequest: - util.DEBUG_LOG('404 on section: {0}'.format(repr(self.section.title))) + util.DEBUG_LOG('404 on hub: {0}'.format(repr(self.hub.hubIdentifier))) + except util.NoDataException: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + except: + util.DEBUG_LOG('Something went wrong when updating hub: {0}'.format(repr(self.hub.hubIdentifier))) class ExtendHubTask(backgroundthread.Task): - def setup(self, hub, callback, canceledCallback=None): + def setup(self, hub, callback, canceledCallback=None, size=HUB_PAGE_SIZE, reselect_pos=None): self.hub = hub self.callback = callback self.canceledCallback = canceledCallback + self.size = size + self.reselect_pos = reselect_pos return self def run(self): @@ -118,16 +137,20 @@ def run(self): try: start = self.hub.offset.asInt() + self.hub.size.asInt() - items = self.hub.extend(start=start, size=HUB_PAGE_SIZE) + items = self.hub.extend(start=start, size=self.size) if self.isCanceled(): if self.canceledCallback: self.canceledCallback(self.hub) return - self.callback(self.hub, items) + self.callback(self.hub, items, reselect_pos=self.reselect_pos) except plexnet.exceptions.BadRequest: util.DEBUG_LOG('404 on hub: {0}'.format(repr(self.hub.hubIdentifier))) if self.canceledCallback: self.canceledCallback(self.hub) + except util.NoDataException: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + except: + util.DEBUG_LOG('Something went wrong when extending hub: {0}'.format(repr(self.hub.hubIdentifier))) class HomeSection(object): @@ -135,12 +158,24 @@ class HomeSection(object): type = 'home' title = T(32332, 'Home') + locations = [] + isMapped = False + + +home_section = HomeSection() + class PlaylistsSection(object): key = 'playlists' type = 'playlists' title = T(32333, 'Playlists') + locations = [] + isMapped = False + + +playlists_section = PlaylistsSection() + class ServerListItem(kodigui.ManagedListItem): uuid = None @@ -228,7 +263,7 @@ def onDestroy(self): self.unHookSignals() -class HomeWindow(kodigui.BaseWindow, util.CronReceiver): +class HomeWindow(kodigui.BaseWindow, util.CronReceiver, SpoilersMixin): xmlFile = 'script-plex-home.xml' path = util.ADDON.getAddonInfo('path') theme = 'Main' @@ -280,18 +315,24 @@ class HomeWindow(kodigui.BaseWindow, util.CronReceiver): HUBMAP = { # HOME 'home.continue': {'index': 0, 'with_progress': True, 'with_art': True, 'do_updates': True, 'text2lines': True}, + # This hub can be enabled in the settings so PM4K behaves like any other Plex client. + # It overrides home.continue and home.ondeck + 'continueWatching': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, 'home.ondeck': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, 'home.television.recent': {'index': 2, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + # This is a virtual hub and it appears when the library recommendation is customized in Plex and + # Recently Released is checked. + 'home.VIRTUAL.movies.recentlyreleased': {'index': 3, 'do_updates': True, 'with_progress': True, 'text2lines': True}, 'home.movies.recent': {'index': 4, 'do_updates': True, 'with_progress': True, 'text2lines': True}, 'home.music.recent': {'index': 5, 'text2lines': True}, 'home.videos.recent': {'index': 6, 'with_progress': True, 'ar16x9': True}, #'home.playlists': {'index': 9}, # No other Plex home screen shows playlists so removing it from here 'home.photos.recent': {'index': 10, 'text2lines': True}, # SHOW - 'tv.ondeck': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, - 'tv.recentlyaired': {'index': 2, 'do_updates': True, 'with_progress': True, 'text2lines': True}, - 'tv.recentlyadded': {'index': 3, 'do_updates': True, 'with_progress': True, 'text2lines': True}, - 'tv.inprogress': {'index': 4, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'tv.inprogress': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'tv.ondeck': {'index': 2, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'tv.recentlyaired': {'index': 3, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'tv.recentlyadded': {'index': 4, 'do_updates': True, 'with_progress': True, 'text2lines': True}, 'tv.startwatching': {'index': 7, 'with_progress': True, 'do_updates': True}, 'tv.rediscover': {'index': 8, 'with_progress': True, 'do_updates': True}, 'tv.morefromnetwork': {'index': 13, 'with_progress': True, 'do_updates': True}, @@ -341,7 +382,8 @@ class HomeWindow(kodigui.BaseWindow, util.CronReceiver): def __init__(self, *args, **kwargs): kodigui.BaseWindow.__init__(self, *args, **kwargs) - self.lastSection = HomeSection + SpoilersMixin.__init__(self, *args, **kwargs) + self.lastSection = home_section self.tasks = [] self.closeOption = None self.hubControls = None @@ -350,12 +392,17 @@ def __init__(self, *args, **kwargs): self.sectionChangeTimeout = 0 self.lastFocusID = None self.lastNonOptionsFocusID = None - self._lastSelectedItem = None self.sectionHubs = {} self.updateHubs = {} self.changingServer = False self._shuttingDown = False self._skipNextAction = False + self._reloadOnReinit = False + self._ignoreTick = False + self.librarySettings = None + self.anyLibraryHidden = False + self.wantedSections = None + self.movingSection = False windowutils.HOME = self self.lock = threading.Lock() @@ -364,7 +411,7 @@ def __init__(self, *args, **kwargs): def onFirstInit(self): # set last BG image if possible - if util.advancedSettings.dynamicBackgrounds: + if util.addonSettings.dynamicBackgrounds: bgUrl = util.getSetting("last_bg_url") if bgUrl: self.windowSetBackground(bgUrl) @@ -417,8 +464,13 @@ def onFirstInit(self): self.hookSignals() util.CRON.registerReceiver(self) self.updateProperties() + self.checkPlexDirectHosts(plexapp.SERVERMANAGER.allConnections, source="stored") def onReInit(self): + if self._reloadOnReinit: + self.serverRefresh() + self._reloadOnReinit = False + if self.lastFocusID: # try focusing the last focused ID. if that's a hub and it's empty (=not focusable), try focusing the # next best hub @@ -436,9 +488,80 @@ def onReInit(self): else: self.setFocusId(self.lastFocusID) + def checkPlexDirectHosts(self, hosts, source="stored", *args, **kwargs): + handlePD = util.getSetting('handle_plexdirect', 'ask') + if handlePD == "never": + return + + knownHosts = pdm.getHosts() + pdHosts = [host for host in hosts if ".plex.direct:" in host] + + util.DEBUG_LOG("Checking host mapping for {} {} connections".format(len(pdHosts), source)) + + newHosts = set(pdHosts) - set(knownHosts) + if newHosts: + pdm.newHosts(newHosts, source=source) + diffLen = len(pdm.diff) + + # there are situations where the myPlexManager's resources are ready earlier than + # any other. In that case, force the check. + force = plexapp.MANAGER.gotResources + + if ((source == "stored" and plexapp.ACCOUNT.isOffline) or source == "myplex" or force) and pdm.differs: + if handlePD == 'ask': + button = optionsdialog.show( + T(32993, '').format(diffLen), + T(32994, '').format(diffLen), + T(32328, 'Yes'), + T(32035, 'Always'), + T(32033, 'Never'), + ) + if button not in (0, 1, 2): + return + + if button == 1: + util.setSetting('handle_plexdirect', 'always') + elif button == 2: + util.setSetting('handle_plexdirect', 'never') + return + + hadHosts = pdm.hadHosts + pdm.write() + + if not hadHosts and handlePD == "ask": + optionsdialog.show( + T(32995, ''), + T(32996, ''), + T(32997, 'OK'), + ) + else: + # be less intrusive + util.showNotification(T(32996, ''), header=T(32995, '')) + + def loadLibrarySettings(self): + setting_key = 'home.settings.{}.{}'.format(plexapp.SERVERMANAGER.selectedServer.uuid[-8:], plexapp.ACCOUNT.ID) + data = util.getSetting(setting_key, '') + self.librarySettings = {} + try: + self.librarySettings = json.loads(data) + except ValueError: + pass + except: + util.ERROR() + + def saveLibrarySettings(self): + if self.librarySettings: + setting_key = 'home.settings.{}.{}'.format(plexapp.SERVERMANAGER.selectedServer.uuid[-8:], + plexapp.ACCOUNT.ID) + util.setSetting(setting_key, json.dumps(self.librarySettings)) + def updateProperties(self, *args, **kwargs): self.setBoolProperty('bifurcation_lines', util.getSetting('hubs_bifurcation_lines', False)) + def setTheme(self, *args, **kwargs): + util.theme = kwargs["value"] + util.applyTheme() + def focusFirstValidHub(self, startIndex=None): indices = self.hubFocusIndexes if startIndex is not None: @@ -471,12 +594,24 @@ def hookSignals(self): plexapp.SERVERMANAGER.on('reachable:server', self.displayServerAndUser) plexapp.util.APP.on('change:selectedServer', self.onSelectedServerChange) + plexapp.util.APP.on('loaded:server_connections', self.checkPlexDirectHosts) plexapp.util.APP.on('account:response', self.displayServerAndUser) plexapp.util.APP.on('sli:reachability:received', self.displayServerAndUser) plexapp.util.APP.on('change:hubs_bifurcation_lines', self.updateProperties) + plexapp.util.APP.on('change:no_episode_spoilers2', self.setDirty) + plexapp.util.APP.on('change:no_unwatched_episode_titles', self.setDirty) + plexapp.util.APP.on('change:spoilers_allowed_genres', self.setDirty) + plexapp.util.APP.on('change:hubs_use_new_continue_watching', self.setDirty) + plexapp.util.APP.on('change:use_alt_watched', self.setDirty) + plexapp.util.APP.on('change:hide_aw_bg', self.setDirty) + plexapp.util.APP.on('change:theme', self.setTheme) player.PLAYER.on('session.ended', self.updateOnDeckHubs) util.MONITOR.on('changed.watchstatus', self.updateOnDeckHubs) + util.MONITOR.on('screensaver.deactivated', self.refreshLastSection) + util.MONITOR.on('dpms.deactivated', self.refreshLastSection) + util.MONITOR.on('system.sleep', self.disableUpdates) + util.MONITOR.on('system.wakeup', self.refreshLastSection) def unhookSignals(self): plexapp.SERVERMANAGER.off('new:server', self.onNewServer) @@ -485,22 +620,34 @@ def unhookSignals(self): plexapp.SERVERMANAGER.off('reachable:server', self.displayServerAndUser) plexapp.util.APP.off('change:selectedServer', self.onSelectedServerChange) + plexapp.util.APP.off('loaded:server_connections', self.checkPlexDirectHosts) plexapp.util.APP.off('account:response', self.displayServerAndUser) plexapp.util.APP.off('sli:reachability:received', self.displayServerAndUser) plexapp.util.APP.off('change:hubs_bifurcation_lines', self.updateProperties) + plexapp.util.APP.off('change:no_episode_spoilers2', self.setDirty) + plexapp.util.APP.off('change:no_unwatched_episode_titles', self.setDirty) + plexapp.util.APP.off('change:spoilers_allowed_genres', self.setDirty) + plexapp.util.APP.off('change:hubs_use_new_continue_watching', self.setDirty) + plexapp.util.APP.off('change:use_alt_watched', self.setDirty) + plexapp.util.APP.off('change:hide_aw_bg', self.setDirty) + plexapp.util.APP.off('change:theme', self.setTheme) player.PLAYER.off('session.ended', self.updateOnDeckHubs) util.MONITOR.off('changed.watchstatus', self.updateOnDeckHubs) + util.MONITOR.off('screensaver.deactivated', self.refreshLastSection) + util.MONITOR.off('dpms.deactivated', self.refreshLastSection) + util.MONITOR.off('system.sleep', self.disableUpdates) + util.MONITOR.off('system.wakeup', self.refreshLastSection) def tick(self): - if not self.lastSection: + if not self.lastSection or self._ignoreTick: return hubs = self.sectionHubs.get(self.lastSection.key) - if not hubs: + if hubs is None: return - if time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL: + if time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL and not xbmc.Player().isPlayingVideo(): self.showHubs(self.lastSection, update=True) def shutdown(self): @@ -514,7 +661,7 @@ def shutdown(self): self.storeLastBG() def storeLastBG(self): - if util.advancedSettings.dynamicBackgrounds: + if util.addonSettings.dynamicBackgrounds: oldbg = util.getSetting("last_bg_url", "") # store BG url of first hub, first item, as this is most likely to be the one we're focusing on the # next start @@ -555,6 +702,16 @@ def onAction(self, action): self.setFocusId(self.lastFocusID) if controlID == self.SECTION_LIST_ID: + if self.movingSection: + self.sectionMover(self.movingSection, action) + return + + if action == xbmcgui.ACTION_CONTEXT_MENU: + if not self.sectionMenu(): + return + else: + self.serverRefresh() + return self.checkSectionItem(action=action) if controlID == self.SERVER_BUTTON_ID: @@ -590,10 +747,11 @@ def onAction(self, action): elif controlID == self.PLAYER_STATUS_BUTTON_ID and action == xbmcgui.ACTION_MOVE_RIGHT: self.setFocusId(self.SERVER_BUTTON_ID) elif 399 < controlID < 500: - if action.getId() in MOVE_SET: - self.checkHubItem(controlID, actionID=action.getId()) - return - elif action.getId() == xbmcgui.ACTION_PLAYER_PLAY: + if action.getId() in MOVE_SET or action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + _continue = self.checkHubItem(controlID, action=action) + if not _continue: + return + elif action == xbmcgui.ACTION_PLAYER_PLAY: self.hubItemClicked(controlID, auto_play=True) return @@ -614,10 +772,10 @@ def onAction(self, action): if controlID == self.SECTION_LIST_ID and self.sectionList.control.getSelectedPosition() > 0: self.sectionList.setSelectedItemByPos(0) - self.showHubs(HomeSection) + self.showHubs(home_section) return - if util.advancedSettings.fastBack and not optionsFocused and offSections \ + if util.addonSettings.fastBack and not optionsFocused and offSections \ and self.lastFocusID not in (self.USER_BUTTON_ID, self.SERVER_BUTTON_ID, self.SEARCH_BUTTON_ID, self.SECTION_LIST_ID): self.setProperty('hub.focus', '0') @@ -626,7 +784,7 @@ def onAction(self, action): if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): if not optionsFocused and offSections \ - and (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + and (not util.addonSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): self.lastNonOptionsFocusID = self.lastFocusID self.setFocusId(self.OPTIONS_GROUP_ID) return @@ -658,7 +816,8 @@ def onAction(self, action): def onClick(self, controlID): if controlID == self.SECTION_LIST_ID: - self.sectionClicked() + if not self.movingSection: + self.sectionClicked() # elif controlID == self.SERVER_BUTTON_ID: # self.showServers() elif controlID == self.SERVER_LIST_ID: @@ -736,15 +895,40 @@ def updateOnDeckHubs(self, **kwargs): for mli in self.sectionList: if mli.dataSource is not None and mli.dataSource != self.lastSection: sections.add(mli.dataSource) - tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback) for s in [self.lastSection] + list(sections)] + tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback, self.wantedSections) + for s in [self.lastSection] + list(sections)] else: - tasks = [UpdateHubTask().setup(hub, self.updateHubCallback) for hub in self.updateHubs.values()] + tasks = [UpdateHubTask().setup(hub, self.updateHubCallback) + for hub in self.updateHubs.values()] self.tasks += tasks backgroundthread.BGThreader.addTasks(tasks) def showBusy(self, on=True): self.setProperty('busy', on and '1' or '') + def setDirty(self, *args, **kwargs): + self._reloadOnReinit = True + self.storeSpoilerSettings() + + def fullyRefreshHome(self, *args, **kwargs): + self.showSections() + self.backgroundSet = False + self.showHubs(home_section) + + def disableUpdates(self, *args, **kwargs): + util.LOG("Sleep event, stopping updates") + self._ignoreTick = True + + def enableUpdates(self, *args, **kwargs): + util.LOG("Wake event, resuming updates") + self._ignoreTick = False + + def refreshLastSection(self, *args, **kwargs): + if not xbmc.Player().isPlayingVideo(): + util.LOG("Refreshing last section after wake events") + self.showHubs(self.lastSection, force=True) + self.enableUpdates() + @busy.dialog() def serverRefresh(self): backgroundthread.BGThreader.reset() @@ -755,13 +939,12 @@ def serverRefresh(self): with self.lock: self.setProperty('hub.focus', '') self.displayServerAndUser() + self.loadLibrarySettings() if not plexapp.SERVERMANAGER.selectedServer: self.setFocusId(self.USER_BUTTON_ID) return False - self.showSections() - self.backgroundSet = False - self.showHubs(HomeSection) + self.fullyRefreshHome() return True def hubItemClicked(self, hubControlID, auto_play=False): @@ -774,13 +957,8 @@ def hubItemClicked(self, hubControlID, auto_play=False): return carryProps = None - if auto_play and self.hubControls: - # carry over some props to the new window as we might end up showing a resume dialog not rendering the - # underlying window. the new window class will invalidate the old one temporarily, though, as it seems - # and the properties vanish, resulting in all text2lines enabled hubs to lose their title2 labels - carryProps = dict( - ('hub.text2lines.4{0:02d}'.format(i), '1') for i, hubCtrl in enumerate(self.hubControls) if - hubCtrl.dataSource and self.HUBMAP[hubCtrl.dataSource.getCleanHubIdentifier()].get("text2lines")) + if auto_play: + carryProps = self.carriedProps try: command = opener.open(mli.dataSource, auto_play=auto_play, dialog_props=carryProps) @@ -827,6 +1005,163 @@ def processCommand(self, command): self.lastSection = mli.dataSource self.sectionChanged() + @property + def carriedProps(self): + # carry over some props to the new window as we might end up showing a dialog not rendering the + # underlying window. the new window class will invalidate the old one temporarily, though, as it seems + # and the properties vanish, resulting in all text2lines enabled hubs to lose their title2 labels + if self.hubControls: + return dict( + ('hub.text2lines.4{0:02d}'.format(i), '1') for i, hubCtrl in enumerate(self.hubControls) if + hubCtrl.dataSource and self.HUBMAP[hubCtrl.dataSource.getCleanHubIdentifier()].get("text2lines")) + + def sectionMenu(self): + item = self.sectionList.getSelectedItem() + if not item or not item.getProperty('item'): + return + + section = item.dataSource + choice = None + if not section.key: + # home section + sections = [playlists_section] + plexapp.SERVERMANAGER.selectedServer.library.sections() + options = [] + + if "order" in self.librarySettings and self.librarySettings["order"]: + options.append({'key': 'reset_order', 'display': T(33040, "Reset library order")}) + + for s in sections: + section_settings = self.librarySettings.get(s.key) + if section_settings and not section_settings.get("show", True): + options.append({'key': 'show', + 'section_id': s.key, + 'display': T(33029, "Show library: {}").format(s.title) + } + ) + if options: + choice = dropdown.showDropdown( + options, + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=T(33034, "Library settings"), + select_index=0, + align_items="left", + dialog_props=self.carriedProps + ) + + else: + options = [] + + if section.locations: + for loc in section.locations: + source, target = section.getMappedPath(loc) + loc_is_mapped = source and target + options.append( + {'key': 'map', 'mapped': loc_is_mapped, 'path': loc, 'display': T(33026, + "Map path: {}").format(loc) + if not loc_is_mapped else T(33027, "Remove mapping: {}").format(target) + } + ) + + options.append({'key': 'hide', 'display': T(33028, "Hide library")}) + options.append({'key': 'move', 'display': T(33039, "Move")}) + + choice = dropdown.showDropdown( + options, + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=T(33030, 'Choose action for: {}').format(section.title), + select_index=0, + align_items="left", + dialog_props=self.carriedProps + ) + + if not choice: + return + + if choice["key"] == "map": + is_mapped = choice.get("mapped") + if is_mapped: + # show deletion + source, target = section.getMappedPath(choice["path"]) + section.deleteMapping(target) + return True + + else: + # show fb + # select loc to map + d = xbmcgui.Dialog().browse(0, T(33031, "Select Kodi source for {}").format(choice["path"]), "files") + if not d: + return + pmm.addPathMapping(d, choice["path"]) + return True + elif choice["key"] == "hide": + if section.key not in self.librarySettings: + self.librarySettings[section.key] = {} + self.librarySettings[section.key]['show'] = False + self.saveLibrarySettings() + return True + elif choice["key"] == "show": + if choice["section_id"] in self.librarySettings: + self.librarySettings[choice["section_id"]]['show'] = True + self.saveLibrarySettings() + return True + elif choice["key"] == "move": + self.sectionMover(item, "init") + elif choice["key"] == "reset_order": + if "order" in self.librarySettings: + del self.librarySettings["order"] + self.saveLibrarySettings() + return True + + def sectionMover(self, item, action): + def stop_moving(): + # set everything to non-moving and re-insert home item + self.movingSection = False + self.setBoolProperty("moving", False) + item.setBoolProperty("moving", False) + homemli = kodigui.ManagedListItem(T(32332, 'Home'), data_source=home_section) + homemli.setProperty('is.home', '1') + homemli.setProperty('item', '1') + self.sectionList.insertItem(0, homemli) + self.sectionList.selectItem(0) + self.sectionChanged() + + if action == "init": + self.movingSection = item + self.setBoolProperty("moving", True) + + # remove home item + self.sectionList.removeItem(0) + self.sectionList.setSelectedItem(item) + + item.setBoolProperty("moving", True) + + elif action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + stop_moving() + + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + direction = "left" if action == xbmcgui.ACTION_MOVE_LEFT else "right" + index = self.sectionList.getManagedItemPosition(item) + last_index = len(self.sectionList) - 1 + next_index = min(max(0, index - 1 if direction == "left" else index + 1), last_index) + if index == 0 and direction == "left": + next_index = last_index + self.sectionList.selectItem(last_index) + elif index == last_index and direction == "right": + next_index = 0 + self.sectionList.selectItem(0) + + self.sectionList.moveItem(item, next_index) + + elif action == xbmcgui.ACTION_SELECT_ITEM: + stop_moving() + # store section order + self.librarySettings["order"] = [i.dataSource.key for i in self.sectionList.items if i.dataSource] + self.saveLibrarySettings() + def checkSectionItem(self, force=False, action=None): item = self.sectionList.getSelectedItem() if not item: @@ -843,29 +1178,25 @@ def checkSectionItem(self, force=False, action=None): if item.getProperty('is.home'): self.storeLastBG() - if item.dataSource != self.lastSection: - self.sectionChanged(force) + if item.dataSource != self.lastSection or force: + self.sectionChanged(force=force) - def checkHubItem(self, controlID, actionID=None): + def checkHubItem(self, controlID, action=None): control = self.hubControls[controlID - 400] mli = control.getSelectedItem() is_valid_mli = mli and mli.getProperty('is.end') != '1' - is_last_item = is_valid_mli and control.isLastItem(mli) - if util.advancedSettings.dynamicBackgrounds and is_valid_mli: + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + if control.getSelectedPos() > 0: + control.selectItem(0) + self.updateBackgroundFrom(control[0].dataSource) + return + return True + + if util.addonSettings.dynamicBackgrounds and is_valid_mli: self.updateBackgroundFrom(mli.dataSource) if not mli or not mli.getProperty('is.end') or mli.getProperty('is.updating') == '1': - mlipos = control.getManagedItemPosition(mli) - - # in order to not round robin when the next chunk is loading, implement our own cheap round robining - # by storing the last selected item of the current control. if we've seen it twice, we need to wrap around - if mli and not mli.getProperty('is.end') and is_last_item and actionID == xbmcgui.ACTION_MOVE_RIGHT: - if (controlID, mlipos) == self._lastSelectedItem: - control.selectItem(0) - self._lastSelectedItem = None - else: - self._lastSelectedItem = (controlID, mlipos) return mli.setBoolProperty('is.updating', True) @@ -897,50 +1228,70 @@ def displayServerAndUser(self, **kwargs): self.setProperty('server.iconmod2', '') def cleanTasks(self): - self.tasks = [t for t in self.tasks if t.isValid()] + self.tasks = [t for t in self.tasks if t] def sectionChanged(self, force=False): self.sectionChangeTimeout = time.time() + 0.5 - if not self.sectionChangeThread or not self.sectionChangeThread.is_alive() or force: - if self.sectionChangeThread and self.sectionChangeThread.is_alive(): - self.sectionChangeThread.join() + # wait 2s at max if we're currently awaiting any hubs to reload + # fixme: this can be done in a better way, probably + waited = 0 + while any(self.tasks) and waited < 20: + self.showBusy(True) + util.MONITOR.waitForAbort(0.1) + waited += 1 + self.showBusy(False) + + if force: + self.sectionChangeTimeout = None + self._sectionChanged(immediate=True) + return + + if not self.sectionChangeThread or (self.sectionChangeThread and not self.sectionChangeThread.is_alive()): self.sectionChangeThread = threading.Thread(target=self._sectionChanged, name="sectionchanged") self.sectionChangeThread.start() - def _sectionChanged(self): - while not util.MONITOR.waitForAbort(0.1): - if time.time() >= self.sectionChangeTimeout: - break + def _sectionChanged(self, immediate=False): + if not immediate: + if not self.sectionChangeTimeout: + return + while not util.MONITOR.waitForAbort(0.1): + if time.time() >= self.sectionChangeTimeout: + break ds = self.sectionList.getSelectedItem().dataSource if self.lastSection == ds: return - self.lastSection = ds + self._sectionReallyChanged(ds) - self._sectionReallyChanged() - - def _sectionReallyChanged(self): + def _sectionReallyChanged(self, section): with self.lock: - section = self.lastSection self.setProperty('hub.focus', '') - if util.advancedSettings.dynamicBackgrounds: + if util.addonSettings.dynamicBackgrounds: self.backgroundSet = False util.DEBUG_LOG('Section changed ({0}): {1}'.format(section.key, repr(section.title))) self.showHubs(section) self.lastSection = section + + # timing issue + cur_sel_ds = self.sectionList.getSelectedItem().dataSource + if self.lastSection != cur_sel_ds: + util.DEBUG_LOG("Section changed in the " + "meantime from {} to {}, re-running the section change".format( + section.key, + cur_sel_ds.key)) self.checkSectionItem(force=True) - def sectionHubsCallback(self, section, hubs): + def sectionHubsCallback(self, section, hubs, reselect_pos_dict=None): with self.lock: update = bool(self.sectionHubs.get(section.key)) self.sectionHubs[section.key] = hubs if self.lastSection == section: - self.showHubs(section, update=update) + self.showHubs(section, update=update, reselect_pos_dict=reselect_pos_dict) - def updateHubCallback(self, hub, items=None): + def updateHubCallback(self, hub, items=None, reselect_pos=None): with self.lock: for mli in self.sectionList: section = mli.dataSource @@ -955,45 +1306,74 @@ def updateHubCallback(self, hub, items=None): for idx, ihub in enumerate(hubs): if ihub == hub: if self.lastSection == section: - util.DEBUG_LOG('Hub {0} updated - refreshing section: {1}'.format(hub.hubIdentifier, repr(section.title))) + util.DEBUG_LOG('Hub {0} updated - refreshing section: {1}'.format(hub.hubIdentifier, + repr(section.title))) hubs[idx] = hub - self.showHub(hub, items=items) + self.showHub(hub, items=items, reselect_pos=reselect_pos) return - def extendHubCallback(self, hub, items): - util.DEBUG_LOG('ExtendHub called: {0} [{1}]'.format(hub.hubIdentifier, len(hub.items))) - self.updateHubCallback(hub, items) + def extendHubCallback(self, hub, items, reselect_pos=None): + util.DEBUG_LOG('ExtendHub called: {0} [{1}] (reselect: {2})'.format(hub.hubIdentifier, len(hub.items), + reselect_pos)) + self.updateHubCallback(hub, items, reselect_pos=reselect_pos) def showSections(self): self.sectionHubs = {} items = [] - homemli = kodigui.ManagedListItem(T(32332, 'Home'), data_source=HomeSection) + homemli = kodigui.ManagedListItem(T(32332, 'Home'), data_source=home_section) homemli.setProperty('is.home', '1') homemli.setProperty('item', '1') items.append(homemli) - pl = plexapp.SERVERMANAGER.selectedServer.playlists() - if pl: - plli = kodigui.ManagedListItem('Playlists', thumbnailImage='script.plex/home/type/playlists.png', data_source=PlaylistsSection) - plli.setProperty('is.playlists', '1') - plli.setProperty('item', '1') - items.append(plli) + sections = [] + + if "playlists" not in self.librarySettings \ + or ("playlists" in self.librarySettings and self.librarySettings["playlists"].get("show", True)): + pl = plexapp.SERVERMANAGER.selectedServer.playlists() + if pl: + sections.append(playlists_section) try: - sections = plexapp.SERVERMANAGER.selectedServer.library.sections() + _sections = plexapp.SERVERMANAGER.selectedServer.library.sections() except plexnet.exceptions.BadRequest: self.setFocusId(self.SERVER_BUTTON_ID) util.messageDialog("Error", "Bad request") return + self.wantedSections = [] + for section in _sections: + if section.key in self.librarySettings and not self.librarySettings[section.key].get("show", True): + self.anyLibraryHidden = True + continue + sections.append(section) + self.wantedSections.append(section.key) + + # sort libraries + if "order" in self.librarySettings: + sections = sorted(sections, key=lambda s: self.librarySettings["order"].index(s.key) + if s.key in self.librarySettings["order"] else -1) + + # speedup if we don't have any hidden libraries + if not self.anyLibraryHidden: + self.wantedSections = None + if plexapp.SERVERMANAGER.selectedServer.hasHubs(): - self.tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback) for s in [HomeSection, PlaylistsSection] + sections] + self.tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback, self.wantedSections) + for s in [home_section] + sections] backgroundthread.BGThreader.addTasks(self.tasks) + show_pm_indicator = util.getSetting('path_mapping_indicators', True) for section in sections: - mli = kodigui.ManagedListItem(section.title, thumbnailImage='script.plex/home/type/{0}.png'.format(section.type), data_source=section) + mli = kodigui.ManagedListItem(section.title, + thumbnailImage='script.plex/home/type/{0}.png'.format(section.type), + data_source=section) mli.setProperty('item', '1') + if section == playlists_section: + mli.setProperty('is.playlists', '1') + mli.setThumbnailImage('script.plex/home/type/playlists.png') + if pmm.mapping and show_pm_indicator: + mli.setBoolProperty('is.mapped', section.isMapped) items.append(mli) self.bottomItem = len(items) - 1 @@ -1002,7 +1382,7 @@ def showSections(self): mli = kodigui.ManagedListItem() items.append(mli) - self.lastSection = HomeSection + self.lastSection = home_section self.sectionList.reset() self.sectionList.addItems(items) @@ -1011,16 +1391,16 @@ def showSections(self): else: self.setFocusId(self.SERVER_BUTTON_ID) - def showHubs(self, section=None, update=False): + def showHubs(self, section=None, update=False, force=False, reselect_pos_dict=None): self.setBoolProperty('no.content', False) if not update: self.setProperty('drawing', '1') try: - self._showHubs(section=section, update=update) + self._showHubs(section=section, update=update, force=force, reselect_pos_dict=reselect_pos_dict) finally: self.setProperty('drawing', '') - def _showHubs(self, section=None, update=False): + def _showHubs(self, section=None, update=False, force=False, reselect_pos_dict=None): if not update: self.clearHubs() @@ -1034,30 +1414,47 @@ def _showHubs(self, section=None, update=False): self.showBusy(True) hubs = self.sectionHubs.get(section.key) - if hubs is False: - self.showBusy(False) - self.setBoolProperty('no.content', True) - return + section_stale = False - if not hubs: - for task in self.tasks: - if task.section == section: - backgroundthread.BGThreader.moveToFront(task) - break + if not force: + if hubs is not None: + section_stale = time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL - if section.type != "home": + # hubs.invalid is True when the last hub update errored. if the hub is stale, refresh it, though + if hubs is not None and hubs.invalid and not section_stale: + util.DEBUG_LOG("Section fetch has failed: {}".format(section.key)) self.showBusy(False) self.setBoolProperty('no.content', True) - return + return - if time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL: - util.DEBUG_LOG('Section is stale: REFRESHING - update: {0}'.format(update)) + if not hubs and not section_stale: + for task in self.tasks: + if task.section == section: + backgroundthread.BGThreader.moveToFront(task) + break + + if section.type != "home": + self.showBusy(False) + self.setBoolProperty('no.content', True) + return + + if section_stale or force: + util.DEBUG_LOG('Section is stale: {0} REFRESHING - update: {1}, failed before: {2}'.format( + "Home" if section.key is None else section.key, update, "Unknown" if not hubs else hubs.invalid)) hubs.lastUpdated = time.time() self.cleanTasks() + # remember selected positions in hubs + is_home = section.key is None + _rp = {} + for hub in self.sectionHubs.get(section.key, []): + identifier = hub.getCleanHubIdentifier(is_home=is_home) + if identifier in self.HUBMAP: + _rp[identifier] = self.hubControls[self.HUBMAP[identifier]['index']].getSelectedPos() if not update: if section.key in self.sectionHubs: self.sectionHubs[section.key] = None - self.tasks.append(SectionHubsTask().setup(section, self.sectionHubsCallback)) + self.tasks.append(SectionHubsTask().setup(section, self.sectionHubsCallback, self.wantedSections, + reselect_pos_dict=_rp)) backgroundthread.BGThreader.addTask(self.tasks[-1]) return @@ -1065,16 +1462,19 @@ def _showHubs(self, section=None, update=False): try: hasContent = False skip = {} + for hub in hubs: - identifier = hub.getCleanHubIdentifier() + identifier = hub.getCleanHubIdentifier(is_home=not section.key) if identifier not in self.HUBMAP: - util.DEBUG_LOG('UNHANDLED - Hub: {0} [{1}]({2})'.format(hub.hubIdentifier, identifier, len(hub.items))) + util.DEBUG_LOG('UNHANDLED - Hub: {0} [{1}]({2})'.format(hub.hubIdentifier, identifier, + len(hub.items))) continue skip[self.HUBMAP[identifier]['index']] = 1 - if self.showHub(hub): + if self.showHub(hub, is_home=not section.key, + reselect_pos=reselect_pos_dict.get(identifier) if reselect_pos_dict else None): if hub.items: hasContent = True if self.HUBMAP[identifier].get('do_updates'): @@ -1103,15 +1503,16 @@ def _showHubs(self, section=None, update=False): finally: self.showBusy(False) - def showHub(self, hub, items=None): - identifier = hub.getCleanHubIdentifier() + def showHub(self, hub, items=None, is_home=False, reselect_pos=None): + identifier = hub.getCleanHubIdentifier(is_home=is_home) if identifier in self.HUBMAP: util.DEBUG_LOG('HUB: {0} [{1}]({2}, {3})'.format(hub.hubIdentifier, identifier, len(hub.items), len(items) if items else None)) - self._showHub(hub, hubitems=items, **self.HUBMAP[identifier]) + self._showHub(hub, hubitems=items, reselect_pos=reselect_pos, identifier=identifier, + **self.HUBMAP[identifier]) return True else: util.DEBUG_LOG('UNHANDLED - Hub: {0} [{1}]({1})'.format(hub.hubIdentifier, identifier, len(hub.items))) @@ -1140,7 +1541,7 @@ def createSimpleListItem(self, obj, thumb_w, thumb_h): def createEpisodeListItem(self, obj, wide=False): mli = self.createGrandparentedListItem(obj, *self.THUMB_POSTER_DIM) if obj.index: - subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), obj.parentIndex, T(32311, 'E'), obj.index) + subtitle = u'{0} \u2022 {1}'.format(T(32310, 'S').format(obj.parentIndex), T(32311, 'E').format(obj.index)) else: subtitle = obj.originallyAvailableAt.asDatetime('%m/%d/%y') @@ -1152,6 +1553,7 @@ def createEpisodeListItem(self, obj, wide=False): mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') if not obj.isWatched: mli.setProperty('unwatched', '1') + mli.setBoolProperty('watched', obj.isFullyWatched) return mli def createSeasonListItem(self, obj, wide=False): @@ -1160,6 +1562,7 @@ def createSeasonListItem(self, obj, wide=False): mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') if not obj.isWatched: mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + mli.setBoolProperty('watched', obj.isFullyWatched) return mli def createMovieListItem(self, obj, wide=False): @@ -1167,6 +1570,7 @@ def createMovieListItem(self, obj, wide=False): mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie.png') if not obj.isWatched: mli.setProperty('unwatched', '1') + mli.setBoolProperty('watched', obj.isFullyWatched) return mli def createShowListItem(self, obj, wide=False): @@ -1174,6 +1578,7 @@ def createShowListItem(self, obj, wide=False): mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') if not obj.isWatched: mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + mli.setBoolProperty('watched', obj.isFullyWatched) return mli def createAlbumListItem(self, obj, wide=False): @@ -1247,8 +1652,8 @@ def clearHubs(self): for control in self.hubControls: control.reset() - def _showHub(self, hub, hubitems=None, index=None, with_progress=False, with_art=False, ar16x9=False, - text2lines=False, **kwargs): + def _showHub(self, hub, hubitems=None, reselect_pos=None, identifier=None, index=None, with_progress=False, + with_art=False, ar16x9=False, text2lines=False, **kwargs): control = self.hubControls[index] control.dataSource = hub @@ -1264,11 +1669,23 @@ def _showHub(self, hub, hubitems=None, index=None, with_progress=False, with_art items = [] + check_spoilers = False for obj in hubitems or hub.items: if not self.backgroundSet: if self.updateBackgroundFrom(obj): self.backgroundSet = True - mli = self.createListItem(obj, wide=with_art) + + wide = with_art + no_spoilers = False + if obj.type == 'episode' and hub.hubIdentifier == "home.continue" and self.spoilerSetting != "off": + check_spoilers = True + obj._noSpoilers = no_spoilers = self.hideSpoilers(obj, use_cache=False) + + if obj.type == 'episode' and util.addonSettings.continueUseThumb and wide: + # with_art sets the wide parameter which includes the episode title + wide = no_spoilers in ("funwatched", "unwatched") and not self.noTitles + + mli = self.createListItem(obj, wide=wide) if mli: items.append(mli) @@ -1277,18 +1694,23 @@ def _showHub(self, hub, hubitems=None, index=None, with_progress=False, with_art mli.setProperty('progress', util.getProgressImage(mli.dataSource)) if with_art: for mli in items: - thumb = (util.advancedSettings.continueUseThumb - and mli.dataSource.type == 'episode' - and mli.dataSource.thumb - ) \ - or mli.dataSource.art - mli.setThumbnailImage(thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM)) + extra_opts = {} + thumb = mli.dataSource.art + # use episode thumbnail for in progress episodes + if mli.dataSource.type == 'episode' and util.addonSettings.continueUseThumb and check_spoilers: + # blur them if we don't want any spoilers and the episode hasn't been fully watched + if mli.dataSource._noSpoilers: + extra_opts = {"blur": util.addonSettings.episodeNoSpoilerBlur} + thumb = mli.dataSource.thumb + + mli.setThumbnailImage(thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM, **extra_opts)) mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie16x9.png') if ar16x9: for mli in items: mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie16x9.png') - if hub.more.asBool(): + more = hub.more.asBool() + if more: end = kodigui.ManagedListItem('') end.setBoolProperty('is.end', True) items.append(end) @@ -1297,10 +1719,29 @@ def _showHub(self, hub, hubitems=None, index=None, with_progress=False, with_art end = control.size() - 1 control.replaceItem(end, items[0]) control.addItems(items[1:]) - control.selectItem(end) + if reselect_pos is None: + control.selectItem(end) else: control.replaceItems(items) + if reselect_pos is not None and reselect_pos > 0: + pos = reselect_pos + if pos < control.size() - (more and 1 or 0): + control.selectItem(pos) + else: + if more: + # re-extend the hub to its original size so we can reselect the position + # calculate how many pages we need to re-arrive at the last selected position + # fixme: someone check for an off-by-one please + size = max(math.ceil((pos + 2 - control.size()) / HUB_PAGE_SIZE), 1) * HUB_PAGE_SIZE + task = ExtendHubTask().setup(control.dataSource, self.extendHubCallback, + canceledCallback=lambda hub: mli.setBoolProperty('is.updating', False), + size=size, reselect_pos=pos) + self.tasks.append(task) + backgroundthread.BGThreader.addTask(task) + else: + control.selectItem(control.size() - 1) + def updateListItem(self, mli): if not mli or not mli.dataSource: # May have become invalid return @@ -1308,7 +1749,9 @@ def updateListItem(self, mli): obj = mli.dataSource if obj.type in ('episode', 'movie'): mli.setProperty('unwatched', not obj.isWatched and '1' or '') + mli.setProperty('watched', obj.isFullyWatched and '1' or '') elif obj.type in ('season', 'show', 'album'): + mli.setProperty('watched', obj.isFullyWatched and '1' or '') if obj.isWatched: mli.setProperty('unwatched.count', '') else: @@ -1344,6 +1787,7 @@ def onReachableServer(self, server=None, **kwargs): self.onNewServer() def onSelectedServerChange(self, **kwargs): + util.DEBUG_LOG("YEELLO") if self.serverRefresh(): self.setFocusId(self.SECTION_LIST_ID) self.changingServer = False @@ -1430,6 +1874,9 @@ def selectServer(self): def showUserMenu(self, mouse=False): items = [] if plexapp.ACCOUNT.isSignedIn: + if not len(plexapp.ACCOUNT.homeUsers) and not util.addonSettings.cacheHomeUsers: + plexapp.ACCOUNT.updateHomeUsers(refreshSubscription=True) + if len(plexapp.ACCOUNT.homeUsers) > 1: items.append(kodigui.ManagedListItem(T(32342, 'Switch User'), data_source='switch')) else: @@ -1471,7 +1918,7 @@ def doUserOption(self): elif option == 'go_online': plexapp.ACCOUNT.refreshAccount() elif option == 'refresh_users': - plexapp.ACCOUNT.updateHomeUsers() + plexapp.ACCOUNT.updateHomeUsers(refreshSubscription=True) return True else: self.closeOption = option diff --git a/script.plexmod/lib/windows/info.py b/script.plexmod/lib/windows/info.py index 7c610b9ca3..66631b7f20 100644 --- a/script.plexmod/lib/windows/info.py +++ b/script.plexmod/lib/windows/info.py @@ -1,11 +1,13 @@ from __future__ import absolute_import -from . import kodigui -from . import windowutils -from lib import util -from plexnet.video import Episode, Movie, Clip import os +from plexnet.video import Episode, Movie, Clip + +from lib import util +from . import kodigui +from . import windowutils + def split2len(s, n): def _f(s, n): @@ -33,6 +35,7 @@ def __init__(self, *args, **kwargs): self.title = kwargs.get('title') self.subTitle = kwargs.get('sub_title') self.thumb = kwargs.get('thumb') + self.thumb_opts = kwargs.get('thumb_opts', {}) self.thumbFallback = kwargs.get('thumb_fallback') self.info = kwargs.get('info') self.background = kwargs.get('background') @@ -72,6 +75,7 @@ def getVideoInfo(self): addMedia.append("Unavailable: {}".format(os.path.basename(part.file))) continue + pmFolder = part.getPathMappedUrl(return_only_folder=True) addMedia.append("File: ") splitFnAt = 74 fnLen = len(os.path.basename(part.file)) @@ -82,6 +86,8 @@ def getVideoInfo(self): appended = True continue addMedia.append("{}\n".format(s)) + if pmFolder: + addMedia.append("Mapped via: {}\n".format(pmFolder)) addMedia.append("Duration: {}, Size: {}\n".format(util.durationToShortText(int(part.duration)), util.simpleSize(int(part.size)))) @@ -152,7 +158,7 @@ def onFirstInit(self): self.setProperty('title.main', self.title) self.setProperty('title.sub', self.subTitle) self.setProperty('thumb.fallback', self.thumbFallback) - self.setProperty('thumb', self.thumb.asTranscodedImageURL(*self.thumbDim)) + self.setProperty('thumb', self.thumb.asTranscodedImageURL(*self.thumbDim, **self.thumb_opts)) self.setProperty('info', self.getVideoInfo()) self.setProperty('background', self.background) diff --git a/script.plexmod/lib/windows/kodigui.py b/script.plexmod/lib/windows/kodigui.py index 40f466b7db..8d53307352 100644 --- a/script.plexmod/lib/windows/kodigui.py +++ b/script.plexmod/lib/windows/kodigui.py @@ -1,18 +1,22 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from kodi_six import xbmc -from kodi_six import xbmcgui -import time + import threading +import time import traceback + +from kodi_six import xbmc +from kodi_six import xbmcgui +from plexnet import util as pnUtil from six.moves import range from six.moves import zip + from .. import util MONITOR = None -class BaseFunctions: +class BaseFunctions(object): xmlFile = '' path = '' theme = '' @@ -100,6 +104,8 @@ def setBoolProperty(self, key, boolean): class BaseWindow(xbmcgui.WindowXML, BaseFunctions): + __slots__ = ("_closing", "_winID", "started", "finishedInit", "dialogProps", "isOpen") + def __init__(self, *args, **kwargs): BaseFunctions.__init__(self) self._closing = False @@ -111,6 +117,8 @@ def __init__(self, *args, **kwargs): carryProps = kwargs.get("window_props", None) if carryProps: self.setProperties(list(carryProps.keys()), list(carryProps.values())) + self.setBoolProperty('use_alt_watched', util.getSetting('use_alt_watched', True)) + self.setBoolProperty('hide_aw_bg', util.getSetting('hide_aw_bg', False)) def onInit(self): global LAST_BG_URL @@ -118,19 +126,19 @@ def onInit(self): BaseFunctions.lastWinID = self._winID self.setProperty('use_solid_background', util.hasCustomBGColour and '1' or '') if util.hasCustomBGColour: - bgColour = util.advancedSettings.backgroundColour if util.advancedSettings.backgroundColour != "-" \ + bgColour = util.addonSettings.backgroundColour if util.addonSettings.backgroundColour != "-" \ else "ff000000" self.setProperty('background_colour', "0x%s" % bgColour.lower()) self.setProperty('background_colour_opaque', "0x%s" % bgColour.lower()) else: # set background color to 0 to avoid kodi UI BG clearing, improves performance - if util.advancedSettings.dbgCrossfade: + if util.addonSettings.dbgCrossfade: self.setProperty('background_colour', "0x00000000") else: self.setProperty('background_colour', "0xff111111") self.setProperty('background_colour_opaque', "0xff111111") - self.setBoolProperty('use_bg_fallback', util.advancedSettings.useBgFallback) + self.setBoolProperty('use_bg_fallback', util.addonSettings.useBgFallback) try: if self.started: @@ -151,9 +159,11 @@ def onFirstInit(self): def onReInit(self): pass - def waitForOpen(self): + def waitForOpen(self, base_win_id=None): tries = 0 - while not self.isOpen and not util.MONITOR.waitForAbort(2) and tries < 60: + while ((not base_win_id and not self.isOpen) or + (base_win_id and xbmcgui.getCurrentWindowId() <= base_win_id)) \ + and not util.MONITOR.waitForAbort(1) and tries < 120: if tries == 0: util.LOG("Couldn't open window {}, other dialog open? Retrying for 120s.".format(self)) self.show() @@ -177,12 +187,12 @@ def setProperty(self, key, value): xbmc.log('kodigui.BaseWindow.setProperty: Missing window', xbmc.LOGDEBUG) def updateBackgroundFrom(self, ds): - if util.advancedSettings.dynamicBackgrounds: + if util.addonSettings.dynamicBackgrounds: return self.windowSetBackground(util.backgroundFromArt(ds.art, width=self.width, height=self.height)) def windowSetBackground(self, value): - if not util.advancedSettings.dbgCrossfade: + if not util.addonSettings.dbgCrossfade: if not value: return self.setProperty("background_static", value) @@ -227,6 +237,8 @@ def onClosed(self): class BaseDialog(xbmcgui.WindowXMLDialog, BaseFunctions): + __slots__ = ("_closing", "_winID", "started", "isOpen") + def __init__(self, *args, **kwargs): BaseFunctions.__init__(self) self._closing = False @@ -236,6 +248,8 @@ def __init__(self, *args, **kwargs): carryProps = kwargs.get("dialog_props", None) if carryProps: self.setProperties(list(carryProps.keys()), list(carryProps.values())) + self.setBoolProperty('use_alt_watched', util.getSetting('use_alt_watched', True)) + self.setBoolProperty('hide_aw_bg', util.getSetting('hide_aw_bg', False)) def onInit(self): self._winID = xbmcgui.getCurrentWindowDialogId() @@ -341,7 +355,16 @@ def __setattr__(self, key, value): class ManagedListItem(object): - def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', data_source=None, properties=None): + __slots__ = ("_listItem", "dataSource", "properties", "label", "label2", "iconImage", "thumbnailImage", "path", + "_ID", "_manager", "_valid") + + PROPS = { + 'use_alt_watched': util.getSetting('use_alt_watched', True) and '1' or '', + 'hide_aw_bg': util.getSetting('hide_aw_bg', False) and '1' or '' + } + + def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', data_source=None, + properties=None): self._listItem = xbmcgui.ListItem(label, label2, path=path) self._listItem.setArt({"thumb": thumbnailImage, "icon": iconImage}) self.dataSource = data_source @@ -354,6 +377,9 @@ def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='' self._ID = None self._manager = None self._valid = True + for k, v in self.PROPS.items(): + self.setProperty(k, v) + if properties: for k, v in properties.items(): self.setProperty(k, v) @@ -497,7 +523,19 @@ def onDestroy(self): pass +def watchMarkerSettingsChanged(*args, **kwargs): + ManagedListItem.PROPS['use_alt_watched'] = util.getSetting('use_alt_watched', True) and '1' or '' + ManagedListItem.PROPS['hide_aw_bg'] = util.getSetting('hide_aw_bg', False) and '1' or '' + + +pnUtil.APP.on('change:use_alt_watched', watchMarkerSettingsChanged) +pnUtil.APP.on('change:hide_aw_bg', watchMarkerSettingsChanged) + + class ManagedControlList(object): + __slots__ = ("controlID", "control", "items", "_sortKey", "_idCounter", "_maxViewIndex", "_properties", + "dataSource") + def __init__(self, window, control_id, max_view_index, data_source=None): self.controlID = control_id self.control = window.getControl(control_id) @@ -633,10 +671,24 @@ def getSelectedItem(self): return None return self.getListItem(pos) + def getSelectedPos(self): + pos = self.control.getSelectedPosition() + if not self.positionIsValid(pos): + pos = self.size() - 1 + + if pos < 0: + return None + return pos + def setSelectedItemByPos(self, pos): if self.positionIsValid(pos): self.control.selectItem(pos) + def setSelectedItem(self, item): + pos = self.getManagedItemPosition(item) + if self.positionIsValid(pos): + self.control.selectItem(pos) + def removeItem(self, index): old = self.items.pop(index) old.onDestroy() @@ -791,6 +843,8 @@ def newControl(self, window=None, control_id=None): class _MWBackground(ControlledWindow): + __slots__ = ("_multiWindow", "started") + def __init__(self, *args, **kwargs): self._multiWindow = kwargs.get('multi_window') self.started = False @@ -805,6 +859,8 @@ def onInit(self): class MultiWindow(object): + __slots__ = ("_windows", "_next", "_properties", "_current", "_allClosed", "exitCommand", "_currentOnAction") + def __init__(self, windows=None, default_window=None, **kwargs): self._windows = windows self._next = default_window or self._windows[0] @@ -1113,6 +1169,8 @@ def reset(self, close_win=None, init=None): class WindowProperty(): + __slots__ = ("win", "prop", "val", "end", "old") + def __init__(self, win, prop, val='1', end=None): self.win = win self.prop = prop @@ -1129,6 +1187,8 @@ def __exit__(self, exc_type, exc_value, traceback): class GlobalProperty(): + __slots__ = ("_addonID", "prop", "val", "end", "old") + def __init__(self, prop, val='1', end=None): from kodi_six import xbmcaddon self._addonID = xbmcaddon.Addon().getAddonInfo('id') diff --git a/script.plexmod/lib/windows/library.py b/script.plexmod/lib/windows/library.py index 1ef8812dac..951fc7c67e 100644 --- a/script.plexmod/lib/windows/library.py +++ b/script.plexmod/lib/windows/library.py @@ -1,34 +1,33 @@ from __future__ import absolute_import + +import json import os import random -import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error -import json -import time import threading +import plexnet +import six +import six.moves.urllib.error +import six.moves.urllib.parse +import six.moves.urllib.request from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from plexnet import playqueue +from six.moves import range -from lib import util from lib import backgroundthread from lib import player - +from lib import util +from lib.util import T from . import busy -from . import subitems -from . import preplay -from . import search -import plexnet from . import dropdown +from . import kodigui from . import opener +from . import preplay +from . import search +from . import subitems from . import windowutils -from plexnet import playqueue - -from lib.util import T -import six -from six.moves import range - KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' MOVE_SET = frozenset( @@ -348,7 +347,7 @@ def reset(self): self.alreadyFetchedChunkList = set() self.finalChunkPosition = 0 - self.CHUNK_SIZE = util.advancedSettings.libraryChunkSize + self.CHUNK_SIZE = util.addonSettings.libraryChunkSize key = self.section.key if not key.isdigit(): @@ -412,7 +411,7 @@ def onAction(self, action): if mli: self.requestChunk(mli.pos()) - if util.advancedSettings.dynamicBackgrounds: + if util.addonSettings.dynamicBackgrounds: if mli and mli.dataSource: self.updateBackgroundFrom(mli.dataSource) @@ -434,7 +433,7 @@ def onAction(self, action): elif action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)) and \ - (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + (not util.addonSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): if xbmc.getCondVisibility('Integer.IsGreater(Container(101).ListItem.Property(index),5)'): self.showPanelControl.selectItem(0) return @@ -566,7 +565,7 @@ def playButtonClicked(self, shuffle=False): args['unwatched'] = '1' pq = playqueue.createPlayQueueForItem(self.section, options={'shuffle': shuffle}, args=args) - opener.open(pq) + opener.open(pq, auto_play=True) def shuffleButtonClicked(self): self.playButtonClicked(shuffle=True) @@ -786,7 +785,7 @@ def sortShowPanel(self, choice, force_refresh=False): self.showPanelControl.selectItem(0) self.setFocusId(self.POSTERS_PANEL_ID) self.backgroundSet = False - self.setBackground([item.dataSource for item in self.showPanelControl], 0, randomize=not util.advancedSettings.dynamicBackgrounds) + self.setBackground([item.dataSource for item in self.showPanelControl], 0, randomize=not util.addonSettings.dynamicBackgrounds) def subOptionCallback(self, option): check = 'script.plex/home/device/check.png' @@ -983,6 +982,7 @@ def updateUnwatchedAndProgress(self, mli): mli.dataSource.reload() if mli.dataSource.isWatched: mli.setProperty('unwatched', '') + mli.setBoolProperty('watched', mli.dataSource.isFullyWatched) mli.setProperty('unwatched.count', '') else: if self.section.TYPE == 'show' or mli.dataSource.TYPE == 'show' or mli.dataSource.TYPE == 'season': @@ -1156,7 +1156,7 @@ def fillShows(self): # If we're retrieving media as we navigate then we just want to request the first # chunk of media and stop. We'll fetch the rest as the user navigates to those items - if not util.advancedSettings.retrieveAllMediaUpFront: + if not util.addonSettings.retrieveAllMediaUpFront: # Calculate the end chunk's starting position based on the totalSize of items self.finalChunkPosition = (totalSize // self.CHUNK_SIZE) * self.CHUNK_SIZE # Keep track of the chunks we've already fetched by storing the chunk's starting position @@ -1290,15 +1290,11 @@ def _chunkCallback(self, items, start): with self.lock: pos = start - self.setBackground(items, pos, randomize=not util.advancedSettings.dynamicBackgrounds) + self.setBackground(items, pos, randomize=not util.addonSettings.dynamicBackgrounds) thumbDim = TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie'])['thumb_dim'] artDim = TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie']).get('art_dim', (256, 256)) - showUnwatched = False - if (self.section.TYPE in ('movie', 'show') and items[0].TYPE != 'collection') or (self.section.TYPE == 'collection' and items[0].TYPE in ('movie', 'show', 'episode')): # NOTE: A collection with Seasons doesn't have the leafCount/viewedLeafCount until you actually go into the season so we can't update the unwatched count here - showUnwatched = True - if ITEM_TYPE == 'episode': for offset, obj in enumerate(items): if not self.showPanelControl: @@ -1309,7 +1305,8 @@ def _chunkCallback(self, items, start): mli.dataSource = obj mli.setProperty('index', str(pos)) if obj.index: - subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), obj.parentIndex, T(32311, 'E'), obj.index) + subtitle = u'{0} \u2022 {1}'.format(T(32310, 'S').format(obj.parentIndex), + T(32311, 'E').format(obj.index)) mli.setProperty('subtitle', subtitle) subtitle = "\n" + subtitle else: @@ -1324,6 +1321,8 @@ def _chunkCallback(self, items, start): mli.setProperty('art', obj.defaultArt.asTranscodedImageURL(*artDim)) if not obj.isWatched: mli.setProperty('unwatched', '1') + mli.setBoolProperty('watched', obj.isFullyWatched) + mli.setProperty('initialized', '1') else: mli.clear() if obj is False: @@ -1372,12 +1371,16 @@ def _chunkCallback(self, items, start): mli.setProperty('art', obj.artCompositeURL(*colArtDim)) mli.setThumbnailImage(obj.artCompositeURL(*thumbDim)) else: - mli.setThumbnailImage(obj.defaultThumb.asTranscodedImageURL(*thumbDim)) + if obj.TYPE == 'photodirectory' and obj.composite: + mli.setThumbnailImage(obj.composite.asTranscodedImageURL(*thumbDim)) + else: + mli.setThumbnailImage(obj.defaultThumb.asTranscodedImageURL(*thumbDim)) mli.dataSource = obj mli.setProperty('summary', obj.get('summary')) + mli.setProperty('year', obj.get('year')) - if showUnwatched and obj.TYPE != 'collection': - if not obj.isDirectory(): + if obj.TYPE != 'collection': + if not obj.isDirectory() and obj.get('duration').asInt(): mli.setLabel2(util.durationToText(obj.fixedDuration())) mli.setProperty('art', obj.defaultArt.asTranscodedImageURL(*artDim)) if not obj.isWatched and obj.TYPE != "Directory": @@ -1385,6 +1388,9 @@ def _chunkCallback(self, items, start): mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) else: mli.setProperty('unwatched', '1') + elif obj.isFullyWatched and obj.TYPE != "Directory": + mli.setBoolProperty('watched', '1') + mli.setProperty('initialized', '1') mli.setProperty('progress', util.getProgressImage(obj)) else: @@ -1397,7 +1403,7 @@ def _chunkCallback(self, items, start): pos += 1 def requestChunk(self, start): - if util.advancedSettings.retrieveAllMediaUpFront: + if util.addonSettings.retrieveAllMediaUpFront: return # Calculate the correct starting chunk position for the item they passed in diff --git a/script.plexmod/lib/windows/mixins.py b/script.plexmod/lib/windows/mixins.py index f35ac80fbb..24bfeddbda 100644 --- a/script.plexmod/lib/windows/mixins.py +++ b/script.plexmod/lib/windows/mixins.py @@ -2,12 +2,14 @@ import math -from lib import util +from plexnet import util as pnUtil +from lib import util +from lib.data_cache import dcm +from lib.util import T +from . import busy from . import kodigui from . import optionsdialog -from . import busy -from lib.util import T class SeasonsMixin: @@ -64,6 +66,7 @@ def fillSeasons(self, show, update=False, seasonsFilter=None, selectSeason=None) mli.setProperty('index', str(idx)) mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') mli.setProperty('unwatched.count', not season.isWatched and str(season.unViewedLeafCount) or '') + mli.setBoolProperty('watched', season.isFullyWatched) if not season.isWatched: mli.setProperty('progress', util.getProgressImage(None, self.getSeasonProgress(show, season))) items.append(mli) @@ -81,9 +84,10 @@ def fillSeasons(self, show, update=False, seasonsFilter=None, selectSeason=None) class DeleteMediaMixin: def delete(self, item=None): + item = item or self.mediaItem button = optionsdialog.show( T(32326, 'Really delete?'), - T(32327, 'Are you sure you really want to delete this media?'), + T(33035, "Delete {}: {}?").format(type(item).__name__, item.defaultTitle), T(32328, 'Yes'), T(32329, 'No') ) @@ -91,16 +95,16 @@ def delete(self, item=None): if button != 0: return - if not self._delete(item=item or self.mediaItem): + if not self._delete(item=item): util.messageDialog(T(32330, 'Message'), T(32331, 'There was a problem while attempting to delete the media.')) return return True @busy.dialog() - def _delete(self, item): + def _delete(self, item, do_close=False): success = item.delete() - util.LOG('Media DELETE: {0} - {1}'.format(self.mediaItem, success and 'SUCCESS' or 'FAILED')) - if success: + util.LOG('Media DELETE: {0} - {1}'.format(item, success and 'SUCCESS' or 'FAILED')) + if success and do_close: self.doClose() return success @@ -137,3 +141,82 @@ def sanitize(src): 'script.plex/ratings/{0}.png'.format(sanitize(video.audienceRatingImage))) else: setProperty('rating', video.rating) + + +class SpoilersMixin(object): + def __init__(self, *args, **kwargs): + self._noSpoilers = None + self.spoilerSetting = "unwatched" + self.noTitles = False + self.spoilersAllowedFor = True + self.storeSpoilerSettings() + + def storeSpoilerSettings(self): + self.spoilerSetting = util.getSetting('no_episode_spoilers2', "unwatched") + self.noTitles = util.getSetting('no_unwatched_episode_titles', False) + self.spoilersAllowedFor = util.getSetting('spoilers_allowed_genres', True) + + @property + def noSpoilers(self): + return self.getNoSpoilers() + + def getCachedGenres(self, rating_key): + genres = dcm.getCacheData("show_genres", rating_key) + if genres: + return [pnUtil.AttributeDict(tag=g) for g in genres] + + def getNoSpoilers(self, item=None, show=None): + """ + when called without item or show, retains a global noSpoilers value, otherwise return dynamically based on item + or show + returns: "off" if spoilers unnecessary, otherwise "unwatched" or "funwatched" + """ + if not item and not show and self._noSpoilers is not None: + return self._noSpoilers + + if item and item.type != "episode": + return "off" + + nope = self.spoilerSetting + + if nope != "off" and self.spoilersAllowedFor: + # instead of making possibly multiple separate API calls to find genres for episode's shows, try to get + # a cached value instead + genres = [] + if item or show: + genres = self.getCachedGenres(item and item.grandparentRatingKey or show.ratingKey) + + if not genres: + show = getattr(self, "show_", show or (item and item.show()) or None) + if not show: + return "off" + + if not genres and show: + genres = show.genres() + + for g in genres: + if g.tag in util.SPOILER_ALLOWED_GENRES: + nope = "off" + break + + if item or show: + self._noSpoilers = nope + return self._noSpoilers + return nope + + def hideSpoilers(self, ep, fully_watched=None, watched=None, use_cache=True): + """ + returns boolean on whether we should hide spoilers for the given episode + """ + watched = watched if watched is not None else ep.isWatched + fullyWatched = fully_watched if fully_watched is not None else ep.isFullyWatched + nspoil = self.getNoSpoilers(item=ep if not use_cache else None) + return ((nspoil == 'funwatched' and not fullyWatched) or + (nspoil == 'unwatched' and not watched)) + + def getThumbnailOpts(self, ep, fully_watched=None, watched=None, hide_spoilers=None): + if self.getNoSpoilers(item=ep) == "off": + return {} + return (hide_spoilers if hide_spoilers is not None else + self.hideSpoilers(ep, fully_watched=fully_watched, watched=watched)) \ + and {"blur": util.addonSettings.episodeNoSpoilerBlur} or {} diff --git a/script.plexmod/lib/windows/musicplayer.py b/script.plexmod/lib/windows/musicplayer.py index d2277eb5ee..58490298bc 100644 --- a/script.plexmod/lib/windows/musicplayer.py +++ b/script.plexmod/lib/windows/musicplayer.py @@ -1,12 +1,13 @@ from __future__ import absolute_import + from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui -from . import currentplaylist -from . import opener from lib import player from lib import util +from . import currentplaylist +from . import kodigui +from . import opener def timeDisplay(ms): @@ -49,6 +50,7 @@ def __init__(self, *args, **kwargs): self.album = kwargs.get('album') self.selectedOffset = 0 self.exitCommand = None + self.ignoreStopCommands = False if self.track: self.duration = self.track.duration.asInt() @@ -61,17 +63,24 @@ def onFirstInit(self): self.setupSeekbar() self.selectionBoxMax = self.SEEK_IMAGE_WIDTH - (self.selectionBoxHalf - 3) + self.commonInit() self.updateProperties() self.play() self.setFocusId(406) def doClose(self, **kwargs): - player.PLAYER.off('playback.started', self.onPlayBackStarted) + player.PLAYER.off('av.started', self.onPlayBackStarted) if self.playlist and self.playlist.isRemote: self.playlist.off('change', self.updateProperties) + + self.commonDeinit() kodigui.ControlledWindow.doClose(self) def onAction(self, action): + if self.ignoreStopCommands and action in (xbmcgui.ACTION_PREVIOUS_MENU, + xbmcgui.ACTION_NAV_BACK, + xbmcgui.ACTION_STOP): + return try: if action == xbmcgui.ACTION_STOP: self.stopButtonClicked() @@ -79,7 +88,7 @@ def onAction(self, action): except: util.ERROR() - super().onAction(action) + super(MusicPlayerWindow, self).onAction(action) def onClick(self, controlID): if controlID == self.PLAYLIST_BUTTON_ID: @@ -119,6 +128,7 @@ def skipPrevButtonClicked(self): if not self.playlist.refresh(force=True, wait=True): return + self.onAudioStarting() xbmc.executebuiltin('PlayerControl(Previous)') def skipNextButtonClicked(self): @@ -127,12 +137,14 @@ def skipNextButtonClicked(self): if not self.playlist.refresh(force=True, wait=True): return + self.onAudioStarting() xbmc.executebuiltin('PlayerControl(Next)') def showPlaylist(self): self.processCommand(opener.handleOpen(currentplaylist.CurrentPlaylistWindow, winID=xbmcgui.getCurrentWindowId())) def stopButtonClicked(self): + player.PLAYER.stopAndWait() self.doClose() def updateProperties(self, **kwargs): @@ -153,6 +165,8 @@ def play(self): if util.trackIsPlaying(self.track): return + self.onAudioStarting() + fanart = None if self.playlist: fanart = self.playlist.get('composite') or self.playlist.defaultArt diff --git a/script.plexmod/lib/windows/opener.py b/script.plexmod/lib/windows/opener.py index 591163d2a1..37ba37f328 100644 --- a/script.plexmod/lib/windows/opener.py +++ b/script.plexmod/lib/windows/opener.py @@ -1,9 +1,10 @@ from __future__ import absolute_import -from . import busy +import six from plexnet import playqueue, plexapp, plexlibrary + from lib import util -import six +from . import busy def open(obj, **kwargs): @@ -14,10 +15,10 @@ def open(obj, **kwargs): return handleOpen(musicplayer.MusicPlayerWindow, track=obj.current(), playlist=obj) elif obj.type == 'photo': from . import photos - return handleOpen(photos.PhotoWindow, play_queue=obj) + return handleOpen(photos.PhotoWindow, play_queue=obj, **kwargs) else: from . import videoplayer - videoplayer.play(play_queue=obj) + videoplayer.play(play_queue=obj, **kwargs) return '' elif isinstance(obj, six.string_types): key = obj diff --git a/script.plexmod/lib/windows/optionsdialog.py b/script.plexmod/lib/windows/optionsdialog.py index 317ee6eb40..e6e8f18bd3 100644 --- a/script.plexmod/lib/windows/optionsdialog.py +++ b/script.plexmod/lib/windows/optionsdialog.py @@ -1,7 +1,7 @@ from __future__ import absolute_import -from . import kodigui from lib import util +from . import kodigui class OptionsDialog(kodigui.BaseDialog): diff --git a/script.plexmod/lib/windows/pagination.py b/script.plexmod/lib/windows/pagination.py index 474be481c7..3e963dd621 100644 --- a/script.plexmod/lib/windows/pagination.py +++ b/script.plexmod/lib/windows/pagination.py @@ -1,7 +1,9 @@ from __future__ import absolute_import -from . import kodigui + from kodi_six import xbmcgui + from lib import util +from . import kodigui class MCLPaginator(object): @@ -271,4 +273,5 @@ def prepareListItem(self, data, mli): mli.setProperty('unwatched.count', str(mli.dataSource.unViewedLeafCount)) else: mli.setProperty('unwatched', not mli.dataSource.isWatched and '1' or '') + mli.setBoolProperty('watched', mli.dataSource.isFullyWatched) mli.setProperty('progress', util.getProgressImage(mli.dataSource)) diff --git a/script.plexmod/lib/windows/photos.py b/script.plexmod/lib/windows/photos.py index 7595ba7f5c..fcfe9b1cf9 100644 --- a/script.plexmod/lib/windows/photos.py +++ b/script.plexmod/lib/windows/photos.py @@ -1,20 +1,20 @@ from __future__ import absolute_import -import threading -import time + +import hashlib import os -import tempfile import shutil -import hashlib -import requests +import threading +import time -from kodi_six import xbmc, xbmcvfs +import requests +from kodi_six import xbmc from kodi_six import xbmcgui - -from . import kodigui -from . import busy +from plexnet import plexapp, plexplayer, playqueue +from plexnet import util as plexnetUtil from lib import util, colors -from plexnet import plexapp, plexplayer, playqueue +from . import busy +from . import kodigui class PhotoWindow(kodigui.BaseWindow): @@ -49,6 +49,7 @@ class PhotoWindow(kodigui.BaseWindow): def __init__(self, *args, **kwargs): kodigui.BaseWindow.__init__(self, *args, **kwargs) self.photo = kwargs.get('photo') + self.autoPlay = False self.playQueue = kwargs.get('play_queue') self.playerObject = None self.timelineType = 'photo' @@ -87,6 +88,13 @@ def onFirstInit(self): self.osdTimer = kodigui.PropertyTimer(self._winID, 4, 'OSD', '', init_value=False, callback=self.osdTimerCallback) self.imageControl = self.getControl(600) + if self.autoPlay: + self.play() + + def doAutoPlay(self): + self.autoPlay = True + return True + def osdTimerCallback(self): self.setFocusId(self.OVERLAY_BUTTON_ID) @@ -221,7 +229,7 @@ def getPlayQueue(self, shuffle=False): if busy.widthDialog(self.playQueue.waitForInitialization, None, delay=True): util.DEBUG_LOG('playQueue initialized: {0}'.format(self.playQueue)) else: - util.DEBUG_LOG('playQueue timed out wating for initialization') + util.DEBUG_LOG('playQueue timed out waiting for initialization') self.showPhoto() @@ -522,7 +530,16 @@ def updateNowPlaying(self, force=False, refreshQueue=False, state=None): if refreshQueue and self.playQueue: self.playQueue.refreshOnTimeline = True - plexapp.util.APP.nowplayingmanager.updatePlaybackState(self.timelineType, self.playerObject, state, time, self.playQueue) + data = plexnetUtil.AttributeDict({ + "key": str(item.key), + "ratingKey": str(item.ratingKey), + "guid": str(item.guid), + "url": str(item.url), + "duration": item.duration.asInt(), + "containerKey": str(item.container.address) + }) + + plexapp.util.APP.nowplayingmanager.updatePlaybackState(self.timelineType, data, state, time, self.playQueue) def showOSD(self): self.osdTimer.reset(init=False) diff --git a/script.plexmod/lib/windows/playbacksettings.py b/script.plexmod/lib/windows/playbacksettings.py index 5e614e1aec..b870608515 100644 --- a/script.plexmod/lib/windows/playbacksettings.py +++ b/script.plexmod/lib/windows/playbacksettings.py @@ -1,9 +1,8 @@ # coding=utf-8 -from lib import util -from lib.util import T - from plexnet.util import INTERFACE +from lib import util +from lib.util import T from . import dropdown diff --git a/script.plexmod/lib/windows/playerbackground.py b/script.plexmod/lib/windows/playerbackground.py index e34073b15e..31ce2bfaad 100644 --- a/script.plexmod/lib/windows/playerbackground.py +++ b/script.plexmod/lib/windows/playerbackground.py @@ -1,7 +1,9 @@ from __future__ import absolute_import + import contextlib -from . import kodigui + from lib import util +from . import kodigui class PlayerBackground(kodigui.BaseWindow): diff --git a/script.plexmod/lib/windows/playersettings.py b/script.plexmod/lib/windows/playersettings.py index 51fbb5c1b2..bea121edb3 100644 --- a/script.plexmod/lib/windows/playersettings.py +++ b/script.plexmod/lib/windows/playersettings.py @@ -1,13 +1,13 @@ from __future__ import absolute_import + +import plexnet from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui -from lib import util from lib import metadata +from lib import util from lib.util import T - -import plexnet +from . import kodigui class VideoSettingsDialog(kodigui.BaseDialog, util.CronReceiver): diff --git a/script.plexmod/lib/windows/playlist.py b/script.plexmod/lib/windows/playlist.py index 705fed3725..c5bfab5c4e 100644 --- a/script.plexmod/lib/windows/playlist.py +++ b/script.plexmod/lib/windows/playlist.py @@ -1,25 +1,23 @@ from __future__ import absolute_import + import threading +import plexnet from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from six.moves import range +from lib import backgroundthread +from lib import player +from lib import util +from lib.util import T from . import busy -from . import videoplayer -from . import windowutils from . import dropdown -from . import search -import plexnet +from . import kodigui from . import opener - -from lib import colors -from lib import util -from lib import player -from lib import backgroundthread - -from lib.util import T -from six.moves import range +from . import search +from . import videoplayer +from . import windowutils PLAYLIST_PAGE_SIZE = 500 PLAYLIST_INITIAL_SIZE = 100 @@ -114,6 +112,8 @@ def onAction(self, action): try: if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): self.doClose() + elif self.playlist.playlistType == 'video' and action == xbmcgui.ACTION_CONTEXT_MENU: + return self.plItemPlaybackMenu() except: util.ERROR() @@ -140,10 +140,44 @@ def doClose(self): self.tasks.cancel() ChunkRequestTask.reset() + def plItemPlaybackMenu(self, select_choice='visit'): + mli = self.playlistListControl.getSelectedItem() + if not mli or not mli.dataSource: + return + + can_resume = mli.dataSource.viewOffset.asInt() + + options = [ + {'key': 'visit', 'display': T(33019, 'Visit Media Item')}, + {'key': 'play', 'display': T(33020, 'Play') if not can_resume else T(32317, 'Play from beginning')}, + ] + if can_resume: + options.append({'key': 'resume', 'display': T(32429, 'Resume from {0}').format( + util.timeDisplay(mli.dataSource.viewOffset.asInt()).lstrip('0').lstrip(':'))}) + + choice = dropdown.showDropdown( + options, + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=T(33021, 'Choose action'), + select_index=2 if select_choice == 'resume' else 1 if util.addonSettings.playlistVisitMedia else 0 + ) + + if not choice: + return + + if choice['key'] == 'visit': + self.openItem(mli.dataSource) + elif choice['key'] == 'play': + self.playlistListClicked(resume=False, play=True) + elif choice['key'] == 'resume': + self.playlistListClicked(resume=True, play=True) + def searchButtonClicked(self): self.processCommand(search.dialog(self)) - def playlistListClicked(self, no_item=False, shuffle=False): + def playlistListClicked(self, no_item=False, shuffle=False, resume=None, play=False): if no_item: mli = None else: @@ -167,17 +201,20 @@ def playlistListClicked(self, no_item=False, shuffle=False): pq = plexnet.playqueue.createPlayQueueForItem(self.playlist, options=args) opener.open(pq) elif self.playlist.playlistType == 'video': - if not util.advancedSettings.playlistVisitMedia: + if not util.addonSettings.playlistVisitMedia or play: + if resume is None and bool(mli.dataSource.viewOffset.asInt()): + return self.plItemPlaybackMenu(select_choice='resume') + if self.playlist.leafCount.asInt() <= PLAYLIST_INITIAL_SIZE: self.playlist.setShuffle(shuffle) self.playlist.setCurrent(mli and mli.pos() or 0) - videoplayer.play(play_queue=self.playlist) + videoplayer.play(play_queue=self.playlist, resume=resume) else: args = {'shuffle': shuffle} if mli: args['key'] = mli.dataSource.key pq = plexnet.playqueue.createPlayQueueForItem(self.playlist, options=args) - opener.open(pq) + opener.open(pq, resume=resume) else: if not mli: firstItem = 0 @@ -272,7 +309,8 @@ def createTrackListItem(self, mli, track): def createEpisodeListItem(self, mli, episode): label2 = u'{0} \u2022 {1}'.format( - episode.grandparentTitle, u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + episode.grandparentTitle, u'{0} \u2022 {1}'.format(T(32310, 'S').format(episode.parentIndex), + T(32311, 'E').format(episode.index)) ) mli.setLabel2(label2) mli.setThumbnailImage(episode.thumb.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM)) diff --git a/script.plexmod/lib/windows/playlists.py b/script.plexmod/lib/windows/playlists.py index 12d22bb1fa..b2593a83d4 100644 --- a/script.plexmod/lib/windows/playlists.py +++ b/script.plexmod/lib/windows/playlists.py @@ -1,17 +1,15 @@ from __future__ import absolute_import + from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from plexnet import plexapp +from lib import util from . import busy +from . import kodigui from . import playlist -from . import windowutils from . import search - -from lib import util -from lib import colors - -from plexnet import plexapp +from . import windowutils class PlaylistsWindow(kodigui.ControlledWindow, windowutils.UtilMixin): diff --git a/script.plexmod/lib/windows/preplay.py b/script.plexmod/lib/windows/preplay.py index e72bae1097..e3f5a3dd16 100644 --- a/script.plexmod/lib/windows/preplay.py +++ b/script.plexmod/lib/windows/preplay.py @@ -2,29 +2,24 @@ from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from plexnet import plexplayer, media +from lib import metadata +from lib import util +from lib.util import T from . import busy -from . import opener +from . import dropdown from . import info -from . import videoplayer +from . import kodigui +from . import opener +from . import optionsdialog +from . import pagination from . import playersettings from . import search -from . import dropdown +from . import videoplayer from . import windowutils -from . import optionsdialog -from . import preplayutils -from . import pagination from .mixins import RatingsMixin -from plexnet import plexplayer, media - -from lib import util -from lib import metadata - -from lib.util import T - - VIDEO_RELOAD_KW = dict(includeExtras=1, includeExtrasCount=10, includeChapters=1, includeReviews=1) @@ -141,7 +136,7 @@ def onAction(self, action): elif action == xbmcgui.ACTION_NAV_BACK: if (not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( self.OPTIONS_GROUP_ID)) or not controlID) and \ - not util.advancedSettings.fastBack: + not util.addonSettings.fastBack: if self.getProperty('on.extras'): self.setFocusId(self.OPTIONS_GROUP_ID) return @@ -310,7 +305,7 @@ def mediaButtonClicked(self): def delete(self): button = optionsdialog.show( T(32326, 'Really delete?'), - T(32327, 'Are you sure you really want to delete this media?'), + T(33035, "Delete {}: {}?").format(type(self.video).__name__, self.video.defaultTitle), T(32328, 'Yes'), T(32329, 'No') ) @@ -528,6 +523,7 @@ def setInfo(self, skip_bg=False): self.setProperty('duration', util.durationToText(self.video.duration.asInt())) self.setProperty('summary', self.video.summary.strip().replace('\t', ' ')) self.setProperty('unwatched', not self.video.isWatched and '1' or '') + self.setBoolProperty('watched', self.video.isFullyWatched) directors = u' / '.join([d.tag for d in self.video.directors()][:3]) directorsLabel = len(self.video.directors) > 1 and T(32401, u'DIRECTORS').upper() or T(32383, u'DIRECTOR').upper() @@ -542,7 +538,7 @@ def setInfo(self, skip_bg=False): self.setProperty('content.rating', '') self.setProperty('thumb', self.video.defaultThumb.asTranscodedImageURL(*self.THUMB_POSTER_DIM)) self.setProperty('preview', self.video.thumb.asTranscodedImageURL(*self.PREVIEW_DIM)) - self.setProperty('info', u'{0} {1} {2} {3}'.format(T(32303, 'Season'), self.video.parentIndex, T(32304, 'Episode'), self.video.index)) + self.setProperty('info', u'{0} {1}'.format(T(32303, 'Season').format(self.video.parentIndex), T(32304, 'Episode').format(self.video.index))) self.setProperty('date', util.cleanLeadingZeros(self.video.originallyAvailableAt.asDatetime('%B %d, %Y'))) self.setProperty('related.header', T(32306, 'Related Shows')) elif self.video.type == 'movie': diff --git a/script.plexmod/lib/windows/preplayutils.py b/script.plexmod/lib/windows/preplayutils.py index 7d000622fe..03ea300a6d 100644 --- a/script.plexmod/lib/windows/preplayutils.py +++ b/script.plexmod/lib/windows/preplayutils.py @@ -1,7 +1,7 @@ from __future__ import absolute_import -from . import dropdown from lib.util import T +from . import dropdown def chooseVersion(video): diff --git a/script.plexmod/lib/windows/search.py b/script.plexmod/lib/windows/search.py index 2b4e2998c7..47394643dd 100644 --- a/script.plexmod/lib/windows/search.py +++ b/script.plexmod/lib/windows/search.py @@ -1,17 +1,17 @@ from __future__ import absolute_import -import time + import threading +import time from kodi_six import xbmcgui, xbmc +from plexnet import plexapp +from lib import util +from lib.kodijsonrpc import rpc from . import kodigui from . import opener from . import windowutils -from lib import util -from lib.kodijsonrpc import rpc - -from plexnet import plexapp class SearchDialog(kodigui.BaseDialog, windowutils.UtilMixin): xmlFile = 'script-plex-search.xml' diff --git a/script.plexmod/lib/windows/seekdialog.py b/script.plexmod/lib/windows/seekdialog.py index 1be1fd0f7e..f23528f6b1 100644 --- a/script.plexmod/lib/windows/seekdialog.py +++ b/script.plexmod/lib/windows/seekdialog.py @@ -1,28 +1,25 @@ from __future__ import absolute_import + import re -import time import threading -import math +import time +from collections import OrderedDict from kodi_six import xbmc from kodi_six import xbmcgui -from collections import OrderedDict - -from . import kodigui -from . import playersettings -from . import dropdown -from . import busy from plexnet import plexapp - -from lib import util -from plexnet.videosession import VideoSessionInfo, ATTRIBUTE_TYPES as SESSION_ATTRIBUTE_TYPES from plexnet.exceptions import ServerNotOwned, NotFound -from plexnet.signalsmixin import SignalsMixin +from plexnet.videosession import VideoSessionInfo, ATTRIBUTE_TYPES as SESSION_ATTRIBUTE_TYPES +from six.moves import range +import lib.cache +from lib import util from lib.kodijsonrpc import builtin - from lib.util import T -from six.moves import range +from . import busy +from . import dropdown +from . import kodigui +from . import playersettings KEY_MOVE_SET = frozenset( ( @@ -135,7 +132,7 @@ class SeekDialog(kodigui.BaseDialog): SKIP_STEPS = {"negative": [-10000], "positive": [30000]} def __init__(self, *args, **kwargs): - kodigui.BaseDialog.__init__(self, *args, **kwargs) + super(SeekDialog, self).__init__(*args, **kwargs) # fixme: heyo, there's a lot of disorder in here. self.handler = kwargs.get('handler') @@ -171,7 +168,7 @@ def __init__(self, *args, **kwargs): self._delayedSeekTimeout = 0 self._osdHideAnimationTimeout = 0 self._hideDelay = self.HIDE_DELAY - self._autoSeekDelay = util.advancedSettings.autoSeek and util.advancedSettings.autoSeekDelay or 0 + self._autoSeekDelay = util.addonSettings.autoSeek and util.addonSettings.autoSeekDelay or 0 self._atSkipStep = -1 self._lastSkipDirection = None self._forcedLastSkipAmount = None @@ -203,8 +200,8 @@ def __init__(self, *args, **kwargs): self._creditsSkipShownStarted = None self._currentMarker = None self.skipSteps = self.SKIP_STEPS - self.useAutoSeek = util.advancedSettings.autoSeek - self.useDynamicStepsForTimeline = util.advancedSettings.dynamicTimelineSeek + self.useAutoSeek = util.addonSettings.autoSeek + self.useDynamicStepsForTimeline = util.addonSettings.dynamicTimelineSeek self.bingeMode = False self.autoSkipIntro = False @@ -212,14 +209,14 @@ def __init__(self, *args, **kwargs): self.showIntroSkipEarly = False self.skipPostPlay = False - self.skipIntroButtonTimeout = util.advancedSettings.skipIntroButtonTimeout - self.skipCreditsButtonTimeout = util.advancedSettings.skipCreditsButtonTimeout - self.showItemEndsInfo = util.advancedSettings.showMediaEndsInfo - self.showItemEndsLabel = util.advancedSettings.showMediaEndsLabel + self.skipIntroButtonTimeout = util.addonSettings.skipIntroButtonTimeout + self.skipCreditsButtonTimeout = util.addonSettings.skipCreditsButtonTimeout + self.showItemEndsInfo = util.addonSettings.showMediaEndsInfo + self.showItemEndsLabel = util.addonSettings.showMediaEndsLabel self.player.video.server.on("np:timelineResponse", self.timelineResponseCallback) - if util.kodiSkipSteps and util.advancedSettings.kodiSkipStepping: + if util.kodiSkipSteps and util.addonSettings.kodiSkipStepping: self.skipSteps = {"negative": [], "positive": []} for step in util.kodiSkipSteps: key = "negative" if step < 0 else "positive" @@ -290,16 +287,30 @@ def trueOffset(self): @property def markers(self): - # fixme: fix transcoded marker skip if not self._enableMarkerSkip: return None - if not self._markers and hasattr(self.handler.player.video, "markers"): + if self._markers is None and hasattr(self.handler.player.video, "markers"): markers = [] for marker in self.handler.player.video.markers: if marker.type in MARKERS: + # skip completely bad markers + if marker.startTimeOffset.asInt() > self.duration: + continue + + # skip intro markers that are too late + if marker.type == "intro" and \ + marker.startTimeOffset.asInt() > util.addonSettings.introMarkerMaxOffset * 1000: + util.DEBUG_LOG("Throwing away intro marker {}, as its start time offset is bigger than the" + " configured maximum".format(marker)) + continue + m = MARKERS[marker.type].copy() + marker.startTimeOffset = marker.startTimeOffset.asInt() \ + if not isinstance(marker.startTimeOffset, int) else marker.startTimeOffset + marker.endTimeOffset = marker.endTimeOffset.asInt() \ + if not isinstance(marker.endTimeOffset, int) else marker.endTimeOffset m["marker"] = marker m["marker_type"] = marker.type markers.append(m) @@ -312,6 +323,38 @@ def markers(self): def markers(self, val): self._markers = val + def getCurrentMarkerDef(self, offset=None): + """ + Show intro/credits skip button at current time + """ + + if not self.markers: + return + + off = offset if offset is not None else self.trueOffset() + + for markerDef in self.markers: + marker = markerDef["marker"] + if marker: + startTimeOffset = marker.startTimeOffset + + # show intro skip early? (only if intro is during the first X minutes) + if self.showIntroSkipEarly and markerDef["marker_type"] == "intro" and \ + startTimeOffset <= util.addonSettings.skipIntroButtonShowEarlyThreshold1 * 1000: + startTimeOffset = 0 + markerDef["overrideStartOff"] = 0 + + # fix markers with a bad endTimeOffset + if marker.endTimeOffset > self.duration: + marker.endTimeOffset = self.duration + util.DEBUG_LOG("Fixing marker endTimeOffset for: {}".format(marker)) + + markerEndNegoff = FINAL_MARKER_NEGOFF if getattr(markerDef["marker"], "final", False) else 0 + + if startTimeOffset - MARKER_SHOW_NEGOFF <= off < marker.endTimeOffset - markerEndNegoff: + + return markerDef + def onFirstInit(self): try: self._onFirstInit() @@ -349,6 +392,18 @@ def _onFirstInit(self): self.setBoolProperty('nav.repeat', showRepeat) self.setBoolProperty('nav.ffwdrwd', showFfwdRwd) self.setBoolProperty('nav.shuffle', showShuffle) + navPlaylist = util.getSetting('video_show_playlist', 'eponly') + self.setBoolProperty('nav.playlist', (navPlaylist == "eponly" and + (self.player.video.type == 'episode' or self.handler.playlist)) or + navPlaylist == "always") + + if not self.getProperty('nav.playlist'): + self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE + + navPrevNext = util.getSetting('video_show_prevnext', 'eponly') + self.setBoolProperty('nav.prevnext', (navPrevNext == "eponly" and + (self.player.video.type == 'episode' or self.handler.playlist)) or + navPrevNext == "always") if showQuickSubs: self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE * len( @@ -379,34 +434,25 @@ def setup(self, duration, meta, offset=0, bif_url=None, title='', title2='', cha this is called by our handler and occurs earlier than onFirstInit. """ util.DEBUG_LOG("SeekDialog: setup, keepMarkerDef={}".format(keepMarkerDef)) + self._duration = duration self.title = title self.title2 = title2 self.chapters = chapters or [] self.isDirectPlay = not meta.isTranscoded self.isTranscoded = not self.isDirectPlay - self.showChapters = util.getUserSetting('show_chapters', True) and ( - bool(chapters) or (util.getUserSetting('virtual_chapters', True) and bool(self.markers))) self.setProperty('video.title', title) self.setProperty('is.show', (self.player.video.type == 'episode') and '1' or '') self.setProperty('ep.year', (self.player.video.type == 'episode') and self.player.video.year or '') self.setProperty('has.playlist', self.handler.playlist and '1' or '') self.setProperty('shuffled', (self.handler.playlist and self.handler.playlist.isShuffled) and '1' or '') - self.setProperty('has.chapters', self.showChapters and '1' or '') - self.setProperty('show.buffer', (util.advancedSettings.playerShowBuffer and self.isDirectPlay) and '1' or '') + self.setProperty('show.buffer', (util.addonSettings.playerShowBuffer and self.isDirectPlay) and '1' or '') + self.setProperty('theme', 'modern') self.killTimeKeeper() - navPlaylist = util.getSetting('video_show_playlist', 'eponly') - self.setBoolProperty('nav.playlist', (navPlaylist == "eponly" and self.player.video.type == 'episode') or - navPlaylist == "always") - if not self.getProperty('nav.playlist'): self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE - navPrevNext = util.getSetting('video_show_prevnext', 'eponly') - self.setBoolProperty('nav.prevnext', (navPrevNext == "eponly" and self.player.video.type == 'episode') or - navPrevNext == "always") - if not self.getProperty('nav.prevnext'): self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE @@ -434,11 +480,15 @@ def setup(self, duration, meta, offset=0, bif_url=None, title='', title2='', cha except IndexError: self.doClose(delete=True) raise util.NoDataException + + self.showChapters = util.getUserSetting('show_chapters', True) and ( + bool(chapters) or (util.getUserSetting('virtual_chapters', True) and bool(self.markers))) + self.setProperty('has.chapters', self.showChapters and '1' or '') + self.baseOffset = offset self.offset = 0 self.idleTime = None self.lastSubtitleNavAction = "forward" - self._duration = duration self._videoBelowOneHour = duration / 3600000 < 1 if self._videoBelowOneHour: self.timeFmtKodi = self.timeFmtKodi.replace("hh:", "") @@ -513,13 +563,15 @@ def onAction(self, action): if markerDef["marker"]: marker = markerDef["marker"] final = getattr(marker, "final", False) - markerOff = 0 if final else MARKER_END_JUMP_OFF + markerOff = -FINAL_MARKER_NEGOFF if final else MARKER_END_JUMP_OFF - util.DEBUG_LOG('MarkerSkip: Skipping marker {}'.format(markerDef["marker"])) + util.DEBUG_LOG('MarkerSkip: Skipping marker' + ' {} (final: {}, to: {}, offset: {})'.format(markerDef["marker"], + final, marker.endTimeOffset, markerOff)) self.setProperty('show.markerSkip', '') self.setProperty('show.markerSkip_OSDOnly', '') markerDef["skipped"] = True - self.doSeek(math.ceil(float(marker.endTimeOffset)) + markerOff) + self.doSeek(marker.endTimeOffset + markerOff) self.hideOSD(skipMarkerFocus=True) if marker.type == "credits" and not final: @@ -663,16 +715,16 @@ def onAction(self, action): # immediate marker timer actions if self.countingDownMarker: if controlID != self.BIG_SEEK_LIST_ID and \ - (util.advancedSettings.skipMarkerTimerCancel - or util.advancedSettings.skipMarkerTimerImmediate): - if util.advancedSettings.skipMarkerTimerCancel and \ + (util.addonSettings.skipMarkerTimerCancel + or util.addonSettings.skipMarkerTimerImmediate): + if util.addonSettings.skipMarkerTimerCancel and \ action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): self.displayMarkers(cancelTimer=True) return # skip the first second of a marker shown with countdown to avoid unexpected OK/SELECT # behaviour - elif util.advancedSettings.skipMarkerTimerImmediate \ + elif util.addonSettings.skipMarkerTimerImmediate \ and action == xbmcgui.ACTION_SELECT_ITEM and \ self._currentMarker["countdown"] is not None and \ self._currentMarker["countdown_initial"] is not None and \ @@ -695,14 +747,14 @@ def onAction(self, action): self.hideOSD() return - if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): - if self._osdHideAnimationTimeout: + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_STOP): + if action != xbmcgui.ACTION_STOP and self._osdHideAnimationTimeout: if self._osdHideAnimationTimeout >= time.time(): return else: self._osdHideAnimationTimeout = None - if self.osdVisible(): + if action != xbmcgui.ACTION_STOP and self.osdVisible(): self.hideOSD() else: self.sendTimeline(state=self.player.STATE_STOPPED) @@ -779,7 +831,7 @@ def onClick(self, controlID): if not self._seeking: # we might be reacting to an immediate marker skip while showing a marker with timeout; # in that case, don't show the OSD - if not self._currentMarker or not util.advancedSettings.skipMarkerTimerImmediate or \ + if not self._currentMarker or not util.addonSettings.skipMarkerTimerImmediate or \ self._currentMarker["countdown"] is None: self.showOSD() else: @@ -1123,7 +1175,8 @@ def optionsButtonClicked(self): # Button currently commented out. def subtitleButtonClicked(self): options = [] - options.append({'key': 'download', 'display': T(32405, 'Download Subtitles')}) + if self.isDirectPlay: + options.append({'key': 'download', 'display': T(32405, 'Download Subtitles')}) # select "enable" by default selectIndex = 1 @@ -1177,14 +1230,43 @@ def subtitleButtonClicked(self): if self.handler and self.handler.player and self.handler.player.playerObject \ and util.getSetting('calculate_oshash', False): meta = self.handler.player.playerObject.metadata - oss_hash = util.getOpenSubtitlesHash(meta.size, meta.streamUrls[0]) - if oss_hash: - util.DEBUG_LOG("OpenSubtitles hash: %s" % oss_hash) - util.setGlobalProperty("current_oshash", oss_hash, base='videoinfo.{0}') + if not meta.size: + util.LOG("Can't calculate OpenSubtitles hash because we're transcoding") + + else: + oss_hash = util.getOpenSubtitlesHash(meta.size, meta.streamUrls[0]) + if oss_hash: + util.DEBUG_LOG("OpenSubtitles hash: %s" % oss_hash) + util.setGlobalProperty("current_oshash", oss_hash, base='videoinfo.{0}') else: util.setGlobalProperty("current_oshash", '', base='videoinfo.{0}') self.lastSubtitleNavAction = "download" + + # remove the Year info from the current video info tag for better OSS search results + t = self.player.getVideoInfoTag() + changed_info_tag = False + item = xbmcgui.ListItem() + item.setPath(self.player.getPlayingFile()) + if t: + util.DEBUG_LOG("GOGOBO: %s" % t.getSeason()) + year = t.getYear() + if year: + item.setInfo("video", {"year": 0}) + self.player.updateInfoTag(item) + changed_info_tag = year + builtin.ActivateWindow('SubtitleSearch') + # wait for the window to activate + while not xbmc.getCondVisibility('Window.IsActive(SubtitleSearch)'): + util.MONITOR.waitForAbort(0.1) + # wait for the window to close + while xbmc.getCondVisibility('Window.IsActive(SubtitleSearch)'): + util.MONITOR.waitForAbort(0.1) + + if changed_info_tag: + item.setInfo("video", {"year": changed_info_tag}) + self.player.updateInfoTag(item) + elif choice['key'] == 'delay': self.hideOSD() self.lastSubtitleNavAction = "delay" @@ -1213,6 +1295,8 @@ def toggleSubtitles(self): def disableSubtitles(self): self.player.video.disableSubtitles() self.setSubtitles() + if self.isTranscoded: + self.doSeek(self.trueOffset(), settings_changed=True) def cycleSubtitles(self, forward=True): """ @@ -1221,6 +1305,8 @@ def cycleSubtitles(self, forward=True): stream = self.player.video.cycleSubtitles(forward=forward) self.setSubtitles(honor_forced_subtitles_override=False) util.showNotification(str(stream), time_ms=1500, header=util.T(32396, "Subtitles")) + if self.isTranscoded: + self.doSeek(self.trueOffset(), settings_changed=True) def setSubtitles(self, do_sleep=False, honor_forced_subtitles_override=False): self.handler.setSubtitles(do_sleep=do_sleep, honor_forced_subtitles_override=honor_forced_subtitles_override) @@ -1309,12 +1395,24 @@ def updateProperties(self, **kwargs): self.setFocusId(self.MAIN_BUTTON_ID) self.fromSeek = 0 + v = self.player.video + is_show = v.type == 'episode' + self.setProperty('has.bif', self.bifURL and '1' or '') self.setProperty('video.title', self.title) self.setProperty('video.title2', self.title2) - self.setProperty('is.show', (self.player.video.type == 'episode') and '1' or '') + self.setProperty('is.show', is_show and '1' or '') self.setProperty('media.show_ends', self.showItemEndsInfo and '1' or '') self.setProperty('time.ends_label', self.showItemEndsLabel and (util.T(32543, 'Ends at')) or '') + self.setBoolProperty('no.osd.hide_info', util.getSetting('no_spoilers', False)) + + no_spoilers = util.getSetting('no_episode_spoilers2', "unwatched") + hide_title = False + if is_show and no_spoilers != "off" and util.getSetting('no_unwatched_episode_titles', False): + hide_title = ((no_spoilers == 'funwatched' and not v.isFullyWatched) or + (no_spoilers == 'unwatched' and not v.isWatched)) + + self.setBoolProperty('hide.title', hide_title) if self.isDirectPlay: self.setProperty('time.fmt', self.timeFmtKodi) @@ -1392,8 +1490,8 @@ def updateChapters(self): marker = markerDef["marker"] if marker: if markerDef["marker_type"] == "intro": - preparedMarkers.append((int(marker.startTimeOffset), T(33608, "Intro"), False)) - preparedMarkers.append((int(marker.endTimeOffset), T(33610, "Main"), False)) + preparedMarkers.append((marker.startTimeOffset, T(33608, "Intro"), False)) + preparedMarkers.append((marker.endTimeOffset, T(33610, "Main"), False)) elif markerDef["marker_type"] == "credits": creditsCounter += 1 @@ -1401,7 +1499,7 @@ def updateChapters(self): label = T(33635, "Final Credits") else: label = T(33609, "Credits") + "{}" - preparedMarkers.append((int(marker.startTimeOffset), label, True)) + preparedMarkers.append((marker.startTimeOffset, label, True)) # add staggered virtual markers preparedMarkers.append((int(self.duration * 0.25), "25 %", False)) @@ -1458,7 +1556,7 @@ def updateCurrent(self, update_position_control=True, atOffset=None): self.positionControl.setWidth(w) # update cache/buffer bar - if util.advancedSettings.playerShowBuffer and self.isDirectPlay and util.KODI_VERSION_MAJOR > 18: + if util.addonSettings.playerShowBuffer and self.isDirectPlay and util.KODI_VERSION_MAJOR > 18: cache_w = int(xbmc.getInfoLabel("Player.ProgressCache")) * self.SEEK_IMAGE_WIDTH // 100 self.cacheControl.setWidth(cache_w) @@ -1554,33 +1652,6 @@ def seekMouse(self, action, without_osd=False, preview=False): self.updateProgress(set_to_current=False) self.setProperty('button.seek', '1') - def getCurrentMarkerDef(self, offset=None): - """ - Show intro/credits skip button at current time - """ - - if not self.markers: - return - - off = offset if offset is not None else self.trueOffset() - - for markerDef in self.markers: - marker = markerDef["marker"] - if marker: - startTimeOffset = int(marker.startTimeOffset) - - # show intro skip early? (only if intro is during the first X minutes) - if self.showIntroSkipEarly and markerDef["marker_type"] == "intro" and \ - startTimeOffset <= util.advancedSettings.skipIntroButtonShowEarlyThreshold1 * 1000: - startTimeOffset = 0 - markerDef["overrideStartOff"] = 0 - - markerEndNegoff = FINAL_MARKER_NEGOFF if getattr(markerDef["marker"], "final", False) else 0 - - if startTimeOffset - MARKER_SHOW_NEGOFF <= off < int(marker.endTimeOffset) - markerEndNegoff: - - return markerDef - @property def duration(self): @@ -1672,7 +1743,7 @@ def waitForBuffer(self): currentBufferPerc = int(xbmc.getInfoLabel("Player.ProgressCache")) - int(xbmc.getInfoLabel("Player.Progress")) # configured buffer size - bufferBytes = util.kcm.memorySize * 1024 * 1024 + bufferBytes = lib.cache.kcm.memorySize * 1024 * 1024 # wait for the full buffer or for 10% of the file at max # a full buffer is typically 30% of the configured cache value @@ -1693,7 +1764,7 @@ def waitForBuffer(self): wasPlaying = True waitedFor = 0 - waitMax = util.advancedSettings.bufferWaitMax + waitMax = util.addonSettings.bufferWaitMax waitExceeded = False self.waitingForBuffer = True self.showOSD(focusButton=False) @@ -1744,7 +1815,7 @@ def waitForBuffer(self): util.DEBUG_LOG("SeekDialog.buffer: Buffer already filled, not waiting for buffer") else: - wait = util.advancedSettings.bufferInsufficientWait + wait = util.addonSettings.bufferInsufficientWait util.DEBUG_LOG("SeekDialog.buffer: Buffer is too small for us to see, waiting {} seconds".format(wait)) self.waitingForBuffer = True @@ -1905,7 +1976,7 @@ def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=F # getCurrentMarkerDef might have overridden the startTimeOffset, use that startTimeOff = markerDef["overrideStartOff"] if markerDef["overrideStartOff"] is not None else \ - int(markerDef["marker"].startTimeOffset) + markerDef["marker"].startTimeOffset markerAutoSkip = getattr(self, markerDef["markerAutoSkip"]) @@ -1916,14 +1987,14 @@ def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=F markerAutoSkipped = markerDef["markerAutoSkipped"] - sTOffWThres = startTimeOff + util.advancedSettings.autoSkipOffset * 1000 + sTOffWThres = startTimeOff + util.addonSettings.autoSkipOffset * 1000 # we just want to return an early marker if we want to autoSkip it, so we can tell the handler to seekOnStart if onlyReturnIntroMD and markerDef["marker_type"] == "intro" and markerAutoSkip: if startTimeOff == 0 and not markerDef["markerAutoSkipped"]: if setSkipped: markerDef["markerAutoSkipped"] = True - return int(markerDef["marker"].endTimeOffset) + MARKER_END_JUMP_OFF + return markerDef["marker"].endTimeOffset + MARKER_END_JUMP_OFF return False if cancelTimer and self.countingDownMarker: @@ -1953,8 +2024,8 @@ def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=F if getattr(markerDef["marker"], "final", False): # final marker is _not_ at the end of video, seek and do nothing - if int(markerDef["marker"].endTimeOffset) < self.duration - FINAL_MARKER_NEGOFF: - target = int(markerDef["marker"].endTimeOffset) + if markerDef["marker"].endTimeOffset < self.duration - FINAL_MARKER_NEGOFF: + target = markerDef["marker"].endTimeOffset util.DEBUG_LOG( "MarkerAutoSkip: Skipping final marker, its endTime is too early, " "though, seeking and playing back") @@ -1982,7 +2053,7 @@ def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=F return False util.DEBUG_LOG('MarkerAutoSkip: Skipping marker {}'.format(markerDef["marker"])) - self.doSeek(int(markerDef["marker"].endTimeOffset) + MARKER_END_JUMP_OFF) + self.doSeek(markerDef["marker"].endTimeOffset + MARKER_END_JUMP_OFF) return True # got a marker, display logic @@ -2014,13 +2085,22 @@ def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=F # reset countdown on new marker if not self._currentMarker or self._currentMarker != markerDef or markerDef["countdown"] is None: # fixme: round might not be right here, but who cares - markerDef["countdown"] = int(max(round((sTOffWThres - self.trueOffset()) / 1000.0) + 1, 1)) + to = self.trueOffset() + # set the countdown to either the auto skip offset, or, if we're already "inside" the marker time + # area through seeking, at max the difference between the current offset and the end of the + # video + markerDef["countdown"] = int( + max( + round((sTOffWThres - to) / 1000.0) + 1, + min(util.addonSettings.autoSkipOffset, int((self.duration - to) / 1000.0)) + ) + ) isNew = True if self.player.playState == self.player.STATE_PLAYING and not self.osdVisible(): markerDef["countdown"] -= 1 - if isNew: - markerDef["countdown_initial"] = markerDef["countdown"] + if isNew: + markerDef["countdown_initial"] = markerDef["countdown"] self.setProperty('marker.countdown', '1') @@ -2108,10 +2188,14 @@ def playlistDialogVisible(self, value): self.setProperty('playlist.visible', '1' if value else '') def showPlaylistDialog(self): + created = False if not self.playlistDialog: self.playlistDialog = PlaylistDialog.create(show=False, handler=self.handler) + created = True self.playlistDialogVisible = True + if not created: + self.playlistDialog.updatePlayingItem() self.playlistDialog.doModal() self.resetTimeout() self.playlistDialogVisible = False @@ -2196,14 +2280,15 @@ def createListItem(self, pi): def createEpisodeListItem(self, episode): label2 = u'{0} \u2022 {1}'.format( episode.grandparentTitle, - u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + u'{0} \u2022 {1}'.format(T(32310, 'S').format(episode.parentIndex), T(32311, 'E').format(episode.index)) ) mli = kodigui.ManagedListItem(episode.title, label2, thumbnailImage=episode.thumb.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM), data_source=episode) mli.setProperty('track.duration', util.durationToShortText(episode.duration.asInt())) mli.setProperty('video', '1') - mli.setProperty('watched', episode.isWatched and '1' or '') + mli.setProperty('unwatched', not episode.isWatched and '1' or '') + mli.setProperty('watched', episode.isFullyWatched and '1' or '') return mli def createMovieListItem(self, movie): @@ -2212,7 +2297,8 @@ def createMovieListItem(self, movie): data_source=movie) mli.setProperty('track.duration', util.durationToShortText(movie.duration.asInt())) mli.setProperty('video', '1') - mli.setProperty('watched', movie.isWatched and '1' or '') + mli.setProperty('unwatched', not movie.isWatched and '1' or '') + mli.setProperty('watched', movie.isFullyWatched and '1' or '') return mli def playQueueCallback(self, **kwargs): diff --git a/script.plexmod/lib/windows/settings.py b/script.plexmod/lib/windows/settings.py index 841cae9172..339612bfbe 100644 --- a/script.plexmod/lib/windows/settings.py +++ b/script.plexmod/lib/windows/settings.py @@ -1,15 +1,16 @@ from __future__ import absolute_import + +import sys + +import plexnet from kodi_six import xbmc from kodi_six import xbmcgui -from kodi_six import xbmcvfs -from . import kodigui -from . import windowutils +import lib.cache from lib import util from lib.util import T - -import plexnet -import sys +from . import kodigui +from . import windowutils class Setting(object): @@ -150,7 +151,7 @@ def optionIndex(self): class BufferSetting(OptionsSetting): def get(self): - return util.kcm.memorySize + return lib.cache.kcm.memorySize def set(self, val): old = self.get() @@ -158,12 +159,12 @@ def set(self, val): util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.ID, old, val)) plexnet.util.APP.trigger('change:{0}'.format(self.ID), value=val) - util.kcm.write(memorySize=val) + lib.cache.kcm.write(memorySize=val) class ReadFactorSetting(OptionsSetting): def get(self): - return util.kcm.readFactor + return lib.cache.kcm.readFactor def set(self, val): old = self.get() @@ -171,7 +172,7 @@ def set(self, val): util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.ID, old, val)) plexnet.util.APP.trigger('change:{0}'.format(self.ID), value=val) - util.kcm.write(readFactor=val) + lib.cache.kcm.write(readFactor=val) class InfoSetting(BasicSetting): @@ -255,19 +256,49 @@ class Settings(object): T(32100, 'Skip user selection and pin entry on startup.') ), BoolSetting( - 'speedy_home_hubs2', T(33503, 'Use alternative hubs refresh'), False + 'use_alt_watched', T(33022, ''), True ).description( - T( - 33504, - "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of " - "only those likely affected. Use this if you find a hub that doesn't update properly." + T(33023, "") + ), + BoolSetting( + 'hide_aw_bg', T(33024, ''), False + ).description( + T(33025, "") + ), + OptionsSetting( + 'no_episode_spoilers2', T(33006, ''), + 'unwatched', + ( + ('off', T(32481, '')), + ('unwatched', T(33010, '')), + ('funwatched', T(33011, '')), ) + ).description(T(33007, "")), + BoolSetting( + 'no_unwatched_episode_titles', T(33012, ''), True + ).description( + T(33013, "") + ), + BoolSetting( + 'spoilers_allowed_genres', T(33016, ''), True + ).description( + T(33017, "").format(", ".join('"{}"'.format(t) for t in util.SPOILER_ALLOWED_GENRES)) + ), + BoolSetting( + 'hubs_use_new_continue_watching', T(32998, ''), False + ).description( + T(32999, "") ), BoolSetting( 'hubs_bifurcation_lines', T(32961, 'Show hub bifurcation lines'), False ).description( T(32962, "Visually separate hubs horizontally using a thin line.") ), + BoolSetting( + 'path_mapping_indicators', T(33032, 'Show path mapping indicators'), True + ).description( + T(33033, "When path mapping is active for a library, display an indicator.") + ), BoolSetting( 'search_use_kodi_kbd', T(32955, 'Use Kodi keyboard for searching'), False ), @@ -359,6 +390,22 @@ class Settings(object): ), 'player': ( T(32940, 'Player UI'), ( + OptionsSetting( + 'theme', + T(32983, 'Player Theme'), + util.DEF_THEME, + ( + ('modern', T(32985, 'Modern')), + ('modern-dotted', T(32986, 'Modern (dotted)')), + ('modern-colored', T(32989, 'Modern (colored)')), + ('classic', T(32987, 'Classic')), + ('custom', T(32988, 'Custom')), + ) + ).description( + T(32984, 'stub') + ), + BoolSetting('no_spoilers', T(33004, ''), False).description( + T(33005, '')), BoolSetting('subtitle_downloads', T(32932, 'Show subtitle quick-actions button'), False).description( T(32939, 'Only applies to video player UI')), BoolSetting('video_show_ffwdrwd', T(32933, 'Show FFWD/RWD buttons'), False).description( @@ -370,14 +417,14 @@ class Settings(object): OptionsSetting( 'video_show_playlist', T(32936, 'Show playlist button'), 'eponly', ( - ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes')), + ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes/Playlists')), ('never', T(32033, 'Never')) ) ).description(T(32939, 'Only applies to video player UI')), OptionsSetting( 'video_show_prevnext', T(32937, 'Show prev/next button'), 'eponly', ( - ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes')), + ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes/Playlists')), ('never', T(32033, 'Never')) ) ).description(T(32939, 'Only applies to video player UI')), @@ -467,6 +514,12 @@ class Settings(object): ) ), BoolSetting('gdm_discovery', T(32042, 'Server Discovery (GDM)'), False), + OptionsSetting( + 'handle_plexdirect', T(32990), 'ask', + (('ask', T(32991)), ('always', T(32035)), ('never', T(32033))) + ).description( + T(32992, 'stub') + ), IPSetting('manual_ip_0', T(32044, 'Connection 1 IP'), ''), IntegerSetting('manual_port_0', T(32045, 'Connection 1 Port'), 32400), IPSetting('manual_ip_1', T(32046, 'Connection 2 IP'), ''), @@ -479,25 +532,26 @@ class Settings(object): BoolSetting('kiosk.mode', T(32043, 'Start Plex On Kodi Startup'), False), BoolSetting('exit_default_is_quit', T(32965, 'Start Plex On Kodi Startup'), False) .description(T(32966, "stub")), + BoolSetting('path_mapping', T(33000, ''), True).description(T(33001, '')), BufferSetting('cache_size', T(33613, 'Kodi Buffer Size (MB)'), 20, - [(mem, '{} MB'.format(mem)) for mem in util.kcm.viableOptions]) + [(mem, '{} MB'.format(mem)) for mem in lib.cache.kcm.viableOptions]) .description( '{}{}'.format(T(33614, 'stub1').format( - util.kcm.free, util.kcm.recMax), - '' if util.kcm.useModernAPI else ' '+T(32954, 'stub2')) + lib.cache.kcm.free, lib.cache.kcm.recMax), + '' if lib.cache.kcm.useModernAPI else ' ' + T(32954, 'stub2')) ), ReadFactorSetting('readfactor', T(32922, 'Kodi Cache Readfactor'), 4, - [(rf, str(rf) if rf > 0 else T(32976, 'stub')) for rf in util.kcm.readFactorOpts]) + [(rf, str(rf) if rf > 0 else T(32976, 'stub')) for rf in lib.cache.kcm.readFactorOpts]) .description( T(32923, 'Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}.' 'With "Slow connection" enabled this will be set to {2}, as otherwise the cache doesn\'t' - 'fill fast/aggressively enough.').format(util.kcm.defRF, - util.kcm.recRFRange, - util.kcm.defRFSM) + 'fill fast/aggressively enough.').format(lib.cache.kcm.defRF, + lib.cache.kcm.recRFRange, + lib.cache.kcm.defRFSM) ), BoolSetting( 'slow_connection', T(32915, 'Slow connection'), False diff --git a/script.plexmod/lib/windows/signin.py b/script.plexmod/lib/windows/signin.py index 444933662d..257f3361d7 100644 --- a/script.plexmod/lib/windows/signin.py +++ b/script.plexmod/lib/windows/signin.py @@ -1,7 +1,9 @@ from __future__ import absolute_import + from kodi_six import xbmcgui -from . import kodigui + from lib import util +from . import kodigui class Background(kodigui.BaseWindow): diff --git a/script.plexmod/lib/windows/slidehshow.py b/script.plexmod/lib/windows/slidehshow.py index d25f8b670b..65eb27adfc 100644 --- a/script.plexmod/lib/windows/slidehshow.py +++ b/script.plexmod/lib/windows/slidehshow.py @@ -1,10 +1,11 @@ -import time import random +import time -from . import kodigui +from plexnet import plexapp from lib import util -from plexnet import plexapp +from . import kodigui + class Slideshow(kodigui.BaseWindow, util.CronReceiver): xmlFile = 'script-plex-slideshow.xml' @@ -25,7 +26,7 @@ def __init__(self, *args, **kwargs): self.timeBetweenImages = self.TIME_BETWEEN_IMAGES self.timeBetweenDisplayMove = self.TIME_DISPLAY_MOVE self.timeTitleIsHidden = self.TIME_HIDE_TITLE_IN_QUIZ - self.quizMode = util.advancedSettings.screensaverQuiz + self.quizMode = util.addonSettings.screensaverQuiz self.initialized = False def onFirstInit(self): diff --git a/script.plexmod/lib/windows/subitems.py b/script.plexmod/lib/windows/subitems.py index 915ec15f11..6b7e800e2d 100644 --- a/script.plexmod/lib/windows/subitems.py +++ b/script.plexmod/lib/windows/subitems.py @@ -1,30 +1,28 @@ from __future__ import absolute_import + import gc from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui +from plexnet import playlist -from lib import util from lib import metadata from lib import player - -from plexnet import playlist - +from lib import util +from lib.util import T from . import busy +from . import dropdown from . import episodes -from . import tracks -from . import opener from . import info +from . import kodigui from . import musicplayer -from . import videoplayer -from . import dropdown -from . import windowutils -from . import search +from . import opener from . import pagination from . import playbacksettings - -from lib.util import T +from . import search +from . import tracks +from . import videoplayer +from . import windowutils from .mixins import SeasonsMixin, DeleteMediaMixin, RatingsMixin @@ -136,7 +134,7 @@ def updateProperties(self): elif self.mediaItem.studio: self.setProperty('directors', u'{0} {1}'.format(T(32386, 'Studio').upper(), self.mediaItem.studio)) - cast = u' / '.join([r.tag for r in self.mediaItem.roles()][:5]) + cast = self.mediaItem.roles and u' / '.join([r.tag for r in self.mediaItem.roles()][:5]) or '' castLabel = T(32419, 'Cast').upper() self.setProperty('writers', cast and u'{0} {1}'.format(castLabel, cast) or '') @@ -188,7 +186,7 @@ def onAction(self, action): elif action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( self.OPTIONS_GROUP_ID)) and \ - (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + (not util.addonSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): if self.getProperty('on.extras'): self.setFocusId(self.OPTIONS_GROUP_ID) return @@ -404,7 +402,7 @@ def playButtonClicked(self, shuffle=False): pl = playlist.LocalPlaylist(items, self.mediaItem.getServer()) resume = False if not shuffle and self.mediaItem.type == 'show': - resume = self.getPlaylistResume(pl, items, self.mediaItem.title) + resume = self.getNextShowEp(pl, items, self.mediaItem.title) if resume is None: return @@ -487,7 +485,11 @@ def optionsButtonClicked(self, from_item=None): if self.delete(item): # cheap way of requesting a home hub refresh because of major deletion util.MONITOR.watchStatusChanged() - self.goHome() + self.initialized = False + self.setBoolProperty("initialized", False) + self.setup() + self.initialized = True + self.setFocusId(self.PLAY_BUTTON_ID) def roleClicked(self): mli = self.rolesListControl.getSelectedItem() diff --git a/script.plexmod/lib/windows/tracks.py b/script.plexmod/lib/windows/tracks.py index 90dc430436..b000a6b9b2 100644 --- a/script.plexmod/lib/windows/tracks.py +++ b/script.plexmod/lib/windows/tracks.py @@ -1,21 +1,18 @@ from __future__ import absolute_import + from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui - -from lib import colors -from lib import util - from plexnet import playlist +from lib import util +from lib.util import T from . import busy -from . import musicplayer from . import dropdown -from . import windowutils +from . import kodigui +from . import musicplayer from . import opener from . import search - -from lib.util import T +from . import windowutils class AlbumWindow(kodigui.ControlledWindow, windowutils.UtilMixin): diff --git a/script.plexmod/lib/windows/userselect.py b/script.plexmod/lib/windows/userselect.py index 656e705534..3175d562d1 100644 --- a/script.plexmod/lib/windows/userselect.py +++ b/script.plexmod/lib/windows/userselect.py @@ -1,15 +1,14 @@ from __future__ import absolute_import + from kodi_six import xbmc from kodi_six import xbmcgui - -from . import kodigui -from . import dropdown -from . import busy - -from lib import util from plexnet import plexapp +from lib import util from lib.util import T +from . import busy +from . import dropdown +from . import kodigui class UserSelectWindow(kodigui.BaseWindow): @@ -82,7 +81,7 @@ def onClick(self, controlID): with self.propertyContext('busy'): self.userList.reset() self.setProperty('initialized', '') - plexapp.ACCOUNT.updateHomeUsers() + plexapp.ACCOUNT.updateHomeUsers(refreshSubscription=True) self.start(with_busy=False) else: self.userSelected(item) @@ -216,8 +215,10 @@ def finished(self): self.task.cancel() -def start(): - w = UserSelectWindow.open() +def start(base_win_id): + w = UserSelectWindow.create() + if w.waitForOpen(base_win_id=base_win_id): + w.modal() selected = w.selected del w return selected diff --git a/script.plexmod/lib/windows/videoplayer.py b/script.plexmod/lib/windows/videoplayer.py index a67e5018df..c2e5472385 100644 --- a/script.plexmod/lib/windows/videoplayer.py +++ b/script.plexmod/lib/windows/videoplayer.py @@ -1,26 +1,25 @@ from __future__ import absolute_import -import time -import threading + import math +import threading +import time from kodi_six import xbmc from kodi_six import xbmcgui -from . import kodigui -from . import windowutils -from . import opener -from . import busy -from . import search -from . import dropdown -from . import pagination - -from lib import util -from lib import player from lib import colors from lib import kodijsonrpc - +from lib import player +from lib import util from lib.util import T - +from . import busy +from . import dropdown +from . import kodigui +from . import opener +from . import pagination +from . import search +from . import windowutils +from .mixins import SpoilersMixin PASSOUT_PROTECTION_DURATION_SECONDS = 7200 PASSOUT_LAST_VIDEO_DURATION_MILLIS = 1200000 @@ -44,17 +43,20 @@ def readyForPaging(self): def prepareListItem(self, data, mli): mli.setProperty('progress', util.getProgressImage(mli.dataSource)) mli.setProperty('unwatched', not mli.dataSource.isWatched and '1' or '') + mli.setProperty('watched', mli.dataSource.isFullyWatched and '1' or '') if data.type in 'episode': mli.setLabel2( - u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), data.parentIndex, T(32311, 'E'), data.index)) + u'{0} \u2022 {1}'.format(T(32310, 'S').format(data.parentIndex), T(32311, 'E').format(data.index))) else: mli.setLabel2(data.year) def createListItem(self, ondeck): title = ondeck.grandparentTitle or ondeck.title if ondeck.type == 'episode': - thumb = ondeck.thumb.asTranscodedImageURL(*self.parentWindow.ONDECK_DIM) + hide_spoilers = self.parentWindow.hideSpoilers(ondeck, use_cache=False) + thumb_opts = self.parentWindow.getThumbnailOpts(ondeck, hide_spoilers=hide_spoilers) + thumb = ondeck.thumb.asTranscodedImageURL(*self.parentWindow.ONDECK_DIM, **thumb_opts) else: thumb = ondeck.defaultArt.asTranscodedImageURL(*self.parentWindow.ONDECK_DIM) @@ -63,10 +65,13 @@ def createListItem(self, ondeck): return mli def getData(self, offset, amount): - return (self.parentWindow.prev or self.parentWindow.next).sectionOnDeck(offset=offset, limit=amount) + data = (self.parentWindow.prev or self.parentWindow.next).sectionOnDeck(offset=offset, limit=amount) + if self.parentWindow.next: + return list(filter(lambda x: x.ratingKey != self.parentWindow.next.ratingKey, data)) + return data -class VideoPlayerWindow(kodigui.ControlledWindow, windowutils.UtilMixin): +class VideoPlayerWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SpoilersMixin): xmlFile = 'script-plex-video_player.xml' path = util.ADDON.getAddonInfo('path') theme = 'Main' @@ -97,6 +102,7 @@ class VideoPlayerWindow(kodigui.ControlledWindow, windowutils.UtilMixin): def __init__(self, *args, **kwargs): kodigui.ControlledWindow.__init__(self, *args, **kwargs) windowutils.UtilMixin.__init__(self) + SpoilersMixin.__init__(self, *args, **kwargs) self.playQueue = kwargs.get('play_queue') self.video = kwargs.get('video') self.resume = bool(kwargs.get('resume')) @@ -129,6 +135,9 @@ def doClose(self): def onFirstInit(self): player.PLAYER.on('session.ended', self.sessionEnded) player.PLAYER.on('av.started', self.playerPlaybackStarted) + player.PLAYER.on('starting.video', self.onVideoStarting) + player.PLAYER.on('started.video', self.onVideoStarted) + player.PLAYER.on('changed.video', self.onVideoChanged) player.PLAYER.on('post.play', self.postPlay) player.PLAYER.on('change.background', self.changeBackground) @@ -140,6 +149,16 @@ def onFirstInit(self): self.resetPassoutProtection() self.play(resume=self.resume) + def onVideoStarting(self, *args, **kwargs): + util.setGlobalProperty('ignore_spinner', '1') + + def onVideoStarted(self, *args, **kwargs): + util.setGlobalProperty('ignore_spinner', '') + + def onVideoChanged(self, *args, **kwargs): + #util.setGlobalProperty('ignore_spinner', '') + pass + def onReInit(self): self.setBackground() @@ -152,7 +171,7 @@ def onAction(self, action): self.resetPassoutProtection() if action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): - if not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU: + if not util.addonSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU: self.lastNonOptionsFocusID = self.lastFocusID self.setFocusId(self.OPTIONS_GROUP_ID) return @@ -195,7 +214,7 @@ def onClick(self, controlID): return timeoutCanceled = False - if util.advancedSettings.postplayCancel: + if util.addonSettings.postplayCancel: timeoutCanceled = bool(self.timeout) self.cancelTimer() @@ -422,7 +441,7 @@ def startTimer(self): millis = (self.passoutProtection - time.time()) * 1000 util.DEBUG_LOG('Post play auto-play: Passout protection in {0}'.format(util.durationToShortText(millis))) - self.timeout = time.time() + abs(util.advancedSettings.postplayTimeout) + self.timeout = time.time() + abs(util.addonSettings.postplayTimeout) util.DEBUG_LOG('Starting post-play timer until: %i' % self.timeout) threading.Thread(target=self.countdown).start() @@ -447,8 +466,8 @@ def countdown(self): # self.playVideo() break elif self.timeout is not None: - cd = min(abs(util.advancedSettings.postplayTimeout-1), int((self.timeout or now) - now)) - base = 15 / float(util.advancedSettings.postplayTimeout-1) + cd = min(abs(util.addonSettings.postplayTimeout - 1), int((self.timeout or now) - now)) + base = 15 / float(util.addonSettings.postplayTimeout - 1) self.setProperty('countdown', str(int(math.ceil(base*cd)))) def getHubs(self): @@ -472,14 +491,26 @@ def getHubs(self): self.setProperty('has.next', '1') def setInfo(self): + hide_spoilers = False + if self.next and self.next.type == "episode": + hide_spoilers = self.hideSpoilers(self.next, use_cache=False) if self.next: self.setProperty( 'post.play.background', util.backgroundFromArt(self.next.art, width=self.width, height=self.height) ) - self.setProperty('info.title', self.next.title) + if self.next.type == "episode" and hide_spoilers: + if self.noTitles: + self.setProperty('info.title', + u'{0} \u2022 {1}'.format(T(32310, 'S').format(self.next.parentIndex), + T(32311, 'E').format(self.next.index))) + else: + self.setProperty('info.title', self.next.title) + self.setProperty('info.summary', T(33008, '')) + else: + self.setProperty('info.title', self.next.title) + self.setProperty('info.summary', self.next.summary) self.setProperty('info.duration', util.durationToText(self.next.duration.asInt())) - self.setProperty('info.summary', self.next.summary) if self.prev: self.setProperty( @@ -493,18 +524,25 @@ def setInfo(self): if self.prev.type == 'episode': self.setProperty('related.header', T(32306, 'Related Shows')) if self.next: - self.setProperty('next.thumb', self.next.thumb.asTranscodedImageURL(*self.NEXT_DIM)) - self.setProperty('info.date', util.cleanLeadingZeros(self.next.originallyAvailableAt.asDatetime('%B %d, %Y'))) + thumb_opts = {} + if hide_spoilers: + thumb_opts = self.getThumbnailOpts(self.next, hide_spoilers=hide_spoilers) + self.setProperty('next.thumb', self.next.thumb.asTranscodedImageURL(*self.NEXT_DIM, **thumb_opts)) + self.setProperty('info.date', + util.cleanLeadingZeros(self.next.originallyAvailableAt.asDatetime('%B %d, %Y'))) self.setProperty('next.title', self.next.grandparentTitle) self.setProperty( - 'next.subtitle', u'{0} {1} \u2022 {2} {3}'.format(T(32303, 'Season'), self.next.parentIndex, T(32304, 'Episode'), self.next.index) + 'next.subtitle', + u'{0} \u2022 {1}'.format(T(32303, 'Season').format(self.next.parentIndex), + T(32304, 'Episode').format(self.next.index)) ) if self.prev: self.setProperty('prev.thumb', self.prev.thumb.asTranscodedImageURL(*self.PREV_DIM)) self.setProperty('prev.title', self.prev.grandparentTitle) self.setProperty( - 'prev.subtitle', u'{0} {1} \u2022 {2} {3}'.format(T(32303, 'Season'), self.prev.parentIndex, T(32304, 'Episode'), self.prev.index) + 'prev.subtitle', u'{0} \u2022 {1}'.format(T(32303, 'Season').format(self.prev.parentIndex), + T(32304, 'Episode').format(self.prev.index)) ) self.setProperty('prev.info.date', util.cleanLeadingZeros(self.prev.originallyAvailableAt.asDatetime('%B %d, %Y'))) elif self.prev.type == 'movie': @@ -610,6 +648,9 @@ def play(video=None, play_queue=None, resume=False): player.PLAYER.off('session.ended', w.sessionEnded) player.PLAYER.off('post.play', w.postPlay) player.PLAYER.off('av.started', w.playerPlaybackStarted) + player.PLAYER.off('starting.video', w.onVideoStarting) + player.PLAYER.off('started.video', w.onVideoStarted) + player.PLAYER.off('changed.video', w.onVideoChanged) player.PLAYER.off('change.background', w.changeBackground) player.PLAYER.reset() command = w.exitCommand diff --git a/script.plexmod/lib/windows/windowutils.py b/script.plexmod/lib/windows/windowutils.py index 9914d8c879..edf5415e36 100644 --- a/script.plexmod/lib/windows/windowutils.py +++ b/script.plexmod/lib/windows/windowutils.py @@ -1,10 +1,9 @@ from __future__ import absolute_import -from lib import util -from . import opener -from . import dropdown +from lib import util from lib.util import T - +from . import dropdown +from . import opener HOME = None @@ -41,37 +40,50 @@ def showAudioPlayer(self, **kwargs): from . import musicplayer self.processCommand(opener.handleOpen(musicplayer.MusicPlayerWindow, **kwargs)) - def getPlaylistResume(self, pl, items, title): - resume = False + def getNextShowEp(self, pl, items, title): + revitems = list(reversed(items)) + in_progress = [i for i in revitems if i.get('viewOffset').asInt()] + if in_progress: + n = in_progress[0] + pl.setCurrent(n) + choice = dropdown.showDropdown( + options=[ + {'key': 'resume', 'display': T(32429, 'Resume from {0}').format( + util.timeDisplay(n.viewOffset.asInt()).lstrip('0').lstrip(':'))}, + {'key': 'play', 'display': T(32317, 'Play from beginning')} + ], + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=u'{0} - {1} \u2022 {2}'.format(title, + T(32310, 'S').format(n.parentIndex), + T(32311, 'E').format(n.index)) + ) + + if not choice: + return None + + if choice['key'] == 'resume': + return True + return False + watched = False - for i in items: - if (watched and not i.isWatched) or i.get('viewOffset').asInt(): - if i.get('viewOffset'): - choice = dropdown.showDropdown( - options=[ - {'key': 'resume', 'display': T(32429, 'Resume from {0}').format(util.timeDisplay(i.viewOffset.asInt()).lstrip('0').lstrip(':'))}, - {'key': 'play', 'display': T(32317, 'Play from beginning')} - ], - pos=(660, 441), - close_direction='none', - set_dropdown_prop=False, - header=u'{0} - {1}{2} \u2022 {3}{4}'.format(title, T(32310, 'S'), i.parentIndex, T(32311, 'E'), i.index) - ) - - if not choice: - return None - - if choice['key'] == 'resume': - resume = True - - pl.setCurrent(i) - break - elif i.isWatched: + for (k, i) in enumerate(revitems): + if watched: + try: + pl.setCurrent(revitems[k-2]) + return False + except IndexError: + break + if i.get('viewCount').asInt() > 0: watched = True - else: - break - return resume + non_special = [i for i in revitems if i.get('parentIndex').asInt() and i.get('viewCount').asInt() == 0] + use = items[0] + if non_special: + use = non_special[-1] + pl.setCurrent(use) + return False def shutdownHome(): diff --git a/script.plexmod/resources/language/resource.language.de_de/strings.po b/script.plexmod/resources/language/resource.language.de_de/strings.po index e40ba633f3..cde146f18a 100644 --- a/script.plexmod/resources/language/resource.language.de_de/strings.po +++ b/script.plexmod/resources/language/resource.language.de_de/strings.po @@ -4,7 +4,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: POEditor.com\n" -"Project-Id-Version: PM4K\n" +"Project-Id-Version: PM4K / PlexMod for Kodi\n" "Language: de\n" #: @@ -95,7 +95,7 @@ msgstr "Qualität für entfernte Geräte" #: msgctxt "#32022" msgid "Online Quality" -msgstr "Online Qualität" +msgstr "Online-Qualität" #: msgctxt "#32023" @@ -115,7 +115,7 @@ msgstr "Direkte Wiedergabe erlauben" #: msgctxt "#32026" msgid "Allow Direct Stream" -msgstr "Direktes streamen erlauben" +msgstr "Direktes Streamen erlauben" #: msgctxt "#32027" @@ -130,18 +130,13 @@ msgstr "Immer" #: msgctxt "#32029" msgid "Only Image Formats" -msgstr "nur Bildformate" +msgstr "Nur Bildformate" #: msgctxt "#32030" msgid "Auto" msgstr "Automatisch" -#: -msgctxt "#32031" -msgid "Burn-in Subtitles" -msgstr "Untertitel einbrennen" - #: msgctxt "#32032" msgid "Allow Insecure Connections" @@ -185,17 +180,17 @@ msgstr "Automatisch nächsten Titel abspielen" #: msgctxt "#32040" msgid "Enable Subtitle Downloading" -msgstr "Untertitel download aktivieren" +msgstr "Untertitel-Download aktivieren" #: msgctxt "#32041" msgid "Enable Subtitle Downloading" -msgstr "Untertitel download aktivieren" +msgstr "Untertitel-Download aktivieren" #: msgctxt "#32042" msgid "Server Discovery (GDM)" -msgstr "Server Erkennung (GDM)" +msgstr "Server-Erkennung (GDM)" #: msgctxt "#32043" @@ -255,12 +250,12 @@ msgstr "Video" #: msgctxt "#32054" msgid "Addon Version" -msgstr "Erweiterung Version" +msgstr "Version der Erweiterung" #: msgctxt "#32055" msgid "Kodi Version" -msgstr "Kodi Version" +msgstr "Kodi-Version" #: msgctxt "#32056" @@ -270,7 +265,7 @@ msgstr "Bilschirmauflösung" #: msgctxt "#32057" msgid "Current Server Version" -msgstr "Aktuelle Server Version" +msgstr "Aktuelle Server-Version" #: msgctxt "#32058" @@ -287,41 +282,11 @@ msgctxt "#32060" msgid "Use Kodi audio channels" msgstr "Kodi-Audiokanäle verwenden" -#: -msgctxt "#32061" -msgid "When transcoding audio, target the audio channels set in Kodi." -msgstr "Beim Transkodieren von Audio werden die in Kodi eingestellten Audiokanäle verwendet." - -#: -msgctxt "#32062" -msgid "Transcode audio to AC3" -msgstr "Transkodiere Ton zu AC3" - -#: -msgctxt "#32063" -msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." -msgstr "Transkodiere Ton zu AC3 in bestimmten Umständen (nützlich für Passthrough)" - #: msgctxt "#32064" msgid "Treat DTS like AC3" msgstr "DTS wie AC3 behandeln" -#: -msgctxt "#32065" -msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" -msgstr "Wenn einer der AC3 Optionen aktiviert ist, wird DTS wie AC3 behandelt (nützlich für Optical Passthrough)" - - -msgctxt "#32066" -msgid "Force audio to AC3" -msgstr "Audio immer als AC3 erzwingen" - - -msgctxt "#32067" -msgid "Only force multichannel audio to AC3" -msgstr "Nur Mehrkanal-Audio als AC3 erzwingen" - #: msgctxt "#32100" msgid "Skip user selection and pin entry on startup." @@ -330,22 +295,22 @@ msgstr "Benutzerauswahl und Pin-Eingabe beim Start überspringen." #: msgctxt "#32101" msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." -msgstr "Falls aktiviert wird, sofern verfügbar, nachdem die Wiedergabe endet ein 'nächster Titel' automatisch nach 15 Sekunden gestartet." +msgstr "Falls die Option aktiviert und ein weiterer Titel verfügbar ist, wird dieser 15 Sekunden nach der aktuellen Wiedergabe gestartet." #: msgctxt "#32102" msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." -msgstr "Aktiviert 4K Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." +msgstr "Aktiviert 4K-Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." #: msgctxt "#32103" msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." -msgstr "Aktiviert HEVC/H265 Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." +msgstr "Aktiviert HEVC/H265-Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." #: msgctxt "#32104" msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" -msgstr "Wann soll zu Servern ohne 'sichere Verbindung' verbunden werden[CR]* [B]Nie[/B]: Niemals unsicher zu eine Server verbinden.[CR]* [B]Im selben Netzwerk[/B]: Im selben Netzwerk erlauben[CR]* [B]Immer[/B]: Im selben Netzwerk und bei entfernter Verbindung erlauben" +msgstr "Wann soll zu Servern ohne 'sichere Verbindung' verbunden werden?[CR][CR]* [B]Nie[/B]: Niemals unsicher zu eine Server verbinden.[CR]* [B]Im selben Netzwerk[/B]: Im selben Netzwerk erlauben[CR]* [B]Immer[/B]: Im selben Netzwerk und bei entfernter Verbindung erlauben" #: msgctxt "#32201" @@ -380,7 +345,7 @@ msgstr "Szene" #: msgctxt "#32207" msgid "Live Music Video" -msgstr "Live Musikvideo" +msgstr "Live-Musikvideo" #: msgctxt "#32208" @@ -422,16 +387,6 @@ msgctxt "#32302" msgid "Go to {0}" msgstr "Gehe zu {0}" -#: -msgctxt "#32303" -msgid "Season" -msgstr "Staffel" - -#: -msgctxt "#32304" -msgid "Episode" -msgstr "Folge" - #: msgctxt "#32305" msgid "Extras" @@ -445,7 +400,7 @@ msgstr "Ähnliche Serien" #: msgctxt "#32307" msgid "More" -msgstr "Film" +msgstr "Mehr" #: msgctxt "#32308" @@ -457,16 +412,6 @@ msgctxt "#32309" msgid "None" msgstr "Ohne" -#: -msgctxt "#32310" -msgid "S" -msgstr "S" - -#: -msgctxt "#32311" -msgid "E" -msgstr "E" - #: msgctxt "#32312" msgid "Unavailable" @@ -545,7 +490,7 @@ msgstr "Wirklich löschen?" #: msgctxt "#32327" msgid "Are you sure you really want to delete this media?" -msgstr "Soll diese Mediandatei wirklich gelöscht werden?" +msgstr "Soll diese Mediendatei wirklich gelöscht werden?" #: msgctxt "#32328" @@ -585,7 +530,7 @@ msgstr "Beenden bestätigen" #: msgctxt "#32335" msgid "Are you ready to exit Plex?" -msgstr "Bereit um Plex zu beenden?" +msgstr "Bereit, um Plex zu beenden?" #: msgctxt "#32336" @@ -615,7 +560,7 @@ msgstr "Verbindungstests laufen. Bitte warten." #: msgctxt "#32341" msgid "Server is not accessible. Please sign into your server and check your connection." -msgstr "Der Server ist nicht erreichbar. Bitte einloggen und Verbindung prüfen." +msgstr "Der Server ist nicht erreichbar. Bitte am Server einloggen und Verbindung prüfen." #: msgctxt "#32342" @@ -900,12 +845,12 @@ msgstr "Qualität" #: msgctxt "#32398" msgid "Kodi Video Settings" -msgstr "Kodi Bild Einstellungen" +msgstr "Kodi-Bildeinstellungen" #: msgctxt "#32399" msgid "Kodi Audio Settings" -msgstr "Kodi Ton Einstellungen" +msgstr "Kodi-Toneinstellungen" #: msgctxt "#32400" @@ -925,7 +870,7 @@ msgstr "Autor(in)" #: msgctxt "#32403" msgid "Writers" -msgstr "Autoren" +msgstr "Autor(inn)en" #: msgctxt "#32404" @@ -940,7 +885,7 @@ msgstr "Untertitel downloaden" #: msgctxt "#32406" msgid "Subtitle Delay" -msgstr "Untertitel Verzögerung" +msgstr "Untertitel-Verzögerung" #: msgctxt "#32407" @@ -950,7 +895,7 @@ msgstr "Nächster Untertitel" #: msgctxt "#32408" msgid "Disable Subtitles" -msgstr "Untertitel deaktiviren" +msgstr "Untertitel deaktivieren" #: msgctxt "#32409" @@ -960,7 +905,7 @@ msgstr "Untertitel aktivieren" #: msgctxt "#32410" msgid "Platform Version" -msgstr "Platform Version" +msgstr "Platform-Version" #: msgctxt "#32411" @@ -975,7 +920,7 @@ msgstr "Bearbeiten oder bereinigen" #: msgctxt "#32413" msgid "Edit IP address or clear the current setting?" -msgstr "IP Adresse bearbeiten oder die aktuellen Einstellungen bereinigen?" +msgstr "IP-Adresse bearbeiten oder die aktuellen Einstellungen bereinigen?" #: msgctxt "#32414" @@ -990,12 +935,12 @@ msgstr "Ändern" #: msgctxt "#32416" msgid "Enter IP Address" -msgstr "IP Adresse eingeben" +msgstr "IP-Adresse eingeben" #: msgctxt "#32417" msgid "Enter Port Number" -msgstr "Port Nummer eingeben" +msgstr "Port-Nummer eingeben" #: msgctxt "#32418" @@ -1080,7 +1025,7 @@ msgstr "Bereinigen" #: msgctxt "#32434" msgid "Searching..." -msgstr "Suche..." +msgstr "Suche ..." #: msgctxt "#32435" @@ -1142,6 +1087,7 @@ msgctxt "#32446" msgid "Stereo" msgstr "Stereo" +#. Depending on context, this could also be translated differently. Same with all the "none"s in the list #: msgctxt "#32447" msgid "None" @@ -1165,12 +1111,12 @@ msgstr "Version auswählen" #: msgctxt "#32451" msgid "Play Version..." -msgstr "Version abspielen..." +msgstr "Version abspielen ..." #: msgctxt "#32452" msgid "No Content available in this library" -msgstr "In dieser Bibliothek ist nichts vorhanden" +msgstr "Keine Inhalte in dieser Bibliothek vorhanden" #: msgctxt "#32453" @@ -1240,7 +1186,7 @@ msgstr "Skip-Schritte-Einstellung von Kodi benutzen" #: msgctxt "#32466" msgid "Automatically seek selected position after a delay" -msgstr "Automatisch verzögert zur ausgewählten Position springen" +msgstr "Nach Verzögerung automatisch zur ausgewählten Position springen" #: msgctxt "#32467" @@ -1277,6 +1223,7 @@ msgctxt "#32481" msgid "Off" msgstr "Aus" +#. What is going on here? #: msgctxt "#32482" msgid "%(percentage)s %%" @@ -1285,17 +1232,17 @@ msgstr "%(percentage)s %%" #: msgctxt "#32483" msgid "Hide Stream Info" -msgstr "Stream Info nicht anzeigen" +msgstr "Stream-Info nicht anzeigen" #: msgctxt "#32484" msgid "Show Stream Info" -msgstr "Stream Info anzeigen" +msgstr "Stream-Info anzeigen" #: msgctxt "#32485" msgid "Go back instantly with the previous menu action in scrolled views" -msgstr "Mit der Previous-Menu-Aktion In gescrollten Ansichten sofort zurückgehen" +msgstr "Mit 'Vorheriges Menü' in gescrollten Ansichten sofort zurückgehen" #: msgctxt "#32487" @@ -1325,12 +1272,7 @@ msgstr "Ordner" #: msgctxt "#32492" msgid "Kodi Subtitle Settings" -msgstr "Kodi Untertitel-Einstellungen" - -#: -msgctxt "#32493" -msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." -msgstr "Hat eine Mediendatei erzwungenge Untertitel für eine Sprache, bei der Untertitel erwünscht sind, wählt der Plex Media Server diesen standardmäßig aus. Das Verhalten ist normalerweise unerwünscht und nicht konfigurierbar. Diese Einstellung behebt das Problem, indem versucht wird, einen Untertitel der selben Sprache ohne Erzwungen-Markierung zu wählen." +msgstr "Kodi-Untertiteleinstellungen" #: msgctxt "#32495" @@ -1345,12 +1287,12 @@ msgstr "Abspann überspringen" #: msgctxt "#32500" msgid "Always show post-play screen (even for short videos)" -msgstr "Immer den Nachwiedergabe-Bildschirm anzeigen (auch bei kurzen Videos)" +msgstr "Immer den Nach-Wiedergabe-Bildschirm anzeigen (auch bei kurzen Videos)" #: msgctxt "#32501" msgid "Time-to-wait between videos on post-play" -msgstr "Wartezeit zwischen Videos bei Nachwiedergabe" +msgstr "Wartezeit zwischen Videos bei Nach-Wiedergabe" #: msgctxt "#32505" @@ -1360,18 +1302,13 @@ msgstr "Medien in der Video-Wiedergabeliste anzeigen, anstatt sie abzuspielen" #: msgctxt "#32521" msgid "Skip Intro Button Timeout" -msgstr "Zeitlimit für die Schaltfläche Intro überspringen" +msgstr "Zeitlimit für die Schaltfläche 'Intro überspringen'" #: msgctxt "#32522" msgid "Automatically Skip Intro" msgstr "Intro automatisch überspringen" -#: -msgctxt "#32523" -msgid "Automatically skip intros if available. Doesn't override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Automatisches Überspringen von Intros, falls vorhanden. Überschreibt aktivierten Binge-Modus nicht.\nKann pro Serie aktiviert/deaktiviert werden." - #: msgctxt "#32524" msgid "Set how long the skip intro button shows for." @@ -1380,22 +1317,17 @@ msgstr "Festlegen, wie lange die Schaltfläche zum Überspringen des Intros ange #: msgctxt "#32525" msgid "Skip Credits Button Timeout" -msgstr "Zeitlimit für die Schaltfläche Abspann überspringen" +msgstr "Zeitlimit für die Schaltfläche 'Abspann überspringen'" #: msgctxt "#32526" msgid "Automatically Skip Credits" -msgstr "Automatisches Überspringen von Abspann" - -#: -msgctxt "#32527" -msgid "Automatically skip credits if available. Doesn't override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Automatisches Überspringen von Abspann, falls vorhanden. Überschreibt aktivierten Binge-Modus nicht.\nKann pro Serie aktiviert/deaktiviert werden." +msgstr "Abspann automatisch überspringen" #: msgctxt "#32528" msgid "Set how long the skip credits button shows for." -msgstr "Festlegen, wie lange die Schaltfläche zum Überspringen des Abspann angezeigt werden soll." +msgstr "Festlegen, wie lange die Schaltfläche zum Überspringen des Abspanns angezeigt werden soll." #: msgctxt "#32540" @@ -1405,12 +1337,12 @@ msgstr "Im Player anzeigen, wann das aktuelle Video endet" #: msgctxt "#32541" msgid "Shows time left and at which time the media will end." -msgstr "Zeigt im Player an, wann und zu welchem Zeitpunkt das aktuelle Video endet." +msgstr "Zeigt verbleibende Restzeit und Uhrzeit, wann das Video endet, im Player an." #: msgctxt "#32542" msgid "Show \"Ends at\" label for the end-time as well" -msgstr "\"Endet um\"-Label für die Endzeit auch anzeigen" +msgstr "'Endet um'-Label für Ende des Abspielens anzeigen" #: msgctxt "#32543" @@ -1460,7 +1392,7 @@ msgstr "Inhaltsbewertung" #: msgctxt "#33107" msgid "By Critic Rating" -msgstr "Nach kritischer Bewertung" +msgstr "Nach Kritikerwertung" #: msgctxt "#33108" @@ -1475,7 +1407,7 @@ msgstr "Hintergrundfarbe" #: msgctxt "#33201" msgid "Specify solid Background Color instead of using media images" -msgstr "Hintergrundfarbe anstatt Bilder verwenden" +msgstr "Hintergrundfarbe anstatt Medienbildern verwenden" #: msgctxt "#33400" @@ -1485,546 +1417,1059 @@ msgstr "Altes Kompatibilitätsprofil verwenden" #: msgctxt "#33401" msgid "Uses the Chrome client profile instead of the custom one. Might fix rare issues with 3D playback." -msgstr "Benutzt das alte Chrome Client-Profil anstatt des angepassten. Könnte seltene Fehler beim Abspielen von 3D-Inhalten verhindern." +msgstr "Benutzt das alte Chrome-Client-Profil anstatt des angepassten. Könnte seltene Fehler beim Abspielen von 3D-Inhalten verhindern." #: -msgctxt "#32348" -msgid "movies" -msgstr "Filme" +msgctxt "#32031" +msgid "Burn-in Subtitles" +msgstr "Untertitel einbrennen" #: -msgctxt "#32466" -msgid "Automatically seek to the selected timeline position after a second" -msgstr "Nach Verzögerung automatisch zur aktuell gewählten Position springen" +msgctxt "#32061" +msgid "When transcoding audio, target the audio channels set in Kodi." +msgstr "Beim Transkodieren von Audio werden die in Kodi eingestellten Audiokanäle verwendet." + +#: +msgctxt "#32062" +msgid "Transcode audio to AC3" +msgstr "Transkodiere Ton zu AC3" + +#: +msgctxt "#32063" +msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." +msgstr "Transkodiere Ton zu AC3 unter bestimmten Umständen (nützlich für Passthrough)" + +#: +msgctxt "#32065" +msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" +msgstr "Wenn eine der AC3-Optionen aktiviert ist, wird DTS wie AC3 behandelt (nützlich für Optical Passthrough)" + +#: +msgctxt "#32066" +msgid "Force audio to AC3" +msgstr "Audio immer als AC3 erzwingen" + +#: +msgctxt "#32067" +msgid "Only force multichannel audio to AC3" +msgstr "Nur Mehrkanal-Audio als AC3 erzwingen" + +#: +msgctxt "#32493" +msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." +msgstr "Hat eine Mediendatei erzwungenge Untertitel für eine Sprache, bei der Untertitel erwünscht sind, wählt der Plex Media Server diesen standardmäßig aus. Das Verhalten ist normalerweise unerwünscht und nicht konfigurierbar. Diese Einstellung behebt das Problem, indem versucht wird, einen Untertitel der selben Sprache ohne Erzwungen-Markierung zu wählen." +#: +msgctxt "#32523" +msgid "Automatically skip intros if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Automatisches Überspringen vorhandener Intros. Überschreibt aktivierten Binge-Modus nicht.\n" +"Kann pro Serie aktiviert/deaktiviert werden." + +#: +msgctxt "#32527" +msgid "Automatically skip credits if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Automatisches Überspringen von vorhandenem Abspann. Überschreibt aktivierten Binge-Modus nicht.\n" +"Kann pro Serie aktiviert/deaktiviert werden." + +#: msgctxt "#33501" msgid "Video played threshold" msgstr "Video-abgespielt-Grenzwert" +#: msgctxt "#33502" msgid "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to avoid certain pitfalls, Default: 90 %" -msgstr "Auf dem selben wert wie im Plex server setzen (Einstellungen>Mediathek>Video played threshold) um bestimmte Fehler zu umgehen, Standardwert: 90 %" +msgstr "Auf den selben Wert wie im Plex-Server setzen (unter Einstellungen > Mediathek zu finden), um bestimmte Fehler zu umgehen; Standardwert: 90 %" +#: msgctxt "#33503" msgid "Use alternative hubs refresh" msgstr "Alternative Aktualisierung der Hubs" +#: msgctxt "#33504" msgid "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of only those likely affected. Use this if you find a hub that doesn't update properly." msgstr "Aktualisiert alle Hubs aller Bibliotheken, anstatt nur die, die möglicherweise zutreffend sind, nachdem sich der Abspielstatus eines Items geändert hat. Benutzen, wenn sich ein Hub nicht aktualisiert." +#: msgctxt "#33505" msgid "Show intro skip button early" -msgstr "Intro überspringen früher anzeigen" +msgstr "'Intro überspringen' früher anzeigen" +#: msgctxt "#33506" -msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\'t override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Zeige den Intro-Überspringen-Knopf von Anfang an. Die Automatische-Intro-Überspringen-Einstellung wird angewandt. Überschreibt aktivierten Binge-Modus nicht.\nKann pro Serie aktiviert/deaktiviert werden." +msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\\'t override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Zeige den 'Intro überspringen'-Knopf von Anfang an. Die 'Automatisch überspringen'-Einstellung wird weiterhin angewandt. Überschreibt aktivierten Binge-Modus nicht.\n" +"Kann pro Serie aktiviert/deaktiviert werden." +#: msgctxt "#33507" msgid "Enabled" msgstr "Aktiviert" +#: msgctxt "#33508" msgid "Disabled" msgstr "Deaktiviert" +#: msgctxt "#33509" msgid "Early intro skip threshold (default: < 60s/1m)" -msgstr "Frühes Intro-Überspringen Grenzwert (default: < 60s/1m)" +msgstr "Grenzwert für 'Früher Intro überspringen' (Standardwert < 60s)" +#: msgctxt "#33510" -msgid "When showing the intro skip button early, only do so if the intro starts within the first X seconds." -msgstr "Wenn der Into-Überspringen-Knopf früher angezeigt werden soll, nur anzeigen, wenn das Intro innerhalb der ersten X Sekunden startet." +msgid "When showing the intro skip button early, only do so if the intro occurs within the first X seconds." +msgstr "Wenn der 'Intro überspringen'-Knopf früher angezeigt werden soll, nur anzeigen, wenn das Intro innerhalb der ersten X Sekunden startet." +#: msgctxt "#33600" msgid "System" msgstr "System" +#: msgctxt "#33601" msgid "Show video chapters" msgstr "Video-Kapitel anzeigen" +#: msgctxt "#33602" msgid "If available, show video chapters from the video-file instead of the timeline-big-seek-steps." msgstr "Wenn verfügbar, Video-Kapitel aus der Video-Datei anstatt der Zeitleiste-Big-Seek-Schritte anzeigen." +#: msgctxt "#33603" msgid "Use virtual chapters" msgstr "Virtuelle Kapitel verwenden" +#: msgctxt "#33604" msgid "When the above is enabled and no video chapters are available, simulate them by using the markers identified by the Plex Server (Intro, Credits)." -msgstr "Wenn die obrige aktiviert ist und keine Video-Kapitel verfügbar sind, virtuelle Kapitel aus den Plex Server Markern (Intro, Abspann) erzeugen." +msgstr "Wenn die obige Option aktiviert ist und keine Video-Kapitel verfügbar sind, virtuelle Kapitel aus den Plex-Server-Markern (Intro, Abspann) erzeugen." +#: msgctxt "#33605" msgid "Video Chapters" msgstr "Video-Kapitel" +#: msgctxt "#33606" msgid "Virtual Chapters" msgstr "Virtuelle Kapitel" +#: msgctxt "#33607" msgid "Chapter {}" msgstr "Kapitel {}" +#: msgctxt "#33608" msgid "Intro" msgstr "Intro" +#: msgctxt "#33609" msgid "Credits" msgstr "Abspann" +#: msgctxt "#33610" msgid "Main" msgstr "Haupt" +#: msgctxt "#33611" msgid "Chapters" msgstr "Kapitel" +#: msgctxt "#33612" msgid "Markers" msgstr "Markierungen" +#: msgctxt "#33613" msgid "Kodi Buffer Size (MB)" msgstr "Kodi Puffergröße (MB)" +#: msgctxt "#33614" -msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~100 MB, Recommended max: {} MB, Default: 20 MB." -msgstr "Setzt die Kodi Cache/Puffer Größe. Frei: {} MB, empfohlen: ~100 MB, empfohlenes Max.: {} MB, Default: 20 MB." +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~50 MB, Recommended max: {} MB, Default: 20 MB." +msgstr "Setzt die Kodi Cache/Puffer Größe. Frei: {} MB, empfohlen: ~50 MB, empfohlenes Max.: {} MB, Default: 20 MB." +#: msgctxt "#33615" msgid "{time} left" msgstr "{time} übrig" +#: msgctxt "#33616" msgid "Addon Path" msgstr "Addon-Pfad" +#: msgctxt "#33617" msgid "Userdata/Profile Path" msgstr "Benutzerdaten-/Profilpfad" +#: msgctxt "#33618" msgid "TV binge-viewing mode" -msgstr "TV Binge-Viewing-Modus" +msgstr "TV-Binge-Viewing-Modus" +#: msgctxt "#33619" -msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\'t skip the intro of the first episode of a season and doesn't skip the final credits of a show.\n\nCan be disabled/enabled per TV show.\nOverrides any setting below." -msgstr "Überspringt automatisch Intros und Abspänne von Episoden und versucht Recaps zu vermeiden. Überspringt das Intro der ersten Episode einer Staffel und den Abspann der letzten Episode einer Serie nicht.\n\nKann pro Serie aktiviert/deaktiviert werden.\nÜberschreibt jegliche Einstellung unterhalb dieser." +msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\\'t skip the intro of the first episode of a season and doesn't skip the final credits of a show.\n" +"\n" +"Can be disabled/enabled per TV show.\n" +"Overrides any setting below." +msgstr "Überspringt automatisch Intros und Abspänne von Episoden und versucht Recaps zu vermeiden. Überspringt das Intro der ersten Episode einer Staffel und den Abspann der letzten Episode einer Serie nicht.\n" +"\n" +"Kann pro Serie aktiviert/deaktiviert werden.\n" +"Überschreibt jegliche Einstellung unterhalb dieser." +#: msgctxt "#33620" msgid "Plex requests timeout (seconds)" -msgstr "Plex HTTP Zeitlimit (Sekunden)" +msgstr "Plex-HTTP-Zeitlimit (Sekunden)" +#: msgctxt "#33621" -msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5 seconds" -msgstr "Setzt das Zeitlimit für die Python Requests Bibliothek bei asynchronen Verbindungen und Verbindungsanfragen. Default: 5 Sekunden" +msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5" +msgstr "Setzt das Zeitlimit für die Python-Requests-Bibliothek bei asynchronen Verbindungen und Verbindungsanfragen. Default: 5" +#: msgctxt "#33622" msgid "LAN reachability timeout (ms)" msgstr "LAN-Erreichbarkeits-Zeitlimit (ms)" +#: msgctxt "#33623" -msgid "When checking for Server-in-LAN reachability, use this timeout. Default: 10ms" +msgid "When checking for LAN reachability, use this timeout. Default: 10ms" msgstr "Wenn die lokale Serververbindung im LAN überprüft wird, benutze dieses Zeitlimit. Default: 10ms" +#: msgctxt "#33624" msgid "Network" msgstr "Netzwerk" +#: msgctxt "#33625" msgid "Smart LAN/local server discovery" -msgstr "Smartes LAN/lokale Server-Auffinden" +msgstr "Smartes LAN/lokale Server auffinden" +#: msgctxt "#33626" -msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n\nNOTE: Only works on Kodi 19 or above." -msgstr "Überprüft, ob von Plex.tv zurückgegebene Server tatsächlich lokal/in Deinem LAN sind. Für bestimmte Setups (z. B. Docker) könnte Plex.tv einen lokalen Servern nicht als solchen entdecken.\n\nACHTUNG: Funktioniert nur unter Kodi 19 oder neuer." +msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n" +"\n" +"NOTE: Only works on Kodi 19 or above." +msgstr "Überprüft, ob von Plex.tv zurückgegebene Server tatsächlich lokal/in Deinem LAN sind. Für bestimmte Setups (z. B. Docker) könnte Plex.tv einen lokalen Server nicht als solchen entdecken.\n" +"\n" +"ACHTUNG: Funktioniert nur mit Kodi 19 oder neuer." +#: msgctxt "#33627" msgid "Prefer LAN/local servers over security" msgstr "LAN/lokale Server bevorzugen" +#: msgctxt "#33628" msgid "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". Can be used to enforce manual servers." -msgstr "Priorisiert lokale Verbindungen über sichere. Benötigt die korrekte Einstellung in \"Unsichere Verbindung erlauben\" und die Plex Server Einstellung \"Sichere Verbindungen\" auf \"Bevorzugt\". Kann verwendet werden um manuelle Server zu forcieren." +msgstr "Priorisiert lokale Verbindungen über sichere. Benötigt die korrekte Einstellung in \"Unsichere Verbindung erlauben\" und die Plex-Server-Einstellung \"Sichere Verbindungen\". Kann verwendet werden, um manuelle Server zu forcieren." +#: msgctxt "#33629" msgid "Auto-skip intro/credits offset" msgstr "Auto-Überspringen-Versatz" +#: msgctxt "#33630" msgid "Intro/credits markers might be a little early in Plex. When auto skipping add (or subtract) this many seconds from the marker. This avoids cutting off content, while possibly skipping the marker a little late." -msgstr "Intro-/Abspann-Markierungen können ein wenig früh erscheinen in Plex. Wenn diese automatisch übersprungen werden sollen, addiere (oder subtrahiere) diese Menge an Sekunden. Das verhindert das verfrühte Abschneiden von Inhalten, kann aber zu etwas verspätetem Springen führen." +msgstr "Intro-/Abspann-Markierungen können zeitversetzt sein. Um passend automatisch zu überspringen, addiere (oder subtrahiere) die eingestellte Menge an Sekunden. Das verhindert das verfrühte Abschneiden von Inhalten, kann aber zu etwas verspätetem Springen führen." +#: msgctxt "#32631" msgid "Playback (user-specific)" msgstr "Wiedergabe (benutzerspezifisch)" +#: msgctxt "#33632" msgid "Server connectivity check timeout (seconds)" -msgstr "Server Konnektivitätscheck-Timeout (Sekunden)" +msgstr "Timeout für Server-Konnektivitäts-Check (Sekunden)" +#: msgctxt "#33633" msgid "Set the maximum amount of time a server connection has to answer a connectivity request. Default: 2.5" msgstr "Setzt die maximale Zeit, in der eine Serververbindung antworten muss. Voreinstellung: 2.5" +#: msgctxt "#33634" msgid "Combined Chapters" msgstr "Kombinierte Kapitel" +#: msgctxt "#33635" msgid "Final Credits" msgstr "Finaler Abspann" +#: msgctxt "#32700" msgid "Action on Sleep event" msgstr "Aktion bei Sleep-Ereignis" +#: msgctxt "#32701" msgid "When Kodi receives a sleep event from the system, run the following action." msgstr "Wenn Kodi ein Sleep-Ereignis vom System erhält, folgende Aktion ausführen." +#: msgctxt "#32702" msgid "Nothing" msgstr "Nichts" +#: msgctxt "#32703" msgid "Stop playback" msgstr "Abspielen stoppen" +#: msgctxt "#32704" msgid "Quit Kodi" msgstr "Kodi beenden" +#: msgctxt "#32705" msgid "CEC Standby" msgstr "CEC Standby" +#: msgctxt "#32800" msgid "Skipping intro" msgstr "Überspringe Intro" +#: msgctxt "#32801" msgid "Skipping credits" msgstr "Überspringe Abspann" +#: msgctxt "#32900" msgid "While playing back an item and seeking on the seekbar, automatically seek to the selected position after a delay instead of having to confirm the selection." msgstr "Wird während des Abspielens die Position auf der Zeitleiste verändert, automatisch nach einer Verzögerung auf die gewählte Position springen, ohne diese bestätigen zu müssen." +#: msgctxt "#32901" msgid "Seek delay in seconds." msgstr "Sprungverzögerung in Sekunden." +#: msgctxt "#32902" msgid "Kodi has its own skip step settings. Try to use them if they're configured instead of the default ones." -msgstr "Kodi besitzt seine eigenen Sprungeinstellungen. Versuche diese anstatt der standardmäßigen zu verwenden, sollten sie konfiguriert sein." +msgstr "Kodi besitzt seine eigenen Sprungeinstellungen. Versuche diese zu verwenden, sollten sie konfiguriert sein." +#: msgctxt "#32903" msgid "Use the above for seeking on the timeline as well." -msgstr "Benutze die obrige Einstellung auch für die Zeitleiste." +msgstr "Benutze die obige Einstellung auch für die Zeitleiste." +#: msgctxt "#32904" msgid "In seconds." msgstr "In Sekunden." +#: msgctxt "#32905" msgid "Cancel post-play timer by pressing OK/SELECT" -msgstr "Post-Play Timer durch OK/AUSWAHL abbrechen" +msgstr "Post-Play-Timer durch OK/AUSWAHL abbrechen" +#: msgctxt "#32906" msgid "Cancel skip marker timer with BACK" msgstr "Markierung überspringen durch Zurück-Taste abbrechen" +#: msgctxt "#32907" msgid "When auto-skipping a marker, allow cancelling the timer by pressing BACK." msgstr "Wenn eine Markierung mit einem Zeitgeber automatisch übersprungen wird, durch Zurück-Taste abbrechen." +#: msgctxt "#32908" msgid "Immediately skip marker with OK/SELECT" msgstr "Markierung sofort überspringen mit OK/AUSWAHL" +#: msgctxt "#32909" msgid "When auto-skipping a marker with a timer, allow skipping immediately by pressing OK/SELECT." msgstr "Wenn eine Markierung mit einem Zeitgeber automatisch übersprungen wird, durch OK/AUSWAHL-Taste sofort überspringen." +#: msgctxt "#32912" msgid "Show buffer-state on timeline" msgstr "Zeige Puffer-Stand auf Zeitachse" +#: msgctxt "#32913" msgid "Shows the current Kodi buffer/cache state on the video player timeline." -msgstr "Zeigt den aktuellen Kodi Puffer/Cache-Stand auf der Videoplayer Zeitachse." +msgstr "Zeigt den aktuellen Kodi-Puffer/Cache-Stand auf der Videoplayer-Zeitachse." +#: msgctxt "#32914" msgid "Loading" msgstr "Lädt" +#: msgctxt "#32915" msgid "Slow connection" msgstr "Langsame Verbindung" +#: msgctxt "#32916" msgid "Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually wait for item refreshes and waits for the buffer to fill when starting playback. Automatically sets readfactor=20, requires Kodi restart." -msgstr "Bei langsamer Verbindung benutzen, z.B. im Hotel. Passt die Oberfläche visuell an um auf Media Auffrischungen zu warten und füllt den Kodi Puffer vor dem Abspielen. Setzt automatisch Kodi Cache readfactor=20, benötigt einen Kodi Neustart." +msgstr "Bei langsamer Verbindung benutzen, z.B. im Hotel. Passt die Oberfläche visuell an, um auf Medien-Auffrischungen zu warten, und füllt den Kodi-Puffer vor dem Abspielen. Setzt automatisch Kodi-Cache readfactor=20. Benötigt einen Kodi-Neustart." +#: msgctxt "#32917" msgid "Couldn't fill buffer in time ({}s)" msgstr "Konnte den Puffer nicht schnell genug füllen ({} Sek.)" +#: msgctxt "#32918" msgid "Buffer wait timeout (seconds)" msgstr "Puffer-Wartezeit (Sekunden)" +#: msgctxt "#32919" msgid "When slow connection is enabled in the addon, wait this long for the buffer to fill. Default: 120 s" -msgstr "Wenn Langsame Verbindung im Addon aktiviert ist, warte so lange bis der Puffer gefüllt ist. Voreinstellung: 120 Sek." +msgstr "Wenn 'Langsame Verbindung' im Addon aktiviert ist, warte so lange, bis der Puffer gefüllt ist. Voreinstellung: 120 Sek." +#: msgctxt "#32920" msgid "Insufficient buffer wait (seconds)" -msgstr "Ungenügender Puffer Wartezeit (Sekunden)" +msgstr "Ungenügender-Puffer-Wartezeit (Sekunden)" +#: msgctxt "#32921" -msgid "When slow connection is enabled in the addon and the configured cache/buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" -msgstr "Wenn \"Langsame Verbindung\" im Addon aktiviert ist und der konfigurierte Puffer/Cache nicht groß genug ist, um seinen Füllstand zu ermitteln, wird so lange gewartet, bevor die Wiedergabe gestartet wird. Voreinstellung: 10 Sek." +msgid "When slow connection is enabled in the addon and the configured buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" +msgstr "Wenn 'Langsame Verbindung' im Addon aktiviert ist und der konfigurierte Puffer/Cache nicht groß genug ist, um seinen Füllstand zu ermitteln, wird so lange gewartet, bevor die Wiedergabe gestartet wird. Voreinstellung: 10 Sek." +#: msgctxt "#32922" msgid "Kodi Cache Readfactor" -msgstr "Kodi Puffer Readfactor" +msgstr "Kodi-Puffer/Cache-Readfactor" +#: msgctxt "#32923" msgid "Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}. With \"Slow connection\" enabled this will be set to {2}, as otherwise the cache doesn't fill fast/aggressively enough." -msgstr "Setzt den Kodi Cache/Puffer readfactor Wert. Standard: {0}, Empfohlen: {1}. Bei aktivem \"Langsame Verbindung\" wird dies automatisch auf {2} gesetzt, da ansonsten der Cache nicht schnell/agressiv genug gefüllt wird." +msgstr "Setzt den Kodi-Cache/Puffer-Readfactor-Wert. Standard: {0}, Empfohlen: {1}. Bei aktivem 'Langsame Verbindung' wird dies automatisch auf {2} gesetzt, da ansonsten der Cache nicht schnell/agressiv genug gefüllt wird." +#: msgctxt "#32924" msgid "Minimize" msgstr "Minimieren" +#: msgctxt "#32925" msgid "Playback Settings" -msgstr "Abspieleinstell." +msgstr "Abspieleinstellungen" +#: msgctxt "#32926" msgid "Wrong pin entered!" msgstr "Falsche PIN eingegeben!" +#: msgctxt "#32927" msgid "Use episode thumbnails in continue hub" msgstr "Vorschaubild für Episoden im Fortsetzen-Hub verwenden" +#: msgctxt "#32928" msgid "Instead of using media artwork, use thumbnails for episodes in the continue hub on the home screen if available." msgstr "Verwende Vorschaubilder anstatt Media-Artwork für Episoden im Fortsetzen-Hub auf der Start-Ansicht." +#: msgctxt "#32929" msgid "Use legacy background fallback image" -msgstr "Veraltetes Ausweich-Hintergrundbild verwenden" +msgstr "Klassisches Ausweichhintergrundbild verwenden" +#: msgctxt "#32930" msgid "Previous Subtitle" msgstr "Vorheriger Untertitel" +#: msgctxt "#32931" msgid "Audio/Subtitles" msgstr "Audio/Untertitel" +#: msgctxt "#32932" msgid "Show subtitle quick-actions button" msgstr "Zeige Untertitel-Schnellaktionen-Knopf" +#: msgctxt "#32933" msgid "Show FFWD/RWD buttons" -msgstr "Zeige Vorspulen-Zurückspulen-Knopf" +msgstr "Zeige Vor-/Zurückspulen-Knopf" +#: msgctxt "#32934" msgid "Show repeat button" msgstr "Zeige Wiederholen-Knopf" +#: msgctxt "#32935" msgid "Show shuffle button" msgstr "Zeige Zufällige-Wiedergabe-Knopf" +#: msgctxt "#32936" msgid "Show playlist button" msgstr "Zeige Wiedergabeliste-Knopf" +#: msgctxt "#32937" msgid "Show prev/next button" msgstr "Zeige Vorheriger/Nächster-Knopf" -msgctxt "#32938" -msgid "Only for Episodes" -msgstr "Nur bei Episoden" - +#: msgctxt "#32939" msgid "Only applies to video player UI" -msgstr "Gilt nur für die Video Abspieloberfläche" +msgstr "Gilt nur für die Video-Abspieloberfläche" +#: msgctxt "#32940" msgid "Player UI" msgstr "Abspieloberfläche" +#: msgctxt "#32941" msgid "Forced subtitles fix" msgstr "Erzwungene Untertitel beheben" +#: msgctxt "#32942" msgid "Other seasons" msgstr "Weitere Staffeln" +#: msgctxt "#32943" msgid "Crossfade dynamic background art" msgstr "Dynamische Hintergrundbilder überblenden" +#: msgctxt "#32944" msgid "Burn-in SSA subtitles (DirectStream)" msgstr "SSA-Untertitel einbrennen (DirectStream)" +#: msgctxt "#32945" msgid "When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus transcoding the video stream). If disabled it will not touch the video stream, but will convert the subtitle to unstyled text." -msgstr "Wird Direct Streaming verwendet, den Plex Server dazu bringen, SSA/ASS-Untertitel einzubrennen (also den Video Stream zu transcoden). Wenn deaktiviert, wird dieser den Video Stream nicht anfassen, jedoch den Untertitel als reinen Text anzeigen." +msgstr "Wird Direct Streaming verwendet, den Plex-Server dazu bringen, SSA/ASS-Untertitel einzubrennen (mit Video-Stream-Transcode). Wenn deaktiviert, wird der Video-Stream nicht verändert, jedoch der Untertitel zu reinem Text konvertiert." +#: msgctxt "#32946" msgid "Stop video playback on idle after" msgstr "Bei Inaktivität Video stoppen nach" +#: msgctxt "#32947" msgid "Stop video playback on screensaver" msgstr "Video stoppen bei Bildschirmschoner" +#: msgctxt "#32948" msgid "Allow auto-skip when transcoding" msgstr "Überspringen beim Transkodieren erlauben" +#: msgctxt "#32949" msgid "When transcoding/DirectStreaming, allow auto-skip functionality." msgstr "Beim Transkodieren/DirectStream die automatische Überspringen-Funktionalität erlauben." +#: msgctxt "#32950" msgid "Use extended title for subtitles" msgstr "Erweiterten Titel für Untertitel verwenden" +#: msgctxt "#32951" msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." msgstr "Verwende erweiterte Namen wenn Untertitelnamen angezeigt werden." -msgctxt "#32952" -msgid "Dialog-Flackern beheben" -msgstr "" - +#: msgctxt "#32953" msgid "Reviews" msgstr "Kritik" +#: msgctxt "#32954" -msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n\nTo customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" -msgstr "Benötigt Kodi Neustart. ACHTUNG: Überschreibt advancedsettings.xml!\n\nUm weitere Cache-/Netzwerkbezogene Werte zu ändern, kopiere \"script.plexmod/pm4k_cache_template.xml\" in den Profilordner und editiere sie. (Für die Dateipfade schaue in die Über-Sektion)" +msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n" +"\n" +"To customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" +msgstr "Benötigt Kodi-Neustart. ACHTUNG: Überschreibt advancedsettings.xml!\n" +"\n" +"Um weitere Cache-/Netzwerkbezogene Werte zu ändern, kopiere \"script.plexmod/pm4k_cache_template.xml\" in den Profilordner und editiere sie. (Für die Dateipfade schaue in Abschnitt 'Über')" +#: msgctxt "#32955" msgid "Use Kodi keyboard for searching" msgstr "Kodi-Tastatur bei der Suche verwenden" +#: msgctxt "#32956" msgid "Poster resolution scaling %" msgstr "Poster Auflösungsskalierung %" +#: msgctxt "#32957" -msgid "In percent. Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance, will increase the cache usage accordingly. Recommended: 200-300 % for big screens if your hardware can handle it. Needs addon restart." -msgstr "In Prozent. Skaliert die Auflösung aller Poster/Vorschaubilder für bessere Bildqualität. Kann die PMS/PM4K Performance beeinflussen, wird die Cache-Nutzung dementsprechend erhöhen. Empfohlen: 200-300 % für große Bildschirme, wenn die Hardware damit umgehen kann. Benötigt Addon-Neustart." +msgid "In percent. Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance, will increase the cache usage accordingly. Recommended: 200-300 % for for big screens if your hardware can handle it. Needs addon restart." +msgstr "In Prozent. Skaliert die Auflösung aller Poster/Vorschaubilder für bessere Bildqualität. Kann die PMS/PM4K Performance beeinflussen; wird die Cache-Nutzung dementsprechend erhöhen. Empfohlen: 200-300 % für große Bildschirme, wenn die Hardware damit umgehen kann. Benötigt Addon-Neustart." +#: msgctxt "#32958" msgid "Calculate OpenSubtitles.com hash" -msgstr "OpenSubtitles.com Prüfsumme berechnen" +msgstr "OpenSubtitles.com-Prüfsumme berechnen" +#: msgctxt "#32959" msgid "When opening the subtitle download feature, automatically calculate the OpenSubtitles.com hash for the given file. Can improve search results, downloads 2*64 KB of the video file to calculate the hash." msgstr "Beim Öffnen der Untertitel-Herunterladen-Funktion die Prüfsumme der Datei für OpenSubtitles.com automatisch berechnen. Kann die Suchergebnisse verbessern, lädt 2*64 KB der Videodatei herunter um die Prüfsumme zu berechnen." +#: msgctxt "#32960" msgid "Similar Artists" msgstr "Ähnliche Künstler" +#: msgctxt "#32961" msgid "Show hub bifurcation lines" msgstr "Hub-Trennlinie anzeigen" +#: msgctxt "#32962" msgid "Visually separate hubs horizontally using a thin line." msgstr "Trennt Hubs visuell horizontal mit Hilfe einer dünnen Linie." +#: msgctxt "#32963" msgid "Wait between videos (s)" -msgstr "Zwischen Videos warten (s)" +msgstr "Zwischen Videos warten (in Sekunden)" +#: msgctxt "#32964" msgid "When playing back consecutive videos (e.g. TV shows), wait this long before starting the next one in the queue. Might fix compatibility issues with certain configurations." msgstr "Werden aufeinanderfolgende Videos abgespielt (z. B. Serien), so lange warten, bis das nächste Video in der Warteschlange abgespielt wird. Könnte Kompatibilitätsprobleme mit gewissen Konfigurationen beheben." +#: msgctxt "#32965" msgid "Quit Kodi on exit by default" -msgstr "Kodi beenden anstatt PM4K" +msgstr "Bei Beenden von PM4K auch Kodi beenden" +#: msgctxt "#32966" -msgid "When showing the addon exit confirmation, use \"Quit Kodi\" as default option. Can be dynamically switched using CONTEXT_MENU (often longpress SELECT)" -msgstr "Wenn die Addon-Beendigungsbestätigung angezeigt wird, \"Kodi beenden\" als Standardoption verwenden. Kann dynamisch mit CONTEXT_MENU (oft lange gedrücktes SELECT) getauscht werden." +msgid "When exiting the addon, use \"Quit Kodi\" as default option. Can be dynamically switched using CONTEXT_MENU (often longpress SELECT)" +msgstr "Wenn das Addon beendet wird, \"Kodi beenden\" als Standardoption verwenden. Kann dynamisch mit CONTEXT_MENU (oft lange gedrücktes SELECT) getauscht werden." +#: msgctxt "#32967" msgid "Kodi Colour Management" -msgstr "Kodi Farbeinstellungen" +msgstr "Kodi-Farbeinstellungen" +#: msgctxt "#32968" msgid "Kodi Resolution Settings" -msgstr "Kodi Auflösungseinstellungen" +msgstr "Kodi-Auflösungseinstellungen" +#: msgctxt "#32969" msgid "Always request all library media items at once" msgstr "Immer die vollständige Bibliothek laden" +#: msgctxt "#32970" msgid "Retrieve all media in library up front instead of fetching it in chunks as the user navigates through the library" msgstr "Alle Medieninhalte einer Bibliothek anfordern, anstatt diese gestückelt nachzuladen" +#: msgctxt "#32971" msgid "Library item-request chunk size" -msgstr "Bibliothek Anfrage-Blockgröße" +msgstr "Bibliotheksanfrage-Blockgröße" +#: msgctxt "#32972" msgid "Request this amount of media items per chunk request in library view (+6-30 depending on view mode; less can be less straining for the UI at first, but puts more strain on the server)" msgstr "Diese Anzahl an Medieninhalten pro gestückelter Anfrage in der Bibliothekenansicht laden (+6-30 abhängig von der Ansicht; weniger kann vorerst geringere UI-Last bedeuten, aber mehr Last beim Server erzeugen)" +#: msgctxt "#32973" msgid "Episodes: Skip Post Play screen" msgstr "Direkt zur nächsten Episode springen" +#: msgctxt "#32974" -msgid "When finishing an episode, don't show Post Play but go to the next one immediately.\nCan be disabled/enabled per TV show. Doesn't override enabled binge mode. Overrides the Post Play setting." -msgstr "Beim Beenden einer Episode nicht in die Post Play Ansicht gehen und stattdessen sofort zur nächsten Episode springen.\nCan be disabled/enabled per TV show. Überschreibt aktivierten Binge-Modus nicht. Überschreibt die \"Automatisch nächsten Titel abspielen\"-Einstellung" +msgid "When finishing an episode, don't show Post Play but go to the next one immediately.\n" +"Can be disabled/enabled per TV show. Doesn't override enabled binge mode. Overrides the Post Play setting." +msgstr "Beim Beenden einer Episode nicht in die Post-Play-Ansicht gehen und stattdessen sofort zur nächsten Episode springen.\n" +"Kann pro TV-Sendung eingestellt werden. Überschreibt aktivierten Binge-Modus nicht. Überschreibt die \"Automatisch nächsten Titel abspielen\"-Einstellung." +#: msgctxt "#32975" msgid "Delete Season" msgstr "Staffel löschen" +#: msgctxt "#32976" msgid "Adaptive" msgstr "Adaptiv" +#: msgctxt "#32977" msgid "Allow VC1" msgstr "VC1 zulassen" +#: msgctxt "#32978" msgid "Enable this if your hardware can handle VC1. Disable it to force transcoding." msgstr "Diese Option aktivieren, wenn die Hardware VC1 verarbeiten kann. Deaktivieren, um die Transkodierung zu erzwingen." +#: msgctxt "#32979" msgid "Allows the server to only transcode streams of a video that need transcoding, while streaming the others unaltered. If disabled, force the server to transcode everything not direct playable." msgstr "Erlaubt dem Server nur Streams eines Videos zu transkodieren, die dies benötigen, während die anderen unverändert übertragen werden. Ist dies deaktiviert, wird der Server dazu gezwungen, alles zu transkodieren, was nicht direkt abspielbar ist." +#: msgctxt "#32980" msgid "Refresh Users" -msgstr "Benutzer aktual." +msgstr "Benutzer aktualisieren" + +#: +msgctxt "#32981" +msgid "Background worker count" +msgstr "Anzahl Hintergrund-Worker" + +#: +msgctxt "#32982" +msgid "Depending on how many cores your CPU has and how much it can handle, increasing this might improve certain situations. If you experience crashes or other annormalities, leave this at its default (3). Needs an addon restart." +msgstr "Je nachdem, wie viele Kerne Deine CPU hat und wie viel sie verarbeiten kann, kann eine höhere Zahl bestimmte Situationen verbessern. Solltest Du Abstürze oder andere Anomalien bemerken, stelle dies auf den Standardwert zurück (3). Benötigt einen Addon-Neustart." + +#: +msgctxt "#32983" +msgid "Player Theme" +msgstr "Player-Theme" + +#: +msgctxt "#32984" +msgid "Sets the player theme. Currently only customizes the playback control buttons. ATTENTION: [I]Might[/I] need an addon restart.\n" +"In order to customize this, copy one of the xml's in script.plexmod/resources/skins/Main/1080i/templates to addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml and adjust it to your liking, then select \"Custom\" as your theme." +msgstr "Wählt das Theme des Players. Verändert aktuell nur die Abspiel-Knöpfe. ACHTUNG: Ein Addon-Neustart [I]könnte[/I] notwendig sein.\n" +"Um dies individuell anzupassen, kopiere eine der xml's aus script.plexmod/resources/skins/Main/1080i/templates nach addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml und passe es nach Deinen Ansprüchen an; danach \"Individualisiert\" als Theme wählen." + +#: +msgctxt "#32985" +msgid "Modern" +msgstr "Modern" + +#: +msgctxt "#32986" +msgid "Modern (dotted)" +msgstr "Modern (gepunktet)" + +#: +msgctxt "#32987" +msgid "Classic" +msgstr "Klassisch" + +#: +msgctxt "#32988" +msgid "Custom" +msgstr "Individualisiert" + +#: +msgctxt "#32989" +msgid "Modern (colored)" +msgstr "Modern (eingefärbt)" + +#: +msgctxt "#32990" +msgid "Handle plex.direct mapping" +msgstr "plex.direct-Zuordnung abwickeln" + +#: +msgctxt "#32991" +msgid "Notify" +msgstr "Benachrichtigen" + +#: +msgctxt "#32992" +msgid "When using servers with a plex.direct connection (most of them), should we automatically adjust advancedsettings.xml to cope with plex.direct domains? If not, you might want to add plex.direct to your router's DNS rebind exemption list." +msgstr "Wenn Server mit einer plex.direct Verbindung verwendet werden (die meisten), sollen wir automatisch die advancedsettings.xml anpassen? Wenn nicht, solltest Du plex.direct in die DNS Rebind Ausschlussliste Deines Routers eintragen." + +#: +msgctxt "#32993" +msgid "{} unhandled plex.direct connections found" +msgstr "{} unbehandelte plex.direct-Verbindungen gefunden" + +#: +msgctxt "#32994" +msgid "In order for PM4K to work properly, we need to add special handling for plex.direct connections. We've found {} new unhandled connections. Do you want us to write those to Kodi's advancedsettings.xml automatically? If not, you might want to add plex.direct to your router's DNS rebind exemption list. This can be changed in the settings as well." +msgstr "Damit PM4K korrekt funktioniert, müssen wir spezielles Handling für plex.direct-Verbindungen einrichten. Es wurden {} neue unbehandelte Verbindungen gefunden. Sollen wir diese in Kodis advancedsettings.xml eintragen? Wenn nicht, solltest Du plex.direct in die DNS-Rebind-Ausschlussliste Deines Routers eintragen. Dies kann später in den Einstellungen verändert werden." + +#: +msgctxt "#32995" +msgid "Advancedsettings.xml modified (plex.direct mappings)" +msgstr "Advancedsettings.xml modifiziert (plex.direct mappings)" + +#: +msgctxt "#32996" +msgid "The advancedsettings.xml file has been modified. Please restart Kodi for the changes to apply." +msgstr "Die advancedsettings.xml-Datei wurde modifiziert. Bitte starte Kodi neu, damit die Änderungen angewandt werden." + +#: +msgctxt "#32997" +msgid "OK" +msgstr "OK" + +#: +msgctxt "#32998" +msgid "Use new Continue Watching hub on Home" +msgstr "Neuen 'Weiterschauen'-Home-Hub verwenden" + +#: +msgctxt "#32999" +msgid "Instead of separating Continue Watching and On Deck hubs, behave like the modern Plex clients, which combine those two types of hubs into one Continue Watching hub." +msgstr "Anstatt von separaten 'Fortsetzen' und 'Als nächstes'-Hubs, kombiniere beide in einen einzigen 'Weiterschauen'-Hub, wie bei anderen modernen Plex-Clients." + +#: +msgctxt "#33000" +msgid "Enable path mapping" +msgstr "Path-Mapping aktivieren" + +#: +msgctxt "#33001" +msgid "Honor path_mapping.json in the addon_data/script.plexmod folder when DirectPlaying media. This can be used to stream using other techniques such as SMB/NFS/etc. instead of the default HTTP handler. path_mapping.example.json is included in the addon's main directory." +msgstr "Bei DirectPlay von Videos, nutze path_mapping.json im addon_data/script.plexmod-Ordner. Dies kann verwendet werden, um andere Dateizugriff-Technologien zu benutzen, wie z. B. SMB/NFS/usw., anstatt des HTTP-Handlers. path_mapping.example.json liegt im Hauptverzeichnis vom Addon." + +#: +msgctxt "#33002" +msgid "Verify mapped files exist" +msgstr "Gemappte Dateien verifizieren" + +#: +msgctxt "#33003" +msgid "When path mapping is enabled and we've successfully mapped a file, verify its existence." +msgstr "Wenn Path-Mapping aktiviert ist und wir erfolgreich eine Datei gemappt haben, auch ihre Existenz verifizieren." + +#: +msgctxt "#33004" +msgid "No spoilers without OSD" +msgstr "Keine Spoiler ohne OSD" + +#: +msgctxt "#33005" +msgid "When seeking without the OSD open, hide all time-related information from the user." +msgstr "Wenn ohne OSD gesprungen wird, alle zeitrelevanten Informationen verstecken." + +#: +msgctxt "#33006" +msgid "No TV spoilers" +msgstr "Keine TV-Spoiler" + +#: +msgctxt "#33007" +msgid "When visiting an episode/season view, blur unwatched/unwatched+in-progress episode thumbnails, previews and redact summaries. When the Addon Setting \"Use episode thumbnails in continue hub\" is enabled, blur them as well." +msgstr "In der Episoden-/Staffelansicht Vorschaubilder nicht geschauter oder angefangener Episoden verschleiern und Zusammenfassungen zensieren. Wenn die Addon-Einstellung \"Vorschaubild für Episoden im Fortsetzen-Hub verwenden\" aktiviert ist, diese ebenfalls verschleiern." + +#: +msgctxt "#33008" +msgid "[Spoilers removed]" +msgstr "[Spoiler entfernt]" + +#: +msgctxt "#33009" +msgid "Blur amount for unwatched/in-progress episodes" +msgstr "Stärke der Verschleierung nicht geschauter Episoden" + +#: +msgctxt "#33010" +msgid "Unwatched" +msgstr "Nicht geschaut" + +#: +msgctxt "#33011" +msgid "Unwatched/in progress" +msgstr "Nicht geschaut/Angefangen" + +#: +msgctxt "#33012" +msgid "No unwatched episode titles" +msgstr "Keine Episodentitel für nicht geschaute Folgen" + +#: +msgctxt "#33013" +msgid "When the above is anything but \"off\", hide episode titles as well." +msgstr "Wenn die vorherige Einstellung nicht \"aus\" ist, Episodentitel ebenfalls zensieren." + +#: +msgctxt "#33014" +msgid "Ignore plex.direct docker hosts" +msgstr "plex.direct-Docker-Hosts ignorieren" + +#: +msgctxt "#33015" +msgid "When checking for plex.direct host mapping, ignore local Docker IPv4 addresses (172.16.0.0/12)." +msgstr "Wenn das plex.direct-Host-Mapping überprüft wird, lokale Docker-IPv4-Adressen ignorieren (172.16.0.0/12)." + +#: +msgctxt "#33016" +msgid "Allow TV spoilers for specific genres" +msgstr "Erlaube TV-Spoiler für bestimmte Genres" + +#: +msgctxt "#33017" +msgid "Overrides the above for: {}" +msgstr "Überschreibt die vorherigen Einstellungen für: {}" + +#: +msgctxt "#32303" +msgid "Season {}" +msgstr "Staffel {}" + +#: +msgctxt "#32304" +msgid "Episode {}" +msgstr "Folge {}" + +#: +msgctxt "#32310" +msgid "S{}" +msgstr "S{}" + +#: +msgctxt "#32311" +msgid "E{}" +msgstr "E{}" + +#: +msgctxt "#32938" +msgid "Only for Episodes/Playlists" +msgstr "Nur bei Episoden/Wiedergabelisten" + +#: +msgctxt "#33018" +msgid "Cache Plex Home users" +msgstr "Plex-Heimbenutzer zwischenspeichern" + +#: +msgctxt "#33019" +msgid "Visit media item" +msgstr "Mediendetails anschauen" + +#: +msgctxt "#33020" +msgid "Play" +msgstr "Abspielen" + +#: +msgctxt "#33021" +msgid "Choose action" +msgstr "Aktion wählen" + +#: +msgctxt "#33022" +msgid "Use modern inverted watched states" +msgstr "Modernen, invertierten Geschaut-Status benutzen" + +#: +msgctxt "#33023" +msgid "Instead of marking unwatched items, mark watched items with a checkmark (modern clients; default: off)" +msgstr "Anstatt nicht geschaute Elemente zu markieren, geschaute Elemente mit einem Haken markieren (moderne Clients; standard: aus)" + +#: +msgctxt "#33024" +msgid "Hide black backdrop in inverted watched states" +msgstr "Kein Hintergrund beim invertierten Geschaut-Status" + +#: +msgctxt "#33025" +msgid "When the above is enabled, hide the black backdrop of the watched state." +msgstr "Wenn die vorherige Option aktiviert ist, den schwarzen Hintergrund nicht darstellen." + +#: +msgctxt "#33026" +msgid "Map path: {}" +msgstr "Pfad zuordnen: {}" + +#: +msgctxt "#33027" +msgid "Remove mapping: {}" +msgstr "Zuordnung entfernen: {}" + +#: +msgctxt "#33028" +msgid "Hide library" +msgstr "Bibliothek verstecken" + +#: +msgctxt "#33029" +msgid "Show library: {}" +msgstr "Bibliothek anzeigen: {}" + +#: +msgctxt "#33030" +msgid "Choose action for: {}" +msgstr "Aktion wählen für: {}" + +#: +msgctxt "#33031" +msgid "Select Kodi source for {}" +msgstr "Kodi Quelle auswählen für {}" + +#: +msgctxt "#33032" +msgid "Show path mapping indicators" +msgstr "Pfadzuordnungs-Indikator anzeigen" + +#: +msgctxt "#33033" +msgid "When path mapping is active for a library, display an indicator." +msgstr "Wenn eine Pfadzuordnung für eine Bibliothek aktiv ist, einen Indikator anzeigen." + +#: +msgctxt "#33034" +msgid "Library settings" +msgstr "Bibliothek-Einstellungen" + + +#: +msgctxt "#33035" +msgid "Delete {}: {}?" +msgstr "{}: {} löschen?" + +#: +msgctxt "#33036" +msgid "Delete episode S{0:02d}E{1:02d} from {2}?" +msgstr "Episode S{0:02d}E{1:02d} von {2} löschen?" + +#: +msgctxt "#33037" +msgid "Maximum intro offset to consider" +msgstr "Maximales erwägtes Intro-Offset" + +#: +msgctxt "#33038" +msgid "When encountering an intro marker with a start time offset greater than this, ignore it (default: 600s/10m)" +msgstr "Wenn ein Intro-Marker mit einem Startzeitpunkt größer als diese Einstellung ist, diesen ignorieren (default: 600s/10m)" + +#: +msgctxt "#33039" +msgid "Move" +msgstr "Verschieben" + +#: +msgctxt "#33040" +msgid "Reset library order" +msgstr "Ordnung der Bibliotheken zurücksetzen" diff --git a/script.plexmod/resources/language/resource.language.en_gb/strings.po b/script.plexmod/resources/language/resource.language.en_gb/strings.po index 3125aff8bb..79e8aa5772 100644 --- a/script.plexmod/resources/language/resource.language.en_gb/strings.po +++ b/script.plexmod/resources/language/resource.language.en_gb/strings.po @@ -346,11 +346,11 @@ msgid "Go to {0}" msgstr "" msgctxt "#32303" -msgid "Season" +msgid "Season {}" msgstr "" msgctxt "#32304" -msgid "Episode" +msgid "Episode {}" msgstr "" msgctxt "#32305" @@ -374,11 +374,11 @@ msgid "None" msgstr "" msgctxt "#32310" -msgid "S" +msgid "S{}" msgstr "" msgctxt "#32311" -msgid "E" +msgid "E{}" msgstr "" msgctxt "#32312" @@ -1295,7 +1295,7 @@ msgid "Kodi Buffer Size (MB)" msgstr "" msgctxt "#33614" -msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~100 MB, Recommended max: {} MB, Default: 20 MB." +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~50 MB, Recommended max: {} MB, Default: 20 MB." msgstr "" msgctxt "#33615" @@ -1559,7 +1559,7 @@ msgid "Show prev/next button" msgstr "" msgctxt "#32938" -msgid "Only for Episodes" +msgid "Only for Episodes/Playlists" msgstr "" msgctxt "#32939" @@ -1614,10 +1614,6 @@ msgctxt "#32951" msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." msgstr "" -msgctxt "#32952" -msgid "Dialog flicker fix" -msgstr "" - msgctxt "#32953" msgid "Reviews" msgstr "" @@ -1729,3 +1725,243 @@ msgstr "" msgctxt "#32980" msgid "Refresh Users" msgstr "" + +msgctxt "#32981" +msgid "Background worker count" +msgstr "" + +msgctxt "#32982" +msgid "Depending on how many cores your CPU has and how much it can handle, increasing this might improve certain situations. If you experience crashes or other annormalities, leave this at its default (3). Needs an addon restart." +msgstr "" + +msgctxt "#32983" +msgid "Player Theme" +msgstr "" + +msgctxt "#32984" +msgid "Sets the player theme. Currently only customizes the playback control buttons. ATTENTION: [I]Might[/I] need an addon restart.\nIn order to customize this, copy one of the xml's in script.plexmod/resources/skins/Main/1080i/templates to addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml and adjust it to your liking, then select \"Custom\" as your theme." +msgstr "" + +msgctxt "#32985" +msgid "Modern" +msgstr "" + +msgctxt "#32986" +msgid "Modern (dotted)" +msgstr "" + +msgctxt "#32987" +msgid "Classic" +msgstr "" + +msgctxt "#32988" +msgid "Custom" +msgstr "" + +msgctxt "#32989" +msgid "Modern (colored)" +msgstr "" + +msgctxt "#32990" +msgid "Handle plex.direct mapping" +msgstr "" + +msgctxt "#32991" +msgid "Notify" +msgstr "" + +msgctxt "#32992" +msgid "When using servers with a plex.direct connection (most of them), should we automatically adjust advancedsettings.xml to cope with plex.direct domains? If not, you might want to add plex.direct to your router's DNS rebind exemption list." +msgstr "" + +msgctxt "#32993" +msgid "{} unhandled plex.direct connections found" +msgstr "" + +msgctxt "#32994" +msgid "In order for PM4K to work properly, we need to add special handling for plex.direct connections. We've found {} new unhandled connections. Do you want us to write those to Kodi's advancedsettings.xml automatically? If not, you might want to add plex.direct to your router's DNS rebind exemption list. This can be changed in the settings as well." +msgstr "" + +msgctxt "#32995" +msgid "Advancedsettings.xml modified (plex.direct mappings)" +msgstr "" + +msgctxt "#32996" +msgid "The advancedsettings.xml file has been modified. Please restart Kodi for the changes to apply." +msgstr "" + +msgctxt "#32997" +msgid "OK" +msgstr "" + +msgctxt "#32998" +msgid "Use new Continue Watching hub on Home" +msgstr "" + +msgctxt "#32999" +msgid "Instead of separating Continue Watching and On Deck hubs, behave like the modern Plex clients, which combine those two types of hubs into one Continue Watching hub." +msgstr "" + +msgctxt "#33000" +msgid "Enable path mapping" +msgstr "" + +msgctxt "#33001" +msgid "Honor path_mapping.json in the addon_data/script.plexmod folder when DirectPlaying media. This can be used to stream using other techniques such as SMB/NFS/etc. instead of the default HTTP handler. path_mapping.example.json is included in the addon's main directory." +msgstr "" + +msgctxt "#33002" +msgid "Verify mapped files exist" +msgstr "" + +msgctxt "#33003" +msgid "When path mapping is enabled and we've successfully mapped a file, verify its existence." +msgstr "" + +msgctxt "#33004" +msgid "No spoilers without OSD" +msgstr "" + +msgctxt "#33005" +msgid "When seeking without the OSD open, hide all time-related information from the user." +msgstr "" + +msgctxt "#33006" +msgid "No TV spoilers" +msgstr "" + +msgctxt "#33007" +msgid "When visiting an episode/season view, blur unwatched/unwatched+in-progress episode thumbnails, previews and redact summaries. When the Addon Setting \"Use episode thumbnails in continue hub\" is enabled, blur them as well." +msgstr "" + +msgctxt "#33008" +msgid "[Spoilers removed]" +msgstr "" + +msgctxt "#33009" +msgid "Blur amount for unwatched/in-progress episodes" +msgstr "" + +msgctxt "#33010" +msgid "Unwatched" +msgstr "" + +msgctxt "#33011" +msgid "Unwatched/in progress" +msgstr "" + +msgctxt "#33012" +msgid "No unwatched episode titles" +msgstr "" + +msgctxt "#33013" +msgid "When the above is anything but \"off\", hide episode titles as well." +msgstr "" + +msgctxt "#33014" +msgid "Ignore plex.direct docker hosts" +msgstr "" + +msgctxt "#33015" +msgid "When checking for plex.direct host mapping, ignore local Docker IPv4 addresses (172.16.0.0/12)." +msgstr "" + +msgctxt "#33016" +msgid "Allow TV spoilers for specific genres" +msgstr "" + +msgctxt "#33017" +msgid "Overrides the above for: {}" +msgstr "" + +msgctxt "#33018" +msgid "Cache Plex Home users" +msgstr "" + +msgctxt "#33019" +msgid "Visit media item" +msgstr "" + +msgctxt "#33020" +msgid "Play" +msgstr "" + +msgctxt "#33021" +msgid "Choose action" +msgstr "" + +msgctxt "#33022" +msgid "Use modern inverted watched states" +msgstr "" + +msgctxt "#33023" +msgid "Instead of marking unwatched items, mark watched items with a checkmark (modern clients; default: off)" +msgstr "" + +msgctxt "#33024" +msgid "Hide black backdrop in inverted watched states" +msgstr "" + +msgctxt "#33025" +msgid "When the above is enabled, hide the black backdrop of the watched state." +msgstr "" + +msgctxt "#33026" +msgid "Map path: {}" +msgstr "" + +msgctxt "#33027" +msgid "Remove mapping: {}" +msgstr "" + +msgctxt "#33028" +msgid "Hide library" +msgstr "" + +msgctxt "#33029" +msgid "Show library: {}" +msgstr "" + +msgctxt "#33030" +msgid "Choose action for: {}" +msgstr "" + +msgctxt "#33031" +msgid "Select Kodi source for {}" +msgstr "" + +msgctxt "#33032" +msgid "Show path mapping indicators" +msgstr "" + +msgctxt "#33033" +msgid "When path mapping is active for a library, display an indicator." +msgstr "" + +msgctxt "#33034" +msgid "Library settings" +msgstr "" + +msgctxt "#33035" +msgid "Delete {}: {}?" +msgstr "" + +msgctxt "#33036" +msgid "Delete episode S{0:02d}E{1:02d} from {2}?" +msgstr "" + +msgctxt "#33037" +msgid "Maximum intro offset to consider" +msgstr "" + +msgctxt "#33038" +msgid "When encountering an intro marker with a start time offset greater than this, ignore it (default: 600s/10m)" +msgstr "" + +msgctxt "#33039" +msgid "Move" +msgstr "" + +msgctxt "#33040" +msgid "Reset library order" +msgstr "" diff --git a/script.plexmod/resources/language/resource.language.es_es/strings.po b/script.plexmod/resources/language/resource.language.es_es/strings.po index ae77eb6a46..14a0bbe8a1 100644 --- a/script.plexmod/resources/language/resource.language.es_es/strings.po +++ b/script.plexmod/resources/language/resource.language.es_es/strings.po @@ -1,1711 +1,2476 @@ -# XBMC Media Center language file msgid "" msgstr "" -"Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" -"POT-Creation-Date: 2013-12-12 22:56+0000\n" -"PO-Revision-Date: 2024-01-28 13:15+0100\n" -"Last-Translator: DeciBelioS\n" -"Language-Team: LANGUAGE\n" -"Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: PM4K / PlexMod for Kodi\n" +"Language: es\n" +#: msgctxt "#32000" msgid "Main" msgstr "Principal" +#: msgctxt "#32001" msgid "Original" msgstr "Original" +#: msgctxt "#32002" msgid "20 Mbps 1080p" msgstr "20 Mbps 1080p" +#: msgctxt "#32003" msgid "12 Mbps 1080p" msgstr "12 Mbps 1080p" +#: msgctxt "#32004" msgid "10 Mbps 1080p" msgstr "10 Mbps 1080p" +#: msgctxt "#32005" msgid "8 Mbps 1080p" msgstr "8 Mbps 1080p" +#: msgctxt "#32006" msgid "4 Mbps 720p" msgstr "4 Mbps 720p" +#: msgctxt "#32007" msgid "3 Mbps 720p" msgstr "3 Mbps 720p" +#: msgctxt "#32008" msgid "2 Mbps 720p" msgstr "2 Mbps 720p" +#: msgctxt "#32009" msgid "1.5 Mbps 480p" msgstr "1.5 Mbps 480p" +#: msgctxt "#32010" msgid "720 kbps" msgstr "720 kbps" +#: msgctxt "#32011" msgid "320 kbps" msgstr "320 kbps" +#: msgctxt "#32012" msgid "208 kbps" msgstr "208 kbps" +#: msgctxt "#32013" msgid "96 kbps" msgstr "96 kbps" +#: msgctxt "#32014" msgid "64 kbps" msgstr "64 kbps" +#: msgctxt "#32020" msgid "Local Quality" msgstr "Calidad Local" +#: msgctxt "#32021" msgid "Remote Quality" -msgstr "Calidad Remoto" +msgstr "Calidad Remota" +#: msgctxt "#32022" msgid "Online Quality" -msgstr "Calidad Internet" +msgstr "Calidad en línea" +#: msgctxt "#32023" msgid "Transcode Format" msgstr "Formato transcodificación" +#: msgctxt "#32024" msgid "Debug Logging" -msgstr "Log Depuración" +msgstr "Log de Depuración" +#: msgctxt "#32025" msgid "Allow Direct Play" msgstr "Permitir reproducción directa" +#: msgctxt "#32026" msgid "Allow Direct Stream" msgstr "Permitir transmisión directa" +#: msgctxt "#32027" msgid "Force" msgstr "Forzar" +#: msgctxt "#32028" msgid "Always" msgstr "Siempre" +#: msgctxt "#32029" msgid "Only Image Formats" msgstr "Solo formatos de imagen" +#: msgctxt "#32030" msgid "Auto" msgstr "Auto" -msgctxt "#32031" -msgid "Burn-in Subtitles" -msgstr "Subtítulos quemados" - +#: msgctxt "#32032" msgid "Allow Insecure Connections" -msgstr "Permetir conexiones inseguras" +msgstr "Permitir conexiones inseguras" +#: msgctxt "#32033" msgid "Never" msgstr "Nunca" +#: msgctxt "#32034" msgid "On Same network" msgstr "En la misma red" +#: msgctxt "#32035" msgid "Always" msgstr "Siempre" +#: msgctxt "#32036" msgid "Allow 4K" msgstr "Permitir 4K" +#: msgctxt "#32037" msgid "Allow HEVC (h265)" -msgstr "Permetir HEVC (h265)" +msgstr "Permitir HEVC (h265)" +#: msgctxt "#32038" msgid "Automatically Sign In" -msgstr "Accesso automatico" +msgstr "Accesso automático" +#: msgctxt "#32039" msgid "Post Play Auto Play" msgstr "Reproducción automática posterior" +#: msgctxt "#32040" msgid "Enable Subtitle Downloading" msgstr "Activar la descarga de subtítulos" +#: msgctxt "#32041" msgid "Enable Subtitle Downloading" msgstr "Activar la descarga de subtítulos" +#: msgctxt "#32042" msgid "Server Discovery (GDM)" msgstr "Detección del Servidor (GDM)" +#: msgctxt "#32043" msgid "Start Plex On Kodi Startup" msgstr "Arrancar Plex al iniciar Kodi" +#: msgctxt "#32044" msgid "Connection 1 IP" -msgstr "IP Conexión 1" +msgstr "Conexión IP 1" +#: msgctxt "#32045" msgid "Connection 1 Port" msgstr "Puerto Conexión 1" +#: msgctxt "#32046" msgid "Connection 2 IP" -msgstr "IP Conexión 2" +msgstr "Conexión IP 2" +#: msgctxt "#32047" msgid "Connection 2 Port" msgstr "Puerto conexión 2" +#: msgctxt "#32048" msgid "Audio" msgstr "Audio" +#: msgctxt "#32049" msgid "Advanced" -msgstr "Avanzar" +msgstr "Avanzado" +#: msgctxt "#32050" msgid "Manual Servers" msgstr "Servidores manuales" +#: msgctxt "#32051" msgid "Privacy" msgstr "Privacidad" +#: msgctxt "#32052" msgid "About" msgstr "Acerca de" +#: msgctxt "#32053" msgid "Video" msgstr "Video" +#: msgctxt "#32054" msgid "Addon Version" -msgstr "Versión Add-on" +msgstr "Versión del Add-on" +#: msgctxt "#32055" msgid "Kodi Version" -msgstr "Versión Kodi" +msgstr "Versión de Kodi" +#: msgctxt "#32056" msgid "Screen Resolution" msgstr "Resolución de Pantalla" +#: msgctxt "#32057" msgid "Current Server Version" -msgstr "Versión del Servidor" +msgstr "Versión actual del Servidor" +#: msgctxt "#32058" msgid "Never exceed original audio codec" -msgstr "No superar nunca el códec de audio original" +msgstr "Nunca superar el códec de audio original" +#: msgctxt "#32059" msgid "When transcoding audio, never exceed the original audio bitrate or channel count on the same codec." msgstr "Cuando transcodifiques audio, nunca superes la tasa de bits o el número de canales del audio original en el mismo códec." +#: msgctxt "#32060" msgid "Use Kodi audio channels" msgstr "Utilizar los canales de audio de Kodi" -msgctxt "#32061" -msgid "When transcoding audio, target the audio channels set in Kodi." -msgstr "Al transcodificar audio, apunte a los canales de audio configurados en Kodi." - -msgctxt "#32062" -msgid "Transcode audio to AC3" -msgstr "Transcodificar audio a AC3" - -msgctxt "#32063" -msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." -msgstr "Transcodifica el audio a AC3 en determinadas condiciones (útil para passthrough)." - +#: msgctxt "#32064" msgid "Treat DTS like AC3" msgstr "Tratar DTS como AC3" -msgctxt "#32065" -msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" -msgstr "Cuando cualquiera de los ajustes de forzar AC3 está activado, trata DTS igual que AC3 (útil para el passthrough)" - -msgctxt "#32066" -msgid "Force audio to AC3" -msgstr "Forzar audio a AC3" - -msgctxt "#32067" -msgid "Only force multichannel audio to AC3" -msgstr "Forzar sólo el audio multicanal a AC3" - +#: msgctxt "#32100" msgid "Skip user selection and pin entry on startup." -msgstr "Omitir selección de usuario y PIN al iniciar." +msgstr "Omitir la selección de usuario y PIN al iniciar." +#: msgctxt "#32101" msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." msgstr "Si se activa, cuando acabe la reproducción y 'Siguiente Capítulo' esté disponible, se reproducirá automáticamente a los 15 segundos." +#: msgctxt "#32102" msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." -msgstr "Activa esto si se puede reproducir contenido 4K. Desactívalo para forzar la trasncodificación." +msgstr "Activa esto si tu Hardware soporta la reproducción de contenido 4K. Desactívalo para forzar la transcodificación." +#: msgctxt "#32103" msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." -msgstr "Activa esto si se puede reproducir HEVC/h265. Desactiva para forzar la trascodificación." +msgstr "Activa esto si tu Hardware soporta la reproducción HEVC/h265. Desactiva para forzar la transcodificación." +#: msgctxt "#32104" msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" -msgstr "Conectar a servidores con conexiones no seguras.[CR][CR]* [B]Nunca[/B]: Nunca conectarse a un servidor de forma no segura[CR]* [B]En la misma red[/B]: Permitir para la misma red[CR]* [B]Siempre[/B]: Permitir para todas las conexiones" +msgstr "Conectar a servidores con conexiones no seguras.[CR][CR]* [B]Nunca[/B]: Nunca conectarse a un servidor de forma no segura[CR]* [B]En la misma red[/B]: Permitir si está en la misma red[CR]* [B]Siempre[/B]: Permitir para todas las conexiones" +#: msgctxt "#32201" msgid "Trailer" msgstr "Tráiler" +#: msgctxt "#32202" msgid "Deleted Scene" msgstr "Escenas eliminadas" +#: msgctxt "#32203" msgid "Interview" msgstr "Entrevista" +#: msgctxt "#32204" msgid "Music Video" msgstr "Video musical" +#: msgctxt "#32205" msgid "Behind the Scenes" -msgstr "Detrás de las escenas" +msgstr "Detrás de escenas" +#: msgctxt "#32206" msgid "Scene" msgstr "Escena" +#: msgctxt "#32207" msgid "Live Music Video" -msgstr "Videos de música en vivo" +msgstr "Video musical en vivo" +#: msgctxt "#32208" msgid "Lyric Music Video" -msgstr "Videos de letras musicales" +msgstr "Video de letras musicales" +#: msgctxt "#32209" msgid "Concert" -msgstr "Conciertos" +msgstr "Concierto" +#: msgctxt "#32210" msgid "Featurette" msgstr "Featurette" +#: msgctxt "#32211" msgid "Short" msgstr "Cortos" +#: msgctxt "#32212" msgid "Other" msgstr "Otros" +#: msgctxt "#32300" msgid "Go to Album" msgstr "Ir a Album" +#: msgctxt "#32301" msgid "Go to Artist" msgstr "Ir a Artistas" +#: msgctxt "#32302" msgid "Go to {0}" msgstr "Ir a {0}" -msgctxt "#32303" -msgid "Season" -msgstr "Temporada" - -msgctxt "#32304" -msgid "Episode" -msgstr "Capítulo" - +#: msgctxt "#32305" msgid "Extras" msgstr "Extras" +#: msgctxt "#32306" msgid "Related Shows" msgstr "Series relacionadas" +#: msgctxt "#32307" msgid "More" msgstr "Más" +#: msgctxt "#32308" msgid "Available" msgstr "Disponible" +#: msgctxt "#32309" msgid "None" msgstr "Ninguno" -msgctxt "#32310" -msgid "S" -msgstr "S" - -msgctxt "#32311" -msgid "E" -msgstr "E" - +#: msgctxt "#32312" msgid "Unavailable" msgstr "No disponible" +#: msgctxt "#32313" msgid "This item is currently unavailable." msgstr "Este elemento no está disponible actualmente." +#: msgctxt "#32314" msgid "In Progress" msgstr "En curso" +#: msgctxt "#32315" msgid "Resume playback?" msgstr "¿Continuar reproducción?" +#: msgctxt "#32316" msgid "Resume" msgstr "Continuar" +#: msgctxt "#32317" msgid "Play from beginning" msgstr "Reproducir desde el principio" +#: msgctxt "#32318" msgid "Mark Unplayed" msgstr "Marcar como no visto" +#: msgctxt "#32319" msgid "Mark Played" msgstr "Marcar como visto" +#: msgctxt "#32320" msgid "Mark Season Unplayed" msgstr "Marca la Temporada como no vista" +#: msgctxt "#32321" msgid "Mark Season Played" msgstr "Marcar la Temporada como vista" +#: msgctxt "#32322" msgid "Delete" msgstr "Borrar" +#: msgctxt "#32323" msgid "Go To Show" msgstr "Ir a la Serie" +#: msgctxt "#32324" msgid "Go To {0}" msgstr "Ir a {0}" +#: msgctxt "#32325" msgid "Play Next" msgstr "Reproducir el siguiente" +#: msgctxt "#32326" msgid "Really Delete?" msgstr "¿Borrarlo de verdad?" +#: msgctxt "#32327" msgid "Are you sure you really want to delete this media?" msgstr "¿Seguro que quieres borrar este medio?" +#: msgctxt "#32328" msgid "Yes" msgstr "Sí" +#: msgctxt "#32329" msgid "No" msgstr "No" +#: msgctxt "#32330" msgid "Message" msgstr "Mensaje" +#: msgctxt "#32331" msgid "There was a problem while attempting to delete the media." msgstr "Hubo un problema al intentar borrar este medio." +#: msgctxt "#32332" msgid "Home" msgstr "Inicio" +#: msgctxt "#32333" msgid "Playlists" msgstr "Listas de reproducción" +#: msgctxt "#32334" msgid "Confirm Exit" msgstr "Confirmar salida" +#: msgctxt "#32335" msgid "Are you ready to exit Plex?" msgstr "¿Listo para salir de Plex?" +#: msgctxt "#32336" msgid "Exit" msgstr "Salir" +#: msgctxt "#32337" msgid "Cancel" msgstr "Cancelar" +#: msgctxt "#32338" msgid "No Servers Found" msgstr "Ningún servidor encontrado" +#: msgctxt "#32339" msgid "Server is not accessible" msgstr "Servidor no accessible" +#: msgctxt "#32340" msgid "Connection tests are in progress. Please wait." -msgstr "Tests de conexión en curso. Espere por favor." +msgstr "Pruebas de conexión en curso. Espere por favor." +#: msgctxt "#32341" msgid "Server is not accessible. Please sign into your server and check your connection." msgstr "Servidor no accessible. Por favor, comprueba la conexión de tu servidor." +#: msgctxt "#32342" msgid "Switch User" msgstr "Cambiar usuario" +#: msgctxt "#32343" msgid "Settings" msgstr "Configuración" +#: msgctxt "#32344" msgid "Sign Out" -msgstr "Desconectar" +msgstr "Cerrar sesión" +#: msgctxt "#32345" msgid "All" msgstr "Todo" +#: msgctxt "#32346" msgid "By Name" msgstr "Por Nombre" +#: msgctxt "#32347" msgid "Artists" msgstr "Artistas" +#: msgctxt "#32348" msgid "Movies" msgstr "Películas" +#: msgctxt "#32349" msgid "photos" msgstr "fotos" +#: msgctxt "#32350" msgid "Shows" msgstr "Series" +#: msgctxt "#32351" msgid "By Date Added" -msgstr "Por fecha" +msgstr "Por fecha de añadido" +#: msgctxt "#32352" msgid "Date Added" -msgstr "Fecha" +msgstr "Fecha de añadido" +#: msgctxt "#32353" msgid "By Release Date" msgstr "Por fecha de estreno" +#: msgctxt "#32354" msgid "Release Date" msgstr "Fecha de estreno" +#: msgctxt "#32355" msgid "By Date Viewed" -msgstr "Por fecha de visionado" +msgstr "Por fecha en la que se vio" +#: msgctxt "#32356" msgid "Date Viewed" -msgstr "Fecha de visionado" +msgstr "Fecha en la que se vio" +#: msgctxt "#32357" msgid "By Name" msgstr "Por nombre" +#: msgctxt "#32358" msgid "Name" msgstr "Nombre" +#: msgctxt "#32359" msgid "By Rating" msgstr "Por valoración" +#: msgctxt "#32360" msgid "Rating" msgstr "Valoración" +#: msgctxt "#32361" msgid "By Resolution" msgstr "Por Resolución" +#: msgctxt "#32362" msgid "Resolution" msgstr "Resolución" +#: msgctxt "#32363" msgid "By Duration" msgstr "Por Duración" +#: msgctxt "#32364" msgid "Duration" msgstr "Duración" +#: msgctxt "#32365" msgid "By First Aired" msgstr "Por primera emisión" +#: msgctxt "#32366" msgid "First Aired" msgstr "Primera emisión" +#: msgctxt "#32367" msgid "By Unplayed" msgstr "Por No Vistos" +#: msgctxt "#32368" msgid "Unplayed" -msgstr "Non visto" +msgstr "No visto" +#: msgctxt "#32369" msgid "By Date Played" msgstr "Por Fecha de Reproducción" +#: msgctxt "#32370" msgid "Date Played" msgstr "Fecha de Reproducción" +#: msgctxt "#32371" msgid "By Play Count" msgstr "Por Número de Reproducciones" +#: msgctxt "#32372" msgid "Play Count" msgstr "Número de Reproducciones" +#: msgctxt "#32373" msgid "By Date Taken" msgstr "Por Fecha de Captura" +#: msgctxt "#32374" msgid "Date Taken" msgstr "Fecha de Captura" +#: msgctxt "#32375" msgid "No filters available" -msgstr "Sin filtros disponbles" +msgstr "Sin filtros disponibles" +#: msgctxt "#32376" msgid "Clear Filter" msgstr "Quitar filtros" +#: msgctxt "#32377" msgid "Year" msgstr "Año" +#: msgctxt "#32378" msgid "Decade" msgstr "Década" +#: msgctxt "#32379" msgid "Genre" msgstr "Género" +#: msgctxt "#32380" msgid "Content Rating" msgstr "Clasificación por Edad" +#: msgctxt "#32381" msgid "Network" msgstr "Red" +#: msgctxt "#32382" msgid "Collection" msgstr "Colección" +#: msgctxt "#32383" msgid "Director" msgstr "Dirección" +#: msgctxt "#32384" msgid "Actor" -msgstr "Actores" +msgstr "Actor" +#: msgctxt "#32385" msgid "Country" msgstr "País" +#: msgctxt "#32386" msgid "Studio" msgstr "Estudio" +#: msgctxt "#32387" msgid "Labels" msgstr "Etiquetas" +#: msgctxt "#32388" msgid "Camera Make" msgstr "Marca de Cámara" +#: msgctxt "#32389" msgid "Camera Model" msgstr "Modelo de Cámera" +#: msgctxt "#32390" msgid "Aperture" msgstr "Apertura" +#: msgctxt "#32391" msgid "Shutter Speed" msgstr "Velocidad de apertura" +#: msgctxt "#32392" msgid "Lens" msgstr "Lente" +#: msgctxt "#32393" msgid "TV Shows" msgstr "Series de TV" +#: msgctxt "#32394" msgid "Music" msgstr "Música" +#: msgctxt "#32395" msgid "Audio" msgstr "Audio" +#: msgctxt "#32396" msgid "Subtitles" msgstr "Subtítulos" +#: msgctxt "#32397" msgid "Quality" msgstr "Calidad" +#: msgctxt "#32398" msgid "Kodi Video Settings" msgstr "Configuración de Video de Kodi" +#: msgctxt "#32399" msgid "Kodi Audio Settings" msgstr "Configuración de Audio de Kodi" +#: msgctxt "#32400" msgid "Go To Season" msgstr "Ir a Temporada" +#: msgctxt "#32401" msgid "Directors" msgstr "Dirección" +#: msgctxt "#32402" msgid "Writer" msgstr "Autor" +#: msgctxt "#32403" msgid "Writers" msgstr "Guionistas" +#: msgctxt "#32404" msgid "Related Movies" msgstr "Películas relacionadas" +#: msgctxt "#32405" msgid "Download Subtitles" msgstr "Descargar Subtítulos" +#: msgctxt "#32406" msgid "Subtitle Delay" msgstr "Retardo en Subtítulos" +#: msgctxt "#32407" msgid "Next Subtitle" msgstr "Siguiente Subtítulo" +#: msgctxt "#32408" msgid "Disable Subtitles" msgstr "Desactivar Subtítulos" +#: msgctxt "#32409" msgid "Enable Subtitles" msgstr "Activar Subtítulos" +#: msgctxt "#32410" msgid "Platform Version" msgstr "Versión de la Plataforma" +#: msgctxt "#32411" msgid "Unknown" msgstr "Desconocido" +#: msgctxt "#32412" msgid "Edit Or Clear" msgstr "Modificar o Borrar" +#: msgctxt "#32413" msgid "Edit IP address or clear the current setting?" -msgstr "¿Editar la direeción IP o borrar la configuración?" +msgstr "¿Editar la direción IP o borrar la configuración?" +#: msgctxt "#32414" msgid "Clear" msgstr "Borrar" +#: msgctxt "#32415" msgid "Edit" msgstr "Modificar" +#: msgctxt "#32416" msgid "Enter IP Address" msgstr "Introduce la Dirección IP" +#: msgctxt "#32417" msgid "Enter Port Number" msgstr "Introduce el Puerto" +#: msgctxt "#32418" msgid "Creator" msgstr "Creador" +#: msgctxt "#32419" msgid "Cast" msgstr "Elenco" +#: msgctxt "#32420" msgid "Disc" msgstr "Disco" +#: msgctxt "#32421" msgid "Sign Out" -msgstr "Desconectar" +msgstr "Cerrar sesión" +#: msgctxt "#32422" msgid "Exit" msgstr "Salir" +#: msgctxt "#32423" msgid "Shutdown" msgstr "Apagar" +#: msgctxt "#32424" msgid "Suspend" msgstr "Suspender" +#: msgctxt "#32425" msgid "Hibernate" msgstr "Hibernar" +#: msgctxt "#32426" msgid "Reboot" msgstr "Reiniciar" +#: msgctxt "#32427" msgid "Failed" msgstr "Fallo" +#: msgctxt "#32428" msgid "Login failed!" msgstr "¡El acceso falló!" +#: msgctxt "#32429" msgid "Resume from {0}" msgstr "Continuar desde {0}" +#: msgctxt "#32430" msgid "Discovery" msgstr "Descubrimiento" +#: msgctxt "#32431" msgid "Search" msgstr "Búsqueda" +#: msgctxt "#32432" msgid "Space" msgstr "Espacio" +#: msgctxt "#32433" msgid "Clear" msgstr "Borrar" +#: msgctxt "#32434" msgid "Searching..." msgstr "Buscando..." +#: msgctxt "#32435" msgid "No Results" msgstr "Sin resultados" +#: msgctxt "#32436" msgid "Paused" msgstr "En Pausa" +#: msgctxt "#32437" msgid "Welcome" msgstr "Bienvenido" +#: msgctxt "#32438" msgid "Previous" msgstr "Anterior" +#: msgctxt "#32439" msgid "Playing Next" msgstr "Reproducir el Siguiente" +#: msgctxt "#32440" msgid "On Deck" msgstr "En Portada" +#: msgctxt "#32441" msgid "Unknown" msgstr "Desconocido" +#: msgctxt "#32442" msgid "Embedded" msgstr "Integrado" +#: msgctxt "#32443" msgid "Forced" msgstr "Forzado" +#: msgctxt "#32444" msgid "Lyrics" msgstr "Letras" +#: msgctxt "#32445" msgid "Mono" msgstr "Mono" +#: msgctxt "#32446" msgid "Stereo" msgstr "Stereo" +#: msgctxt "#32447" msgid "None" msgstr "Ninguno" +#: msgctxt "#32448" msgid "Playback Failed!" msgstr "¡La reproducción falló!" +#: msgctxt "#32449" msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." -msgstr "No pude conectarme a plex.tv[CR]Comprueba la conexión y vuelve a intentarlo." +msgstr "No se pudo conectar a plex.tv[CR]Comprueba la conexión y vuelve a intentarlo." +#: msgctxt "#32450" msgid "Choose Version" msgstr "Elegir una Versión" +#: msgctxt "#32451" msgid "Play Version..." msgstr "Reproduce un Versión..." +#: msgctxt "#32452" msgid "No Content available in this library" msgstr "No hay contenido disponible en esta bilioteca" +#: msgctxt "#32453" msgid "Please add content and/or check that 'Include in dashboard' is enabled." -msgstr "Por favo, añade contenido y/o comprueba que 'Incluir en el dashboard' esté activado." +msgstr "Por favor, añade contenido y/o comprueba que 'Incluir en el dashboard' esté activado." +#: msgctxt "#32454" msgid "No Content available for this filter" msgstr "Sin contenido disponible por este filtro" +#: msgctxt "#32455" msgid "Please change change or remove the current filter" msgstr "Por favor, cambie o quite el filtro actual" +#: msgctxt "#32456" msgid "Show" msgstr "Serie" +#: msgctxt "#32457" msgid "By Show" msgstr "Por Serie" +#: msgctxt "#32458" msgid "Episodes" msgstr "Capítulo" +#: msgctxt "#32459" msgid "Offline Mode" msgstr "Modo Offline" +#: msgctxt "#32460" msgid "Sign In" -msgstr "Acceso" +msgstr "Iniciar sesión" +#: msgctxt "#32461" msgid "Albums" -msgstr "Albums" +msgstr "Álbumes" +#: msgctxt "#32462" msgid "Artist" msgstr "Artista" +#: msgctxt "#32463" msgid "By Artist" msgstr "Por Artista" +#: msgctxt "#32464" msgid "Player" msgstr "Reproductor" +#: msgctxt "#32465" msgid "Use skip step settings from Kodi" msgstr "Utilizar la configuración de salto de paso de Kodi" +#: msgctxt "#32466" msgid "Automatically seek selected position after a delay" msgstr "Búsqueda automática de la posición seleccionada tras un retardo" +#: msgctxt "#32467" msgid "User Interface" msgstr "Interfaz de usuario" +#: msgctxt "#32468" msgid "Show dynamic background art" msgstr "Mostrar arte de fondo dinámico" +#: msgctxt "#32469" msgid "Background art blur amount" msgstr "Cantidad de desenfoque del arte de fondo" +#: msgctxt "#32470" msgid "Background art opacity" msgstr "Opacidad del arte de fondo" +#: msgctxt "#32471" msgid "Use Plex/Kodi steps for timeline" msgstr "Utiliza los pasos de Plex/Kodi para la línea de tiempo" +#: msgctxt "#32480" msgid "Theme music" msgstr "Tema musical" +#: msgctxt "#32481" msgid "Off" msgstr "Apagado" +#: msgctxt "#32482" msgid "%(percentage)s %%" msgstr "%(percentage)s %%" +#: msgctxt "#32483" msgid "Hide Stream Info" msgstr "Ocultar mediainfo" +#: msgctxt "#32484" msgid "Show Stream Info" msgstr "Mostrar mediainfo" +#: msgctxt "#32485" msgid "Go back instantly with the previous menu action in scrolled views" msgstr "Retroceder instantáneamente con la acción del menú anterior en vistas desplazadas" +#: msgctxt "#32487" msgid "Seek Delay" msgstr "Retraso de búsqueda" +#: msgctxt "#32488" msgid "Screensaver" msgstr "Salvapantallas" +#: msgctxt "#32489" msgid "Quiz Mode" msgstr "Modo concurso" +#: msgctxt "#32490" msgid "Collections" msgstr "Colecciones" +#: msgctxt "#32491" msgid "Folders" msgstr "Carpetas" +#: msgctxt "#32492" msgid "Kodi Subtitle Settings" msgstr "Configuración de subtítulos de Kodi" -msgctxt "#32493" -msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." -msgstr "Cuando un archivo multimedia tiene un subtítulo forzado/extranjero para un idioma habilitado para subtítulos, el Plex Media Server lo preselecciona. Este comportamiento no suele ser necesario y no es configurable. Este ajuste lo soluciona ignorando la decisión del PMS y seleccionando el mismo idioma sin bandera forzada si es posible." - +#: msgctxt "#32495" msgid "Skip intro" msgstr "Saltar introducción" +#: msgctxt "#32496" msgid "Skip credits" msgstr "Saltar créditos" +#: msgctxt "#32500" msgid "Always show post-play screen (even for short videos)" -msgstr "Mostrar siempre la pantalla posterior a la reproducción (incluso para vídeos cortos)" +msgstr "Mostrar siempre la pantalla posterior a la reproducción (incluso para videos cortos)" +#: msgctxt "#32501" msgid "Time-to-wait between videos on post-play" -msgstr "Tiempo de espera entre vídeos en post-play" +msgstr "Tiempo de espera entre videos en post-play" +#: msgctxt "#32505" msgid "Visit media in video playlist instead of playing it" msgstr "Visitar medios en la lista de reproducción de vídeo en lugar de reproducirlos" +#: msgctxt "#32521" msgid "Skip Intro Button Timeout" msgstr "Tiempo de espera del botón de intro" +#: msgctxt "#32522" msgid "Automatically Skip Intro" msgstr "Saltar automáticamente la introducción" -msgctxt "#32523" -msgid "Automatically skip intros if available. Doesn't override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Salta automáticamente las intros si están disponibles. No anula el modo atracón activado.\nPuede desactivarse/activarse por programa de TV." - +#: msgctxt "#32524" msgid "Set how long the skip intro button shows for." msgstr "Establece el tiempo que se mostrará el botón de salto de introducción." +#: msgctxt "#32525" msgid "Skip Credits Button Timeout" msgstr "Salto del tiempo de espera del botón de créditos" +#: msgctxt "#32526" msgid "Automatically Skip Credits" msgstr "Saltar créditos automáticamente" -msgctxt "#32527" -msgid "Automatically skip credits if available. Doesn't override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Salta automáticamente los créditos si están disponibles. No anula el modo atracón activado.\nPuede desactivarse/activarse por programa de TV." - +#: msgctxt "#32528" msgid "Set how long the skip credits button shows for." msgstr "Establece el tiempo que se mostrará el botón de saltar créditos." +#: msgctxt "#32540" msgid "Show when the current video will end in player" msgstr "Mostrar cuándo terminará el vídeo actual en el reproductor" +#: msgctxt "#32541" msgid "Shows time left and at which time the media will end." msgstr "Muestra el tiempo restante y a qué hora terminará el medio." +#: msgctxt "#32542" msgid "Show \"Ends at\" label for the end-time as well" msgstr "Mostrar la etiqueta \"Finaliza en\" también para la hora de finalización" +#: msgctxt "#32543" msgid "Ends at" msgstr "Termina en" +#: msgctxt "#32601" msgid "Allow AV1" msgstr "Permitir AV1" +#: msgctxt "#32602" msgid "Enable this if your hardware can handle AV1. Disable it to force transcoding." msgstr "Actívelo si su hardware puede manejar AV1. Desactívalo para forzar la transcodificación." +#: msgctxt "#33101" msgid "By Audience Rating" msgstr "Por índice de audiencia" +#: msgctxt "#33102" msgid "Audience Rating" msgstr "Clasificación del público" +#: msgctxt "#33103" msgid "By my Rating" msgstr "Según mi valoración" +#: msgctxt "#33104" msgid "My Rating" msgstr "Mi valoración" +#: msgctxt "#33105" msgid "By Content Rating" msgstr "Por clasificación de contenidos" +#: msgctxt "#33106" msgid "Content Rating" msgstr "Clasificación del contenido" +#: msgctxt "#33107" msgid "By Critic Rating" msgstr "Por valoración crítica" +#: msgctxt "#33108" msgid "Critic Rating" msgstr "Valoración de la crítica" +#: msgctxt "#33200" msgid "Background Color" msgstr "Color de fondo" +#: msgctxt "#33201" msgid "Specify solid Background Color instead of using media images" msgstr "Especifique un color de fondo sólido en lugar de utilizar imágenes multimedia" +#: msgctxt "#33400" msgid "Use old compatibility profile" msgstr "Utilizar el antiguo perfil de compatibilidad" +#: msgctxt "#33401" msgid "Uses the Chrome client profile instead of the custom one. Might fix rare issues with 3D playback." msgstr "Utiliza el perfil de cliente de Chrome en lugar del personalizado. Podría solucionar problemas poco frecuentes con la reproducción 3D." +#: +msgctxt "#32031" +msgid "Burn-in Subtitles" +msgstr "Subtítulos quemados" + +#: +msgctxt "#32061" +msgid "When transcoding audio, target the audio channels set in Kodi." +msgstr "Al transcodificar audio, apunte a los canales de audio configurados en Kodi." + +#: +msgctxt "#32062" +msgid "Transcode audio to AC3" +msgstr "Transcodificar audio a AC3" + +#: +msgctxt "#32063" +msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." +msgstr "Transcodifica el audio a AC3 en determinadas condiciones (útil para passthrough)." + +#: +msgctxt "#32065" +msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" +msgstr "Cuando cualquiera de los ajustes de forzar AC3 está activado, trata DTS igual que AC3 (útil para el passthrough)" + +#: +msgctxt "#32066" +msgid "Force audio to AC3" +msgstr "Forzar audio a AC3" + +#: +msgctxt "#32067" +msgid "Only force multichannel audio to AC3" +msgstr "Forzar sólo el audio multicanal a AC3" + +#: +msgctxt "#32493" +msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." +msgstr "Cuando un archivo multimedia tiene un subtítulo forzado/extranjero para un idioma habilitado para subtítulos, el Plex Media Server lo preselecciona. Este comportamiento no suele ser necesario y no es configurable. Este ajuste lo soluciona ignorando la decisión del PMS y seleccionando el mismo idioma sin bandera forzada si es posible." + +#: +msgctxt "#32523" +msgid "Automatically skip intros if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Salta automáticamente las intros si están disponibles. No anula el modo maratón activado.\n" +"Puede desactivarse/activarse por programa de TV." + +#: +msgctxt "#32527" +msgid "Automatically skip credits if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Salta automáticamente los créditos si están disponibles. No anula el modo maratón activado.\n" +"Puede desactivarse/activarse por programa de TV." + +#: msgctxt "#33501" msgid "Video played threshold" msgstr "Umbral de reproducción de vídeo" +#: msgctxt "#33502" msgid "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to avoid certain pitfalls, Default: 90 %" -msgstr "Ajústelo al mismo valor que su servidor Plex (Configuración>Biblioteca>Umbral de vídeo reproducido) para evitar ciertas trampas, Predeterminado: 90%" +msgstr "Ajústelo al mismo valor que su servidor Plex (Configuración>Biblioteca>Umbral de vídeo reproducido) para evitar ciertas caídas, Predeterminado: 90%" +#: msgctxt "#33503" msgid "Use alternative hubs refresh" -msgstr "Utilizar centros alternativos refrescar" +msgstr "Utilizar un método alternativo para refrescar los Hubs" +#: msgctxt "#33504" msgid "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of only those likely affected. Use this if you find a hub that doesn't update properly." -msgstr "Actualiza todos los concentradores de todas las bibliotecas después de que el estado de vigilancia de un elemento haya cambiado, en lugar de sólo los probablemente afectados. Utilícelo si encuentra un concentrador que no se actualiza correctamente." +msgstr "Actualiza todos los Hubs de todas las bibliotecas después de que el estado de reproducción de un elemento haya cambiado, en lugar de sólo los probablemente afectados. Utilícelo si encuentra un Hub que no se actualiza correctamente." +#: msgctxt "#33505" msgid "Show intro skip button early" msgstr "Mostrar el botón de salto de introducción antes de tiempo" +#: msgctxt "#33506" -msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\'t override enabled binge mode.\nCan be disabled/enabled per TV show." -msgstr "Mostrar el botón de salto de introducción desde el inicio de un vídeo con un marcador de introducción. Se aplica la configuración de salto automático. No anula el modo atracón activado.\nPuede desactivarse/activarse por programa de TV." +msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\\'t override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Mostrar el botón de salto de introducción desde el inicio de un vídeo con un marcador de introducción. Se aplica la configuración de salto automático. No anula el modo maratón activado.\n" +"Puede desactivarse/activarse por programa de TV." +#: msgctxt "#33507" msgid "Enabled" msgstr "Activado" +#: msgctxt "#33508" msgid "Disabled" msgstr "Desactivado" +#: msgctxt "#33509" msgid "Early intro skip threshold (default: < 60s/1m)" msgstr "Umbral de salto de introducción temprana (por defecto: < 60s/1m)" +#: msgctxt "#33510" msgid "When showing the intro skip button early, only do so if the intro occurs within the first X seconds." -msgstr "Cuando muestre el botón de salto de introducción antes de tiempo, hágalo sólo si la introducción se produce en los primeros X segundos." +msgstr "Cuando se muestre el botón de salto de introducción antes de tiempo, hacerlo sólo si la introducción se produce en los primeros X segundos." +#: msgctxt "#33600" msgid "System" msgstr "Sistema" +#: msgctxt "#33601" msgid "Show video chapters" msgstr "Mostrar capítulos de vídeo" +#: msgctxt "#33602" msgid "If available, show video chapters from the video-file instead of the timeline-big-seek-steps." msgstr "Si está disponible, mostrar capítulos de vídeo del archivo de vídeo en lugar de la línea de tiempo-grandes-pasos-de-búsqueda." +#: msgctxt "#33603" msgid "Use virtual chapters" msgstr "Utilizar capítulos virtuales" +#: msgctxt "#33604" msgid "When the above is enabled and no video chapters are available, simulate them by using the markers identified by the Plex Server (Intro, Credits)." msgstr "Cuando lo anterior esté activado y no haya capítulos de vídeo disponibles, simúlelos utilizando los marcadores identificados por el Servidor Plex (Intro, Créditos)." +#: msgctxt "#33605" msgid "Video Chapters" msgstr "Capítulos de vídeo" +#: msgctxt "#33606" msgid "Virtual Chapters" msgstr "Capítulos virtuales" +#: msgctxt "#33607" msgid "Chapter {}" msgstr "Capítulo {}" +#: msgctxt "#33608" msgid "Intro" msgstr "Introducción" +#: msgctxt "#33609" msgid "Credits" msgstr "Créditos" +#: msgctxt "#33610" msgid "Main" msgstr "Principal" +#: msgctxt "#33611" msgid "Chapters" msgstr "Capítulos" +#: msgctxt "#33612" msgid "Markers" msgstr "Marcadores" +#: msgctxt "#33613" msgid "Kodi Buffer Size (MB)" msgstr "Tamaño del búfer de Kodi (MB)" +#: msgctxt "#33614" -msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~100 MB, Recommended max: {} MB, Default: 20 MB." -msgstr "Establece el tamaño de la Caché/Buffer de Kodi. Libre: {} MB, Recomendado: ~100 MB, Máximo recomendado: {} MB, Por defecto: 20 MB." +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~50 MB, Recommended max: {} MB, Default: 20 MB." +msgstr "Colocar la Caché de Kodi/Tamaño de Buffer. Libre: {} MB, Recomendado: ~50 MB, Recomendado máximo: {} MB, Por defecto: 20 MB." +#: msgctxt "#33615" msgid "{time} left" msgstr "{time} restante" +#: msgctxt "#33616" msgid "Addon Path" msgstr "Ruta de Addon" +#: msgctxt "#33617" msgid "Userdata/Profile Path" -msgstr "Ruta de datos de usuario/perfil" +msgstr "Ruta Userdata/profile" +#: msgctxt "#33618" msgid "TV binge-viewing mode" -msgstr "Modo \"atracón\" de TV" +msgstr "Modo \"maratón\" de TV" +#: msgctxt "#33619" -msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\'t skip the intro of the first episode of a season and doesn't skip the final credits of a show.\n\nCan be disabled/enabled per TV show.\nOverrides any setting below." -msgstr "Se salta automaticamente las intros de los episodios, los créditos e intenta saltarse los resúmenes de los episodios. No salta la intro del primer episodio de una temporada y no salta los créditos finales de un programa.\nPuede ser desactivado/activado por programa de TV.\nAnula cualquier configuración de abajo." - +msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\\'t skip the intro of the first episode of a season and doesn't skip the final credits of a show.\n" +"\n" +"Can be disabled/enabled per TV show.\n" +"Overrides any setting below." +msgstr "Se salta automaticamente las intros de los episodios, los créditos e intenta saltarse los resúmenes de los episodios. No salta la intro del primer episodio de una temporada y no salta los créditos finales de un programa.\n" +"Puede ser desactivado/activado por programa de TV.\n" +"Anula cualquier configuración de abajo." + +#: msgctxt "#33620" msgid "Plex requests timeout (seconds)" msgstr "Tiempo de espera de las solicitudes de Plex (segundos)" +#: msgctxt "#33621" msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5" msgstr "Establece el valor del tiempo de espera (asíncrono y de conexión) de la biblioteca de peticiones de Python en segundos. Predeterminado: 5" +#: msgctxt "#33622" msgid "LAN reachability timeout (ms)" -msgstr "Tiempo de espera de alcanzabilidad de LAN (ms)" +msgstr "Tiempo de espera de accesibilidad LAN (ms)" +#: msgctxt "#33623" msgid "When checking for LAN reachability, use this timeout. Default: 10ms" msgstr "Cuando compruebe la accesibilidad de la LAN, utilice este tiempo de espera. Por defecto: 10ms" +#: msgctxt "#33624" msgid "Network" msgstr "Red" +#: msgctxt "#33625" msgid "Smart LAN/local server discovery" msgstr "Detección inteligente de LAN/servidores locales" +#: msgctxt "#33626" -msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n\nNOTE: Only works on Kodi 19 or above." -msgstr "Comprueba si los servidores devueltos por Plex.tv son realmente locales en su LAN. Para configuraciones específicas (por ejemplo, Docker) Plex.tv podría no detectar correctamente un servidor local.\n\nNOTA: Sólo funciona en Kodi 19 o superior." - +msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n" +"\n" +"NOTE: Only works on Kodi 19 or above." +msgstr "Comprueba si los servidores devueltos por Plex.tv son realmente locales/ en su LAN. Para configuraciones específicas (por ejemplo, Docker) Plex.tv podría no detectar correctamente un servidor local.\n" +"\n" +"NOTA: Sólo funciona en Kodi 19 o superior." + +#: msgctxt "#33627" msgid "Prefer LAN/local servers over security" msgstr "Preferir los servidores LAN/locales a la seguridad" +#: msgctxt "#33628" msgid "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". Can be used to enforce manual servers." msgstr "Prioriza las conexiones locales sobre las seguras. Necesita la configuración adecuada en \"Permitir conexiones inseguras\" y las \"Conexiones seguras\" del servidor Plex en \"Preferidas\". Puede utilizarse para reforzar servidores manuales." +#: msgctxt "#33629" msgid "Auto-skip intro/credits offset" -msgstr "Desplazamiento automático de introducción/créditos" +msgstr "Intervalo de salto automático de introducción/créditos" +#: msgctxt "#33630" msgid "Intro/credits markers might be a little early in Plex. When auto skipping add (or subtract) this many seconds from the marker. This avoids cutting off content, while possibly skipping the marker a little late." msgstr "Los marcadores de introducción/créditos pueden ser un poco tempranos en Plex. Al saltar automáticamente, añada (o reste) esta cantidad de segundos al marcador. Esto evita cortar el contenido, mientras que posiblemente salta el marcador un poco tarde." +#: msgctxt "#32631" msgid "Playback (user-specific)" msgstr "Reproducción (específica del usuario)" +#: msgctxt "#33632" msgid "Server connectivity check timeout (seconds)" msgstr "Tiempo de espera de comprobación de conectividad del servidor (segundos)" +#: msgctxt "#33633" msgid "Set the maximum amount of time a server connection has to answer a connectivity request. Default: 2.5" msgstr "Establece la cantidad máxima de tiempo que una conexión de servidor tiene para responder a una solicitud de conectividad. Predeterminado: 2,5" +#: msgctxt "#33634" msgid "Combined Chapters" msgstr "Capítulos combinados" +#: msgctxt "#33635" msgid "Final Credits" msgstr "Créditos finales" +#: msgctxt "#32700" msgid "Action on Sleep event" msgstr "Acción sobre el evento de suspensión" +#: msgctxt "#32701" msgid "When Kodi receives a sleep event from the system, run the following action." msgstr "Cuando Kodi reciba un evento de suspensión del sistema, ejecuta la siguiente acción." +#: msgctxt "#32702" msgid "Nothing" msgstr "Nada" +#: msgctxt "#32703" msgid "Stop playback" msgstr "Detener la reproducción" +#: msgctxt "#32704" msgid "Quit Kodi" msgstr "Salir de Kodi" +#: msgctxt "#32705" msgid "CEC Standby" msgstr "CEC En espera" +#: msgctxt "#32800" msgid "Skipping intro" msgstr "Saltar introducción" +#: msgctxt "#32801" msgid "Skipping credits" msgstr "Saltar créditos" +#: msgctxt "#32900" msgid "While playing back an item and seeking on the seekbar, automatically seek to the selected position after a delay instead of having to confirm the selection." msgstr "Mientras se reproduce un elemento y se busca en la barra de búsqueda, se busca automáticamente la posición seleccionada tras un retardo en lugar de tener que confirmar la selección." +#: msgctxt "#32901" msgid "Seek delay in seconds." msgstr "Retraso de búsqueda en segundos." +#: msgctxt "#32902" msgid "Kodi has its own skip step settings. Try to use them if they're configured instead of the default ones." msgstr "Kodi tiene sus propios ajustes para saltar pasos. Intenta usarlos si están configurados en lugar de los predeterminados." +#: msgctxt "#32903" msgid "Use the above for seeking on the timeline as well." msgstr "Utilice lo anterior también para buscar en la línea de tiempo." +#: msgctxt "#32904" msgid "In seconds." msgstr "En segundos." +#: msgctxt "#32905" msgid "Cancel post-play timer by pressing OK/SELECT" msgstr "Cancelar el temporizador post-play pulsando OK/SELECCIONAR" +#: msgctxt "#32906" msgid "Cancel skip marker timer with BACK" msgstr "Cancelar el temporizador de salto de marcador con VOLVER" +#: msgctxt "#32907" msgid "When auto-skipping a marker, allow cancelling the timer by pressing BACK." msgstr "Cuando se salta automáticamente un marcador, permite cancelar el temporizador pulsando VOLVER." +#: msgctxt "#32908" msgid "Immediately skip marker with OK/SELECT" msgstr "Saltar inmediatamente el marcador con OK/SELECCIONAR" +#: msgctxt "#32909" msgid "When auto-skipping a marker with a timer, allow skipping immediately by pressing OK/SELECT." msgstr "Cuando se omita automáticamente un marcador con temporizador, permita la omisión inmediatamente pulsando OK/SELECCIONAR." +#: msgctxt "#32912" msgid "Show buffer-state on timeline" -msgstr "Mostrar el estado de la memoria intermedia en la línea de tiempo" +msgstr "Mostrar el estado del Buffer en la línea de tiempo" +#: msgctxt "#32913" msgid "Shows the current Kodi buffer/cache state on the video player timeline." msgstr "Muestra el estado actual del búfer/caché de Kodi en la línea de tiempo del reproductor de vídeo." +#: msgctxt "#32914" msgid "Loading" msgstr "Cargando" +#: msgctxt "#32915" msgid "Slow connection" msgstr "Conexión lenta" +#: msgctxt "#32916" msgid "Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually wait for item refreshes and waits for the buffer to fill when starting playback. Automatically sets readfactor=20, requires Kodi restart." msgstr "Utilícelo con una conexión lenta o inestable, por ejemplo, en una habitación de hotel. Ajusta la interfaz de usuario para esperar visualmente a que se actualicen los elementos y espera a que se llene el búfer al iniciar la reproducción. Establece automáticamente readfactor=20, requiere reiniciar Kodi." +#: msgctxt "#32917" msgid "Couldn't fill buffer in time ({}s)" msgstr "No se ha podido llenar el buffer a tiempo ({}s)" +#: msgctxt "#32918" msgid "Buffer wait timeout (seconds)" msgstr "Tiempo de espera del búfer (segundos)" +#: msgctxt "#32919" msgid "When slow connection is enabled in the addon, wait this long for the buffer to fill. Default: 120 s" msgstr "Cuando la conexión lenta está activada en el addon, espera este tiempo a que se llene el búfer. Por defecto: 120 s" +#: msgctxt "#32920" msgid "Insufficient buffer wait (seconds)" msgstr "Espera de búfer insuficiente (segundos)" +#: msgctxt "#32921" msgid "When slow connection is enabled in the addon and the configured buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" msgstr "Cuando la conexión lenta está activada en el addon y el búfer configurado no es lo suficientemente grande como para que podamos determinar su estado de llenado, espere este tiempo al iniciar la reproducción. Predeterminado: 10 s" +#: msgctxt "#32922" msgid "Kodi Cache Readfactor" -msgstr "Factor de lectura de la caché de Kodi" +msgstr "Factor de lectura (Readfactor) de la caché de Kodi" +#: msgctxt "#32923" msgid "Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}. With \"Slow connection\" enabled this will be set to {2}, as otherwise the cache doesn't fill fast/aggressively enough." -msgstr "Establece el valor del factor de lectura de la caché de Kodi. Por defecto: {0}, recomendado: {1}. Con \"Conexión lenta\" activada, este valor será {2}, ya que de lo contrario la caché no se llena lo suficientemente rápido/agresivamente." +msgstr "Establece el valor del factor de lectura (Readfactor) de la caché de Kodi. Por defecto: {0}, recomendado: {1}. Con \"Conexión lenta\" activada, este valor será {2}, ya que de lo contrario la caché no se llena lo suficientemente rápido/agresivamente." +#: msgctxt "#32924" msgid "Minimize" msgstr "Minimizar" +#: msgctxt "#32925" msgid "Playback Settings" msgstr "Ajustes de reproducción" +#: msgctxt "#32926" msgid "Wrong pin entered!" msgstr "¡Pin introducido erróneo!" +#: msgctxt "#32927" msgid "Use episode thumbnails in continue hub" msgstr "Utilizar miniaturas de episodios en el hub de continuación" +#: msgctxt "#32928" msgid "Instead of using media artwork, use thumbnails for episodes in the continue hub on the home screen if available." msgstr "En lugar de utilizar ilustraciones multimedia, utiliza miniaturas de los episodios en el hub de continuación de la pantalla de inicio, si está disponible." +#: msgctxt "#32929" msgid "Use legacy background fallback image" msgstr "Utilizar la imagen de fondo anterior" +#: msgctxt "#32930" msgid "Previous Subtitle" msgstr "Subtítulos anteriores" +#: msgctxt "#32931" msgid "Audio/Subtitles" msgstr "Audio/Subtítulos" +#: msgctxt "#32932" msgid "Show subtitle quick-actions button" msgstr "Botón de acciones rápidas para mostrar subtítulos" +#: msgctxt "#32933" msgid "Show FFWD/RWD buttons" msgstr "Mostrar botones FFWD/RWD" +#: msgctxt "#32934" msgid "Show repeat button" msgstr "Mostrar botón de repetición" +#: msgctxt "#32935" msgid "Show shuffle button" msgstr "Botón de reproducción aleatoria" +#: msgctxt "#32936" msgid "Show playlist button" msgstr "Botón Mostrar lista de reproducción" +#: msgctxt "#32937" msgid "Show prev/next button" msgstr "Mostrar botón anterior/siguiente" -msgctxt "#32938" -msgid "Only for Episodes" -msgstr "Sólo para episodios" - +#: msgctxt "#32939" msgid "Only applies to video player UI" msgstr "Sólo se aplica a la interfaz de usuario del reproductor de vídeo" +#: msgctxt "#32940" msgid "Player UI" msgstr "Interfaz del reproductor" +#: msgctxt "#32941" msgid "Forced subtitles fix" msgstr "Corrección de subtítulos forzados" +#: msgctxt "#32942" msgid "Other seasons" msgstr "Otras temporadas" +#: msgctxt "#32943" msgid "Crossfade dynamic background art" -msgstr "Arte de fondo dinámico con fundido cruzado" +msgstr "Arte de fondo dinámico con Crossfade" +#: msgctxt "#32944" msgid "Burn-in SSA subtitles (DirectStream)" msgstr "Subtítulos SSA quemados (DirectStream)" +#: msgctxt "#32945" msgid "When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus transcoding the video stream). If disabled it will not touch the video stream, but will convert the subtitle to unstyled text." -msgstr "Cuando se hace Direct Streaming, ordena al Servidor Plex que grabe los subtítulos SSA/ASS (transcodificando así el flujo de vídeo). Si se desactiva no tocará el flujo de vídeo, pero convertirá los subtítulos en texto sin estilo." +msgstr "Cuando se hace Direct Streaming, ordena al Servidor Plex que grabe los subtítulos SSA/ASS (transcodificando así la transmisión del video). Si se desactiva, no tocará la transmisión del video, pero convertirá los subtítulos en texto sin estilo." +#: msgctxt "#32946" msgid "Stop video playback on idle after" msgstr "Detener la reproducción de vídeo en reposo después de" +#: msgctxt "#32947" msgid "Stop video playback on screensaver" msgstr "Detener la reproducción de vídeo en el salvapantallas" +#: msgctxt "#32948" msgid "Allow auto-skip when transcoding" msgstr "Permitir el salto automático al transcodificar" +#: msgctxt "#32949" msgid "When transcoding/DirectStreaming, allow auto-skip functionality." msgstr "Al transcodificar/transmitir directamente, permita la función de salto automático." +#: msgctxt "#32950" msgid "Use extended title for subtitles" msgstr "Utilizar el título ampliado para los subtítulos" +#: msgctxt "#32951" msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." msgstr "Cuando muestre subtítulos utilice el título de pantalla extendida que Plex expone." -msgctxt "#32952" -msgid "Dialog flicker fix" -msgstr "Corrección del parpadeo de los diálogos" - +#: msgctxt "#32953" msgid "Reviews" msgstr "Reseñas" +#: msgctxt "#32954" -msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n\nTo customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" -msgstr "Necesita reiniciar Kodi. ADVERTENCIA: ¡Esto sobrescribirá advancedsettings.xml!\n\nPara personalizar otros valores relacionados con la caché/red, copia \"script.plexmod/pm4k_cache_template.xml\" a la carpeta profile y edítalo a tu gusto. (Consulta la sección Acerca de para ver las rutas de los archivos)" - +msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n" +"\n" +"To customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" +msgstr "Necesita reiniciar Kodi. ADVERTENCIA: ¡Esto sobrescribirá advancedsettings.xml!\n" +"\n" +"Para personalizar otros valores relacionados con la caché/red, copia \"script.plexmod/pm4k_cache_template.xml\" a la carpeta profile y edítalo a tu gusto. (Consulta la sección Acerca de para ver las rutas de los archivos)" + +#: msgctxt "#32955" msgid "Use Kodi keyboard for searching" msgstr "Usa el teclado de Kodi para buscar" +#: msgctxt "#32956" msgid "Poster resolution scaling %" msgstr "Escalado de la resolución del póster %" +#: msgctxt "#32957" msgid "In percent. Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance, will increase the cache usage accordingly. Recommended: 200-300 % for for big screens if your hardware can handle it. Needs addon restart." msgstr "En porcentaje. Escala la resolución de todos los pósters/miniaturas para una mejor calidad de imagen. Puede afectar al rendimiento de PMS/PM4K, aumentará el uso de caché en consecuencia. Recomendado: 200-300 % para pantallas grandes si tu hardware puede soportarlo. Necesita reiniciar el addon." +#: msgctxt "#32958" msgid "Calculate OpenSubtitles.com hash" msgstr "Calcular el hash de OpenSubtitles.com" +#: msgctxt "#32959" msgid "When opening the subtitle download feature, automatically calculate the OpenSubtitles.com hash for the given file. Can improve search results, downloads 2*64 KB of the video file to calculate the hash." msgstr "Al abrir la función de descarga de subtítulos, calcula automáticamente el hash de OpenSubtitles.com para el archivo dado. Puede mejorar los resultados de búsqueda, descarga 2*64 KB del archivo de vídeo para calcular el hash." +#: msgctxt "#32960" msgid "Similar Artists" msgstr "Artistas similares" +#: msgctxt "#32961" msgid "Show hub bifurcation lines" msgstr "Mostrar líneas de bifurcación del buje" +#: msgctxt "#32962" msgid "Visually separate hubs horizontally using a thin line." msgstr "Separe visualmente los cubos horizontalmente mediante una línea fina." +#: msgctxt "#32963" msgid "Wait between videos (s)" -msgstr "Espera entre vídeos (s)" +msgstr "Espera entre videos (s)" +#: msgctxt "#32964" msgid "When playing back consecutive videos (e.g. TV shows), wait this long before starting the next one in the queue. Might fix compatibility issues with certain configurations." -msgstr "Al reproducir vídeos consecutivos (por ejemplo, programas de TV), espere este tiempo antes de iniciar el siguiente de la cola. Podría solucionar problemas de compatibilidad con determinadas configuraciones." +msgstr "Al reproducir videos consecutivos (por ejemplo, programas de TV), espere este tiempo antes de iniciar el siguiente de la cola. Podría solucionar problemas de compatibilidad con determinadas configuraciones." +#: msgctxt "#32965" msgid "Quit Kodi on exit by default" -msgstr "Salir de Kodi al salir por defecto" +msgstr "Cerrar Kodi al salir por defecto" +#: msgctxt "#32966" msgid "When exiting the addon, use \"Quit Kodi\" as default option. Can be dynamically switched using CONTEXT_MENU (often longpress SELECT)" msgstr "Al salir del addon, usa \"Salir de Kodi\" como opción por defecto. Se puede cambiar dinámicamente usando CONTEXT_MENU (a menudo pulsando prolongadamente SELECCIONAR)" +#: msgctxt "#32967" msgid "Kodi Colour Management" msgstr "Gestión del color en Kodi" +#: msgctxt "#32968" msgid "Kodi Resolution Settings" msgstr "Ajustes de resolución de Kodi" +#: msgctxt "#32969" msgid "Always request all library media items at once" -msgstr "Solicite siempre todos los materiales de la biblioteca a la vez" +msgstr "Solicite siempre todos los medios de la biblioteca a la vez" +#: msgctxt "#32970" msgid "Retrieve all media in library up front instead of fetching it in chunks as the user navigates through the library" msgstr "Recuperar todos los medios de la biblioteca por adelantado en lugar de hacerlo por partes a medida que el usuario navega por la biblioteca" +#: msgctxt "#32971" msgid "Library item-request chunk size" msgstr "Tamaño del fragmento de solicitud de ítem de biblioteca" +#: msgctxt "#32972" msgid "Request this amount of media items per chunk request in library view (+6-30 depending on view mode; less can be less straining for the UI at first, but puts more strain on the server)" msgstr "Solicita esta cantidad de elementos multimedia por petición de chunk en la vista de biblioteca (+6-30 dependiendo del modo de vista; menos puede ser menos estresante para la interfaz de usuario al principio, pero pone más tensión en el servidor)" +#: msgctxt "#32973" msgid "Episodes: Skip Post Play screen" msgstr "Episodios: Saltar pantalla de reproducción" +#: msgctxt "#32974" -msgid "When finishing an episode, don't show Post Play but go to the next one immediately.\nCan be disabled/enabled per TV show. Doesn't override enabled binge mode. Overrides the Post Play setting." -msgstr "Al terminar un episodio, no muestra Post Play sino que pasa al siguiente inmediatamente.\nPuede desactivarse/activarse por programa de TV. No anula el modo atracón activado. Anula la configuración de Post Play." +msgid "When finishing an episode, don't show Post Play but go to the next one immediately.\n" +"Can be disabled/enabled per TV show. Doesn't override enabled binge mode. Overrides the Post Play setting." +msgstr "Al terminar un episodio, no muestra Post Play sino que pasa al siguiente inmediatamente.\n" +"Puede desactivarse/activarse por programa de TV. No anula el modo maratón activado. Anula la configuración de Post Play." +#: msgctxt "#32975" msgid "Delete Season" msgstr "Borrar temporada" + +#: +msgctxt "#32976" +msgid "Adaptive" +msgstr "Adaptable" + +#: +msgctxt "#32977" +msgid "Allow VC1" +msgstr "Permitir VC1" + +#: +msgctxt "#32978" +msgid "Enable this if your hardware can handle VC1. Disable it to force transcoding." +msgstr "Activa esto si tu Hardware puede manejar VC1. Desactiva esto para forzar la transcodificación." + +#: +msgctxt "#32979" +msgid "Allows the server to only transcode streams of a video that need transcoding, while streaming the others unaltered. If disabled, force the server to transcode everything not direct playable." +msgstr "Permitir al servidor solo transcodificar la reproducción de video que necesita de transcodificación, mientras se reproduce el resto sin cambios. Si se deshabilita, fuerza al servidor a transcodificar todo lo que no sea directamente reproducible." + +#. Refrescar Usuarios o Recargar Usuarios +#: +msgctxt "#32980" +msgid "Refresh Users" +msgstr "Volver a cargar usuarios" + +#. In this sentence Workers should not be translated to Trabajadores in Spanish because there's no real translation for it in the programming context. +#: +msgctxt "#32981" +msgid "Background worker count" +msgstr "Recuento de Workers en segundo plano" + +#: +msgctxt "#32982" +msgid "Depending on how many cores your CPU has and how much it can handle, increasing this might improve certain situations. If you experience crashes or other annormalities, leave this at its default (3). Needs an addon restart." +msgstr "Según cuántos núcleos tenga tu procesador y cuánto pueda manejar, incrementar esto podría mejorar ciertas situaciones. Si experimentas cierres repentinos u otros errores, déjalo con su configuración por defecto (3). Necesita un reinicio del Addon." + +#: +msgctxt "#32983" +msgid "Player Theme" +msgstr "Tema del reproductor" + +#: +msgctxt "#32984" +msgid "Sets the player theme. Currently only customizes the playback control buttons. ATTENTION: [I]Might[/I] need an addon restart.\n" +"In order to customize this, copy one of the xml's in script.plexmod/resources/skins/Main/1080i/templates to addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml and adjust it to your liking, then select \"Custom\" as your theme." +msgstr "Configura el tema del reproductor. Por ahora solo modifica los botones de control del reproductor. Atención: [I]Podría[/I] necesitar un reinicio del Addon. Para personalizarlo, copia uno de los xml's en script.plexmod/resources/skins/Main/1080i/templates a addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml y ajústalo a tu gusto, luego selecciona \"Perzonalizado\" como tema." + +#: +msgctxt "#32985" +msgid "Modern" +msgstr "Moderno" + +#: +msgctxt "#32986" +msgid "Modern (dotted)" +msgstr "Moderno (puntos)" + +#: +msgctxt "#32987" +msgid "Classic" +msgstr "Clásico" + +#: +msgctxt "#32988" +msgid "Custom" +msgstr "Perzonalizado" + +#: +msgctxt "#32989" +msgid "Modern (colored)" +msgstr "Moderno (coloreado)" + +#: +msgctxt "#32990" +msgid "Handle plex.direct mapping" +msgstr "Manejar el mapeo de plex.direct" + +#: +msgctxt "#32991" +msgid "Notify" +msgstr "Notificar" + +#. Haven't heard about an actual translation for "Rebind" in Spanish. These techie words are usually not translated in Spanish. +#: +msgctxt "#32992" +msgid "When using servers with a plex.direct connection (most of them), should we automatically adjust advancedsettings.xml to cope with plex.direct domains? If not, you might want to add plex.direct to your router's DNS rebind exemption list." +msgstr "Cuando se utilicen servidores con una conexión a plex.direct (la mayoría), ¿deberíamos ajustar advancedsettings.xml para lidiar con los dominios de plex.direct? Si no, podrías necesitar añadir plex.direct a la lista de excepción de DNS Rebind de tu router." + +#: +msgctxt "#32993" +msgid "{} unhandled plex.direct connections found" +msgstr "{} conexiones a plex.direct sin manejar encontradas" + +#: +msgctxt "#32994" +msgid "In order for PM4K to work properly, we need to add special handling for plex.direct connections. We've found {} new unhandled connections. Do you want us to write those to Kodi's advancedsettings.xml automatically? If not, you might want to add plex.direct to your router's DNS rebind exemption list. This can be changed in the settings as well." +msgstr "Para que PM4K funcione correctamente, necesitamos añadir un manejo especial para las conexiones a plex.direct. Se han encontrado {} nuevas conexiones sin manejar. ¿Quieres que las coloquemos en el archivo advancedsettings.xml de Kodi automáticamente? Si no, podrías necesitar añadir plex.direct a la lista de excepción de DNS Rebind de tu router. Esto también puede ser cambiado en los ajustes." + +#: +msgctxt "#32995" +msgid "Advancedsettings.xml modified (plex.direct mappings)" +msgstr "Advancedsettings.xml modificado (mapeos de plex.direct)" + +#: +msgctxt "#32996" +msgid "The advancedsettings.xml file has been modified. Please restart Kodi for the changes to apply." +msgstr "El archivo advancedsettings.xml ha sido modificado. Por favor renicia Kodi para que los cambios sean aplicados." + +#: +msgctxt "#32997" +msgid "OK" +msgstr "OK" + +#. Apartado was the best interpretation for Hub that I could think of +#: +msgctxt "#32998" +msgid "Use new Continue Watching hub on Home" +msgstr "Utilizar el nuevo apartado de Continuar Viendo en Inicio" + +#. Cartelera was the best interpretation for On Deck that I could think of +#: +msgctxt "#32999" +msgid "Instead of separating Continue Watching and On Deck hubs, behave like the modern Plex clients, which combine those two types of hubs into one Continue Watching hub." +msgstr "En lugar de separar los apartados de Continuar Viendo y Cartelera, comportarse como los clientes modernos de Plex, los cuales combinan estos dos apartados en uno solo de Continuar Viendo." + +#: +msgctxt "#33000" +msgid "Enable path mapping" +msgstr "Activar mapeo de rutas" + +#: +msgctxt "#33001" +msgid "Honor path_mapping.json in the addon_data/script.plexmod folder when DirectPlaying media. This can be used to stream using other techniques such as SMB/NFS/etc. instead of the default HTTP handler. path_mapping.example.json is included in the addon's main directory." +msgstr "Dar preferencia al archivo path_mapping.json en la carpeta addon_data/scrip.plexmod cuando haya una Reproducción Directa de medios (DirectPlay). Esto se puede usar para transmitir utilizando otras técnicas como SMB/NFS/etc. En lugar del manejo con HTTP por defecto. El archivo path_mapping.example.json se incluye en el directorio principal del Addon." + +#: +msgctxt "#33002" +msgid "Verify mapped files exist" +msgstr "Verificar que los archivos mapeados existan" + +#: +msgctxt "#33003" +msgid "When path mapping is enabled and we've successfully mapped a file, verify its existence." +msgstr "Cuando el Mapeo de Rutas está activo y se ha mapeado un archivo con éxito, verificar su existencia." + +#: +msgctxt "#33004" +msgid "No spoilers without OSD" +msgstr "No Spoilers sin el OSD" + +#: +msgctxt "#33005" +msgid "When seeking without the OSD open, hide all time-related information from the user." +msgstr "Cuando se adelanta o retrocede la reproducción sin el OSD abierto, oculta toda la información relacionada con el tiempo de reproducción al usuario." + +#: +msgctxt "#33006" +msgid "No TV spoilers" +msgstr "Sin Spoilers de TV" + +#: +msgctxt "#33007" +msgid "When visiting an episode/season view, blur unwatched/unwatched+in-progress episode thumbnails, previews and redact summaries. When the Addon Setting \"Use episode thumbnails in continue hub\" is enabled, blur them as well." +msgstr "Cuando se visita la vista de episodio/temporada, desenfocar las miniaturas de episodio de No vistos/No vistos+En progreso, vista previa, y redactar resúmenes. Cuando la opción del Addon \"Utilizar miniaturas de episodio en el apartado de Continuar\" está habilitada, desenfocarlas también." + +#: +msgctxt "#33008" +msgid "[Spoilers removed]" +msgstr "[Spoilers removidos]" + +#: +msgctxt "#33009" +msgid "Blur amount for unwatched/in-progress episodes" +msgstr "Cantidad de desenfoque para episodios No vistos/En progreso" + +#: +msgctxt "#33010" +msgid "Unwatched" +msgstr "No vistos" + +#: +msgctxt "#33011" +msgid "Unwatched/in progress" +msgstr "No vistos/En progreso" + +#: +msgctxt "#33012" +msgid "No unwatched episode titles" +msgstr "Títulos de Episodios no vistos" + +#: +msgctxt "#33013" +msgid "When the above is anything but \"off\", hide episode titles as well." +msgstr "Cuando lo de arriba es cualquier cosa menos \"Apagado\", ocultar los títulos de episodios también." + +#: +msgctxt "#33014" +msgid "Ignore plex.direct docker hosts" +msgstr "Ignorar los Hosts plex.direct de docker" + +#: +msgctxt "#33015" +msgid "When checking for plex.direct host mapping, ignore local Docker IPv4 addresses (172.16.0.0/12)." +msgstr "Cuando se verifica el mapeo de Host de plex.direct, ignorar las direcciones locales IPv4 de Docker (172.16.0.0/12)." + +#: +msgctxt "#33016" +msgid "Allow TV spoilers for specific genres" +msgstr "Permitir Spoilers de TV para géneros específicos" + +#: +msgctxt "#33017" +msgid "Overrides the above for: {}" +msgstr "Cambiar lo de arriba por: {}" + +#: +msgctxt "#32303" +msgid "Season {}" +msgstr "Temporada {}" + +#: +msgctxt "#32304" +msgid "Episode {}" +msgstr "Episodio {}" + +#: +msgctxt "#32310" +msgid "S{}" +msgstr "S{}" + +#: +msgctxt "#32311" +msgid "E{}" +msgstr "E{}" + +#: +msgctxt "#32938" +msgid "Only for Episodes/Playlists" +msgstr "Solo para Episodios/Listas de reproducción" + +#: +msgctxt "#33018" +msgid "Cache Plex Home users" +msgstr "Guardar en Caché los usuarios de Plex Home" + +#: +msgctxt "#33019" +msgid "Visit media item" +msgstr "Visitar un archivo de medio" + +#: +msgctxt "#33020" +msgid "Play" +msgstr "Reproducir" + +#: +msgctxt "#33021" +msgid "Choose action" +msgstr "Elegir acción" + +#: +msgctxt "#33022" +msgid "Use modern inverted watched states" +msgstr "Utilice estados observados invertidos modernos" + +#: +msgctxt "#33023" +msgid "Instead of marking unwatched items, mark watched items with a checkmark (modern clients; default: off)" +msgstr "En lugar de marcar los elementos no vistos, marcar los elementos vistos con una marca de verificación (clientes modernos; por defecto: desactivado)" + +#: +msgctxt "#33024" +msgid "Hide black backdrop in inverted watched states" +msgstr "Ocultar fondo negro en estados de vigilancia invertidos" + +#: +msgctxt "#33025" +msgid "When the above is enabled, hide the black backdrop of the watched state." +msgstr "Cuando se activa lo anterior, oculta el fondo negro del estado vigilado." + +#: +msgctxt "#33026" +msgid "Map path: {}" +msgstr "Asignación de rutas: {}" + +#: +msgctxt "#33027" +msgid "Remove mapping: {}" +msgstr "Quitar mapeo: {}" + +#: +msgctxt "#33028" +msgid "Hide library" +msgstr "Ocultar biblioteca" + +#: +msgctxt "#33029" +msgid "Show library: {}" +msgstr "Mostrar biblioteca: {}" + +#: +msgctxt "#33030" +msgid "Choose action for: {}" +msgstr "Elegir acción para: {}" + +#: +msgctxt "#33031" +msgid "Select Kodi source for {}" +msgstr "Seleccione la fuente Kodi para {}" + +#: +msgctxt "#33032" +msgid "Show path mapping indicators" +msgstr "Mostrar indicadores de asignación de rutas" + +#: +msgctxt "#33033" +msgid "When path mapping is active for a library, display an indicator." +msgstr "Cuando la asignación de rutas está activa para una biblioteca, muestra un indicador." + +#: +msgctxt "#33034" +msgid "Library settings" +msgstr "Configuración de la biblioteca" + + +#: +msgctxt "#33035" +msgid "Delete {}: {}?" +msgstr "Borrar {}: {}?" + +#: +msgctxt "#33036" +msgid "Delete episode S{0:02d}E{1:02d} from {2}?" +msgstr "¿Borrar episodio S{0:02d}E{1:02d} de {2}?" + +#: +msgctxt "#33037" +msgid "Maximum intro offset to consider" +msgstr "Desplazamiento de introducción máximo a considerar" + +#: +msgctxt "#33038" +msgid "When encountering an intro marker with a start time offset greater than this, ignore it (default: 600s/10m)" +msgstr "Cuando encuentre un marcador de introducción con un desfase de tiempo de inicio superior a este valor, ignórelo (por defecto: 600s/10m)" + +#: +msgctxt "#33039" +msgid "Move" +msgstr "Mover" + +#: +msgctxt "#33040" +msgid "Reset library order" +msgstr "Restablecer el orden de la biblioteca" diff --git a/script.plexmod/resources/language/resource.language.it_it/strings.po b/script.plexmod/resources/language/resource.language.it_it/strings.po index 50dee38bce..ddab8dc09f 100644 --- a/script.plexmod/resources/language/resource.language.it_it/strings.po +++ b/script.plexmod/resources/language/resource.language.it_it/strings.po @@ -1,951 +1,2358 @@ -# XBMC Media Center language file msgid "" msgstr "" -"Project-Id-Version: XBMC-Addons\n" -"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" -"POT-Creation-Date: 2013-12-12 22:56+0000\n" -"PO-Revision-Date: 2017-05-25 10:52+0200\n" -"Language-Team: LANGUAGE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: PM4K / PlexMod for Kodi\n" "Language: it\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Last-Translator: \n" -"X-Generator: Poedit 2.0.2\n" +#: msgctxt "#32000" msgid "Main" msgstr "Principale" +#: msgctxt "#32001" msgid "Original" msgstr "Originale" +#: msgctxt "#32002" msgid "20 Mbps 1080p" msgstr "20 Mbps 1080p" +#: msgctxt "#32003" msgid "12 Mbps 1080p" msgstr "12 Mbps 1080p" +#: msgctxt "#32004" msgid "10 Mbps 1080p" msgstr "10 Mbps 1080p" +#: msgctxt "#32005" msgid "8 Mbps 1080p" msgstr "8 Mbps 1080p" +#: msgctxt "#32006" msgid "4 Mbps 720p" msgstr "4 Mbps 720p" +#: msgctxt "#32007" msgid "3 Mbps 720p" msgstr "3 Mbps 720p" +#: msgctxt "#32008" msgid "2 Mbps 720p" msgstr "2 Mbps 720p" +#: msgctxt "#32009" msgid "1.5 Mbps 480p" msgstr "1.5 Mbps 480p" +#: msgctxt "#32010" msgid "720 kbps" msgstr "720 kbps" +#: msgctxt "#32011" msgid "320 kbps" msgstr "320 kbps" +#: msgctxt "#32012" msgid "208 kbps" msgstr "208 kbps" +#: msgctxt "#32013" msgid "96 kbps" msgstr "96 kbps" +#: msgctxt "#32014" msgid "64 kbps" msgstr "64 kbps" +#: msgctxt "#32020" msgid "Local Quality" msgstr "Qualità Locale" +#: msgctxt "#32021" msgid "Remote Quality" msgstr "Qualità Remota" +#: msgctxt "#32022" msgid "Online Quality" msgstr "Qualità Online" +#: msgctxt "#32023" msgid "Transcode Format" msgstr "Formato transcodifica" +#: msgctxt "#32024" msgid "Debug Logging" msgstr "Log di Debug" +#: msgctxt "#32025" msgid "Allow Direct Play" msgstr "Permetti Play Diretto" +#: msgctxt "#32026" msgid "Allow Direct Stream" msgstr "Permetti Stream Diretto" +#: msgctxt "#32027" msgid "Force" msgstr "Forza" +#: msgctxt "#32028" msgid "Always" msgstr "Sempre" +#: msgctxt "#32029" msgid "Only Image Formats" msgstr "Solo formati immagine" +#: msgctxt "#32030" msgid "Auto" msgstr "Auto" -msgctxt "#32031" -msgid "Burn Subtitles (Direct Play Only)" -msgstr "Sottotitoli (Solo Play Diretto)" - +#: msgctxt "#32032" msgid "Allow Insecure Connections" msgstr "Permetti connessioni non sicure" +#: msgctxt "#32033" msgid "Never" msgstr "Mai" +#: msgctxt "#32034" msgid "On Same network" msgstr "Sulla stessa rete" +#: msgctxt "#32035" msgid "Always" msgstr "Sempre" +#: msgctxt "#32036" msgid "Allow 4K" msgstr "Permetti 4K" +#: msgctxt "#32037" msgid "Allow HEVC (h265)" msgstr "Permetti HEVC (h265)" +#: msgctxt "#32038" msgid "Automatically Sign In" msgstr "Accesso (Sign In) automatico" +#: msgctxt "#32039" msgid "Post Play Auto Play" msgstr "Post Play Auto Play" +#: msgctxt "#32040" msgid "Enable Subtitle Downloading" msgstr "Abilita il download dei sottotitoli" +#: msgctxt "#32041" msgid "Enable Subtitle Downloading" msgstr "Abilita il download dei sottotitoli" +#: msgctxt "#32042" msgid "Server Discovery (GDM)" msgstr "Ricerca Server (GDM)" +#: msgctxt "#32043" msgid "Start Plex On Kodi Startup" msgstr "Esegui Plex all'avvio di Kodi" +#: msgctxt "#32044" msgid "Connection 1 IP" msgstr "IP connessione 1" +#: msgctxt "#32045" msgid "Connection 1 Port" msgstr "Porta connessione 1" +#: msgctxt "#32046" msgid "Connection 2 IP" msgstr "IP connessione 2" +#: msgctxt "#32047" msgid "Connection 2 Port" msgstr "Porta connessione 2" +#: msgctxt "#32048" msgid "Audio" msgstr "Audio" +#: msgctxt "#32049" msgid "Advanced" msgstr "Avanzate" +#: msgctxt "#32050" msgid "Manual Servers" msgstr "Server manuali" +#: msgctxt "#32051" msgid "Privacy" msgstr "Privacy" +#: msgctxt "#32052" msgid "About" -msgstr "Circa" +msgstr "Informazioni" +#: msgctxt "#32053" msgid "Video" msgstr "Video" +#: msgctxt "#32054" msgid "Addon Version" msgstr "Versione Addon" +#: msgctxt "#32055" msgid "Kodi Version" msgstr "Versione Kodi" +#: msgctxt "#32056" msgid "Screen Resolution" msgstr "Risoluzione schermo" +#: msgctxt "#32057" msgid "Current Server Version" msgstr "Versione Server attuale" +#: +msgctxt "#32058" +msgid "Never exceed original audio codec" +msgstr "Non superare il codec audio originale" + +#: +msgctxt "#32059" +msgid "When transcoding audio, never exceed the original audio bitrate or channel count on the same codec." +msgstr "Durante la transcodifica dell'audio, non superare mai il bitrate audio originale o il numero di canali sullo stesso codec." + +#: +msgctxt "#32060" +msgid "Use Kodi audio channels" +msgstr "Utilizza i canali audio Kodi" + +#: +msgctxt "#32064" +msgid "Treat DTS like AC3" +msgstr "Tratta DTS come AC3" + +#: msgctxt "#32100" msgid "Skip user selection and pin entry on startup." msgstr "All'avvio salta l'immissione del PIN e la selezione utente." +#: msgctxt "#32101" msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." msgstr "Se abilitato, quando la riproduzione finisce e un 'Titolo Successivo' è disponibile, sarà automaticamente riprodotto dopo un ritardo di 15 secondi." +#: msgctxt "#32102" msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." msgstr "Abilita se il tuo hardware supporta la riproduzione 4K. Disabilita per forzare la trascodifica." +#: msgctxt "#32103" msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." msgstr "Abilita se il tuo hardware supporta HEVC/h265. Disabilita per forzare la trascodifica." +#: msgctxt "#32104" msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" msgstr "Quando connettersi a Server con connessioni non sicure.[CR][CR]* [B]Mai[/B]: Mai connettersi a Server non sicuri[CR]* [B]Sulla stessa Rete[/B]: Permetti se nella stessa Rete[CR]* [B]Sempre[/B]: Permetti sulla stessa Rete e su Reti remote" +#: msgctxt "#32201" msgid "Trailer" msgstr "Trailer" +#: msgctxt "#32202" msgid "Deleted Scene" msgstr "Scene cancellate" +#: msgctxt "#32203" msgid "Interview" msgstr "Intervista" +#: msgctxt "#32204" msgid "Music Video" msgstr "Video musicali" +#: msgctxt "#32205" msgid "Behind the Scenes" msgstr "Dietro le scene" +#: msgctxt "#32206" msgid "Scene" msgstr "Scena" +#: msgctxt "#32207" msgid "Live Music Video" msgstr "Video musicali dal vivo" +#: msgctxt "#32208" msgid "Lyric Music Video" msgstr "Testi video musicali" +#: msgctxt "#32209" msgid "Concert" msgstr "Concerto" +#: msgctxt "#32210" msgid "Featurette" msgstr "Featurette" +#: msgctxt "#32211" msgid "Short" msgstr "Corto" +#: msgctxt "#32212" msgid "Other" msgstr "Altro" +#: msgctxt "#32300" msgid "Go to Album" msgstr "Vai all'Album" +#: msgctxt "#32301" msgid "Go to Artist" msgstr "Vai all'Artista" +#: msgctxt "#32302" msgid "Go to {0}" msgstr "Vai a {0}" -msgctxt "#32303" -msgid "Season" -msgstr "Stagione" - -msgctxt "#32304" -msgid "Episode" -msgstr "Episodio" - +#: msgctxt "#32305" msgid "Extras" msgstr "Extras" +#: msgctxt "#32306" msgid "Related Shows" msgstr "Serie correlate" +#: msgctxt "#32307" msgid "More" msgstr "Più" +#: msgctxt "#32308" msgid "Available" msgstr "Disponibile" +#: msgctxt "#32309" msgid "None" msgstr "Nessuno" -msgctxt "#32310" -msgid "S" -msgstr "S" - -msgctxt "#32311" -msgid "E" -msgstr "E" - +#: msgctxt "#32312" msgid "Unavailable" msgstr "Non disponibile" +#: msgctxt "#32313" msgid "This item is currently unavailable." msgstr "Questo elemento non è al momento diponibile" +#: msgctxt "#32314" msgid "In Progress" msgstr "In Corso" +#: msgctxt "#32315" msgid "Resume playback?" msgstr "Riprendere la riproduzione?" +#: msgctxt "#32316" msgid "Resume" msgstr "Riprendi" +#: msgctxt "#32317" msgid "Play from beginning" msgstr "Riproduci dall'inizio" +#: msgctxt "#32318" msgid "Mark Unplayed" msgstr "Marca come non visto" +#: msgctxt "#32319" msgid "Mark Played" msgstr "Marca come visto" +#: msgctxt "#32320" msgid "Mark Season Unplayed" msgstr "Marca la Stagione come non vista" +#: msgctxt "#32321" msgid "Mark Season Played" msgstr "Marca la Stagione come vista" +#: msgctxt "#32322" msgid "Delete" msgstr "Cancellato" +#: msgctxt "#32323" msgid "Go To Show" msgstr "Vai alla Serie" +#: msgctxt "#32324" msgid "Go To {0}" msgstr "Vai a {0}" +#: msgctxt "#32325" msgid "Play Next" msgstr "Riproduci il titolo succesivo" +#: msgctxt "#32326" msgid "Really Delete?" msgstr "Cancellare veramente?" +#: msgctxt "#32327" msgid "Are you sure you really want to delete this media?" msgstr "Sei sucuro che vuoi davvero cancellare questo media?" +#: msgctxt "#32328" msgid "Yes" msgstr "Sì" +#: msgctxt "#32329" msgid "No" msgstr "No" +#: msgctxt "#32330" msgid "Message" msgstr "Messaggio" +#: msgctxt "#32331" msgid "There was a problem while attempting to delete the media." msgstr "C'è stato un problema nella cancellazione del media." +#: msgctxt "#32332" msgid "Home" msgstr "Home" +#: msgctxt "#32333" msgid "Playlists" msgstr "Playlists" +#: msgctxt "#32334" msgid "Confirm Exit" msgstr "Conferma Uscita" +#: msgctxt "#32335" msgid "Are you ready to exit Plex?" msgstr "Sei pronto per uscire da Plex?" +#: msgctxt "#32336" msgid "Exit" msgstr "Esci" +#: msgctxt "#32337" msgid "Cancel" msgstr "Cancella" +#: msgctxt "#32338" msgid "No Servers Found" msgstr "Nessun Server trovato" +#: msgctxt "#32339" msgid "Server is not accessible" msgstr "Server non accessibile" +#: msgctxt "#32340" msgid "Connection tests are in progress. Please wait." msgstr "Test di connessione in corso. Per favore attenti." +#: msgctxt "#32341" msgid "Server is not accessible. Please sign into your server and check your connection." msgstr "Server non accessibile. Per favore fai il sign in nel Server a controlla la connessione." +#: msgctxt "#32342" msgid "Switch User" msgstr "Cambia utente" +#: msgctxt "#32343" msgid "Settings" msgstr "Impostazioni" +#: msgctxt "#32344" msgid "Sign Out" msgstr "Esci (Sign Out)" +#: msgctxt "#32345" msgid "All" msgstr "Tutto" +#: msgctxt "#32346" msgid "By Name" msgstr "Per Nome" +#: msgctxt "#32347" msgid "Artists" msgstr "Artisti" +#: msgctxt "#32348" -msgid "movies" -msgstr "film" +msgid "Movies" +msgstr "Film" +#: msgctxt "#32349" msgid "photos" msgstr "foto" +#: msgctxt "#32350" msgid "Shows" msgstr "Serie" +#: msgctxt "#32351" msgid "By Date Added" msgstr "Per data di aggiunta" +#: msgctxt "#32352" msgid "Date Added" msgstr "Data di aggiunta" +#: msgctxt "#32353" msgid "By Release Date" msgstr "Per data di uscita" +#: msgctxt "#32354" msgid "Release Date" msgstr "Data di uscita" +#: msgctxt "#32355" msgid "By Date Viewed" msgstr "Per data di già visto" +#: msgctxt "#32356" msgid "Date Viewed" msgstr "Data di già visto" +#: msgctxt "#32357" msgid "By Name" msgstr "Per nome" +#: msgctxt "#32358" msgid "Name" msgstr "Nome" +#: msgctxt "#32359" msgid "By Rating" msgstr "Per Valutazione" +#: msgctxt "#32360" msgid "Rating" msgstr "Valutazione" +#: msgctxt "#32361" msgid "By Resolution" msgstr "Per Risoluzione" +#: msgctxt "#32362" msgid "Resolution" msgstr "Risoluzione" +#: msgctxt "#32363" msgid "By Duration" msgstr "Per Durata" +#: msgctxt "#32364" msgid "Duration" msgstr "Durata" +#: msgctxt "#32365" msgid "By First Aired" msgstr "Per prima trasmissione" +#: msgctxt "#32366" msgid "First Aired" msgstr "Prima trasmissione" +#: msgctxt "#32367" msgid "By Unplayed" msgstr "Per Non visto" +#: msgctxt "#32368" msgid "Unplayed" msgstr "Non visto" +#: msgctxt "#32369" msgid "By Date Played" msgstr "Per data di riproduzione" +#: msgctxt "#32370" msgid "Date Played" msgstr "Data di riproduzione" +#: msgctxt "#32371" msgid "By Play Count" msgstr "Per numero di volte riprodotto" +#: msgctxt "#32372" msgid "Play Count" msgstr "Numero di volte riprodotto" +#: msgctxt "#32373" msgid "By Date Taken" msgstr "Per data di ripresa" +#: msgctxt "#32374" msgid "Date Taken" msgstr "Data di ripresa" +#: msgctxt "#32375" msgid "No filters available" msgstr "Nussun filtro disponibile" +#: msgctxt "#32376" msgid "Clear Filter" msgstr "Pulisci i filtri" +#: msgctxt "#32377" msgid "Year" msgstr "Anno" +#: msgctxt "#32378" msgid "Decade" msgstr "Decennio" +#: msgctxt "#32379" msgid "Genre" msgstr "Genere" +#: msgctxt "#32380" msgid "Content Rating" msgstr "Classificazione dei contenuti" +#: msgctxt "#32381" msgid "Network" msgstr "Rete" +#: msgctxt "#32382" msgid "Collection" msgstr "Collezione" +#: msgctxt "#32383" msgid "Director" msgstr "Regista" +#: msgctxt "#32384" msgid "Actor" msgstr "Attore" +#: msgctxt "#32385" msgid "Country" msgstr "Nazione" +#: msgctxt "#32386" msgid "Studio" msgstr "Studio" +#: msgctxt "#32387" msgid "Labels" msgstr "Etichette" +#: msgctxt "#32388" msgid "Camera Make" msgstr "Marca camera" +#: msgctxt "#32389" msgid "Camera Model" msgstr "Modello camera" +#: msgctxt "#32390" msgid "Aperture" msgstr "Apertura" +#: msgctxt "#32391" msgid "Shutter Speed" msgstr "Velocità di scatto" +#: msgctxt "#32392" msgid "Lens" msgstr "Lenti" +#: msgctxt "#32393" msgid "TV Shows" msgstr "Serie televisive" +#: msgctxt "#32394" msgid "Music" msgstr "Musica" +#: msgctxt "#32395" msgid "Audio" msgstr "Audio" +#: msgctxt "#32396" msgid "Subtitles" msgstr "Sottotitoli" +#: msgctxt "#32397" msgid "Quality" msgstr "Qualità" +#: msgctxt "#32398" msgid "Kodi Video Settings" msgstr "Impostazioni Video di Kodi" +#: msgctxt "#32399" msgid "Kodi Audio Settings" msgstr "Impostazioni Audio di Kodi" +#: msgctxt "#32400" msgid "Go To Season" msgstr "Vai alla Stagione" +#: msgctxt "#32401" msgid "Directors" msgstr "Registi" +#: msgctxt "#32402" msgid "Writer" msgstr "Autore" +#: msgctxt "#32403" msgid "Writers" msgstr "Autori" +#: msgctxt "#32404" msgid "Related Movies" msgstr "Film correlati" +#: msgctxt "#32405" msgid "Download Subtitles" msgstr "Scarica Sottotitoli" +#: msgctxt "#32406" msgid "Subtitle Delay" msgstr "Ritardo Sottotitolo" +#: msgctxt "#32407" msgid "Next Subtitle" msgstr "Sottotitolo successivo" +#: msgctxt "#32408" msgid "Disable Subtitles" msgstr "Disabilita sottotitoli" +#: msgctxt "#32409" msgid "Enable Subtitles" msgstr "Abilita Sottotitoli" +#: msgctxt "#32410" msgid "Platform Version" msgstr "Versione piattaforma" +#: msgctxt "#32411" msgid "Unknown" msgstr "Sconosciuto" +#: msgctxt "#32412" msgid "Edit Or Clear" msgstr "Modifica o Cancella" +#: msgctxt "#32413" msgid "Edit IP address or clear the current setting?" msgstr "Modificare l'indirizzo IP o cancellare le impostazioni correnti?" +#: msgctxt "#32414" msgid "Clear" msgstr "Cancella" +#: msgctxt "#32415" msgid "Edit" msgstr "Modifica" +#: msgctxt "#32416" msgid "Enter IP Address" msgstr "Inserisci l'indirizzo IP" +#: msgctxt "#32417" msgid "Enter Port Number" msgstr "Inserisci il numero della Porta" +#: msgctxt "#32418" msgid "Creator" msgstr "Creatore" +#: msgctxt "#32419" msgid "Cast" msgstr "Cast" +#: msgctxt "#32420" msgid "Disc" msgstr "Disc" +#: msgctxt "#32421" msgid "Sign Out" msgstr "Esci (Sign Out)" +#: msgctxt "#32422" msgid "Exit" msgstr "Esci" +#: msgctxt "#32423" msgid "Shutdown" msgstr "Spegni" +#: msgctxt "#32424" msgid "Suspend" msgstr "Sospenti" +#: msgctxt "#32425" msgid "Hibernate" msgstr "Iberna" +#: msgctxt "#32426" msgid "Reboot" msgstr "Riavvia" +#: msgctxt "#32427" msgid "Failed" msgstr "Fallito" +#: msgctxt "#32428" msgid "Login failed!" msgstr "Login fallita!" +#: msgctxt "#32429" msgid "Resume from {0}" msgstr "Riprendi da {0}" +#: msgctxt "#32430" msgid "Discovery" msgstr "Ricerca" +#: msgctxt "#32431" msgid "Search" msgstr "Cerca" +#: msgctxt "#32432" msgid "Space" msgstr "Spazio" +#: msgctxt "#32433" msgid "Clear" msgstr "Pulisci" +#: msgctxt "#32434" msgid "Searching..." msgstr "In ricerca..." +#: msgctxt "#32435" msgid "No Results" msgstr "Nessun risultato" +#: msgctxt "#32436" msgid "Paused" msgstr "In Pausa" +#: msgctxt "#32437" msgid "Welcome" msgstr "Benvenuto" +#: msgctxt "#32438" msgid "Previous" msgstr "Precedente" +#: msgctxt "#32439" msgid "Playing Next" msgstr "Titolo successivo" +#: msgctxt "#32440" msgid "On Deck" msgstr "Scoprire" +#: msgctxt "#32441" msgid "Unknown" msgstr "Sconosciuto" +#: msgctxt "#32442" msgid "Embedded" msgstr "Integrato" +#: msgctxt "#32443" msgid "Forced" msgstr "Forzato" +#: msgctxt "#32444" msgid "Lyrics" msgstr "Testi" +#: msgctxt "#32445" msgid "Mono" msgstr "Mono" +#: msgctxt "#32446" msgid "Stereo" msgstr "Stereo" +#: msgctxt "#32447" msgid "None" msgstr "Nessuno" +#: msgctxt "#32448" msgid "Playback Failed!" msgstr "Riproduzione fallita!" +#: msgctxt "#32449" msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." msgstr "Impossibile connettersi a plex.tv[CR]Controlla la tua connessione ad Internet e riprova." +#: msgctxt "#32450" msgid "Choose Version" msgstr "Seleziona Versione" +#: msgctxt "#32451" msgid "Play Version..." msgstr "Versione in Play..." +#: msgctxt "#32452" msgid "No Content available in this library" msgstr "Nessun contenuto disponibile in questa libreria" +#: msgctxt "#32453" msgid "Please add content and/or check that 'Include in dashboard' is enabled." msgstr "Per favore aggiungi contenuti e/o controlla che 'Includi nella dashboard' è abilitato." +#: msgctxt "#32454" msgid "No Content available for this filter" msgstr "Nessun contenuto disponibile per questo filtro" +#: msgctxt "#32455" msgid "Please change change or remove the current filter" msgstr "Per favore cambia o rimuovi il filtro corrente" +#: msgctxt "#32456" msgid "Show" msgstr "Serie" +#: msgctxt "#32457" msgid "By Show" msgstr "Per Serie" +#: msgctxt "#32458" msgid "Episodes" msgstr "Episodi" +#: msgctxt "#32459" msgid "Offline Mode" msgstr "Modo Offline" +#: msgctxt "#32460" msgid "Sign In" msgstr "Accedi (Sign In)" +#: msgctxt "#32461" msgid "Albums" msgstr "Albums" +#: msgctxt "#32462" msgid "Artist" msgstr "Artista" +#: msgctxt "#32463" msgid "By Artist" msgstr "Per Artista" + +#: +msgctxt "#32464" +msgid "Player" +msgstr "Player" + +#: +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "Utilizza intervallo salta di Kodi" + +#: +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "Ricerca automaticamente la posizione selezionata dopo un certo ritardo." + +#: +msgctxt "#32467" +msgid "User Interface" +msgstr "Interfaccia Utente" + +#: +msgctxt "#32468" +msgid "Show dynamic background art" +msgstr "Mostra poster dinamici in backgroud" + +#: +msgctxt "#32469" +msgid "Background art blur amount" +msgstr "Trasparenza poster in background" + +#: +msgctxt "#32470" +msgid "Background art opacity" +msgstr "Opacità poster in background" + +#: +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "Usa Plex/Kodi intervalli salta nella timeline" + +#: +msgctxt "#32480" +msgid "Theme music" +msgstr "Tema Musicale" + +#: +msgctxt "#32481" +msgid "Off" +msgstr "Off" + +#: +msgctxt "#32482" +msgid "%(percentage)s %%" +msgstr "%(percentage)s %%" + +#: +msgctxt "#32483" +msgid "Hide Stream Info" +msgstr "Nascondi Steam Info" + +#: +msgctxt "#32484" +msgid "Show Stream Info" +msgstr "Mostra Stream Info" + +#: +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "Torna istantaneamente indietro con l'azione del menu precedente nelle visualizzazioni scorrevoli" + +#: +msgctxt "#32487" +msgid "Seek Delay" +msgstr "Ritardo di ricerca" + +#: +msgctxt "#32488" +msgid "Screensaver" +msgstr "Salvaschermo" + +#: +msgctxt "#32489" +msgid "Quiz Mode" +msgstr "Modalità Quiz" + +#: +msgctxt "#32490" +msgid "Collections" +msgstr "Collezioni" + +#: +msgctxt "#32491" +msgid "Folders" +msgstr "Cartelle" + +#: +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "Impostazioni sottotitoli di Kodi" + +#: +msgctxt "#32495" +msgid "Skip intro" +msgstr "Salta Intro" + +#: +msgctxt "#32496" +msgid "Skip credits" +msgstr "Salta Crediti" + +#: +msgctxt "#32500" +msgid "Always show post-play screen (even for short videos)" +msgstr "Mostra sempre la schermata di post-riproduzione (anche per video brevi)" + +#: +msgctxt "#32501" +msgid "Time-to-wait between videos on post-play" +msgstr "Tempo di attesa tra i video in post-riproduzione" + +#: +msgctxt "#32505" +msgid "Visit media in video playlist instead of playing it" +msgstr "Mostra i media nella playlist video anziché riprodurli" + +#: +msgctxt "#32521" +msgid "Skip Intro Button Timeout" +msgstr "Timeout del pulsante Salta Intro" + +#: +msgctxt "#32522" +msgid "Automatically Skip Intro" +msgstr "Salta automaticamente le Intro" + +#: +msgctxt "#32524" +msgid "Set how long the skip intro button shows for." +msgstr "Imposta per quanto tempo il pulsante Salta Intro viene visualizzato." + +#: +msgctxt "#32525" +msgid "Skip Credits Button Timeout" +msgstr "Timeout del pulsante Salta Crediti" + +#: +msgctxt "#32526" +msgid "Automatically Skip Credits" +msgstr "Salta automaticamente i Crediti" + +#: +msgctxt "#32528" +msgid "Set how long the skip credits button shows for." +msgstr "Imposta per quanto tempo il pulsante Salta Crediti viene visualizzato." + +#: +msgctxt "#32540" +msgid "Show when the current video will end in player" +msgstr "Mostra quando il video attuale finirà nel player" + +#: +msgctxt "#32541" +msgid "Shows time left and at which time the media will end." +msgstr "Mostra il tempo rimasto e a che ora il video finirà." + +#: +msgctxt "#32542" +msgid "Show \"Ends at\" label for the end-time as well" +msgstr "Mostra l'etichetta \"Termina alle\" anche per l'orario di fine" + +#: +msgctxt "#32543" +msgid "Ends at" +msgstr "Termina alle" + +#: +msgctxt "#32601" +msgid "Allow AV1" +msgstr "Abilita AV1" + +#: +msgctxt "#32602" +msgid "Enable this if your hardware can handle AV1. Disable it to force transcoding." +msgstr "Attiva questa opzione se il tuo hardware supporta AV1. Disattivala per forzare la transcodifica." + +#: +msgctxt "#33101" +msgid "By Audience Rating" +msgstr "Secondo Valutazione degli Spettatori" + +#: +msgctxt "#33102" +msgid "Audience Rating" +msgstr "Valutazione degli Spettatori" + +#: +msgctxt "#33103" +msgid "By my Rating" +msgstr "Secondo la valutazione personale" + +#: +msgctxt "#33104" +msgid "My Rating" +msgstr "Valutazione Personale" + +#: +msgctxt "#33105" +msgid "By Content Rating" +msgstr "Secondo Classificazione del Contenuto" + +#: +msgctxt "#33106" +msgid "Content Rating" +msgstr "Classificazione del Contenuto" + +#: +msgctxt "#33107" +msgid "By Critic Rating" +msgstr "Secondo la Valutazione della Critica" + +#: +msgctxt "#33108" +msgid "Critic Rating" +msgstr "Valutazione della Critica" + +#: +msgctxt "#33200" +msgid "Background Color" +msgstr "Colore di Sfondo" + +#: +msgctxt "#33201" +msgid "Specify solid Background Color instead of using media images" +msgstr "Scegli un Colore di Sfondo anziché utilizzare immagini multimediali." + +#: +msgctxt "#33400" +msgid "Use old compatibility profile" +msgstr "Utilizza il vecchio profilo di compatibilità." + +#: +msgctxt "#33401" +msgid "Uses the Chrome client profile instead of the custom one. Might fix rare issues with 3D playback." +msgstr "Utilizza il profilo del client Chrome invece di quello personalizzato.\n" +"Potrebbe risolvere i problemi con la riproduzione in 3D." + +#: +msgctxt "#32031" +msgid "Burn-in Subtitles" +msgstr "Sottotitoli Incisi" + +#: +msgctxt "#32061" +msgid "When transcoding audio, target the audio channels set in Kodi." +msgstr "Durante la transcodifica dell'audio, utilizza i canali audio impostati in Kodi." + +#: +msgctxt "#32062" +msgid "Transcode audio to AC3" +msgstr "Trascodifica audio in AC3" + +#: +msgctxt "#32063" +msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." +msgstr "Trascodifica l'audio in AC3 in determinate condizioni (utile per il passaggio diretto)." + +#: +msgctxt "#32065" +msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" +msgstr "Quando uno qualsiasi dei settaggi di forzatura AC3 è attivato, trattare il DTS allo stesso modo dell'AC3 (utile per il passthrough ottico)" + +#: +msgctxt "#32066" +msgid "Force audio to AC3" +msgstr "Forza l'audio in AC3" + +#: +msgctxt "#32067" +msgid "Only force multichannel audio to AC3" +msgstr "Permetti solo all'audio multicanale di essere forzato in AC3." + +#: +msgctxt "#32493" +msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." +msgstr "Quando un file multimediale ha un sottotitolo forzato/esterno per una lingua abilitata ai sottotitoli, il server multimediale Plex lo preseleziona. Questo comportamento di solito non è necessario e non è configurabile. Questa impostazione risolve il problema ignorando la decisione del server multimediale Plex e selezionando la stessa lingua senza il flag forzato, se possibile." + +#: +msgctxt "#32523" +msgid "Automatically skip intros if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Salta automaticamente le intro se disponibili. Non annulla la modalità maratona abilitata. Può essere disattivato/attivato per ogni serie TV." + +#: +msgctxt "#32527" +msgid "Automatically skip credits if available. Doesn't override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Salta automaticamente i crediti se disponibili. Non annulla la modalità maratona abilitata.\n" +"Può essere disattivato/attivato per ogni serie TV." + +#: +msgctxt "#33501" +msgid "Video played threshold" +msgstr "Soglia di riproduzione dei video" + +#: +msgctxt "#33502" +msgid "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to avoid certain pitfalls, Default: 90 %" +msgstr "Imposta questo valore allo stesso valore del tuo server Plex (Impostazioni>Libreria>Soglia di riproduzione dei video) per evitare certi problemi, Predefinito: 90%" + +#: +msgctxt "#33503" +msgid "Use alternative hubs refresh" +msgstr "Utilizza l'aggiornamento degli hub alternativo" + +#: +msgctxt "#33504" +msgid "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of only those likely affected. Use this if you find a hub that doesn't update properly." +msgstr "Aggiorna tutti gli hub per tutte le librerie dopo che lo stato di visione di un elemento è cambiato, invece di solo quelli interessati. Usa questa opzione se trovi un hub che non si aggiorna correttamente." + +#: +msgctxt "#33505" +msgid "Show intro skip button early" +msgstr "Mostra il pulsante di salto intro in anticipo" + +#: +msgctxt "#33506" +msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\\'t override enabled binge mode.\n" +"Can be disabled/enabled per TV show." +msgstr "Mostra il pulsante di salto intro dall'inizio di un video con un indicatore di intro. L'impostazione di auto-salto si applica. Non annulla la modalità maratona abilitata. \n" +"Può essere disattivato/attivato per ogni serie TV." + +#: +msgctxt "#33507" +msgid "Enabled" +msgstr "Attivato" + +#: +msgctxt "#33508" +msgid "Disabled" +msgstr "Disattivato" + +#: +msgctxt "#33509" +msgid "Early intro skip threshold (default: < 60s/1m)" +msgstr "Soglia di salto anticipato delle Intro (predefinito: < 60s/1m)" + +#: +msgctxt "#33510" +msgid "When showing the intro skip button early, only do so if the intro occurs within the first X seconds." +msgstr "Quando si mostra il pulsante di salto intro anticipato, fallo solo se l'introduzione avviene entro i primi X secondi." + +#: +msgctxt "#33600" +msgid "System" +msgstr "Sistema" + +#: +msgctxt "#33601" +msgid "Show video chapters" +msgstr "Mostra capitoli video" + +#: +msgctxt "#33602" +msgid "If available, show video chapters from the video-file instead of the timeline-big-seek-steps." +msgstr "Se disponibili, mostra i capitoli video dal file video invece degli intervalli della timeline." + +#: +msgctxt "#33603" +msgid "Use virtual chapters" +msgstr "Usa capitoli virtuali" + +#: +msgctxt "#33604" +msgid "When the above is enabled and no video chapters are available, simulate them by using the markers identified by the Plex Server (Intro, Credits)." +msgstr "Quando quanto sopra è abilitato e non ci sono capitoli video disponibili, simulali utilizzando i marker identificati dal server Plex (Introduzione, Crediti)." + +#: +msgctxt "#33605" +msgid "Video Chapters" +msgstr "Capitoli Video" + +#: +msgctxt "#33606" +msgid "Virtual Chapters" +msgstr "Capitoli Virtuali" + +#: +msgctxt "#33607" +msgid "Chapter {}" +msgstr "Capitolo {}" + +#: +msgctxt "#33608" +msgid "Intro" +msgstr "Intro" + +#: +msgctxt "#33609" +msgid "Credits" +msgstr "Crediti" + +#: +msgctxt "#33610" +msgid "Main" +msgstr "Principale" + +#: +msgctxt "#33611" +msgid "Chapters" +msgstr "Capitoli" + +#: +msgctxt "#33612" +msgid "Markers" +msgstr "Indicatori" + +#: +msgctxt "#33613" +msgid "Kodi Buffer Size (MB)" +msgstr "Dimensione del buffer di Kodi (MB)" + +#: +msgctxt "#33614" +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~50 MB, Recommended max: {} MB, Default: 20 MB." +msgstr "Imposta la dimensione della cache/buffer di Kodi. Libero: {} MB, Consigliato: ~50 MB, Massimo consigliato: {} MB, Predefinito: 20 MB." + +#: +msgctxt "#33615" +msgid "{time} left" +msgstr "{time} rimanenti" + +#: +msgctxt "#33616" +msgid "Addon Path" +msgstr "Addon Path" + +#: +msgctxt "#33617" +msgid "Userdata/Profile Path" +msgstr "Userdata/Profile Path" + +#: +msgctxt "#33618" +msgid "TV binge-viewing mode" +msgstr "Modalità maratona TV" + +#: +msgctxt "#33619" +msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\\'t skip the intro of the first episode of a season and doesn't skip the final credits of a show.\n" +"\n" +"Can be disabled/enabled per TV show.\n" +"Overrides any setting below." +msgstr "Modalità maratona TV: Salta automaticamente le intro degli episodi, i crediti e cerca di saltare i riassunti degli episodi. Non salta l'introduzione del primo episodio di una stagione e non salta i crediti finali di uno spettacolo. \n" +"\n" +"\n" +"Può essere disattivata/attivata per singoli spettacoli TV. Sovrascrive qualsiasi impostazione seguente." + +#: +msgctxt "#33620" +msgid "Plex requests timeout (seconds)" +msgstr "Timeout delle richieste Plex (secondi)" + +#: +msgctxt "#33621" +msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5" +msgstr "Imposta il valore di timeout (asincrono e di connessione) della libreria delle richieste Python in secondi. Predefinito: 5" + +#: +msgctxt "#33622" +msgid "LAN reachability timeout (ms)" +msgstr "Timeout di raggiungibilità LAN (ms)" + +#: +msgctxt "#33623" +msgid "When checking for LAN reachability, use this timeout. Default: 10ms" +msgstr "Durante il controllo della raggiungibilità LAN, utilizza questo timeout. Predefinito: 10ms" + +#: +msgctxt "#33624" +msgid "Network" +msgstr "Rete" + +#: +msgctxt "#33625" +msgid "Smart LAN/local server discovery" +msgstr "Smart LAN/Locale server discovery" + +#: +msgctxt "#33626" +msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n" +"\n" +"NOTE: Only works on Kodi 19 or above." +msgstr "Verifica se i server restituiti da Plex.tv sono effettivamente locali/nella LAN. Per configurazioni specifiche (es. Docker), Plex.tv potrebbe non rilevare correttamente un server locale.\n" +"\n" +"NOTA: Funziona solo su Kodi 19 o versioni successive." + +#: +msgctxt "#33627" +msgid "Prefer LAN/local servers over security" +msgstr "Preferire server LAN/locali rispetto alla sicurezza." + +#: +msgctxt "#33628" +msgid "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". Can be used to enforce manual servers." +msgstr "Prioritizza le connessioni locali rispetto a quelle sicure. Richiede l'impostazione corretta di \"Consenti connessioni non sicure\" e le impostazioni del server Plex su \"Connessioni sicure\" impostate su \"Preferite\". Può essere utilizzato per imporre server manuali." + +#: +msgctxt "#33629" +msgid "Auto-skip intro/credits offset" +msgstr "Offset di auto-salto intro/crediti" + +#: +msgctxt "#33630" +msgid "Intro/credits markers might be a little early in Plex. When auto skipping add (or subtract) this many seconds from the marker. This avoids cutting off content, while possibly skipping the marker a little late." +msgstr "I marker dell'intro/crediti potrebbero essere un po' anticipati in Plex. Quando si salta automaticamente, aggiungi (o sottrai) questo numero di secondi dal marker. Questo evita di interrompere il contenuto, anche se potrebbe causare lo skipping del marker un po' in ritardo." + +#: +msgctxt "#32631" +msgid "Playback (user-specific)" +msgstr "Riproduzione (specifica dell'utente)" + +#: +msgctxt "#33632" +msgid "Server connectivity check timeout (seconds)" +msgstr "Timeout del controllo di connettività del server (secondi)" + +#: +msgctxt "#33633" +msgid "Set the maximum amount of time a server connection has to answer a connectivity request. Default: 2.5" +msgstr "Imposta il tempo massimo entro il quale una connessione al server deve rispondere a una richiesta di connettività. Predefinito: 2.5" + +#: +msgctxt "#33634" +msgid "Combined Chapters" +msgstr "Combina Capitoli" + +#: +msgctxt "#33635" +msgid "Final Credits" +msgstr "Crediti Finiali" + +#: +msgctxt "#32700" +msgid "Action on Sleep event" +msgstr "Azione in caso di evento di sospensione" + +#: +msgctxt "#32701" +msgid "When Kodi receives a sleep event from the system, run the following action." +msgstr "Quando Kodi riceve un evento di sospensione dal sistema, esegui l'azione seguente." + +#: +msgctxt "#32702" +msgid "Nothing" +msgstr "Niente" + +#: +msgctxt "#32703" +msgid "Stop playback" +msgstr "Interrompi la riproduzione." + +#: +msgctxt "#32704" +msgid "Quit Kodi" +msgstr "Esci da Kodi" + +#: +msgctxt "#32705" +msgid "CEC Standby" +msgstr "CEC Standby" + +#: +msgctxt "#32800" +msgid "Skipping intro" +msgstr "Salto dell'Intro" + +#: +msgctxt "#32801" +msgid "Skipping credits" +msgstr "Salto dei Crediti" + +#: +msgctxt "#32900" +msgid "While playing back an item and seeking on the seekbar, automatically seek to the selected position after a delay instead of having to confirm the selection." +msgstr "Durante la riproduzione di un elemento e navigando nella timeline, esegui automaticamente la ricerca nella posizione selezionata dopo un ritardo anziché dover confermare la selezione." + +#: +msgctxt "#32901" +msgid "Seek delay in seconds." +msgstr "Ritardo Intervallo Salta" + +#: +msgctxt "#32902" +msgid "Kodi has its own skip step settings. Try to use them if they're configured instead of the default ones." +msgstr "Kodi ha le sue impostazioni di intervalli salta. Prova a utilizzarle se sono configurate invece di quelle predefinite." + +#: +msgctxt "#32903" +msgid "Use the above for seeking on the timeline as well." +msgstr "Utilizza quanto sopra anche per cercare nella timeline." + +#: +msgctxt "#32904" +msgid "In seconds." +msgstr "In secondi." + +#: +msgctxt "#32905" +msgid "Cancel post-play timer by pressing OK/SELECT" +msgstr "Annulla il timer di post-riproduzione premendo OK/SELEZIONA" + +#: +msgctxt "#32906" +msgid "Cancel skip marker timer with BACK" +msgstr "Annulla il timer del marker di salto premendo INDIETRO." + +#: +msgctxt "#32907" +msgid "When auto-skipping a marker, allow cancelling the timer by pressing BACK." +msgstr "Quando si salta automaticamente un marker, consenti di annullare il timer premendo INDIETRO." + +#: +msgctxt "#32908" +msgid "Immediately skip marker with OK/SELECT" +msgstr "Salta immediatamente il marker premendo OK/SELEZIONA" + +#: +msgctxt "#32909" +msgid "When auto-skipping a marker with a timer, allow skipping immediately by pressing OK/SELECT." +msgstr "Quando si salta automaticamente un marker con un timer, consenti di saltare immediatamente premendo OK/SELEZIONA" + +#: +msgctxt "#32912" +msgid "Show buffer-state on timeline" +msgstr "Mostra lo stato del buffer nella timeline" + +#: +msgctxt "#32913" +msgid "Shows the current Kodi buffer/cache state on the video player timeline." +msgstr "Mostra lo stato corrente del buffer/cache di Kodi nella timeline del Player." + +#: +msgctxt "#32914" +msgid "Loading" +msgstr "Caricamento" + +#: +msgctxt "#32915" +msgid "Slow connection" +msgstr "Connessione lenta" + +#: +msgctxt "#32916" +msgid "Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually wait for item refreshes and waits for the buffer to fill when starting playback. Automatically sets readfactor=20, requires Kodi restart." +msgstr "Utilizzare con una connessione instabile/lenta, es. in una stanza d'albergo. Regola l'interfaccia utente per attendere visivamente il refresh degli elementi e attende che il buffer si riempia all'avvio della riproduzione. Imposta automaticamente readfactor=20, richiede il riavvio di Kodi." + +#: +msgctxt "#32917" +msgid "Couldn't fill buffer in time ({}s)" +msgstr "Impossibile riempire il buffer in tempo ({}s)" + +#: +msgctxt "#32918" +msgid "Buffer wait timeout (seconds)" +msgstr "Timeout di attesa del buffer (secondi)" + +#: +msgctxt "#32919" +msgid "When slow connection is enabled in the addon, wait this long for the buffer to fill. Default: 120 s" +msgstr "Quando la connessione lenta è abilitata nell'addon, attendi questo tempo per riempire il buffer. Predefinito: 120 s" + +#: +msgctxt "#32920" +msgid "Insufficient buffer wait (seconds)" +msgstr "Attesa del buffer insufficiente (secondi)" + +#: +msgctxt "#32921" +msgid "When slow connection is enabled in the addon and the configured buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" +msgstr "Quando la connessione lenta è abilitata nell'addon e il buffer configurato non è abbastanza grande per determinare il suo stato di riempimento, attendi questo tempo all'avvio della riproduzione. Predefinito: 10 s" + +#: +msgctxt "#32922" +msgid "Kodi Cache Readfactor" +msgstr "Kodi Cache Readfactor" + +#: +msgctxt "#32923" +msgid "Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}. With \"Slow connection\" enabled this will be set to {2}, as otherwise the cache doesn't fill fast/aggressively enough." +msgstr "Imposta il valore di readfactor della cache di Kodi. Predefinito: {0}, consigliato: {1}. Con 'Connessione lenta' abilitata, questo verrà impostato su {2}, altrimenti la cache non si riempie abbastanza rapidamente/aggressivamente." + +#: +msgctxt "#32924" +msgid "Minimize" +msgstr "Minimizza" + +#: +msgctxt "#32925" +msgid "Playback Settings" +msgstr "Impostazioni di Riproduzione" + +#: +msgctxt "#32926" +msgid "Wrong pin entered!" +msgstr "Pin inserito errato!" + +#: +msgctxt "#32927" +msgid "Use episode thumbnails in continue hub" +msgstr "Utilizza le miniature degli episodi nel riquadro di continua a guardare" + +#: +msgctxt "#32928" +msgid "Instead of using media artwork, use thumbnails for episodes in the continue hub on the home screen if available." +msgstr "Se disponibili, utilizza le miniature degli episodi invece del poster nel riquadro di continua a guardare sulla schermata principale." + +#: +msgctxt "#32929" +msgid "Use legacy background fallback image" +msgstr "Utilizza l'immagine di fallback dello sfondo legacy" + +#: +msgctxt "#32930" +msgid "Previous Subtitle" +msgstr "Sottotitolo Precedente" + +#: +msgctxt "#32931" +msgid "Audio/Subtitles" +msgstr "Audio/Sottotitoli" + +#: +msgctxt "#32932" +msgid "Show subtitle quick-actions button" +msgstr "Mostra il pulsante delle azioni rapide dei sottotitoli" + +#: +msgctxt "#32933" +msgid "Show FFWD/RWD buttons" +msgstr "Mostra i pulsanti FFWD/RWD" + +#: +msgctxt "#32934" +msgid "Show repeat button" +msgstr "Mostra il pulsante Ripeti" + +#: +msgctxt "#32935" +msgid "Show shuffle button" +msgstr "Mostra il pulsante Casuale" + +#: +msgctxt "#32936" +msgid "Show playlist button" +msgstr "Mostra il pulsante Playlist" + +#: +msgctxt "#32937" +msgid "Show prev/next button" +msgstr "Mostra il pulsante prev/next" + +#: +msgctxt "#32938" +msgid "Only for Episodes" +msgstr "Solo per gli episodi" + +#: +msgctxt "#32939" +msgid "Only applies to video player UI" +msgstr "Si applica solo all'interfaccia del Player" + +#: +msgctxt "#32940" +msgid "Player UI" +msgstr "Interfaccia del Player" + +#: +msgctxt "#32941" +msgid "Forced subtitles fix" +msgstr "Fix sottotitoli forzati" + +#: +msgctxt "#32942" +msgid "Other seasons" +msgstr "Altre stagioni" + +#: +msgctxt "#32943" +msgid "Crossfade dynamic background art" +msgstr "Dissolvenza incrociata poster di sfondo" + +#: +msgctxt "#32944" +msgid "Burn-in SSA subtitles (DirectStream)" +msgstr "Sottotitoli SSA incisi (DirectStream)" + +#: +msgctxt "#32945" +msgid "When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus transcoding the video stream). If disabled it will not touch the video stream, but will convert the subtitle to unstyled text." +msgstr "Quando si esegue lo Streaming Diretto, istruisci il Server Plex a imprimere i sottotitoli SSA/ASS (trascodificando quindi lo stream video). Se disabilitato, non modificherà lo stream video, ma convertirà i sottotitoli in testo senza stile." + +#: +msgctxt "#32946" +msgid "Stop video playback on idle after" +msgstr "Interrompi la riproduzione video inattiva dopo" + +#: +msgctxt "#32947" +msgid "Stop video playback on screensaver" +msgstr "Interrompi la riproduzione video durante lo screensaver" + +#: +msgctxt "#32948" +msgid "Allow auto-skip when transcoding" +msgstr "Consenti auto-salto durante la trascodifica" + +#: +msgctxt "#32949" +msgid "When transcoding/DirectStreaming, allow auto-skip functionality." +msgstr "Durante la trascodifica/DirectStreaming, consenti la funzionalità di auto-salto." + +#: +msgctxt "#32950" +msgid "Use extended title for subtitles" +msgstr "Utilizza il titolo esteso per i sottotitoli" + +#: +msgctxt "#32951" +msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." +msgstr "Quando si visualizzano i sottotitoli, utilizza l'extendedDisplayTitle fornito da Plex." + +#: +msgctxt "#32953" +msgid "Reviews" +msgstr "Recensioni" + +#: +msgctxt "#32954" +msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n" +"\n" +"To customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" +msgstr "Necessita riavvio di Kodi. ATTENZIONE: Questo sovrascriverà il file advancedsettings.xml!\n" +"\n" +"Per personalizzare altri valori relativi alla cache/rete, copia \"script.plexmod/pm4k_cache_template.xml\" nella cartella del profilo e modificalo secondo le tue preferenze. (Consulta la sezione Informazioni per i percorsi dei file)" + +#: +msgctxt "#32955" +msgid "Use Kodi keyboard for searching" +msgstr "Utilizza la tastiera di Kodi per la ricerca" + +#: +msgctxt "#32956" +msgid "Poster resolution scaling %" +msgstr "Ridimensionamento della risoluzione dei poster %" + +#: +msgctxt "#32957" +msgid "In percent. Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance, will increase the cache usage accordingly. Recommended: 200-300 % for for big screens if your hardware can handle it. Needs addon restart." +msgstr "In percentuale. Ridimensiona la risoluzione di tutti i poster/miniature per una migliore qualità dell'immagine. Potrebbe influire sulle prestazioni di PMS/PM4K, aumenterà di conseguenza l'utilizzo della cache. Consigliato: 200-300 % per schermi grandi se l'hardware lo supporta. Necessita riavvio dell'addon." + +#: +msgctxt "#32958" +msgid "Calculate OpenSubtitles.com hash" +msgstr "Calcola l'hash di OpenSubtitles.com" + +#: +msgctxt "#32959" +msgid "When opening the subtitle download feature, automatically calculate the OpenSubtitles.com hash for the given file. Can improve search results, downloads 2*64 KB of the video file to calculate the hash." +msgstr "Quando si apre la funzione di download dei sottotitoli, calcola automaticamente l'hash di OpenSubtitles.com per il file fornito. Può migliorare i risultati della ricerca, scarica 2*64 KB del file video per calcolare l'hash." + +#: +msgctxt "#32960" +msgid "Similar Artists" +msgstr "Artisti Simili" + +#: +msgctxt "#32961" +msgid "Show hub bifurcation lines" +msgstr "Mostra linee di biforcazione dell'hub" + +#: +msgctxt "#32962" +msgid "Visually separate hubs horizontally using a thin line." +msgstr "Separa visualmente gli hub orizzontalmente utilizzando una linea sottile." + +#: +msgctxt "#32963" +msgid "Wait between videos (s)" +msgstr "Attesa tra i video" + +#: +msgctxt "#32964" +msgid "When playing back consecutive videos (e.g. TV shows), wait this long before starting the next one in the queue. Might fix compatibility issues with certain configurations." +msgstr "Quando riproduci video consecutivi (es. serie TV), attendi questo tempo prima di avviare il successivo nella coda. Potrebbe risolvere problemi di compatibilità con determinate configurazioni." + +#: +msgctxt "#32965" +msgid "Quit Kodi on exit by default" +msgstr "Esci da Kodi all'uscita per impostazione predefinita" + +#: +msgctxt "#32966" +msgid "When exiting the addon, use \"Quit Kodi\" as default option. Can be dynamically switched using CONTEXT_MENU (often longpress SELECT)" +msgstr "All'uscita dall'addon, utilizza \"Esci da Kodi\" come opzione predefinita. Può essere cambiato dinamicamente utilizzando CONTEXT_MENU (spesso premendo a lungo SELECT)" + +#: +msgctxt "#32967" +msgid "Kodi Colour Management" +msgstr "Gestione del Colore di Kodi" + +#: +msgctxt "#32968" +msgid "Kodi Resolution Settings" +msgstr "Impostazioni di Risoluzione di Kodi" + +#: +msgctxt "#32969" +msgid "Always request all library media items at once" +msgstr "Richiedi sempre tutti gli elementi multimediali della libreria in una volta sola" + +#: +msgctxt "#32970" +msgid "Retrieve all media in library up front instead of fetching it in chunks as the user navigates through the library" +msgstr "Recupera tutti i media nella libreria anticipatamente invece di recuperarli man mano che l'utente naviga attraverso la libreria" + +#: +msgctxt "#32971" +msgid "Library item-request chunk size" +msgstr "Dimensione del blocco di richiesta degli elementi della libreria" + +#: +msgctxt "#32972" +msgid "Request this amount of media items per chunk request in library view (+6-30 depending on view mode; less can be less straining for the UI at first, but puts more strain on the server)" +msgstr "Richiedi questa quantità di elementi multimediali per ciascuna richiesta a blocchi nella visualizzazione della libreria (+6-30 a seconda della modalità di visualizzazione; meno può essere meno carico per l'interfaccia utente all'inizio, ma mette più carico sul server)" + +#: +msgctxt "#32973" +msgid "Episodes: Skip Post Play screen" +msgstr "Episodi: Salta la schermata di post-riproduzione" + +#: +msgctxt "#32974" +msgid "When finishing an episode, don't show Post Play but go to the next one immediately.\n" +"Can be disabled/enabled per TV show. Doesn't override enabled binge mode. Overrides the Post Play setting." +msgstr "Quando si termina un episodio, non mostrare la schermata di post-riproduzione ma passa immediatamente al successivo.\n" +"Può essere disattivato/attivato per ogni serie TV. Non annulla la modalità maratona abilitata. Sovrascrive l'impostazione di Post Play." + +#: +msgctxt "#32975" +msgid "Delete Season" +msgstr "Elimina Stagione" + +#: +msgctxt "#32976" +msgid "Adaptive" +msgstr "Adattivo" + +#: +msgctxt "#32977" +msgid "Allow VC1" +msgstr "Abilita VC1" + +#: +msgctxt "#32978" +msgid "Enable this if your hardware can handle VC1. Disable it to force transcoding." +msgstr "Attiva questa opzione se l'hardware supporta VC1. Disattivala per forzare la trascodifica." + +#: +msgctxt "#32979" +msgid "Allows the server to only transcode streams of a video that need transcoding, while streaming the others unaltered. If disabled, force the server to transcode everything not direct playable." +msgstr "Consente al server di transcodificare solo i flussi video che necessitano della transcodifica, mentre trasmette gli altri inalterati. Se disabilitato, forza il server a trascodificare tutto ciò che non è riproducibile direttamente." + +#: +msgctxt "#32980" +msgid "Refresh Users" +msgstr "Aggiorna Utenti" + +#: +msgctxt "#32981" +msgid "Background worker count" +msgstr "Numero di Background worker" + +#: +msgctxt "#32982" +msgid "Depending on how many cores your CPU has and how much it can handle, increasing this might improve certain situations. If you experience crashes or other annormalities, leave this at its default (3). Needs an addon restart." +msgstr "A seconda di quanti core ha la tua CPU e di quanto può gestire, aumentare questo valore potrebbe migliorare certe situazioni. Se riscontri crash o altre anomalie, lascia questo valore a default(3). Richiede un riavvio dell'addon." + +#: +msgctxt "#32983" +msgid "Player Theme" +msgstr "Tema del Player" + +#: +msgctxt "#32984" +msgid "Sets the player theme. Currently only customizes the playback control buttons. ATTENTION: [I]Might[/I] need an addon restart.\n" +"In order to customize this, copy one of the xml's in script.plexmod/resources/skins/Main/1080i/templates to addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml and adjust it to your liking, then select \"Custom\" as your theme." +msgstr "Imposta il tema del lettore. Attualmente personalizza solo i pulsanti di controllo della riproduzione. ATTENZIONE: [I]Potrebbe[/I] essere necessario riavviare l'addon. \n" +"Per personalizzarlo, copia uno degli xml in script.plexmod/resources/skins/Main/1080i/templates in addon_data/script.plexmod/templates/seek_dialog_buttons_custom.xml e modificalo secondo le tue preferenze, quindi seleziona \"Personalizzato\" come tema." + +#: +msgctxt "#32985" +msgid "Modern" +msgstr "Moderno" + +#: +msgctxt "#32986" +msgid "Modern (dotted)" +msgstr "Moderno (a puntini)" + +#: +msgctxt "#32987" +msgid "Classic" +msgstr "Classico" + +#: +msgctxt "#32988" +msgid "Custom" +msgstr "Custom" + +#: +msgctxt "#32989" +msgid "Modern (colored)" +msgstr "Moderno (colorato)" + +#: +msgctxt "#32990" +msgid "Handle plex.direct mapping" +msgstr "Gestisci la mappatura plex.direct" + +#: +msgctxt "#32991" +msgid "Notify" +msgstr "Notifiche" + +#: +msgctxt "#32992" +msgid "When using servers with a plex.direct connection (most of them), should we automatically adjust advancedsettings.xml to cope with plex.direct domains? If not, you might want to add plex.direct to your router's DNS rebind exemption list." +msgstr "Quando si utilizzano server con una connessione plex.direct (la maggior parte di essi), dovremmo regolare automaticamente il file advancedsettings.xml per gestire i domini plex.direct? Se non lo facciamo, potresti dover aggiungere plex.direct all'elenco di eccezioni del reindirizzamento DNS del router." + +#: +msgctxt "#32993" +msgid "{} unhandled plex.direct connections found" +msgstr "{} connessioni plex.direct non gestite trovate" + +#: +msgctxt "#32994" +msgid "In order for PM4K to work properly, we need to add special handling for plex.direct connections. We've found {} new unhandled connections. Do you want us to write those to Kodi's advancedsettings.xml automatically? If not, you might want to add plex.direct to your router's DNS rebind exemption list. This can be changed in the settings as well." +msgstr "Perché PM4K funzioni correttamente, è necessario aggiungere un trattamento speciale per le connessioni plex.direct. Abbiamo trovato {} nuove connessioni non gestite. Vuoi che le scriviamo automaticamente nel file advancedsettings.xml di Kodi? In alternativa, dovresti aggiungere plex.direct all'elenco di eccezioni del reindirizzamento DNS del router. Questo può essere cambiato anche nelle impostazioni." + +#: +msgctxt "#32995" +msgid "Advancedsettings.xml modified (plex.direct mappings)" +msgstr "Modificato il file advancedsettings.xml (mappature plex.direct)" + +#: +msgctxt "#32996" +msgid "The advancedsettings.xml file has been modified. Please restart Kodi for the changes to apply." +msgstr "Il file advancedsettings.xml è stato modificato. Si prega di riavviare Kodi affinché le modifiche abbiano effetto." + +#: +msgctxt "#32997" +msgid "OK" +msgstr "OK" + +#: +msgctxt "#32998" +msgid "Use new Continue Watching hub on Home" +msgstr "Usa il nuovo hub Continua a Guardare nella Schermata Principale" + +#: +msgctxt "#32999" +msgid "Instead of separating Continue Watching and On Deck hubs, behave like the modern Plex clients, which combine those two types of hubs into one Continue Watching hub." +msgstr "Piuttosto che separare i riquadri 'Continua a guardare' e 'In evidenza', comportati come i client Plex moderni, che combinano questi due tipi di riquadri in un unico riquadro 'Continua a guardare'." + +#: +msgctxt "#33000" +msgid "Enable path mapping" +msgstr "Abilita path mapping" + +#: +msgctxt "#33001" +msgid "Honor path_mapping.json in the addon_data/script.plexmod folder when DirectPlaying media. This can be used to stream using other techniques such as SMB/NFS/etc. instead of the default HTTP handler. path_mapping.example.json is included in the addon's main directory." +msgstr "Rispetta path_mapping.json nella cartella addon_data/script.plexmod quando riproduci direttamente i media. Questo può essere utilizzato per lo streaming utilizzando altre tecniche come SMB/NFS/ecc. invece dell'handler HTTP predefinito. È incluso un'esempio di path_mapping.json nella cartella principale dell'addon come path_mapping.example.json." + +#: +msgctxt "#33002" +msgid "Verify mapped files exist" +msgstr "Verifica che i file mappati esistano" + +#: +msgctxt "#33003" +msgid "When path mapping is enabled and we've successfully mapped a file, verify its existence." +msgstr "Quando il path mapping è abilitato e è stato mappato con successo un file, verifica la sua esistenza." + +#: +msgctxt "#33004" +msgid "No spoilers without OSD" +msgstr "Nessun spoiler senza OSD" + +#: +msgctxt "#33005" +msgid "When seeking without the OSD open, hide all time-related information from the user." +msgstr "Quando si cerca senza OSD aperto, nascondi tutte le informazioni relative al tempo dall'utente." + +#: +msgctxt "#33006" +msgid "No TV spoilers" +msgstr "No TV spoilers" + +#: +msgctxt "#33007" +msgid "When visiting an episode/season view, blur unwatched/unwatched+in-progress episode thumbnails, previews and redact summaries. When the Addon Setting \"Use episode thumbnails in continue hub\" is enabled, blur them as well." +msgstr "Quando si accede alla vista episodio/stagione, sfoca le miniature degli episodi non visti/o + in corso, anteprime e redige i riepiloghi. Quando l'impostazione dell'Addon \"Usa le miniature degli episodi nel riquadro continua a guardare\" è abilitata, sfoca anche loro." + +#: +msgctxt "#33008" +msgid "[Spoilers removed]" +msgstr "[Spoileri rimossi]" + +#: +msgctxt "#33009" +msgid "Blur amount for unwatched/in-progress episodes" +msgstr "Livello di sfocatura per gli episodi non visti/in corso" + +#: +msgctxt "#33010" +msgid "Unwatched" +msgstr "Non visti" + +#: +msgctxt "#33011" +msgid "Unwatched/in progress" +msgstr "Non visti/in corso" + +#: +msgctxt "#33012" +msgid "No unwatched episode titles" +msgstr "Nessun titolo di episodi non visti" + +#: +msgctxt "#33013" +msgid "When the above is anything but \"off\", hide episode titles as well." +msgstr "Quando quanto sopra non è impostato su \"Off\", nascondi anche i titoli degli episodi." + +#: +msgctxt "#33014" +msgid "Ignore plex.direct docker hosts" +msgstr "Ignora gli host docker di plex.direct" + +#: +msgctxt "#33015" +msgid "When checking for plex.direct host mapping, ignore local Docker IPv4 addresses (172.16.0.0/12)." +msgstr "Nel controllo della mappatura dell'host plex.direct, ignorare gli indirizzi IPv4 locali di Docker (172.16.0.0/12)." + +#: +msgctxt "#33016" +msgid "Allow TV spoilers for specific genres" +msgstr "Consenti spoiler TV per generi specifici" + +#: +msgctxt "#33017" +msgid "Overrides the above for: {}" +msgstr "Sovrascrive quanto sopra per: {}" + +#: +msgctxt "#32303" +msgid "Season {}" +msgstr "Stagione {}" + +#: +msgctxt "#32304" +msgid "Episode {}" +msgstr "Episodio {}" + +#: +msgctxt "#32310" +msgid "S{}" +msgstr "S{}" + +#: +msgctxt "#32311" +msgid "E{}" +msgstr "E{}" + diff --git a/script.plexmod/resources/language/resource.language.zh_cn/strings.po b/script.plexmod/resources/language/resource.language.zh_cn/strings.po index 46b8c93dac..bb8975407c 100644 --- a/script.plexmod/resources/language/resource.language.zh_cn/strings.po +++ b/script.plexmod/resources/language/resource.language.zh_cn/strings.po @@ -306,11 +306,11 @@ msgid "Go to {0}" msgstr "跳转到 {0}" msgctxt "#32303" -msgid "Season" +msgid "Season {}" msgstr "季数" msgctxt "#32304" -msgid "Episode" +msgid "Episode {}" msgstr "集数" msgctxt "#32305" @@ -334,11 +334,11 @@ msgid "None" msgstr "无" msgctxt "#32310" -msgid "S" +msgid "S{}" msgstr "" msgctxt "#32311" -msgid "E" +msgid "E{}" msgstr "" msgctxt "#32312" diff --git a/script.plexmod/resources/settings.xml b/script.plexmod/resources/settings.xml index b994c848fe..2e4741a407 100644 --- a/script.plexmod/resources/settings.xml +++ b/script.plexmod/resources/settings.xml @@ -13,11 +13,6 @@ false - - 0 - false - - 0 true @@ -59,6 +54,11 @@ + + 0 + true + + @@ -205,6 +205,19 @@ false + + 0 + 600 + + 1 + 10 + 1800 + + + 33510 + false + + 0 true @@ -294,9 +307,21 @@ true + + 0 + 16 + + 0 + 1 + 256 + + + false + + 0 - true + false @@ -324,9 +349,19 @@ + + 0 + true + + + + 0 + true + + 0 - 5 + 10 0.1 0.1 @@ -360,6 +395,18 @@ false + + 0 + 3 + + 2 + 1 + 32 + + + false + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml index f32092dc3c..a72c4da391 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -597,9 +597,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml index 8d1dac581d..df25674fc8 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -737,9 +737,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml index 7d28bf2fbc..8dcf4816c1 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml @@ -13,7 +13,7 @@ 710 459 500 - 162 + 232.5 script.plex/splash.png @@ -23,7 +23,7 @@ 812 135 300 - 97 + 139 script.plex/user_select/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml index 8ace797b8b..db0df65e7b 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml @@ -86,6 +86,8 @@ center center FFFFFFFF + true + 60 @@ -99,6 +101,8 @@ left center FFFFFFFF + true + 60 @@ -162,6 +166,8 @@ center center FF000000 + true + 60 @@ -175,6 +181,8 @@ left center FF000000 + true + 60 diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml index 3c623d851a..1ca0293717 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml @@ -108,6 +108,8 @@ center center FFFFFFFF + true + 60 @@ -120,6 +122,8 @@ left center FFFFFFFF + true + 60 @@ -133,6 +137,8 @@ left center FFFFFFFF + true + 60 @@ -196,6 +202,8 @@ center center FF000000 + true + 60 @@ -208,6 +216,8 @@ left center FF000000 + true + 60 @@ -221,6 +231,8 @@ left center FF000000 + true + 60 diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml index 38fe38352e..fec9bfe66a 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml @@ -10,12 +10,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -23,7 +23,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -31,7 +31,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -291,12 +291,33 @@ scale - !String.IsEmpty(Container(400).ListItem.Property(unwatched)) + String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(Container(400).ListItem.Property(unwatched)) + String.IsEmpty(Container(400).ListItem.Property(watched)) 682 0 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(Container(400).ListItem.Property(watched)) + 667 + 0 + + 0 + 0 + 50 + 40 + String.IsEmpty(Window.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 17 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + @@ -310,7 +331,7 @@ horizontal true - auto + auto 60 font13 left @@ -660,12 +681,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 264 0 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 259 + 0 + + 0 + 0 + 40 + 32 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + false @@ -786,12 +828,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 264 0 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 259 + 0 + + 0 + 0 + 40 + 32 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + Control.HasFocus(400) @@ -945,26 +1008,26 @@ !String.IsEmpty(ListItem.Property(unwatched.count)) - 113 + 126 0 - 45 - 40 + 32 + 32 script.plex/white-square.png FF000000 - 114 + 127 0 - 44 - 39 + 31 + 31 script.plex/white-square.png FFCC7B19 - 114 + 128 0 - 44 - 39 + 31 + 31 font10 center center @@ -972,6 +1035,27 @@ + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 128 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(progress)) 0 @@ -1045,28 +1129,28 @@ scale - !String.IsEmpty(ListItem.Property(unwatched.count)) + !String.IsEmpty(ListItem.Property(unwatched.count)) - 113 + 126 0 - 45 - 40 + 32 + 32 script.plex/white-square.png FF000000 - 114 + 127 0 - 44 - 39 + 31 + 31 script.plex/white-square.png FFCC7B19 - 114 + 128 0 - 44 - 39 + 31 + 31 font10 center center @@ -1074,6 +1158,27 @@ + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 128 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(progress)) 0 @@ -1541,13 +1646,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1685,12 +1811,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1923,9 +2070,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml index 2817f0deb4..34bb982c8e 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml @@ -10,12 +10,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -23,7 +23,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -31,7 +31,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -177,12 +177,11 @@ 200 Conditional - Conditional + Conditional--> -300 0 2430 240 - 101 203 204 400 @@ -232,6 +231,15 @@ script.plex/home/type/home.png keep + + !String.IsEmpty(ListItem.Property(is.mapped)) + 230 + 0 + 8 + 8 + script.plex/white-square-rounded-4r.png + FF666666 + @@ -271,6 +279,7 @@ script.plex/drop-shadow.png + String.IsEmpty(ListItem.Property(moving)) UnFocus 0 0 @@ -280,6 +289,7 @@ FF1F1F1F + String.IsEmpty(ListItem.Property(moving)) UnFocus 0 0 @@ -288,6 +298,26 @@ script.plex/white-square-rounded.png FFE5A00D + + !String.IsEmpty(ListItem.Property(moving)) + UnFocus + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + 66777777 + + + !String.IsEmpty(ListItem.Property(moving)) + UnFocus + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + 66777777 + !Control.HasFocus(101) @@ -306,6 +336,15 @@ $INFO[ListItem.Thumb] keep + + !String.IsEmpty(ListItem.Property(is.mapped)) + 230 + 0 + 8 + 8 + script.plex/white-square-rounded-4r.png + AAFFFFFFF + !String.IsEmpty(ListItem.Property(is.home)) @@ -379,6 +418,7 @@ 101 401 noop + noop 200 horizontal 4 @@ -453,20 +493,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -605,20 +664,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -722,6 +800,7 @@ 400 402 noop + noop 200 horizontal 4 @@ -819,12 +898,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -967,13 +1067,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1057,6 +1178,7 @@ 401 403 noop + noop 200 horizontal 4 @@ -1154,12 +1276,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1302,13 +1445,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1392,6 +1556,7 @@ 402 404 noop + noop 200 horizontal 4 @@ -1489,12 +1654,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1637,13 +1823,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1727,6 +1934,7 @@ 403 405 noop + noop 200 horizontal 4 @@ -1824,12 +2032,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1972,13 +2201,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -2062,6 +2312,7 @@ 404 406 noop + noop 200 horizontal 4 @@ -2320,6 +2571,7 @@ 405 407 noop + noop 200 horizontal 4 @@ -2394,20 +2646,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -2546,20 +2817,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -2663,6 +2953,7 @@ 406 408 noop + noop 200 horizontal 4 @@ -2760,12 +3051,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -2908,13 +3220,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -2998,6 +3331,7 @@ 407 409 noop + noop 200 horizontal 4 @@ -3095,12 +3429,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -3243,13 +3598,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -3333,6 +3709,7 @@ 408 410 noop + noop 200 horizontal 4 @@ -3592,6 +3969,7 @@ 409 411 noop + noop 200 horizontal 4 @@ -3851,6 +4229,7 @@ 410 412 noop + noop 200 horizontal 4 @@ -4110,6 +4489,7 @@ 411 420 noop + noop 200 horizontal 4 @@ -4369,6 +4749,7 @@ 412 421 noop + noop 200 horizontal 4 @@ -4628,6 +5009,7 @@ 420 422 noop + noop 200 horizontal 4 @@ -4887,6 +5269,7 @@ 421 413 noop + noop 200 horizontal 4 @@ -5146,6 +5529,7 @@ 422 414 noop + noop 200 horizontal 4 @@ -5243,12 +5627,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -5391,13 +5796,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -5481,6 +5907,7 @@ 413 415 noop + noop 200 horizontal 4 @@ -5578,12 +6005,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -5726,13 +6174,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -5816,6 +6285,7 @@ 414 416 noop + noop 200 horizontal 4 @@ -5913,12 +6383,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -6061,13 +6552,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -6151,6 +6663,7 @@ 415 417 noop + noop 200 horizontal 4 @@ -6248,12 +6761,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -6396,13 +6930,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -6485,6 +7040,7 @@ 416 418 noop + noop 200 horizontal 4 @@ -6559,20 +7115,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -6711,20 +7286,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -6827,6 +7421,7 @@ 417 419 noop + noop 200 horizontal 4 @@ -6901,20 +7496,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7053,20 +7667,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7169,6 +7802,7 @@ 418 423 noop + noop 200 horizontal 4 @@ -7243,20 +7877,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7395,20 +8048,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7511,6 +8183,7 @@ 419 423 noop + noop 200 horizontal 4 @@ -7585,20 +8258,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7737,20 +8429,39 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 484 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png - !String.IsEmpty(ListItem.Property(unwatched.count)) - 481 + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 492 0 0 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 481 + 0 51 39 script.plex/white-square.png @@ -7989,9 +8700,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml index b4d1deef78..0b690fea3d 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -278,9 +278,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml index 26fe1b8a11..54dc87fb72 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -222,12 +222,24 @@ 24 - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 880 -3 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(.ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 895 + 0 + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -262,10 +274,20 @@ 72 font10 left - center FFFFFFFF + + !String.IsEmpty(ListItem.Property(year)) + 0 + 30 + 915 + 72 + font10 + left + FFFFFFFF + + @@ -289,12 +311,24 @@ 24 - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 880 -2 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 895 + 0 + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -329,10 +363,20 @@ 72 font10 left - center FFFFFFFF + + !String.IsEmpty(ListItem.Property(year)) + 0 + 30 + 915 + 72 + font10 + left + FFFFFFFF + + @@ -368,12 +412,24 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 957 0 48 48 - script.plex/indicators/unwatched-rounded.png + special://profile/addon_data/script.plexmod/media/unwatched-rounded.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 951 + 8 + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -401,7 +457,7 @@ 60 - 0 + 4 0 0 @@ -409,10 +465,20 @@ 72 font12 left - center DF000000 + + !String.IsEmpty(ListItem.Property(year)) + 0 + 30 + 510 + 72 + font12 + left + DF000000 + + @@ -811,9 +877,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml index b5349dbb0b..a5a063d820 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -253,7 +253,7 @@ -3 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -333,7 +333,7 @@ -2 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -429,7 +429,7 @@ 0 48 48 - script.plex/indicators/unwatched-rounded.png + special://profile/addon_data/script.plexmod/media/unwatched-rounded.png !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -877,9 +877,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml index 3c0ab818fc..1a6027418e 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml @@ -67,6 +67,8 @@ font10 left FFFFFFFF + 200 + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml index 5c642c0c34..392a7251d1 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -247,7 +247,7 @@ -1 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png 226 @@ -402,7 +402,7 @@ -1 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png 226 @@ -585,7 +585,7 @@ 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png 313 @@ -852,9 +852,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml index f53b7f4779..3532e385d3 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -536,9 +536,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml index e8f7380773..93c592633e 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -142,7 +142,7 @@ 2 152 - + 55 137 @@ -187,12 +187,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 115 0 29 29 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 114 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -226,7 +247,19 @@ - String.IsEmpty(ListItem.Property(subtitle)) + String.IsEmpty(ListItem.Property(subtitle)) + !String.IsEmpty(ListItem.Property(year)) + false + 0 + 218 + 144 + 72 + font10 + center + FFFFFFFF + + + + String.IsEmpty(ListItem.Property(subtitle)) + String.IsEmpty(ListItem.Property(year)) false 0 218 @@ -254,7 +287,7 @@ - + 55 137 @@ -312,12 +345,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 115 0 29 29 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 114 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -351,7 +405,19 @@ - String.IsEmpty(ListItem.Property(subtitle)) + String.IsEmpty(ListItem.Property(subtitle)) + !String.IsEmpty(ListItem.Property(year)) + true + 0 + 218 + 144 + 72 + font10 + center + FFFFFFFF + + + + String.IsEmpty(ListItem.Property(subtitle)) + String.IsEmpty(ListItem.Property(year)) true 0 218 @@ -394,7 +460,7 @@ String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) 151 - 1780 + 1810 150 20 920 @@ -790,9 +856,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml index afc001480b..244bde2a9d 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -187,12 +187,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 199 0 45 45 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 198 + 0 + + 0 + 0 + 46 + 46 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 15 + 15 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -236,6 +257,18 @@ FFFFFFFF + + !String.IsEmpty(ListItem.Property(year)) + false + 0 + 396 + 244 + 72 + font10 + center + FFFFFFFF + + @@ -299,12 +332,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 199 0 45 45 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(initialized)) + String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(unwatched.count)) + String.IsEmpty(ListItem.Property(progress)) + 198 + 0 + + 0 + 0 + 46 + 46 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 15 + 15 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -348,6 +402,18 @@ FFFFFFFF + + !String.IsEmpty(ListItem.Property(year)) + false + 0 + 396 + 244 + 72 + font10 + center + FFFFFFFF + + Control.HasFocus(101) @@ -764,9 +830,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml index 6cfca6581c..d9c4739589 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml @@ -10,12 +10,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -23,7 +23,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -31,7 +31,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -198,12 +198,33 @@ scale - !String.IsEmpty(Window.Property(unwatched)) + String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(Window.Property(unwatched)) + String.IsEmpty(Window.Property(watched)) 359 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(Window.Property(use_alt_watched)) + !String.IsEmpty(Window.Property(watched)) + 367 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(Window.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + @@ -1032,13 +1053,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1176,12 +1218,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1447,9 +1510,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml index 114dafa312..3cb4abcfd4 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml @@ -10,12 +10,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -23,7 +23,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -31,7 +31,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -411,26 +411,26 @@ !String.IsEmpty(ListItem.Property(unwatched.count)) - 113 + 126 0 - 45 - 40 + 32 + 32 script.plex/white-square.png FF000000 - 114 + 127 0 - 44 - 39 + 31 + 31 script.plex/white-square.png FFCC7B19 - 114 + 128 0 - 44 - 39 + 31 + 31 font10 center center @@ -438,6 +438,27 @@ + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 128 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(progress)) 0 @@ -511,28 +532,28 @@ scale - !String.IsEmpty(ListItem.Property(unwatched.count)) + !String.IsEmpty(ListItem.Property(unwatched.count)) - 113 + 126 0 - 45 - 40 + 32 + 32 script.plex/white-square.png FF000000 - 114 + 127 0 - 44 - 39 + 31 + 31 script.plex/white-square.png FFCC7B19 - 114 + 128 0 - 44 - 39 + 31 + 31 font10 center center @@ -540,6 +561,27 @@ + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 128 + 0 + + 0 + 0 + 30 + 30 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 9 + 9 + 12 + 12 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(progress)) 0 @@ -982,13 +1024,34 @@ - !String.IsEmpty(ListItem.Property(unwatched)) - 196 - 0 - 48 - 48 - script.plex/indicators/unwatched.png - + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 196 + 0 + 48 + 48 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1126,12 +1189,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1398,9 +1482,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog_skeleton.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog_skeleton.xml new file mode 100644 index 0000000000..259c25f5b9 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog_skeleton.xml @@ -0,0 +1,786 @@ + + + + 1 + 0 + 0 + + 100 + 800 + + + [!String.IsEmpty(Window.Property(show.OSD)) | Window.IsVisible(seekbar) | !String.IsEmpty(Window.Property(button.seek))] + !Window.IsVisible(osdvideosettings) + !Window.IsVisible(osdaudiosettings) + !Window.IsVisible(osdsubtitlesettings) + !Window.IsVisible(subtitlesearch) + !Window.IsActive(playerprocessinfo) + !Window.IsActive(selectdialog) + !Window.IsVisible(osdcmssettings) + Hidden + + String.IsEmpty(Window.Property(settings.visible)) + [Window.IsVisible(seekbar) | Window.IsVisible(videoosd) | Player.ShowInfo] + Hidden + 0 + 0 + + 0 + 0 + 1920 + 1080 + script.plex/player-fade.png + FF080808 + + + + + 0 + 0 + + 0 + 0 + 1920 + 140 + script.plex/white-square.png + A0000000 + + + String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD)) + 0 + 940 + 1920 + 140 + script.plex/white-square.png + A0000000 + + + + + 0 + 40 + + !String.IsEmpty(Window.Property(is.show)) + String.IsEmpty(Window.Property(hide.title)) + 60 + 0 + 1720 + 60 + font13 + left + center + FFFFFFFF + true + 15 + + + + !String.IsEmpty(Window.Property(is.show)) + !String.IsEmpty(Window.Property(hide.title)) + 60 + 0 + 1720 + 60 + font13 + left + center + FFFFFFFF + true + 15 + + + + String.IsEmpty(Window.Property(is.show)) + 60 + 0 + 1720 + 60 + font13 + left + center + FFFFFFFF + true + 15 + + + + 1860 + 0 + 300 + 60 + font12 + right + center + FFFFFFFF + + + + + + 0 + 965 + + !String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 60 + 0 + 1000 + 60 + font13 + left + center + FFFFFFFF + + + + String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 60 + 0 + 1000 + 60 + font13 + left + center + FFFFFFFF + + + + Player.IsTempo + 60 + 40 + 1000 + 60 + font13 + left + center + A0FFFFFF + + + + !String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 1860 + 0 + 800 + 60 + font13 + right + center + FFFFFFFF + + + + String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 1860 + 0 + 800 + 60 + font13 + right + center + FFFFFFFF + + + + !String.IsEmpty(Window.Property(media.show_ends)) + !String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 1860 + 40 + 800 + 60 + font13 + right + center + A0FFFFFF + + + + !String.IsEmpty(Window.Property(media.show_ends)) + String.IsEmpty(Window.Property(direct.play)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 1860 + 40 + 800 + 60 + font13 + right + center + A0FFFFFF + + + + + + + 0 + 940 + + String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD)) + 0 + 0 + 1920 + 10 + script.plex/white-square.png + A0000000 + + + !String.IsEmpty(Window.Property(show.buffer)) + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 0 + 2 + 1 + 6 + script.plex/white-square.png + EE4E4842 + + + String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD)) + 0 + 2 + 1 + 6 + script.plex/white-square.png + FFAC5B00 + + + [Control.HasFocus(100) | !String.IsEmpty(Window.Property(button.seek))] + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 0 + 2 + 1 + 6 + script.plex/white-square.png + FFE5A00D + + + + + String.IsEmpty(Window.Property(show.OSD)) + 0 + 0 + 1920 + 1080 + - + - + + SetProperty(show.OSD,1) + + + + + 0 + 350 + !String.IsEmpty(Window.Property(show.PPI)) + String.IsEmpty(Window.Property(settings.visible)) + String.IsEmpty(Window.Property(playlist.visible)) + Visible + Hidden + + 10 + -220 + 10 + 420 + buttons/dialogbutton-nofo.png + + + 52 + -184 + 1786 + 350 + horizontal + 10 + + 0 + 0 + 793 + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + + font14 + black + + + 793 + 50 + bottom + + font14 + black + + + + 0 + 0 + 993 + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Status)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Mode)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Container)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Video)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + [!String.IsEmpty(Window.Property(ppi.Audio)) | !String.IsEmpty(Window.Property(ppi.Subtitles))] + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.User)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + String.IsEmpty(Window.Property(ppi.Buffered)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Buffered)) + + + + + 52 + 120 + 1786 + 50 + bottom + + font14 + black + + + + !String.IsEmpty(Window.Property(show.OSD)) + !Window.IsVisible(osdvideosettings) + !Window.IsVisible(osdaudiosettings) + !Window.IsVisible(osdsubtitlesettings) + !Window.IsVisible(subtitlesearch) + !Window.IsActive(playerprocessinfo) + !Window.IsActive(selectdialog) + !Window.IsVisible(osdcmssettings) + Hidden + + !String.IsEmpty(Window.Property(has.bif)) + [Control.HasFocus(100) | Control.HasFocus(501) | !String.IsEmpty(Window.Property(button.seek))] + Visible + 0 + 752 + + 0 + 0 + 324 + 184 + script.plex/white-square.png + FF000000 + + + 2 + 2 + 320 + 180 + 10 + $INFO[Window.Property(bif.image)] + + + + + + + 0 + 940 + + + 0 + 0 + 1920 + 10 + 501 + 400 + - + - + + + + + Conditional + + + Conditional + + + String.IsEmpty(Window.Property(mouse.mode)) + String.IsEmpty(Window.Property(hide.bigseek)) + [Control.HasFocus(501) | Control.HasFocus(100)] + [!String.IsEmpty(Window.Property(show.chapters)) | String.IsEmpty(Window.Property(has.chapters))] + -8 + 917 + + -200 + 5 + 2320 + 6 + script.plex/white-square.png + A0000000 + String.IsEmpty(Window.Property(has.chapters)) + + + + 0 + -175 + 1928 + 200 + script.plex/white-square.png + A0000000 + !String.IsEmpty(Window.Property(has.chapters)) + + + 40 + -162 + auto + 20 + font10 + left + center + CC606060 + + !String.IsEmpty(Window.Property(has.chapters)) + !Control.HasFocus(501) + + + 40 + -162 + auto + 20 + font10 + left + center + FFFFFFFF + + !String.IsEmpty(Window.Property(has.chapters)) + Control.HasFocus(501) + + + + + 0 + 0 + 1928 + 16 + 100 + SetProperty(hide.bigseek,) + 200 + horizontal + 4 + + + + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FF606060 + + + + + + + !Control.HasFocus(501) + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FF606060 + + + Control.HasFocus(501) + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FFE5A00D + + + + + + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + CC606060 + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + DDAAAAAA + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + FFAAAAAA + Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + FFAAAAAA + Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + CC606060 + + !Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + FFAAAAAA + + Control.HasFocus(501) + + + + + + + + Focus + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + CC909090 + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + FF666666 + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + + Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + + Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + FFDDDDDD + + !Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + + + Control.HasFocus(501) + + + + + + + + [Control.HasFocus(100) | Control.HasFocus(501) | !String.IsEmpty(Window.Property(button.seek))] + [String.IsEmpty(Window.Property(no.osd.hide_info)) | !String.IsEmpty(Window.Property(show.OSD))] + 0 + 896 + + -50 + 0 + + Visible + 0 + 0 + 101 + 39 + script.plex/indicators/player-selection-time_box.png + D0000000 + + + 0 + 0 + 101 + 40 + font10 + center + center + FFFFFFFF + + + + + Visible + -6 + 39 + 15 + 7 + script.plex/indicators/player-selection-time_arrow.png + D0000000 + + + + + + 30 + 797 + 1670 + 143 + right + horizontal + + [!String.IsEmpty(Window.Property(show.markerSkip)) + String.IsEmpty(Window.Property(show.markerSkip_OSDOnly))] | [!String.IsEmpty(Window.Property(show.markerSkip_OSDOnly)) + !String.IsEmpty(Window.Property(show.OSD))] + Focus + UnFocus + + + + auto + 143 + center + 0 + 0 + script.plex/buttons/blank-focus.png + script.plex/buttons/blank.png + 70 + FF000000 + FF000000 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml index 35321b29b7..ffe5f04456 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml @@ -200,6 +200,7 @@ left center FFFFFFFF + true @@ -292,6 +293,7 @@ left center FF000000 + true @@ -303,6 +305,7 @@ right center FF000000 + true @@ -320,6 +323,7 @@ left center FFFFFFFF + true @@ -331,6 +335,7 @@ right center FFFFFFFF + true @@ -673,9 +678,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml index c6eab7662a..e9ea15ad84 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml @@ -11,12 +11,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -24,7 +24,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -32,7 +32,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -682,9 +682,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml index 0ab23658bd..96b22fdfb4 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml @@ -911,9 +911,9 @@ 153r - 54 + 47.5 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml index 796140288d..e03c56823e 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml @@ -110,12 +110,24 @@ scale - String.IsEmpty(ListItem.Property(watched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 895 -1 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 895 + 0 + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + 226 @@ -243,12 +255,24 @@ scale - String.IsEmpty(ListItem.Property(watched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 895 -1 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 895 + 0 + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + 226 @@ -405,12 +429,24 @@ scale - String.IsEmpty(ListItem.Property(watched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) + 951 + -1 + 35 + 35 + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) 951 0 - 48 - 48 - script.plex/indicators/unwatched.png + + 0 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + 313 diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml index b9abc3a73c..3dc2de7e44 100644 --- a/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml @@ -10,12 +10,12 @@ String.IsEmpty(Window.Property(use_solid_background)) - String.IsEmpty(Window.Property(use_solid_background)) + String.IsEmpty(Window.Property(use_bg_fallback)) + String.IsEmpty(Window.Property(background_static)) + String.IsEmpty(Window.Property(use_bg_fallback)) 0 0 1920 1080 - script.plex/home/background-fallback_black.png + script.plex/home/background-fallback_black.png !String.IsEmpty(Window.Property(use_bg_fallback)) @@ -23,7 +23,7 @@ 0 1920 1080 - script.plex/home/background-fallback.png + script.plex/home/background-fallback.png String.IsEmpty(Window.Property(use_bg_fallback)) @@ -31,7 +31,7 @@ 0 1920 1080 - $INFO[Window.Property(background_static)] + $INFO[Window.Property(background_static)] String.IsEmpty(Window.Property(use_bg_fallback)) @@ -470,12 +470,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 264 0 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 259 + 0 + + 0 + 0 + 40 + 32 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + false @@ -596,12 +617,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 264 0 35 35 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 267 + 0 + + 0 + 0 + 32 + 32 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 8 + 8 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + false @@ -790,12 +832,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(ListItem.Property(watched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -934,12 +997,33 @@ - !String.IsEmpty(ListItem.Property(unwatched)) + String.IsEmpty(Window.Property(use_alt_watched)) +!String.IsEmpty(ListItem.Property(unwatched)) 196 0 48 48 - script.plex/indicators/unwatched.png + special://profile/addon_data/script.plexmod/media/unwatched.png + + + !String.IsEmpty(ListItem.Property(use_alt_watched)) + !String.IsEmpty(ListItem.Property(watched)) + 204 + 0 + + 0 + 0 + 40 + 40 + String.IsEmpty(ListItem.Property(hide_aw_bg)) + script.plex/white-square-bl-rounded_w.png + CC000000 + + + 12 + 12 + 16 + 16 + special://profile/addon_data/script.plexmod/media/watched.png + !String.IsEmpty(ListItem.Property(unwatched.count)) @@ -1314,7 +1398,7 @@ 153r 54 93 - 30 + 43 script.plex/home/plex.png diff --git a/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_classic.xml b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_classic.xml new file mode 100644 index 0000000000..bb52dbb724 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_classic.xml @@ -0,0 +1,334 @@ + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + !String.IsEmpty(Window.Property(nav.repeat)) + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 412 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one-focus.png + + + + + + !String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + false + String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/settings-focus.png + script.plex/buttons/settings.png + + + + + + !String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/skip-forward-focus.png + script.plex/buttons/skip-forward.png + + + + + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 407 + 405 + font12 + - + - + + PlayerControl(Play) + + + !Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/play.png + + + + Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/pause-focus.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/play-focus.png + + + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/skip-forward-focus.png + script.plex/buttons/skip-forward.png + + + + !String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 0 + 0 + 125 + 101 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + + + [!String.IsEmpty(Window.Property(pq.hasnext)) | !String.IsEmpty(Window.Property(pq.hasprev))] + !String.IsEmpty(Window.Property(nav.playlist)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.playlist)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + + + !String.IsEmpty(Window.Property(nav.quick_subtitles)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/subtitle-focus.png + script.plex/buttons/subtitle.png + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-colored.xml b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-colored.xml new file mode 100644 index 0000000000..d6371b6d1f --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-colored.xml @@ -0,0 +1,310 @@ + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + !String.IsEmpty(Window.Property(nav.repeat)) + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 412 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat-one.png + + + + + + !String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/shuffle.png + script.plex/buttons/player/modern/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/player/modern/shuffle.png + script.plex/buttons/player/modern/shuffle.png + + + + false + String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle.png + script.plex/buttons/shuffle.png + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/settings.png + script.plex/buttons/player/modern/settings.png + + + + + + !String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/skip-forward.png + script.plex/buttons/player/modern/skip-forward.png + + + + + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 407 + 405 + font12 + - + - + + PlayerControl(Play) + + + !Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/play.png + + + + Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/play.png + + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/stop.png + script.plex/buttons/player/modern/stop.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/skip-forward.png + script.plex/buttons/player/modern/skip-forward.png + + + + !String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + + + [!String.IsEmpty(Window.Property(pq.hasnext)) | !String.IsEmpty(Window.Property(pq.hasprev))] + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/pqueue.png + script.plex/buttons/player/modern/pqueue.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/pqueue.png + script.plex/buttons/player/modern/pqueue.png + + + + !String.IsEmpty(Window.Property(nav.quick_subtitles)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/subtitle.png + script.plex/buttons/player/modern/subtitle.png + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-dotted.xml b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-dotted.xml new file mode 100644 index 0000000000..a96104cde1 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern-dotted.xml @@ -0,0 +1,310 @@ + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + !String.IsEmpty(Window.Property(nav.repeat)) + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 412 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat-focus.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat-focus.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/repeat-one-focus.png + + + + + + !String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/shuffle-focus.png + script.plex/buttons/player/modern-dotted/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/player/modern-dotted/shuffle-focus.png + script.plex/buttons/player/modern-dotted/shuffle.png + + + + false + String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle.png + script.plex/buttons/shuffle.png + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/settings-focus.png + script.plex/buttons/player/modern-dotted/settings.png + + + + + + !String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/next-focus.png + script.plex/buttons/player/modern-dotted/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/next.png + script.plex/buttons/player/modern-dotted/next.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/skip-forward-focus.png + script.plex/buttons/player/modern-dotted/skip-forward.png + + + + + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 407 + 405 + font12 + - + - + + PlayerControl(Play) + + + !Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/play.png + + + + Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/pause-focus.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/play-focus.png + + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/stop-focus.png + script.plex/buttons/player/modern-dotted/stop.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/skip-forward-focus.png + script.plex/buttons/player/modern-dotted/skip-forward.png + + + + !String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/next-focus.png + script.plex/buttons/player/modern-dotted/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern-dotted/next.png + script.plex/buttons/player/modern-dotted/next.png + + + + + + [!String.IsEmpty(Window.Property(pq.hasnext)) | !String.IsEmpty(Window.Property(pq.hasprev))] + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/pqueue-focus.png + script.plex/buttons/player/modern-dotted/pqueue.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/pqueue.png + script.plex/buttons/player/modern-dotted/pqueue.png + + + + !String.IsEmpty(Window.Property(nav.quick_subtitles)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern-dotted/subtitle-focus.png + script.plex/buttons/player/modern-dotted/subtitle.png + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern.xml b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern.xml new file mode 100644 index 0000000000..580ee5a833 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/templates/seek_dialog_buttons_modern.xml @@ -0,0 +1,312 @@ + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + !String.IsEmpty(Window.Property(nav.repeat)) + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 412 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/repeat-one.png + + + + + + !String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/shuffle.png + script.plex/buttons/player/modern/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/player/modern/shuffle.png + script.plex/buttons/player/modern/shuffle.png + + + + false + String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle.png + script.plex/buttons/shuffle.png + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/settings.png + script.plex/buttons/player/modern/settings.png + + + + + + !String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/skip-forward.png + script.plex/buttons/player/modern/skip-forward.png + + + + + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 407 + 405 + font12 + - + - + + PlayerControl(Play) + + + !Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/play.png + + + + Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/play.png + + + + + + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/stop.png + script.plex/buttons/player/modern/stop.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/skip-forward.png + script.plex/buttons/player/modern/skip-forward.png + + + + !String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 0 + 0 + 125 + 101 + script.plex/buttons/player/modern/next.png + script.plex/buttons/player/modern/next.png + + + + + + [!String.IsEmpty(Window.Property(pq.hasnext)) | !String.IsEmpty(Window.Property(pq.hasprev))] + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/pqueue.png + script.plex/buttons/player/modern/pqueue.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.playlist)) + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/pqueue.png + script.plex/buttons/player/modern/pqueue.png + + + + !String.IsEmpty(Window.Property(nav.quick_subtitles)) + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/player/modern/subtitle.png + script.plex/buttons/player/modern/subtitle.png + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..476b8258bf2755bd02cba3746fe122f7f0233f70 GIT binary patch literal 713 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|J_PuLxB}__BMCgcdgvk0&;BJre!&dC|AsHH`0#Oi!ohd_9ZWM`rhR`d zG~19-B5|_#@wxg>c1sKL?3-7bo}6OyKqS)9z?kJ3f7Z`))+f%qHjne>-FaX8Xy%VR z;Xv+S=DlBc?WxvUxB(c%GM+AuAr-gY-f%5CVj$ugcr(}f!kxr4AgK9$-?xhKV~qaJ z^k3@RL$o4`IDk$;0)LYCW?mA`ymdA8=9B1GE>d^>USC($);+rGQthR)a}&4ZZRc5h zb*1hPSMTo2Z5MB?kZ^mwqV9pX?$`GcZrS^jB-So5dm(=JuF~$di&7HOAG@a=d(OMq z)H+9E?aZy^`!iJY>n=V&y}sn3$qV{VN{KO@*H%9UX$JEKd~G>g0Sc3ouEK0A*M;>8C0C+w?aJG@>mNdE^C@pScb JS?83{1OUwgxoiLc literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/next.png new file mode 100644 index 0000000000000000000000000000000000000000..893989e5c109b02a887efa1bd39cac738f67341a GIT binary patch literal 693 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|UIqAsxB}__BLj3-?_~%2*0&_cFPI_x;Je>{`#)|^=wNy|BklY1+4CNV zSnJPCej+ZN9=Y$Qkb$B3WC@lNiO-Dr8B=U_ALl7`6g-!8<~6Uk_Whl4fj=_yEQF78 z2Q%;ex@!-^9{I<>pcU|RaSW-r_4dZ~FeXC@*M|zm9a}bbv+iD$^q%#|x)sm=#=FZO z*m?fo-@E_Jt5Z``Cvh?Yor3@k^=sy+EuOgS?!D)}`w zhFrc(D@ti-=ExcHr`K2PlBOsQ+mpXNn{1`8HpF@-{n2v?YoxQ+{yn>Q!T=Wt`jw?5jLg`&2hfxbpY(!M6uYC)o7=x-$Fp%H6A& zfI1o0E%AG~^NHa(P2T0ph l{vUTfT*-aUl!4*TS-v{0tKRo|uIPeH^K|udS?83{1OUwugk%5! literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pause.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..6ebe03071a105228f8f459b6b327a745c527a831 GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X3p0?M(bV4pq*&4&eH|GXHuiJ>Nn{1`8Hoi@46zzxC|&3@9E+gl5y|t z^@Y3*4g#(Ry$z-`#5FGCv|7Nl%Ax7eskXNdJ@gXYYQKKT(*!DKaPV*Is64yzt)Tbi zFA>>$i)G$h%wE1;X7Uy|C^^3TcRN2jNb#Q|%*Afg-8q-_Yk|0)u6{1-oD!M<@-}3j literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..42d8ca0fad1d814ab5a032111875f993e4eeaf29 GIT binary patch literal 823 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%zl>>Z2T!DiBM*-dtaNS#70Su;uk|4iehKTR)EQG)8U+`h;r-L{7T4Xet zj6?o3{9~My_})63JMGx1yhUf1-QJk2dvWPE_w$q4JXHHj4y?aZuOMgOqa-e(K2b31 zNzP0EO&TBHnC)CVoz-QD{<&30T(wvJ+Csu<{TZ5R<0?$svq@gw&vIFX;wmQ7o}E4 zeCyWh`}So~{1%h@QLn^x&#Lh(p86*LWB0U6kFVBUymao{uJ|n>`|2NxuYGc`Gk!}+ zP2CIewR>ZCzgWC^&g}{jKmE6HM|_e_zny%2&fm@I6Xq|ve7L!6|Hm%T-OQH*f@j~> z)DPYNY#Ymwy}@O#!=A3Vn`C`0uh90Jt<}`Edf?Zp1eF0$hQ%iosU)=(Y4Zg_iSQx$)C#nQj^>bP0l+XkKk&O+& literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/play.png new file mode 100644 index 0000000000000000000000000000000000000000..c4872bedac695d19a855bdb42ade970604f1bff6 GIT binary patch literal 789 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%zWdnRdT!DiBM*)ryn8-Ih1{gqbB|(0{44?Q~BEG-d|3&!V)|)b#9~%Ds zVG1#3tnZ(6Hj#V%##4FUZnG^@P0qgP@!m&ivTn&S_eBTNJ0xlQt zZ`ple$L`(PkKe7^_&@bP$Ke*kBMSb>?;4!^DTVKlq|{b-P5X;U#h!!spO17lJ?IQku z?!3o=`)syX3lwkE-5!1~r_BAB^Z@9a2 z>dcMSIpWWC>-uZ^{aijPR=6%t;|7x9M#_paT(|RmW8M71<+2k;+{N$V+Yg?0{`pI9 kM`hI>P9(oFFnr*zVc?thK0jgKtkob1Pgg&ebxsLQ0J)L;RsaA1 literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..d2aa311ddd1e746dd9e24c905e0b54be1bb40a4b GIT binary patch literal 717 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|J_PuLxB}__BMCgcdgvk0&;BJre!&cNVhb*Bc+a$F^DT=H8z0QQ!{5aF zP1pOrEnift;_lpC4eLd?7H`uLoL1AtBYsm-p)u#>xz%#%H`wnnSGH{OEIKtk^m|Y3 zj_Y^lFkYJaqO)plpcycT6+B%WLn>~)y>>k4P=H9o!wi=|p{52_>8`H5-@ABs&-q?2 z-Mo1AjFWdaShWA#fA{9!;^$`PL2-rzUbLS4%~^d>|I-b*?P9S)6R)!;UrfGK`e(cQ zwQcN^&uw+{-Wv1LZ*%+M|8sn^d&MlRI{lX}*?C!7_hZwxpvC%E+@_~JN-S5`{S}p2 z@^$4mV^cmc_DOkD<&HdGwpM%nkKM*TKmWX%Q7y2yy!vEXuZ4!sy1lhLsoc-6xVk?w zS+7@hN;Wn`y!L5d-xb&RdG~tKZbv-Z#B@R5zQ1*;!F0tlQ@3AneZO0Ny6C)*aS`S2 z>sNKJ%enUX<|11^JFq7_G=4P3ZxDOi-I1?r)v=g4Y?duA)OWQ{q!;#-?XQ1zO9~|7 M>FVdQ&MBb@0L<06V*mgE literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/pqueue.png new file mode 100644 index 0000000000000000000000000000000000000000..12c201e107261c0e7a19ee8f7e38d4599e07774c GIT binary patch literal 688 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|o(K4ZxB}__BLGCL?$`wMs!vIfUobJN&#&_jSK{ z+eY!VDvIyk)wN!-<|UWHHXXs-#%VVj7W3qYoGVISeS?|(UQbJ?r`)OOm78k6@3?-q zlkw8jIWLy8-Tey;R(?+x$B>F!Z?ByUI^-bG@K8fxLW6=rw|0llqjfv<8#=Q0|NktZ zu`@xd_~gRlzxGGp|L1kq=XfE|MaW=LK$YGN@xNO7i$iZO+N@->bGwd}TJZGG#+U9z z#FeZyjq3V&r(~5`*uNL+vwOuXtH0zgWvRR*t^1GNmcM6Tp!8hb--q+`HGcVJmVDf1 zG~3UgZ@QSR#@>#{(_;POo_$Y=c>4MG*FMqvcp%uSFB<9Gu^)bT;FVthiadWzgo2W+0&vUQvHV-Evw>V oOY*9zopr033>>cmMzZ literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..8dacf1be9e7efc194ca1eca9fc77b1cc515265c6 GIT binary patch literal 611 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|)&=;4xB}__LkH+~ebEBC*s>(ZFPLG$2Mf-#MsLIJe%jyg?T)tG1jDCK zva?#%C#JuCTyD_c#^pQ1QvXFr%>CrK%=0GYOfJ>cvI&0lrtH+58;Zc7;PZ5G45_&F z_Qpv+CPxv6i+KT#0*nVM=RL1n^Z&o0G^;XKTdP{=vpIKPhj-8RTPi*`6=*XA9C`3j z@!*T;&wqbeG~KBj$h@%ATer%vrC^t6b!u(ZiC{rltH08l%qpV#b#@85-N@MYb{c!J z@}?K3R=n77@73F9Y{{yfFaF;Bvgr2v-E&tgHeb#Bd~e~_{n_8<V0>vuYAgx`hIKv_l8Zv>pQSJzf1=);T3K0RYh+ZJz)D literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..a1b2217df36e7cbb52cd13c7aecf8db31193babf GIT binary patch literal 687 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|&II^`xB}__!v$DPddLCvk8??oUogXh4;Gwfjo#i3oB2umTf?3GPYs`B zO~{rj4{c4K@!EG{oBHE^11`&$7y2RhC(YH;Een41#wIyu_Zj9@la)%11?RcEo2VUf z6&Pejo-U3d6}R5rxaoH&K%n8_<)&>S0s=Sh9IZWC8-JnlfB7a+uQiN^Th)wz)t6r` z*869o?i>9a=nf-J{mtdbX0f-&FWQyQGUkQ)xBi6nylr(E T)Bfwe0f~6J`njxgN@xNATqc{V literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat-one.png new file mode 100644 index 0000000000000000000000000000000000000000..4ab80235f67e8ac0abca4962bcec6bb3708d8ac0 GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|4hQ&zxB}__LmV7z(VY+UhC@k^UogYWJD)hu8oj+6_D#ECzv0s-SrfA5 z%0pWpPfYiHt=`YoHp3vqQvXHF{Yi7ng5T&q(y~d;IWtd**?3i{;O@yT?=rT(aR3IB ztfz}(NX4zUH%}o@LMTQqkBj9Z@n+S?WcAMoC-)N{BR-m&;7OO&pya6 z3-X);3U>s^C@4)g`1R@6wYZW{E!F0WLHjm}#fnedJn!47M`4FFdZvAP)Ar`*YS+|@ zm63~Lf84y?`Fzi&jQYqeGA4`mzODG;>r&qT#`XHL>f@^x&)#{%)#Tl|Z(J9>+_S$t ztl7iA{PU5<+O-#N-ar2`>XUt%%i@dg{m;dfJU*IG@>+Mofv7LbZEkhf{VVvk z$k|2M*W}`CA=_^@Gb`?`UisqLm*AzTZ>u6I6Rmzec8JgZqOkea%4;vYHr%>!roNxm s<$v=RtBaYb7d7j7HQ5n<0;>X_y85}Sb4q9e05pq=-T(jq literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/repeat.png new file mode 100644 index 0000000000000000000000000000000000000000..eddcba762f5476b2d4d590bbe177d51610f8e741 GIT binary patch literal 576 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|mInBQxB}__gC6Yo`#2QnP_vRCzhH*_pT2RPHF_I%SG(bk+yuj?PqMRG zXOycO^uM0yo6hyPEkyrCjAiNENt5sA%wta04KCAq^v34yvcL&I6TW-8IEGZ*dVAw! z&>;sAhl>nZ0*nVMk5$fV|M%bDI50_)wY#Bw9tupjis`{(2_r<^G?+jkO=q-D?NT&SwZokS+ve{prU9X7unZA{~dZU|u zP5tuUU%B7@Se$<-bXv@ppSjcGO5W=od~x5UTTC}Sv3S~xw3Lf?YuN7cz#Pudan}0Px_HfrHy_>xiFvyExvXs=R3xwd0|-C}N0{`7@z7w0$| zclJ%@oyPTgs(@Is@GoEx`*^xIhE&{odowb)DM6wwv73uoQ*86zGbWpB?|(lU_Vxe& z*%giZn2*owoAt2t-Rq9$Z{*k zUi_?9)tsT%|L?_Nja$<{irBvXsc>vtip{42#b4)N^yf>8`d&PGEZh5-=i@N{FVUB8 zPkCs%x@wX2vt@r5sQ{1Eqm@w+p?PR z`^&sf1)5I{EIB5H%ZeZTJ9nP?qMtjKE)R*^60SJw^y2yN0trM{nn zAO45zn;-VmAL8BSozs$c-dgmuwP~qt)V)%RIhpG+p#dSGWbe3P(IcyS2ctnEp00i_ I>zopr05a~>VE_OC literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/settings.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c4f2ccf40a08b710eab5514e0a7a41a6cea126 GIT binary patch literal 744 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|J_PuLxB}__BMCgcdgvk0&;BJre!&cHG{4Vn^?56DNR{bW_R76s?jJgo zCuwc*Ra+1dQzLwPCgYt;N~)y?H$7P=ZKXqDdBmFXO?u32I#5;slQ{t+@05 z?u^Mt>JM5xc1tgs^F8wE?Y)PWICv}tx(W&Wc;8i)U@O;cRCMvVYaYRR5;^SBK!O!;`?aO(^C#lQ89xMnT7e06zS&*WEY=e>Bo zYpd?zRnxz??4LdRTl2gZ;li~m)qfp!ng6P@ZnN}5%emXne{tc@j*LDQ`qr-TqV=S* zzh`T!1ngoqar%_a`4D=%_42;UpIuC*M}L{9)xC|&rMRl6`0lhNb9bA&&)PR@n#$W5 q@{INVPk(1l{m+8rT_E$rY&*vP@8_o;w)uDwB;)Do=d#Wzp$P!+;mkGw literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..d75616ff1223d9991dbe264758d6c69959ddc67f GIT binary patch literal 1370 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%zp9T1YxB>ogec9|qdT!KkZ#{YY*&Yd}!q@NjyYTva>v-pA%Mxg~*551kIsWmW;)MVePL@aaDx}KS?*;<%((>=${w=>dcW$2f*5!70 zGVABFC+O?nKRk1@akl`_yXc_)_}@a_#qu_@*2b4G@@~l%kM%Gqm>xfEf4Se;{QW(5 zl^6YTE4wJTXj@lO{Z0RPmfQN~U!F@}ESYk5*F48d>qK2IAA7ftYtz}7)5fzh+)wSy z5PC8p{(w%^=kMFz?U*(7ZK>MjjdFRX9~Zm~_!<)$eo58+we*#*zpuYPb9Cb2Psg?u zHlAKJXX~9Ew}O&l_xv=_sOmgqGuwCWRHH3f%^h#I|2nSr@ZB=g+amwWCd%`v?sSdcGI_e$ZRh-VThwpo`uhBL>e#Zb?BCmsrOP+(>WeIy zDp{K4pZTZ#=f383V!vlD=!pC>)8*?=?&o!t=KIWSRHu7RxyvW~>+!pbIVSgaEWW<+ z{Ee6wUX#<#J`!_PFUb}&U%v}f)&aqv?RyyG53G(ZY3N|u<@&oPL%uF^O(sMRNH)}; Y2!FSYds$MD!Zwhgr>mdKI;Vst0KgA|Q2+n{ literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/shuffle.png new file mode 100644 index 0000000000000000000000000000000000000000..9c79a53970cb18c7e1a5fd3acb5ef68a03ea49e3 GIT binary patch literal 1349 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%z9|!n^xB>^Lj*Rr(V1 z(!W7zzkS*4Z=B>7*;>;haeL1*r^46o_xreXeB*uRXluBZCD1F@|K&%;X>p0^?05VY zEz6qxJ)!9QTMJJ?Q4`(!a-BLCk`HWnBhxDR=|tsCqufVoGgk1dskkS_e@%1S;ureA z%&s4rp7K++e*2b<4xE!hltT}HiJs%(T5!y&aiKwN)5;H*KOZcP+?W6K%qfXueL@Tj zOd*~wjv*Dd-rfi;zU?5=_OLf_-Q!z+Nyqt2=CoZlNS+A-ikim7)pc20SFc?)CC%z` z_h#e!#fjd>&EFsGD@#=bdKex2>t}z==5~L#_31q?m|o=@>+PDb;=khlH6Gu)y6pc+ zRJ*v8_P$y4*JbU|rxPc|*Kur*UHSg>_QjdfyGw0*r_AN{Uf$Ded-TbyeN)m+F3y`$ zeo^Iw^8SX%FOTGNWeaV!XFm5`=DqjMwBt>i4zBXuwQfahui5q2zqh})J9Tv8;ZFy* zmDV0uIcMkTebKQ`?(C_wSJ>5AV>5f{Ixo?vz2Qf$S^iaCoK^hMRU%(`Od(;4P`p-LbXLBCFirJlvQ5zIeX4u~U^uQvC&wybEWa_equ5L|NbO%o9=5 z3_rX6j8$h`-nQA7&!6quH*woy<}}lG`O{Y?dPS+ZE>HQKo?UTa!L!S2jebr3;njM~ zL|$_8+2olz7hjjzb{ofQYWJ`FTxa&<=)YI13)`G!6VIJJT=%u7PHWRS>1zx9EYB{q z*=JpO)-7FsPrkiVoYnO5T}Dnl!nUVBoaT)bP0#*n%`i8ld!grj)i@i;iR%0O$}XyF zy}on!u9)rY*T3xo#eYw}v&etv$#&^G4=3Hx%->cVHRE6FgZ!v>e{Uxr*UB%|-TWd; zvNSC)^3U?0o9;HIeV@BvO5~THE>~N-kJtY;nJ2Sr=`_zNclnb4mVVFJFnj%7lh-%W z^Yu#h9^H^;=y);k@~oq8S35GI$2+US-2axSKu$L8r)ESer)F0*sC z=W)i{SKK6$3QGjDe}7Fh_E(5pna}FPKIM;Q$P1emp*KtaS{1Cw;9cFXf2Zovr_1*w ze~8yb+?@f8_LH72jv*Dd-rfig4-OP@eJJ8%GF8vCr6tSR^=O|6t1ByanSge}(Ov)c z8Pt^8razu>|8?C=<;TzN6_+V70=<9){{8y-_qc~MbJd@W#TjhLsY|7$=B_K>eK9Qa z?fvHF_9?j;C1K2>##Rf=UmGr-y=3}cy{PwbqRnpJ?dNA@yi8He+VX67%oZy-m)>hH z%fxlh_ObO&>)W<7L-u8t)Y^Z)^`d-N2QCiX@qE$JX$u!zyyUXI``XX;z{RD1rgVq7 zS6|6c&EJ{v(kuGHi@W^37hSU-BwfjneO+?VE5281ZOQuXY4iA5d+*gAv@N`-^_NfT zu1)z}*XjHFj`hsjnJM4@Y`Nk5mxt~af9t;y)%(+3=KaR^FYi2F@cyOo^*6^;6Ahn< z8^k)AU(wpKj&5y*P8F{{VW;!@5^F z?bElU=+`}!(&v3|?`_w6p1e-L?cQ$Q$&;EL&3*FYZ*PrwTYMrm?%~m8(pSH3oW&t| z_kOzj{B36w-b~+TYVvEQd~HGV!?|y!vw*{R<5HuOK3V%+)4zq){MNPFxn1<}^k*~K dAmQKe|Af4HcVL%F@g+r&h^MQc%Q~loCIAE5n=k+X literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/skip-forward.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/skip-forward.png new file mode 100644 index 0000000000000000000000000000000000000000..a95d14955d32942dd59b3f533a8a295b26a5481c GIT binary patch literal 1017 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%zQv-ZLT!DiBM*)pPfcIZg5-_04N`m}?83cZQdp+mNTiMS%XMVA%Pn(>) z?CC9YTjzb8s!<1WK8Rji$Sr-2LvDJZ|80qt7bebQJgx95ad|nD?y<;2vllIh7nJd2 z%|32_-=o59X343YH^2VAVw7Z@&*fBL6Y__BiqM-Enk!nC{*#QxtZx6B zr&o;6#jg45^FD=t-}~H77NGNyz>nw8-^>3^<<0oCxZl!PLv)H}cFD5kM|-z@j{Ywn zvm$8Qnv0QJx6cY(YG!w|XWay|+c8`Ay?fH+<~{x1%#6%6rdeC$uJ6dW8rvzgcJ@}i zs991RQo6cfyEE>t28uj0*NYM@U+kuB`)<+F?!AtSmp&8|Uu$i?$SwQl)b6m@+$$NS z`^qj}N+`YXVso$B^F>$dth2VPGvASsng-N$`PrhSTkkEnn9hC2S$yrE`3o+7<#&rM zd03&4_T^OQ@5(Ft`+xc_m-oFTzww6XXZM<0Dc4_KxxL`^m$kpkiYs>t6ubNA-rU^o zHpy`N$A#+OrhHGbd!f1hMtc9UpT}lAf9ce|cjt>+F=xCFd`|z?x1-APg>0c(rvA3O zyoULYS91P-xA0}k8TFQbw%-peIoG#9-)HyhZ;@+$8$5}Q`}LXK{ME&}aHW)z;>EJH z)<(^j^yf}9{nafmyTkFm=eKGmq#$NsX!!g>|JGm6BinacfP>%D)z4*}Q$iB}F>a9w literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..9959be9c53dbaa2a257ff93a0b0136cfad56496d GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X3p0?M(bV4pq*&4&eH|GXHuiJ>Nn{1`8HEalYaqsPQ zL#_q`9*2u@jsh`vV~$E!aD^8ZmQ9GezqeRfcTahP|A{F}Sb*vowj|4QC2##Qkvo6u zmyl)hnQ-uu&Hwv>c4mfM+hVgrs!kS%{5qH39?bquih-f-GvliRVpAGKyn)VVVDNPH Kb6Mw<&;$U=BVT?1 literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/stop.png new file mode 100644 index 0000000000000000000000000000000000000000..b07b3692d191dc7b2c31e3dd99eee19e6e782a74 GIT binary patch literal 205 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X3p0?M(bV4pq*&4&eH|GXHuiJ>Nn{1`8H8UIs{CNswPK!={?us|p09fP5cM7srr{dvC8FL zadFfY;Ci+KO;uunK>+Ow`euoW2 z90Df?c}-e&K{6_1=5xdU{|ielZgN<;DIuWe+|8=u)SLdBoPm0vpdtR+{q5OotqPBn^W{9v*qIF@2egw`IQ5UHmgG zD|cP;dj%J9+m02L!gpu4yjZl{@a<=-U!OEw#A{_Ei!)LK&-RvnxmqF=UA$xOPHqN< z1KUd{E4@4>Hh0au9iBNCk817OaQPg_%M1tXKQZz%x7oTJ`Rf4Ud%F6$taD0e0syKs BMri;5 literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/subtitle.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern-dotted/subtitle.png new file mode 100644 index 0000000000000000000000000000000000000000..6d4ea2e9bded61f0a31d286a0296fb46948a78a4 GIT binary patch literal 524 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|CIt9|xB}__g9%Jyko*92lYU8%UogWip*OCc+uz=>|8hm{^3{{?9IWN9 zsXqMmiIdGkf&HWJotxr&|0sSA+OcQb?)^TYPYc#-0JYxtba4!+xb^mir{7@*0f&of z$5?JpkQCzhZ1ely_GIDB4t;wP3q0TFR)4-4H2um{A)pp0__OI_-SWcsi%#zfzPbBF zV)>VVEg2W<1^3p}-OT>{<-+3^Z&FUbOq{>ipV@7_-L=Z@ufI38YwU{K_jXH_t}p-P z#c{Upww?0jzqrUX-}q(UydS;)TW)UA=sMlK{!n0Cjq%*C#mU94s+}C!Egq`hK~aB)8V`?wxz;Fc&_UU&AEb XSbgtijvp{u85lfW{an^LB{Ts5H`Ov( literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/next.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/next.png new file mode 100644 index 0000000000000000000000000000000000000000..893989e5c109b02a887efa1bd39cac738f67341a GIT binary patch literal 693 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|UIqAsxB}__BLj3-?_~%2*0&_cFPI_x;Je>{`#)|^=wNy|BklY1+4CNV zSnJPCej+ZN9=Y$Qkb$B3WC@lNiO-Dr8B=U_ALl7`6g-!8<~6Uk_Whl4fj=_yEQF78 z2Q%;ex@!-^9{I<>pcU|RaSW-r_4dZ~FeXC@*M|zm9a}bbv+iD$^q%#|x)sm=#=FZO z*m?fo-@E_Jt5Z``Cvh?Yor3@k^=sy+EuOgS?!D)}`w zhFrc(D@ti-=ExcHr`K2PlBOsQ+mpXNn{1`8Hoi@46zzxC|&3@9E+gl5y|t z^@Y3*4g#(Ry$z-`#5FGCv|7Nl%Ax7eskXNdJ@gXYYQKKT(*!DKaPV*Is64yzt)Tbi zFA>>$i)G$h%wE1;X7Uy|C^^3TcRN2jNb#Q|%*Afg-8q-_Yk|0)u6{1-oD!M<@-}3j literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/play.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/play.png new file mode 100644 index 0000000000000000000000000000000000000000..c4872bedac695d19a855bdb42ade970604f1bff6 GIT binary patch literal 789 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%zWdnRdT!DiBM*)ryn8-Ih1{gqbB|(0{44?Q~BEG-d|3&!V)|)b#9~%Ds zVG1#3tnZ(6Hj#V%##4FUZnG^@P0qgP@!m&ivTn&S_eBTNJ0xlQt zZ`ple$L`(PkKe7^_&@bP$Ke*kBMSb>?;4!^DTVKlq|{b-P5X;U#h!!spO17lJ?IQku z?!3o=`)syX3lwkE-5!1~r_BAB^Z@9a2 z>dcMSIpWWC>-uZ^{aijPR=6%t;|7x9M#_paT(|RmW8M71<+2k;+{N$V+Yg?0{`pI9 kM`hI>P9(oFFnr*zVc?thK0jgKtkob1Pgg&ebxsLQ0J)L;RsaA1 literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/pqueue.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/pqueue.png new file mode 100644 index 0000000000000000000000000000000000000000..12c201e107261c0e7a19ee8f7e38d4599e07774c GIT binary patch literal 688 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|o(K4ZxB}__BLGCL?$`wMs!vIfUobJN&#&_jSK{ z+eY!VDvIyk)wN!-<|UWHHXXs-#%VVj7W3qYoGVISeS?|(UQbJ?r`)OOm78k6@3?-q zlkw8jIWLy8-Tey;R(?+x$B>F!Z?ByUI^-bG@K8fxLW6=rw|0llqjfv<8#=Q0|NktZ zu`@xd_~gRlzxGGp|L1kq=XfE|MaW=LK$YGN@xNO7i$iZO+N@->bGwd}TJZGG#+U9z z#FeZyjq3V&r(~5`*uNL+vwOuXtH0zgWvRR*t^1GNmcM6Tp!8hb--q+`HGcVJmVDf1 zG~3UgZ@QSR#@>#{(_;POo_$Y=c>4MG*FMqvcp%uSFB<9Gu^)bT;FVthiadWzgo2W+0&vUQvHV-Evw>V oOY*9zopr033>>cmMzZ literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat-one.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat-one.png new file mode 100644 index 0000000000000000000000000000000000000000..4ab80235f67e8ac0abca4962bcec6bb3708d8ac0 GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|4hQ&zxB}__LmV7z(VY+UhC@k^UogYWJD)hu8oj+6_D#ECzv0s-SrfA5 z%0pWpPfYiHt=`YoHp3vqQvXHF{Yi7ng5T&q(y~d;IWtd**?3i{;O@yT?=rT(aR3IB ztfz}(NX4zUH%}o@LMTQqkBj9Z@n+S?WcAMoC-)N{BR-m&;7OO&pya6 z3-X);3U>s^C@4)g`1R@6wYZW{E!F0WLHjm}#fnedJn!47M`4FFdZvAP)Ar`*YS+|@ zm63~Lf84y?`Fzi&jQYqeGA4`mzODG;>r&qT#`XHL>f@^x&)#{%)#Tl|Z(J9>+_S$t ztl7iA{PU5<+O-#N-ar2`>XUt%%i@dg{m;dfJU*IG@>+Mofv7LbZEkhf{VVvk z$k|2M*W}`CA=_^@Gb`?`UisqLm*AzTZ>u6I6Rmzec8JgZqOkea%4;vYHr%>!roNxm s<$v=RtBaYb7d7j7HQ5n<0;>X_y85}Sb4q9e05pq=-T(jq literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/repeat.png new file mode 100644 index 0000000000000000000000000000000000000000..eddcba762f5476b2d4d590bbe177d51610f8e741 GIT binary patch literal 576 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D$|mInBQxB}__gC6Yo`#2QnP_vRCzhH*_pT2RPHF_I%SG(bk+yuj?PqMRG zXOycO^uM0yo6hyPEkyrCjAiNENt5sA%wta04KCAq^v34yvcL&I6TW-8IEGZ*dVAw! z&>;sAhl>nZ0*nVMk5$fV|M%bDI50_)wY#Bw9tupjis`{(2_r<^G?+jkO=q-D?NT&SwZokS+ve{prU9X7unZA{~dZU|u zP5tuUU%B7@Se$<-bXv@ppSjcGO5W=od~x5UTTC}Sv3S~xw3Lf?YuN7cz#Pudan}0Px_HfrHy_>xiFvyExvX~)y?H$7P=ZKXqDdBmFXO?u32I#5;slQ{t+@05 z?u^Mt>JM5xc1tgs^F8wE?Y)PWICv}tx(W&Wc;8i)U@O;cRCMvVYaYRR5;^SBK!O!;`?aO(^C#lQ89xMnT7e06zS&*WEY=e>Bo zYpd?zRnxz??4LdRTl2gZ;li~m)qfp!ng6P@ZnN}5%emXne{tc@j*LDQ`qr-TqV=S* zzh`T!1ngoqar%_a`4D=%_42;UpIuC*M}L{9)xC|&rMRl6`0lhNb9bA&&)PR@n#$W5 q@{INVPk(1l{m+8rT_E$rY&*vP@8_o;w)uDwB;)Do=d#Wzp$P!+;mkGw literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/shuffle.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/shuffle.png new file mode 100644 index 0000000000000000000000000000000000000000..9c79a53970cb18c7e1a5fd3acb5ef68a03ea49e3 GIT binary patch literal 1349 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X2Q!e2ElO$yQY`6?zK#qG8~eHcB(ehejKx9j zP7LeL$-D%z9|!n^xB>^Lj*Rr(V1 z(!W7zzkS*4Z=B>7*;>;haeL1*r^46o_xreXeB*uRXluBZCD1F@|K&%;X>p0^?05VY zEz6qxJ)!9QTMJJ?Q4`(!a-BLCk`HWnBhxDR=|tsCqufVoGgk1dskkS_e@%1S;ureA z%&s4rp7K++e*2b<4xE!hltT}HiJs%(T5!y&aiKwN)5;H*KOZcP+?W6K%qfXueL@Tj zOd*~wjv*Dd-rfi;zU?5=_OLf_-Q!z+Nyqt2=CoZlNS+A-ikim7)pc20SFc?)CC%z` z_h#e!#fjd>&EFsGD@#=bdKex2>t}z==5~L#_31q?m|o=@>+PDb;=khlH6Gu)y6pc+ zRJ*v8_P$y4*JbU|rxPc|*Kur*UHSg>_QjdfyGw0*r_AN{Uf$Ded-TbyeN)m+F3y`$ zeo^Iw^8SX%FOTGNWeaV!XFm5`=DqjMwBt>i4zBXuwQfahui5q2zqh})J9Tv8;ZFy* zmDV0uIcMkTebKQ`?(C_wSJ>5AV>5f{Ixo?vz2Qf$S^iaCoK^hMRU%(`Od(;4P`p-LbXLBCFirJlvQ5zIeX4u~U^uQvC&wybEWa_equ5L|NbO%o9=5 z3_rX6j8$h`-nQA7&!6quH*woy<}}lG`O{Y?dPS+ZE>HQKo?UTa!L!S2jebr3;njM~ zL|$_8+2olz7hjjzb{ofQYWJ`FTxa&<=)YI13)`G!6VIJJT=%u7PHWRS>1zx9EYB{q z*=JpO)-7FsPrkiVoYnO5T}Dnl!nUVBoaT)bP0#*n%`i8ld!grj)i@i;iR%0O$}XyF zy}on!u9)rY*T3xo#eYw}v&etv$#&^G4=3Hx%->cVHRE6FgZ!v>e{Uxr*UB%|-TWd; zvNSC)^3U?0o9;HIeV@BvO5~THE>~N-kJtY;nJ2Sr=`_zNclnb4mVVFJFnj%7lh-%W z^Yu#h9^H^;=y);k@~oq8S35GI$2) z?CC9YTjzb8s!<1WK8Rji$Sr-2LvDJZ|80qt7bebQJgx95ad|nD?y<;2vllIh7nJd2 z%|32_-=o59X343YH^2VAVw7Z@&*fBL6Y__BiqM-Enk!nC{*#QxtZx6B zr&o;6#jg45^FD=t-}~H77NGNyz>nw8-^>3^<<0oCxZl!PLv)H}cFD5kM|-z@j{Ywn zvm$8Qnv0QJx6cY(YG!w|XWay|+c8`Ay?fH+<~{x1%#6%6rdeC$uJ6dW8rvzgcJ@}i zs991RQo6cfyEE>t28uj0*NYM@U+kuB`)<+F?!AtSmp&8|Uu$i?$SwQl)b6m@+$$NS z`^qj}N+`YXVso$B^F>$dth2VPGvASsng-N$`PrhSTkkEnn9hC2S$yrE`3o+7<#&rM zd03&4_T^OQ@5(Ft`+xc_m-oFTzww6XXZM<0Dc4_KxxL`^m$kpkiYs>t6ubNA-rU^o zHpy`N$A#+OrhHGbd!f1hMtc9UpT}lAf9ce|cjt>+F=xCFd`|z?x1-APg>0c(rvA3O zyoULYS91P-xA0}k8TFQbw%-peIoG#9-)HyhZ;@+$8$5}Q`}LXK{ME&}aHW)z;>EJH z)<(^j^yf}9{nafmyTkFm=eKGmq#$NsX!!g>|JGm6BinacfP>%D)z4*}Q$iB}F>a9w literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/stop.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/player/modern/stop.png new file mode 100644 index 0000000000000000000000000000000000000000..b07b3692d191dc7b2c31e3dd99eee19e6e782a74 GIT binary patch literal 205 zcmeAS@N?(olHy`uVBq!ia0vp^TYz{X3p0?M(bV4pq*&4&eH|GXHuiJ>Nn{1`8H8UIs{CNswPK!={?us|p09fP5cM7srr{dvC8FL zadFfY;C|8hm{^3{{?9IWN9 zsXqMmiIdGkf&HWJotxr&|0sSA+OcQb?)^TYPYc#-0JYxtba4!+xb^mir{7@*0f&of z$5?JpkQCzhZ1ely_GIDB4t;wP3q0TFR)4-4H2um{A)pp0__OI_-SWcsi%#zfzPbBF zV)>VVEg2W<1^3p}-OT>{<-+3^Z&FUbOq{>ipV@7_-L=Z@ufI38YwU{K_jXH_t}p-P z#c{Upww?0jzqrUX-}q(UydS;)TW)UA=sMlK{!n0Cjq%*C#mU94s+}C!Egq`hK~aB)8V`?wxz;Fc&_UU&AEb XSbgtijvp{u85lfW{an^LB{Ts5H`Ov( literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/plex.png b/script.plexmod/resources/skins/Main/media/script.plex/home/plex.png index 9a94e24096b26273c8398858395fa77d23b08bd1..e4c95a6340014961dab8dfaebc7129a0df91031f 100644 GIT binary patch literal 2046 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 ZzW}^GpnTlSyl4Ob002ovPDHLkV1f%V_hbM7 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/watched.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/watched.png new file mode 100644 index 0000000000000000000000000000000000000000..f12c806d6dead6a899be756de996e4dd9a59c64f GIT binary patch literal 596 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 R2a<@0^t6mL8`T|O{STLR)H(nF diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/plex.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/plex.png index 2fbd92adff07dca33b6b5297f372c44fe9443850..74590fed031713282ad493ac2129ebacf02ec531 100644 GIT binary patch literal 4767 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 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded.png new file mode 100644 index 0000000000000000000000000000000000000000..1ce032a36ec40df73a7f70d7db3428cc5f532702 GIT binary patch literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^DImNS%G}U;vjb? zhIQv;UIICy0X`wFK>9xh@Hn5#0+e7Y3GxeOIQjKdiF{(kNqeA(lc$SgNW|f{rws)e z6a`o|BtP5Oz`|0~$g<=?Jd@P6gzopr03eV`U;qFB literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded_w.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-bl-rounded_w.png new file mode 100644 index 0000000000000000000000000000000000000000..7ecb50bd4983d906f89b392fbb0ac88025eea6c6 GIT binary patch literal 488 zcmeAS@N?(olHy`uVBq!ia0vp^DImkCqGpGWV+xm2u)xeACX~kaDgBF}&oc%8#r+9J z?3EI=ER!uytiR}PFthnipIy^QnJ4NWEPe@y6;H5!Q1nGnXIl9Q)(_QR40)z0m>=lh zxV#{ue&u}TaOM8Q4RTN9e%xD|z4_nY)4SNtb#1Z)hSR_DALCaw}t=! literal 0 HcmV?d00001 diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-tr-bl-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-tr-bl-rounded.png new file mode 100644 index 0000000000000000000000000000000000000000..6547d64a8deda3df658e6393cd2cdd42f99fa6f3 GIT binary patch literal 222 zcmeAS@N?(olHy`uVBq!ia0vp^DImNS%G}U;vjb? zhIQv;UIICy0X`wFK>9xh@Hn5#0+e7Y3GxeOI9YP)tGpQN#%!QSsHcl#NW|f{r#A{V zCpwoCmjOE#H{ex0{Kg{lXq|FiB@Z|fWNmHkvJN+Q_(zmxAE?VKYQwroUi`B>NL;-22WQ%mvv4FO#pVuTfhJS literal 0 HcmV?d00001