├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── custom_audit.py ├── main.py ├── notify.py ├── resources ├── binge.py ├── customEntries.py ├── log.py ├── mediaWrapper.py ├── server.py ├── settings.py ├── skipper.py └── sslAlertListener.py └── setup ├── config.ini.sample ├── custom.json.sample ├── logging.ini.sample └── requirements.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################### 2 | ## PlexAutoSkip 3 | ############################# 4 | *.ini 5 | *.json 6 | *.sh 7 | *.bat 8 | *.backup* 9 | *.venv.py 10 | 11 | ################# 12 | ## Eclipse 13 | ################# 14 | 15 | *.pydevproject 16 | .project 17 | .metadata 18 | bin/ 19 | tmp/ 20 | *.tmp 21 | *.bak 22 | *.swp 23 | *~.nib 24 | local.properties 25 | .classpath 26 | .settings/ 27 | .loadpath 28 | 29 | # External tool builders 30 | .externalToolBuilders/ 31 | 32 | # Locally stored "Eclipse launch configurations" 33 | *.launch 34 | 35 | # CDT-specific 36 | .cproject 37 | 38 | # PDT-specific 39 | .buildpath 40 | 41 | 42 | ################# 43 | ## Visual Studio 44 | ################# 45 | 46 | ## Ignore Visual Studio temporary files, build results, and 47 | ## files generated by popular Visual Studio add-ons. 48 | 49 | # User-specific files 50 | *.suo 51 | *.user 52 | *.sln.docstates 53 | 54 | # Build results 55 | 56 | [Dd]ebug/ 57 | [Rr]elease/ 58 | x64/ 59 | build/ 60 | [Bb]in/ 61 | [Oo]bj/ 62 | 63 | # MSTest test Results 64 | [Tt]est[Rr]esult*/ 65 | [Bb]uild[Ll]og.* 66 | 67 | *_i.c 68 | *_p.c 69 | *.ilk 70 | *.meta 71 | *.obj 72 | *.pch 73 | *.pdb 74 | *.pgc 75 | *.pgd 76 | *.rsp 77 | *.sbr 78 | *.tlb 79 | *.tli 80 | *.tlh 81 | *.tmp 82 | *.tmp_proj 83 | *.log 84 | *.log.* 85 | *.vspscc 86 | *.vssscc 87 | .builds 88 | *.pidb 89 | *.log 90 | *.scc 91 | 92 | # Visual C++ cache files 93 | ipch/ 94 | *.aps 95 | *.ncb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | 100 | # Visual Studio profiler 101 | *.psess 102 | *.vsp 103 | *.vspx 104 | 105 | # Guidance Automation Toolkit 106 | *.gpState 107 | 108 | # ReSharper is a .NET coding add-in 109 | _ReSharper*/ 110 | *.[Rr]e[Ss]harper 111 | 112 | # TeamCity is a build add-in 113 | _TeamCity* 114 | 115 | # DotCover is a Code Coverage Tool 116 | *.dotCover 117 | 118 | # NCrunch 119 | *.ncrunch* 120 | .*crunch*.local.xml 121 | 122 | # Installshield output folder 123 | [Ee]xpress/ 124 | 125 | # DocProject is a documentation generator add-in 126 | DocProject/buildhelp/ 127 | DocProject/Help/*.HxT 128 | DocProject/Help/*.HxC 129 | DocProject/Help/*.hhc 130 | DocProject/Help/*.hhk 131 | DocProject/Help/*.hhp 132 | DocProject/Help/Html2 133 | DocProject/Help/html 134 | 135 | # Click-Once directory 136 | publish/ 137 | 138 | # Publish Web Output 139 | *.Publish.xml 140 | *.pubxml 141 | 142 | # NuGet Packages Directory 143 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 144 | #packages/ 145 | 146 | # Windows Azure Build Output 147 | csx 148 | *.build.csdef 149 | 150 | # Windows Store app package directory 151 | AppPackages/ 152 | 153 | # Others 154 | sql/ 155 | *.Cache 156 | ClientBin/ 157 | [Ss]tyle[Cc]op.* 158 | ~$* 159 | *~ 160 | *.dbmdl 161 | *.[Pp]ublish.xml 162 | *.pfx 163 | *.publishsettings 164 | 165 | # RIA/Silverlight projects 166 | Generated_Code/ 167 | 168 | # Backup & report files from converting an old project file to a newer 169 | # Visual Studio version. Backup files are not needed, because we have git ;-) 170 | _UpgradeReport_Files/ 171 | Backup*/ 172 | UpgradeLog*.XML 173 | UpgradeLog*.htm 174 | 175 | # SQL Server files 176 | App_Data/*.mdf 177 | App_Data/*.ldf 178 | 179 | ############# 180 | ## Windows detritus 181 | ############# 182 | 183 | # Windows image file caches 184 | Thumbs.db 185 | ehthumbs.db 186 | 187 | # Folder config file 188 | Desktop.ini 189 | 190 | # Recycle Bin used on file shares 191 | $RECYCLE.BIN/ 192 | 193 | # Mac crap 194 | .DS_Store 195 | 196 | 197 | ############# 198 | ## Python 199 | ############# 200 | 201 | *.py[co] 202 | __pycache__ 203 | 204 | # Packages 205 | *.egg 206 | *.egg-info 207 | venv/ 208 | env/ 209 | dist/ 210 | build/ 211 | eggs/ 212 | parts/ 213 | var/ 214 | sdist/ 215 | develop-eggs/ 216 | .installed.cfg 217 | 218 | # Installer logs 219 | pip-log.txt 220 | 221 | # Unit test / coverage reports 222 | .coverage 223 | .tox 224 | 225 | #Translations 226 | *.mo 227 | 228 | #Mr Developer 229 | .mr.developer.cfg 230 | 231 | ################# 232 | ## VS Code 233 | ################# 234 | .vscode 235 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Michael Higgins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PlexAutoSkip 2 | ============== 3 | **Automatically skip tagged content in Plex** 4 | 5 | A background python script that monitors local playback on your server and will automatically 'press' the Skip Intro button or skip other similarly tagged content automatically. Maintains real-time playback states for your server (not dependent on API updates) for accurate skip timing. Threaded to handle multiple players simultaneously. Several layers of state validation to prevent unnecessary stuttering/buffering. Custom definitions allow you to expand on features and functionality beyond what is automatically detected by Plex. Works with all automatically tagged markers including intros, credits, and advertisements. 6 | 7 | Notice 8 | -------------- 9 | Plex is moving towards adding native intro skipping at the client level which is ultimately the better solution. As a result there won't be any new major features being added to this project. Will continue to maintain minor bug fixes so it keeps working for unsupported players and will be happy to review pull requests 10 | 11 | https://forums.plex.tv/t/player-experience/857990 12 | 13 | Requirements 14 | -------------- 15 | - LAN sessions (not remote) as Plex does not allow seeking adjustments via the API for remote sessions 16 | - "Advertise as Player" / Plex Companion compatible player 17 | 18 | See https://github.com/mdhiggins/PlexAutoSkip/wiki/Troubleshooting#notice for changes to Plex Web based players 19 | 20 | Features 21 | -------------- 22 | - Skip any Plex identified markers with adjustable offsets 23 | - Markers (Can skip any marker but Plex includes the following with Plex Pass) 24 | - Intros 25 | - Commercials 26 | - Advertisements 27 | - Credits 28 | - Chapters 29 | - Only skip for watched content 30 | - Ignore skipping series and season premieres 31 | - Ignore skipping first episodes in every new viewing session 32 | - Skip last chapter (credits) 33 | - Bypass the "Up Next" screen 34 | - Custom Definitions 35 | - Define your own markers when auto detection fails 36 | - Filter clients/users 37 | - Export and audit Plex markers to make corrections / fill in gaps 38 | - Bulk edit marker timing 39 | - Negative value offsets to skip relative to content end 40 | - Mute or lower volume instead of skipping 41 | - Client must support Plex setVolume API call 42 | - Docker 43 | 44 | 45 | Requirements 46 | -------------- 47 | - Python3 48 | - PIP 49 | - PlexPass (for automatic markers) 50 | - PlexAPI 51 | - Websocket-client 52 | 53 | Setup 54 | -------------- 55 | 1. Enable `Enable local network discovery (GDM)` in your Plex Server > Network settings 56 | 2. Enable `Advertise as player` on Plex players 57 | 3. Ensure you have [Python](https://docs.python-guide.org/starting/installation/#installation) and [PIP](https://packaging.python.org/en/latest/tutorials/installing-packages/) installed 58 | 4. Clone the repository 59 | 5. Install requirements using `pip install -r ./setup/requirements.txt` 60 | 6. Run `main.py` once to generate config files or copy samples from the `./setup` directory and rename removing the `.sample` suffix 61 | 7. Edit `./config/config.ini` with your Plex account or Plex server settings 62 | 8. Run `main.py` 63 | 64 | _Script has fallback methods for when GDM is not enabled or is nonfunctional_ 65 | 66 | config.ini 67 | -------------- 68 | - See https://github.com/mdhiggins/PlexAutoSkip/wiki/Configuration#configuration-options-for-configini 69 | 70 | custom.json 71 | -------------- 72 | Optional custom parameters for which movie, show, season, or episode should be included or blocked. You can also define custom skip segments for media if you do not have Plex Pass or would like to skip additional areas of content 73 | - See https://github.com/mdhiggins/PlexAutoSkip/wiki/Configuration#configuration-options-for-customjson 74 | - For a small but hopefully growing repository of community made custom markers, please see https://github.com/mdhiggins/PlexAutoSkipCustomMarkers 75 | 76 | Docker 77 | -------------- 78 | - https://github.com/mdhiggins/plexautoskip-docker 79 | 80 | custom_audit.py 81 | -------------- 82 | Additional support script that contains features to check and modify your custom definition files in mass. Can offset entire collections of markers, export data from Plex, convert between GUID and ratingKey formats and more 83 | 84 | ``` 85 | # Get started 86 | python custom_audit.py --help 87 | ``` 88 | 89 | Special Thanks 90 | -------------- 91 | - Plex 92 | - PlexAPI 93 | - Skippex 94 | - https://github.com/Casvt/Plex-scripts/blob/main/stream_control/intro_skipper.py 95 | - https://github.com/liamcottle 96 | -------------------------------------------------------------------------------- /custom_audit.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | from argparse import ArgumentParser 5 | from resources.customEntries import CustomEntries 6 | from resources.settings import Settings 7 | from resources.mediaWrapper import STARTKEY, ENDKEY, TYPEKEY 8 | from resources.log import getLogger 9 | from plexapi.server import PlexServer 10 | from plexapi.video import Show, Season, Episode, Movie 11 | from resources.server import getPlexServer 12 | from typing import TypeVar 13 | 14 | ComplexMedia = TypeVar("ComplexMedia", Show, Season, Episode, Movie) 15 | 16 | parser = ArgumentParser(description="Plex Autoskip Custom JSON auditer") 17 | parser.add_argument('-c', '--config', help='Specify an alternate configuration file location') 18 | parser.add_argument('-g', '--write_guids', action='store_true', help="Overwrite custom.json ratingKeys with GUIDs") 19 | parser.add_argument('-rk', '--write_ratingkeys', action='store_true', help="Overwrite custom.json GUIDs with ratingKeys") 20 | parser.add_argument('-p', '--path', help="Path to custom JSON file or directory. If unspecified default ./config folder will be used") 21 | parser.add_argument('-o', '--offset', type=int, help="Specify an offset by which to adjust for both start and end") 22 | parser.add_argument('-so', '--startoffset', type=int, help="Specify an offset by which to adjust for start") 23 | parser.add_argument('-eo', '--endoffset', type=int, help="Specify an offset by which to adjust for end") 24 | parser.add_argument('-d', '--duration', type=int, help="Validate marker duration in milliseconds") 25 | parser.add_argument('-dg', '--dump_guids', type=str, help="Dump existing markers using GUIDs. Specify source as ratingKey or GUID") 26 | parser.add_argument('-drk', '--dump_ratingkeys', type=str, help="Dump existing markers using ratingKeys. Specify source as ratingKey or GUID") 27 | args = vars(parser.parse_args()) 28 | 29 | path = args["path"] or os.path.join(os.path.dirname(sys.argv[0]), Settings.CONFIG_DIRECTORY) 30 | 31 | log = getLogger(__name__) 32 | 33 | NEEDS_SERVER = ['write_guids', 'write_ratingkeys', 'dump_guids', 'dump_ratingkeys'] 34 | 35 | 36 | def processData(data, server: PlexServer = None, ratingKeyLookup: dict = None, guidLookup: dict = None) -> dict: 37 | markers = data.get("markers") 38 | for k in markers: 39 | marker = markers[k] 40 | if isinstance(marker, dict): 41 | marker = [marker] 42 | for m in marker: 43 | diff = m['end'] - m['start'] 44 | if args["offset"]: 45 | log.info("Adjusting start offset by %d for %d" % (args["offset"], m['start'])) 46 | log.info("Adjusting end offset by %d for %d" % (args["offset"], m['end'])) 47 | m['start'] = m['start'] + args["offset"] 48 | m['end'] = m['end'] + args["offset"] 49 | else: 50 | if args["startoffset"]: 51 | log.info("Adjusting start offset by %d for %d" % (args["startoffset"], m['start'])) 52 | m['start'] = m['start'] + args["startoffset"] 53 | if args["endoffset"]: 54 | log.info("Adjusting end offset by %d for %d" % (args["endoffset"], m['end'])) 55 | m['end'] = m['end'] + args["endoffset"] 56 | if diff < 0: 57 | log.warning("%s entry is less than zero, likely invalid" % (k)) 58 | if args["duration"] and diff != args["duration"]: 59 | log.warning("%s does not equal specified duration of %d milliseconds (%d)" % (k, args["duration"], diff)) 60 | if m['start'] < 0: 61 | log.info("Start point %d is < 0, setting to 0" % (m['start'])) 62 | m['start'] = 0 63 | if m['end'] < 0: 64 | log.info("End point %d is < 0, setting to 0" % (m['end'])) 65 | m['end'] = 0 66 | if args['write_guids']: 67 | Settings.replaceWithGUIDs(data, server, ratingKeyLookup, log) 68 | elif args['write_ratingkeys']: 69 | Settings.replaceWithRatingKeys(data, server, guidLookup, log) 70 | analyzeMarkers(markers) 71 | return data 72 | 73 | 74 | def processFile(path, server: PlexServer = None, ratingKeyLookup: dict = None, guidLookup: dict = None) -> None: 75 | _, ext = os.path.splitext(Settings.CUSTOM_DEFAULT) 76 | if os.path.splitext(path)[1] == ext: 77 | data = {} 78 | with open(path, encoding='utf-8') as f: 79 | data = json.load(f) 80 | log.info("Accessing file %s" % (path)) 81 | data = processData(data, server, ratingKeyLookup, guidLookup) 82 | Settings.writeCustom(data, path, log) 83 | 84 | 85 | def analyzeMarkers(markers: dict) -> None: 86 | total = len(markers) 87 | populated = len([x for x in markers.values() if x]) 88 | log.info("%d total entries, %d populated, %d empty (%.0f%%)" % (total, populated, total - populated, (populated / total) * 100)) 89 | 90 | 91 | def dumpMarkers(media: ComplexMedia, settings: Settings, useGuid: bool = False) -> dict: 92 | content = [] 93 | if isinstance(media, Show) or isinstance(media, Season): 94 | content.extend(media.episodes()) 95 | else: 96 | content.append(media) 97 | data = dict(Settings.CUSTOM_DEFAULTS) 98 | for c in content: 99 | key = CustomEntries.keyToGuid(c) if useGuid else c.ratingKey 100 | data['markers'][key] = [] 101 | if hasattr(c, 'markers'): 102 | for m in c.markers: 103 | if m.type and m.type.lower() in settings.tags: 104 | data['markers'][key].append({ 105 | STARTKEY: m.start, 106 | ENDKEY: m.end, 107 | TYPEKEY: m.type 108 | }) 109 | if hasattr(c, 'chapters'): 110 | for m in c.chapters: 111 | if m.title and m.title.lower() in settings.tags: 112 | data['markers'][key].append({ 113 | STARTKEY: m.start, 114 | ENDKEY: m.end, 115 | TYPEKEY: m.title 116 | }) 117 | return data 118 | 119 | 120 | def dumpMarkersFromRatingKey(ratingKey: int, ratingKeyLookup: dict, settings: Settings, useGuid: bool) -> dict: 121 | return dumpMarkers(ratingKeyLookup[int(ratingKey)], settings, useGuid) 122 | 123 | 124 | def dumpMarkersFromGuid(guid: str, guidLookup: dict, settings: Settings, useGuid: bool) -> dict: 125 | return dumpMarkers(guidLookup[guid], settings, useGuid) 126 | 127 | 128 | if __name__ == '__main__': 129 | settings = None 130 | server = None 131 | ratingKeyLookup = None 132 | guidLookup = None 133 | 134 | if any(args[x] for x in NEEDS_SERVER): 135 | if args['config'] and os.path.exists(args['config']): 136 | settings = Settings(args['config'], loadCustom=False, logger=log) 137 | elif args['config'] and os.path.exists(os.path.join(os.path.dirname(sys.argv[0]), args['config'])): 138 | settings = Settings(os.path.join(os.path.dirname(sys.argv[0]), args['config']), loadCustom=False, logger=log) 139 | else: 140 | settings = Settings(loadCustom=False, logger=log) 141 | server, _ = getPlexServer(settings, log) 142 | 143 | identifier = args['dump_guids'] or args['dump_ratingkeys'] 144 | if identifier: 145 | useGuid = args['dump_guids'] is not None 146 | output = None 147 | if CustomEntries.keyIsGuid(identifier): 148 | guidLookup = CustomEntries.loadGuids(server, log) 149 | output = dumpMarkersFromGuid(identifier, guidLookup, settings, useGuid) 150 | else: 151 | ratingKeyLookup = CustomEntries.loadRatingKeys(server, log) 152 | output = dumpMarkersFromRatingKey(identifier, ratingKeyLookup, settings, useGuid) 153 | if output: 154 | _, ext = os.path.splitext(Settings.CUSTOM_DEFAULT) 155 | if os.path.splitext(path)[1] == ext: 156 | Settings.writeCustom(output, path, log) 157 | processFile(path, server, ratingKeyLookup, guidLookup) 158 | else: 159 | log.info(json.dumps(output, indent=4)) 160 | processData(output, server, ratingKeyLookup, guidLookup) 161 | sys.exit(0) 162 | 163 | if args['write_guids']: 164 | ratingKeyLookup = CustomEntries.loadRatingKeys(server, log) 165 | elif args['write_ratingkeys']: 166 | guidLookup = CustomEntries.loadGuids(server, log) 167 | 168 | if os.path.isdir(path): 169 | for root, _, files in os.walk(path): 170 | for filename in files: 171 | fullpath = os.path.join(root, filename) 172 | processFile(fullpath, server, ratingKeyLookup, guidLookup) 173 | elif os.path.exists(path): 174 | processFile(path, server, ratingKeyLookup, guidLookup) 175 | else: 176 | log.error("Invalid path %s, does it exist?" % (path)) 177 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import ArgumentParser 4 | from resources.log import getLogger 5 | from resources.settings import Settings 6 | from resources.skipper import Skipper 7 | from resources.server import getPlexServer 8 | 9 | if __name__ == '__main__': 10 | log = getLogger(__name__) 11 | 12 | parser = ArgumentParser(description="Plex Autoskip") 13 | parser.add_argument('-c', '--config', help='Specify an alternate configuration file location') 14 | args = vars(parser.parse_args()) 15 | 16 | if args['config'] and os.path.exists(args['config']): 17 | settings = Settings(args['config'], logger=log) 18 | elif args['config'] and os.path.exists(os.path.join(os.path.dirname(sys.argv[0]), args['config'])): 19 | settings = Settings(os.path.join(os.path.dirname(sys.argv[0]), args['config']), logger=log) 20 | else: 21 | settings = Settings(logger=log) 22 | 23 | plex, sslopt = getPlexServer(settings, log) 24 | 25 | if plex: 26 | skipper = Skipper(plex, settings, log) 27 | skipper.start(sslopt=sslopt) 28 | else: 29 | log.error("Unable to establish Plex Server object via PlexAPI") 30 | -------------------------------------------------------------------------------- /notify.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from resources.server import getPlexServer 3 | from resources.log import getLogger 4 | from resources.settings import Settings 5 | from argparse import ArgumentParser 6 | import os 7 | import sys 8 | import requests 9 | 10 | ########################################################################################################################### 11 | # Credit to https://gist.github.com/liamcottle/86180844b81fcf8085e2c9182daa278c for the original script 12 | # Requires "New Content Added to Library" notification to be enabled 13 | ########################################################################################################################### 14 | 15 | 16 | def csv(arg: str) -> List: 17 | return [x.lower().strip() for x in arg.split(",") if x] 18 | 19 | 20 | log = getLogger(__name__) 21 | 22 | parser = ArgumentParser(description="Plex Autoskip Notification Sender") 23 | parser.add_argument('-c', '--config', help='Specify an alternate configuration file location') 24 | parser.add_argument('-au', '--allowedusers', help="Users to send message to, leave back to send to all users", type=csv) 25 | parser.add_argument('-bu', '--blockedusers', help="Users to exlude sending the message to", type=csv) 26 | parser.add_argument('-u', '--url', help="URL to direct users to when clicked", default="https://github.com/mdhiggins/PlexAutoSkip") 27 | parser.add_argument('message', help='Message to send to users') 28 | 29 | args = vars(parser.parse_args()) 30 | 31 | if args['config'] and os.path.exists(args['config']): 32 | settings = Settings(args['config'], loadCustom=False, logger=log) 33 | elif args['config'] and os.path.exists(os.path.join(os.path.dirname(sys.argv[0]), args['config'])): 34 | settings = Settings(os.path.join(os.path.dirname(sys.argv[0]), args['config']), loadCustom=False, logger=log) 35 | else: 36 | settings = Settings(loadCustom=False, logger=log) 37 | 38 | server, _ = getPlexServer(settings, log) 39 | 40 | message = args['message'] 41 | if not message: 42 | log.warning("No message included, aborting") 43 | sys.exit(1) 44 | 45 | users = args['allowedusers'] 46 | blocked = args['blockedusers'] 47 | 48 | myPlexAccount = server.myPlexAccount() 49 | 50 | if not myPlexAccount: 51 | log.warning("No myPlex account found, aborting") 52 | sys.exit(1) 53 | 54 | myPlexUsers = myPlexAccount.users() + [myPlexAccount] 55 | 56 | if users: 57 | myPlexUsers = [u for u in myPlexUsers if u.username.lower() in users] 58 | if blocked: 59 | myPlexUsers = [u for u in myPlexUsers if u.username.lower() not in blocked] 60 | 61 | uids = [u.id for u in myPlexUsers] 62 | 63 | if not uids: 64 | log.warning("No valid users to notify, aborting") 65 | sys.exit(1) 66 | 67 | log.info("Sending message to %d users" % len(uids)) 68 | 69 | headers = { 70 | "X-Plex-Token": server._token, 71 | } 72 | 73 | data = { 74 | "group": 'media', 75 | "identifier": 'tv.plex.notification.library.new', 76 | "to": uids, 77 | "play": False, 78 | "data": { 79 | "provider": { 80 | "identifier": server.machineIdentifier, 81 | "title": server.friendlyName, 82 | } 83 | }, 84 | "metadata": { 85 | "title": message, 86 | }, 87 | "uri": args['url'], 88 | } 89 | 90 | url = 'https://notifications.plex.tv/api/v1/notifications' 91 | 92 | log.debug(data) 93 | 94 | x = requests.post(url, json=data, headers=headers) 95 | log.debug(x.text) 96 | log.info("Response received with status code %s" % (x.status_code)) 97 | 98 | if x.status_code in [200, 201]: 99 | sys.exit(0) 100 | else: 101 | sys.exit(1) 102 | -------------------------------------------------------------------------------- /resources/binge.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from resources.mediaWrapper import MediaWrapper, GRANDPARENTRATINGKEY 4 | from resources.log import getLogger 5 | from resources.settings import Settings 6 | from plexapi.playqueue import PlayQueue 7 | from typing import Dict, List 8 | 9 | 10 | class BingeSession(): 11 | EPISODETYPE = "episode" 12 | WATCHED_PERCENTAGE = 0.5 # Consider replacing with server watched percentage setting when available in PlexAPI future update 13 | 14 | class BingeSessionException(Exception): 15 | pass 16 | 17 | def __init__(self, mediaWrapper: MediaWrapper, blockCount: int, maxCount: int, safeTags: List[str], sameShowOnly: bool) -> None: 18 | if mediaWrapper.media.type != self.EPISODETYPE: 19 | raise self.BingeSessionException 20 | 21 | self.blockCount: int = blockCount 22 | 23 | try: 24 | pq: PlayQueue = PlayQueue.get(mediaWrapper.server, mediaWrapper.playQueueID) 25 | if pq.items[-1] == mediaWrapper.media: 26 | self.blockCount = 0 27 | if sameShowOnly and hasattr(mediaWrapper.media, GRANDPARENTRATINGKEY) and any([x for x in pq.items if hasattr(x, GRANDPARENTRATINGKEY) and x.grandparentRatingKey != mediaWrapper.media.grandparentRatingKey]): 28 | self.blockCount = 0 29 | except IndexError: 30 | self.blockCount = 0 31 | 32 | self.current: MediaWrapper = mediaWrapper 33 | self.count: int = 1 34 | self.maxCount: int = maxCount 35 | self._maxCount: int = maxCount 36 | self.safeTags: List[str] = safeTags 37 | self.lastUpdate: datetime = datetime.now() 38 | self.sameShowOnly: bool = sameShowOnly 39 | 40 | self.__updateMediaWrapper__() 41 | 42 | @property 43 | def clientIdentifier(self) -> str: 44 | return self.current.clientIdentifier 45 | 46 | @property 47 | def sinceLastUpdate(self) -> float: 48 | return (datetime.now() - self.lastUpdate).total_seconds() 49 | 50 | def __updateMediaWrapper__(self) -> None: 51 | if self.block: 52 | self.current.tags = [t for t in self.current.tags if t in self.safeTags] 53 | self.current.customMarkers = [c for c in self.current.customMarkers if c.type in self.safeTags] 54 | self.current.updateMarkers() 55 | 56 | def update(self, mediaWrapper: MediaWrapper) -> bool: 57 | if self.clientIdentifier == mediaWrapper.clientIdentifier and self.current.plexsession.user == mediaWrapper.plexsession.user: 58 | if self.sameShowOnly and hasattr(self.current.media, GRANDPARENTRATINGKEY) and hasattr(mediaWrapper.media, GRANDPARENTRATINGKEY) and self.current.media.grandparentRatingKey != mediaWrapper.media.grandparentRatingKey: 59 | return False 60 | if mediaWrapper.media != self.current.media or (mediaWrapper.media == self.current.media and self.current.ended and not mediaWrapper.ended): 61 | if self.current.media.duration and (self.current.viewOffset / self.current.media.duration) > self.WATCHED_PERCENTAGE: 62 | if self.blockSkipNext: 63 | self.maxCount += self._maxCount + 1 64 | self.count += 1 65 | self.current = mediaWrapper 66 | self.__updateMediaWrapper__() 67 | self.lastUpdate = datetime.now() 68 | return True 69 | return False 70 | 71 | @property 72 | def block(self) -> bool: 73 | return self.count <= self.blockCount 74 | 75 | @property 76 | def blockSkipNext(self) -> bool: 77 | if not self.maxCount: 78 | return False 79 | return self.count > self.maxCount 80 | 81 | @property 82 | def remaining(self) -> int: 83 | r = self.blockCount - self.count 84 | return r if r > 0 else 0 85 | 86 | def __repr__(self) -> str: 87 | return "%s-%s" % (self.clientIdentifier, self.current.playQueueID) 88 | 89 | 90 | class BingeSessions(): 91 | TIMEOUT = 300 92 | IGNORED_CAP = 200 93 | 94 | def __init__(self, settings: Settings, logger: logging.Logger = None) -> None: 95 | self.log = logger or getLogger(__name__) 96 | self.settings: Settings = settings 97 | self.sessions: Dict[BingeSession] = {} 98 | self.ignored: List[str] = [] 99 | 100 | def update(self, mediaWrapper: MediaWrapper) -> None: 101 | if mediaWrapper.ended: 102 | return 103 | 104 | if mediaWrapper.playQueueID in self.ignored: 105 | return 106 | 107 | if mediaWrapper.clientIdentifier in self.sessions: 108 | oldCount = self.sessions[mediaWrapper.clientIdentifier].count 109 | if self.sessions[mediaWrapper.clientIdentifier].update(mediaWrapper): 110 | if oldCount != self.sessions[mediaWrapper.clientIdentifier].count: 111 | self.log.debug("Updating binge watcher (%s) with %s, remaining %d total %d" % ("active" if self.sessions[mediaWrapper.clientIdentifier].block else "inactive", mediaWrapper, self.sessions[mediaWrapper.clientIdentifier].remaining, self.sessions[mediaWrapper.clientIdentifier].count)) 112 | return 113 | else: 114 | self.log.debug("Binge watcher %s is no longer relavant, player is playing alternative content, deleting" % (self.sessions[mediaWrapper.clientIdentifier])) 115 | del self.sessions[mediaWrapper.clientIdentifier] 116 | 117 | try: 118 | self.sessions[mediaWrapper.clientIdentifier] = BingeSession(mediaWrapper, self.settings.binge, self.settings.skipnextmax, self.settings.bingesafetags, self.settings.bingesameshowonly) 119 | self.log.debug("Creating binge watcher (%s) for %s, remaining %d total %d" % ("active" if self.sessions[mediaWrapper.clientIdentifier].block else "inactive", mediaWrapper, self.sessions[mediaWrapper.clientIdentifier].remaining, self.sessions[mediaWrapper.clientIdentifier].count)) 120 | except BingeSession.BingeSessionException: 121 | self.ignored.append(mediaWrapper.playQueueID) 122 | self.ignored = self.ignored[-self.IGNORED_CAP:] 123 | 124 | def blockSkipNext(self, mediaWrapper: MediaWrapper) -> bool: 125 | if not self.settings.skipnextmax: 126 | return False 127 | 128 | session: BingeSession = self.sessions.get(mediaWrapper.clientIdentifier) 129 | if session: 130 | return session.blockSkipNext 131 | return False 132 | 133 | def clean(self) -> None: 134 | for session in list(self.sessions.values()): 135 | if session.sinceLastUpdate > self.TIMEOUT: 136 | self.log.debug("Binge watcher %s hasn't been updated in %d seconds, removing" % (session, self.TIMEOUT)) 137 | del self.sessions[session.clientIdentifier] 138 | -------------------------------------------------------------------------------- /resources/customEntries.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, TypeVar 3 | from plexapi.server import PlexServer 4 | from plexapi.video import Show, Season, Episode, Movie 5 | from plexapi.exceptions import NotFound 6 | from resources.log import getLogger 7 | 8 | 9 | GuidMedia = TypeVar("GuidMedia", Show, Season, Episode, Movie) 10 | RATINGKEY = "ratingKey" 11 | 12 | 13 | class CustomEntries(): 14 | PREFIXES = ["imdb://", "tmdb://", "tvdb://"] 15 | 16 | @property 17 | def markers(self) -> Dict[str, list]: 18 | return self.data.get("markers", {}) 19 | 20 | @property 21 | def offsets(self) -> Dict[str, dict]: 22 | return self.data.get("offsets", {}) 23 | 24 | @property 25 | def tags(self) -> Dict[str, list]: 26 | return self.data.get("tags", {}) 27 | 28 | @property 29 | def allowed(self) -> Dict[str, List[dict]]: 30 | return self.data.get("allowed", {}) 31 | 32 | @property 33 | def allowedClients(self) -> List[str]: 34 | return self.allowed.get("clients", []) 35 | 36 | @property 37 | def allowedUsers(self) -> List[str]: 38 | return self.allowed.get("users", []) 39 | 40 | @property 41 | def allowedKeys(self) -> List[int]: 42 | return self.allowed.get("keys", []) 43 | 44 | @property 45 | def allowedSkipNext(self) -> List[int]: 46 | return self.allowed.get("skip-next", []) 47 | 48 | @property 49 | def blocked(self) -> List[dict]: 50 | return self.data.get("blocked", {}) 51 | 52 | @property 53 | def blockedClients(self) -> List[str]: 54 | return self.blocked.get("clients", []) 55 | 56 | @property 57 | def blockedUsers(self) -> List[str]: 58 | return self.blocked.get("users", []) 59 | 60 | @property 61 | def blockedKeys(self) -> List[int]: 62 | return self.blocked.get("keys", []) 63 | 64 | @property 65 | def blockedSkipNext(self) -> List[int]: 66 | return self.blocked.get("skip-next", []) 67 | 68 | @property 69 | def clients(self) -> Dict[str, str]: 70 | return self.data.get("clients", {}) 71 | 72 | @property 73 | def mode(self) -> Dict[str, str]: 74 | return self.data.get("mode", {}) 75 | 76 | @property 77 | def needsGuidResolution(self) -> bool: 78 | return any(str(key).startswith(p) for key in (list(self.markers.keys()) + list(self.offsets.keys()) + list(self.tags.keys()) + list(self.mode.keys()) + self.allowedKeys + self.blockedKeys) for p in self.PREFIXES) 79 | 80 | @staticmethod 81 | def loadGuids(plex: PlexServer, logger: logging.Logger = None) -> dict: 82 | log = logger or getLogger(__name__) 83 | log.debug("Generating GUID match table") 84 | guidLookup = {guid.id: item for item in plex.library.all() if hasattr(item, "guids") for guid in item.guids} 85 | log.debug("Finished generated match table with %d entries" % (len(guidLookup))) 86 | return guidLookup 87 | 88 | def convertToRatingKeys(self, server: PlexServer, guidLookup: dict = None) -> None: 89 | guidLookup = guidLookup or CustomEntries.loadGuids(server, self.log) 90 | for k in [x for x in list(self.markers.keys()) if CustomEntries.keyIsGuid(x)]: 91 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 92 | if str(ratingKey) != str(k): 93 | self.log.debug("Resolving custom markers GUID %s to ratingKey %s" % (k, ratingKey)) 94 | self.markers[str(ratingKey)] = self.markers.pop(k) 95 | else: 96 | self.log.error("Unable to resolve GUID %s to ratingKey in custom markers" % (k)) 97 | for k in [x for x in list(self.offsets.keys()) if CustomEntries.keyIsGuid(x)]: 98 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 99 | if str(ratingKey) != str(k): 100 | self.log.debug("Resolving custom offsets GUID %s to ratingKey %s" % (k, ratingKey)) 101 | self.offsets[str(ratingKey)] = self.offsets.pop(k) 102 | else: 103 | self.log.error("Unable to resolve GUID %s to ratingKey in custom offsets" % (k)) 104 | for k in [x for x in list(self.tags.keys()) if CustomEntries.keyIsGuid(x)]: 105 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 106 | if str(ratingKey) != str(k): 107 | self.log.debug("Resolving custom tags GUID %s to ratingKey %s" % (k, ratingKey)) 108 | self.tags[str(ratingKey)] = self.tags.pop(k) 109 | else: 110 | self.log.error("Unable to resolve GUID %s to ratingKey in tags offsets" % (k)) 111 | for k in [x for x in self.allowedKeys if CustomEntries.keyIsGuid(x)]: 112 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 113 | if str(ratingKey) != str(k): 114 | self.log.debug("Resolving custom allowedKey GUID %s to ratingKey %s" % (k, ratingKey)) 115 | self.allowedKeys.append(int(ratingKey)) 116 | self.allowedKeys.remove(k) 117 | else: 118 | self.log.error("Unable to resolve GUID %s to ratingKey in custom allowedKeys" % (k)) 119 | for k in [x for x in self.blockedKeys if CustomEntries.keyIsGuid(x)]: 120 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 121 | if str(ratingKey) != str(k): 122 | self.log.debug("Resolving custom blockedKeys GUID %s to ratingKey %s" % (k, ratingKey)) 123 | self.blockedKeys.append(int(ratingKey)) 124 | self.blockedKeys.remove(k) 125 | else: 126 | self.log.error("Unable to resolve GUID %s to ratingKey in custom blockedKeys" % (k)) 127 | for k in [x for x in list(self.mode.keys()) if CustomEntries.keyIsGuid(x)]: 128 | ratingKey = CustomEntries.resolveGuidToKey(k, guidLookup) 129 | if str(ratingKey) != str(k): 130 | self.log.debug("Resolving custom offsets GUID %s to ratingKey %s" % (k, ratingKey)) 131 | self.mode[str(ratingKey)] = self.mode.pop(k) 132 | else: 133 | self.log.error("Unable to resolve GUID %s to ratingKey in custom mode" % (k)) 134 | 135 | @staticmethod 136 | def loadRatingKeys(server: PlexServer, logger: logging.Logger = None) -> dict: 137 | log = logger or getLogger(__name__) 138 | log.debug("Generating ratingKey match table") 139 | ratingKeyLookup = {item.ratingKey: item for item in server.library.all() if hasattr(item, RATINGKEY)} 140 | for v in list(ratingKeyLookup.values()): 141 | if v.type == "show": 142 | for e in v.episodes(): 143 | ratingKeyLookup[e.ratingKey] = e 144 | for s in v.seasons(): 145 | ratingKeyLookup[s.ratingKey] = s 146 | log.debug("Finished generated match table with %d entries" % (len(ratingKeyLookup))) 147 | return ratingKeyLookup 148 | 149 | def convertToGuids(self, server: PlexServer, ratingKeyLookup: dict = None) -> None: 150 | ratingKeyLookup = ratingKeyLookup or CustomEntries.loadRatingKeys(server, self.log) 151 | for k in [x for x in list(self.markers.keys()) if not CustomEntries.keyIsGuid(x)]: 152 | guid = CustomEntries.resolveKeyToGuid(k, ratingKeyLookup) 153 | if str(guid) != str(k): 154 | self.log.debug("Resolving custom marker ratingKey %s to GUID %s" % (k, guid)) 155 | self.markers[guid] = self.markers.pop(k) 156 | else: 157 | self.log.error("Unable to resolve ratingKey %s to GUID in custom markers" % (k)) 158 | for k in [x for x in list(self.offsets.keys()) if not CustomEntries.keyIsGuid(x)]: 159 | guid = CustomEntries.resolveKeyToGuid(k, ratingKeyLookup) 160 | if str(guid) != str(k): 161 | self.log.debug("Resolving custom offset ratingKey %s to GUID %s" % (k, guid)) 162 | self.offsets[guid] = self.offsets.pop(k) 163 | else: 164 | self.log.error("Unable to resolve ratingKey %s to GUID in custom offsets" % (k)) 165 | for k in [x for x in list(self.tags.keys()) if not CustomEntries.keyIsGuid(x)]: 166 | guid = CustomEntries.resolveKeyToGuid(k, ratingKeyLookup) 167 | if str(guid) != str(k): 168 | self.log.debug("Resolving custom tags ratingKey %s to GUID %s" % (k, guid)) 169 | self.tags[guid] = self.tags.pop(k) 170 | else: 171 | self.log.error("Unable to resolve ratingKey %s to GUID in tags offsets" % (k)) 172 | for k in [x for x in self.allowedKeys if not CustomEntries.keyIsGuid(x)]: 173 | guid = CustomEntries.resolveKeyToGuid(str(k), ratingKeyLookup) 174 | if str(guid) != str(k): 175 | self.log.debug("Resolving custom allowedKey ratingKey %s to GUID %s" % (k, guid)) 176 | self.allowedKeys.append(guid) 177 | self.allowedKeys.remove(k) 178 | else: 179 | self.log.error("Unable to resolve ratingKey %s to GUID in custom allowedKeys" % (k)) 180 | for k in [x for x in self.blockedKeys if not CustomEntries.keyIsGuid(x)]: 181 | guid = CustomEntries.resolveKeyToGuid(str(k), ratingKeyLookup) 182 | if str(guid) != str(k): 183 | self.log.debug("Resolving custom blockedKey ratingKey %s to GUID %s" % (k, guid)) 184 | self.blockedKeys.append(guid) 185 | self.blockedKeys.remove(k) 186 | else: 187 | self.log.error("Unable to resolve ratingKey %s to GUID in custom blockedKeys" % (k)) 188 | for k in [x for x in list(self.mode.keys()) if not CustomEntries.keyIsGuid(x)]: 189 | guid = CustomEntries.resolveKeyToGuid(k, ratingKeyLookup) 190 | if str(guid) != str(k): 191 | self.log.debug("Resolving custom offset ratingKey %s to GUID %s" % (k, guid)) 192 | self.mode[guid] = self.mode.pop(k) 193 | else: 194 | self.log.error("Unable to resolve ratingKey %s to GUID in custom mode" % (k)) 195 | 196 | @staticmethod 197 | def keyIsGuid(key: str) -> bool: 198 | return any(str(key).startswith(p) for p in CustomEntries.PREFIXES) 199 | 200 | @staticmethod 201 | def resolveGuidToKey(key: str, guidLookup: dict) -> str: 202 | k = key.split(".") 203 | base = guidLookup.get(k[0]) 204 | if base: 205 | try: 206 | if len(k) == 2 and base.type == "show": 207 | return base.season(season=int(k[1])).ratingKey 208 | elif len(k) == 3 and base.type == "show": 209 | return base.episode(season=int(k[1]), episode=int(k[2])).ratingKey 210 | else: 211 | return base.ratingKey 212 | except NotFound: 213 | return key 214 | return key 215 | 216 | @staticmethod 217 | def resolveKeyToGuid(key: str, ratingKeyLookup: dict, prefix: str = "tmdb://") -> str: 218 | base = ratingKeyLookup.get(int(key)) 219 | return CustomEntries.keyToGuid(base, prefix) 220 | 221 | @staticmethod 222 | def keyToGuid(base: GuidMedia, prefix: str = "tmdb://") -> str: 223 | if base and hasattr(base, "guids"): 224 | if base.type == "episode": 225 | tmdb = next(g for g in base.show().guids if g.id.startswith(prefix)) 226 | return "%s.%d.%d" % (tmdb.id, base.seasonNumber, base.episodeNumber) 227 | elif base.type == "season": 228 | tmdb = next(g for g in base.show().guids if g.id.startswith(prefix)) 229 | return "%s.%d" % (tmdb.id, base.seasonNumber) 230 | else: 231 | tmdb = next(g for g in base.guids if g.id.startswith(prefix)) 232 | return tmdb.id 233 | return base.ratingKey 234 | 235 | def __init__(self, data: dict, logger: logging.Logger = None) -> None: 236 | self.data = data 237 | for m in self.markers: 238 | if isinstance(self.markers[m], dict): 239 | self.markers[m] = [self.markers[m]] 240 | self.log = logger or logging.getLogger(__name__) 241 | -------------------------------------------------------------------------------- /resources/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | from logging.config import fileConfig 5 | from logging.handlers import BaseRotatingHandler 6 | from configparser import RawConfigParser 7 | 8 | 9 | defaults = { 10 | 'loggers': { 11 | 'keys': 'root', 12 | }, 13 | 'handlers': { 14 | 'keys': 'consoleHandler, fileHandler', 15 | }, 16 | 'formatters': { 17 | 'keys': 'simpleFormatter, minimalFormatter', 18 | }, 19 | 'logger_root': { 20 | 'level': 'DEBUG', 21 | 'handlers': 'consoleHandler, fileHandler', 22 | }, 23 | 'handler_consoleHandler': { 24 | 'class': 'StreamHandler', 25 | 'level': 'INFO', 26 | 'formatter': 'minimalFormatter', 27 | 'args': '(sys.stdout,)', 28 | }, 29 | 'handler_fileHandler': { 30 | 'class': 'handlers.RotatingFileHandler', 31 | 'level': 'INFO', 32 | 'formatter': 'simpleFormatter', 33 | 'args': "('%(logfilename)s', 'a', 100000, 3, 'utf-8')", 34 | }, 35 | 'formatter_simpleFormatter': { 36 | 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', 37 | 'datefmt': '%Y-%m-%d %H:%M:%S', 38 | }, 39 | 'formatter_minimalFormatter': { 40 | 'format': '%(levelname)s - %(message)s', 41 | 'datefmt': '' 42 | } 43 | } 44 | 45 | CONFIG_DEFAULT = "logging.ini" 46 | CONFIG_DIRECTORY = "./config" 47 | RESOURCE_DIRECTORY = "./resources" 48 | RELATIVE_TO_ROOT = "../" 49 | LOG_NAME = "pas.log" 50 | 51 | 52 | def checkLoggingConfig(configfile: str) -> None: 53 | write = True 54 | config = RawConfigParser() 55 | if os.path.exists(configfile): 56 | config.read(configfile) 57 | write = False 58 | for s in defaults: 59 | if not config.has_section(s): 60 | config.add_section(s) 61 | write = True 62 | for k in defaults[s]: 63 | if not config.has_option(s, k): 64 | config.set(s, k, str(defaults[s][k])) 65 | 66 | # Remove sysLogHandler if you're on Windows 67 | if 'sysLogHandler' in config.get('handlers', 'keys'): 68 | config.set('handlers', 'keys', config.get('handlers', 'keys').replace('sysLogHandler', '')) 69 | write = True 70 | while config.get('handlers', 'keys').endswith(",") or config.get('handlers', 'keys').endswith(" "): 71 | config.set('handlers', 'keys', config.get('handlers', 'keys')[:-1]) 72 | write = True 73 | if write: 74 | fp = open(configfile, "w") 75 | config.write(fp) 76 | fp.close() 77 | 78 | 79 | def getLogger(name: str = None, custompath: str = None) -> logging.Logger: 80 | if custompath: 81 | custompath = os.path.realpath(custompath) 82 | if not os.path.isdir(custompath): 83 | custompath = os.path.dirname(custompath) 84 | rootpath = os.path.abspath(custompath) 85 | resourcepath = os.path.normpath(os.path.join(rootpath, RESOURCE_DIRECTORY)) 86 | configpath = os.path.normpath(os.path.join(rootpath, CONFIG_DIRECTORY)) 87 | else: 88 | rootpath = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), RELATIVE_TO_ROOT)) 89 | resourcepath = os.path.normpath(os.path.join(rootpath, RESOURCE_DIRECTORY)) 90 | configpath = os.path.normpath(os.path.join(rootpath, CONFIG_DIRECTORY)) 91 | 92 | logpath = configpath 93 | if not os.path.isdir(logpath): 94 | os.makedirs(logpath) 95 | 96 | if not os.path.isdir(configpath): 97 | os.makedirs(configpath) 98 | 99 | configfile = os.path.abspath(os.path.join(configpath, CONFIG_DEFAULT)).replace("\\", "\\\\") 100 | checkLoggingConfig(configfile) 101 | 102 | logfile = os.path.abspath(os.path.join(logpath, LOG_NAME)).replace("\\", "\\\\") 103 | fileConfig(configfile, defaults={'logfilename': logfile}) 104 | 105 | logger = logging.getLogger(name) 106 | rotatingFileHandlers = [x for x in logger.handlers if isinstance(x, BaseRotatingHandler)] 107 | for rh in rotatingFileHandlers: 108 | rh.rotator = rotator 109 | 110 | return logging.getLogger(name) 111 | 112 | 113 | def rotator(source: str, dest: str) -> None: 114 | if os.path.exists(source): 115 | try: 116 | os.rename(source, dest) 117 | except: 118 | try: 119 | shutil.copyfile(source, dest) 120 | open(source, 'w').close() 121 | except Exception as e: 122 | print("Error rotating logfiles: %s." % (e)) 123 | -------------------------------------------------------------------------------- /resources/mediaWrapper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from plexapi import media, utils 4 | from plexapi.video import Episode, Movie 5 | from plexapi.server import PlexServer 6 | from plexapi.media import Marker, Chapter 7 | from plexapi.client import PlexClient 8 | from plexapi.base import PlexSession 9 | from plexapi.myplex import MyPlexAccount 10 | from plexapi.exceptions import NotFound 11 | from resources.customEntries import CustomEntries 12 | from resources.settings import Settings 13 | from resources.log import getLogger 14 | from typing import TypeVar, List 15 | from math import floor 16 | 17 | 18 | Media = TypeVar("Media", Episode, Movie) 19 | 20 | STARTKEY = "start" 21 | ENDKEY = "end" 22 | TYPEKEY = "type" 23 | TAGKEY = "tags" 24 | 25 | CUSTOMTAG = "custom" 26 | 27 | PLAYINGKEY = "playing" 28 | STOPPEDKEY = "stopped" 29 | PAUSEDKEY = "paused" 30 | CASCADEKEY = "cascade" 31 | BUFFERINGKEY = "buffering" 32 | 33 | MODEKEY = "mode" 34 | MARKERPREFIX = "m" 35 | CHAPTERPREFIX = "c" 36 | 37 | DURATION_TOLERANCE = 0.995 38 | 39 | PARENTRATINGKEY = "parentRatingKey" 40 | GRANDPARENTRATINGKEY = "grandparentRatingKey" 41 | 42 | 43 | # During paused/stopped states some PlexClients will report viewOffset rounded down to the nearest 1000, round accordingly 44 | def rd(num: int, place: int = 1000) -> int: 45 | return int(floor(num / place) * place) 46 | 47 | 48 | def strtobool(val): 49 | val = val.lower() 50 | if val in ('y', 'yes', 't', 'true', 'on', '1'): 51 | return True 52 | elif val in ('n', 'no', 'f', 'false', 'off', '0'): 53 | return False 54 | else: 55 | raise ValueError("Invalid truth value %r" % (val,)) 56 | 57 | 58 | class CustomMarker(): 59 | class CustomMarkerException(Exception): 60 | pass 61 | 62 | class CustomMarkerDurationException(Exception): 63 | pass 64 | 65 | def __init__(self, data: dict, key: str, duration: int, parentMode: Settings.MODE_TYPES = Settings.MODE_TYPES.SKIP) -> None: 66 | if STARTKEY not in data or ENDKEY not in data: 67 | raise self.CustomMarkerException 68 | try: 69 | self._start = int(data[STARTKEY]) 70 | self._end = int(data[ENDKEY]) 71 | self.type = data.get(TYPEKEY, CUSTOMTAG) 72 | self.cascade = data.get(CASCADEKEY, False) 73 | if isinstance(self.cascade, str): 74 | self.cascade = bool(strtobool(self.cascade)) 75 | except ValueError: 76 | raise self.CustomMarkerException 77 | self.mode = Settings.MODE_MATCHER.get(data.get(MODEKEY, "").lower(), parentMode) 78 | self.duration = duration 79 | self.key = key 80 | 81 | if not duration and (self._start < 0 or self._end < 0): 82 | raise self.CustomMarkerDurationException 83 | 84 | def safeRange(self, target) -> int: 85 | if target < 0: 86 | return 0 87 | if self.duration and target > self.duration: 88 | return self.duration 89 | return target 90 | 91 | @property 92 | def start(self) -> int: 93 | return self.safeRange(self.duration + self._start if self._start < 0 else self._start) 94 | 95 | @property 96 | def end(self) -> int: 97 | return self.safeRange(self._end if self._end > 0 else self.duration + self._end) 98 | 99 | def __repr__(self) -> str: 100 | return "" % (utils.millisecondToHumanstr(self.start), utils.millisecondToHumanstr(self.end)) 101 | 102 | @property 103 | def length(self) -> int: 104 | return self.end - self.start 105 | 106 | 107 | class MediaWrapper(): 108 | CLIENT_PORTS = { 109 | "Plex for Roku": 8324, 110 | "Plex for Android (TV)": 32500, 111 | "Plex for Android (Mobile)": 32500, 112 | "Plex for iOS": 32500, 113 | "Plex for Apple TV": 32500, 114 | "Plex for Windows": 32700, 115 | "Plex for Mac": 32700 116 | } 117 | 118 | DEFAULT_CLIENT_PORT = 32500 119 | 120 | def __init__(self, session: PlexSession, clientIdentifier: str, state: str, playQueueID: int, server: PlexServer, settings: Settings, custom: CustomEntries = None, logger: logging.Logger = None) -> None: 121 | self._viewOffset: int = session.viewOffset 122 | self.plexsession: PlexSession = session 123 | self.server: PlexServer = server 124 | self.media: Media = session.source() 125 | 126 | self.clientIdentifier = clientIdentifier 127 | self.state: str = state 128 | self.ended: bool = False 129 | self.playQueueID: int = playQueueID 130 | self.player: PlexClient = session.player 131 | 132 | self.lastUpdate: datetime = datetime.now() 133 | self.lastAlert: datetime = datetime.now() 134 | self.lastSeek: datetime = datetime(1970, 1, 1) 135 | 136 | self.seekTarget: int = 0 137 | self.seekOrigin: int = 0 138 | 139 | self.markers: List[Marker] = [] 140 | self.chapters: List[Chapter] = [] 141 | self.lastchapter: Chapter = None 142 | 143 | self.customOnly: bool = False 144 | self.customMarkers: List[CustomMarker] = [] 145 | 146 | self.leftOffset: int = 0 147 | self.rightOffset: int = 0 148 | self.offsetTags: List[str] = settings.offsetTags 149 | self.commandDelay: int = 0 150 | 151 | self.tags: List[str] = settings.tags 152 | 153 | self.log = logger or getLogger(__name__) 154 | self.customMarkers = [] 155 | 156 | self.mode: Settings.MODE_TYPES = settings.mode 157 | 158 | self.skipnext: bool = settings.skipnext 159 | 160 | self.cachedVolume: int = 0 161 | self.loweringVolume: bool = False 162 | 163 | try: 164 | self.userToken: str = self.plexsession.user._token if isinstance(self.plexsession.user, MyPlexAccount) else self.plexsession.user.get_token(server.machineIdentifier) 165 | except NotFound: 166 | self.userToken: str = None 167 | 168 | client = next((c for c in server.clients() if c.machineIdentifier == self.player.machineIdentifier), None) 169 | if custom and self.player.title in custom.clients: 170 | if custom.clients[self.player.title] == "proxy": 171 | self.player.proxyThroughServer(True, server) 172 | self.log.debug("Overriding player %s to force proxying through the server" % (self.player.title)) 173 | else: 174 | self.player._baseurl = custom.clients[self.player.title].strip('/') 175 | self.player._baseurl = self.player._baseurl if self.player._baseurl.startswith("http://") else "http://%s" % (self.player._baseurl) 176 | self.player.proxyThroughServer(False) 177 | self.log.debug("Overriding player %s with custom baseURL %s, will not proxy through server" % (self.player.title, self.player._baseurl)) 178 | elif custom and self.clientIdentifier in custom.clients: 179 | if custom.clients[self.clientIdentifier] == "proxy": 180 | self.player.proxyThroughServer(True, server) 181 | self.log.debug("Overriding player %s to force proxying through the server" % (self.clientIdentifier)) 182 | else: 183 | self.player._baseurl = custom.clients[self.clientIdentifier].strip('/') 184 | self.player._baseurl = self.player._baseurl if self.player._baseurl.startswith("http://") else "http://%s" % (self.player._baseurl) 185 | self.player.proxyThroughServer(False) 186 | self.log.debug("Overriding player %s with custom baseURL %s, will not proxy through server" % (self.clientIdentifier, self.player._baseurl)) 187 | elif not client or (client.address == self.player.address and "playback" in client.protocolCapabilities): 188 | # If there is no client, direct connect. If there is a client but its IP address matches the device IP, still direct connect. In devices that are proxy dependent 127.0.0.1 will usually be reported 189 | port = int(self.server._myPlexClientPorts().get(self.player.machineIdentifier, self.CLIENT_PORTS.get(self.player.product, self.DEFAULT_CLIENT_PORT))) 190 | baseurl = "http://%s:%d" % (self.player.address, port) 191 | self.player._baseurl = baseurl 192 | self.player.proxyThroughServer(False) 193 | else: 194 | self.player.proxyThroughServer(True, server) 195 | 196 | if custom: 197 | if hasattr(self.media, GRANDPARENTRATINGKEY): 198 | if str(self.media.grandparentRatingKey) in custom.markers: 199 | for markerdata in custom.markers[str(self.media.grandparentRatingKey)]: 200 | try: 201 | cm = CustomMarker(markerdata, self.media.grandparentRatingKey, self.media.duration, settings.mode) 202 | if cm not in self.customMarkers: 203 | self.customMarkers.append(cm) 204 | except CustomMarker.CustomMarkerException: 205 | self.log.error("Invalid CustomMarker data for grandparentRatingKey %s" % (self.media.grandparentRatingKey)) 206 | except CustomMarker.CustomMarkerDurationException: 207 | self.log.error("Invalid CustomMarker data for grandparentRatingKey %s, negative value start/end but API not reporting duration" % (self.media.grandparentRatingKey)) 208 | if str(self.media.grandparentRatingKey) in custom.offsets: 209 | self.leftOffset = custom.offsets[str(self.media.grandparentRatingKey)].get(STARTKEY, self.leftOffset) 210 | self.rightOffset = custom.offsets[str(self.media.grandparentRatingKey)].get(ENDKEY, self.rightOffset) 211 | self.offsetTags = custom.offsets[str(self.media.grandparentRatingKey)].get(TAGKEY, self.offsetTags) 212 | if str(self.media.grandparentRatingKey) in custom.tags: 213 | self.tags = custom.tags[str(self.media.grandparentRatingKey)] 214 | if str(self.media.grandparentRatingKey) in custom.mode: 215 | self.mode = Settings.MODE_MATCHER.get(custom.mode[str(self.media.grandparentRatingKey)], self.mode) 216 | 217 | if hasattr(self.media, PARENTRATINGKEY): 218 | if str(self.media.parentRatingKey) in custom.markers: 219 | filtered = [x for x in self.customMarkers if x.cascade] 220 | if self.customMarkers != filtered: 221 | self.log.debug("Better parentRatingKey markers found, clearing %d previous marker(s)" % (len(self.customMarkers) - len(filtered))) 222 | self.customMarkers = filtered 223 | for markerdata in custom.markers[str(self.media.parentRatingKey)]: 224 | try: 225 | cm = CustomMarker(markerdata, self.media.parentRatingKey, self.media.duration, settings.mode) 226 | if cm not in self.customMarkers: 227 | self.log.debug("Found custom marker range %s entry for %s (parentRatingKey match)" % (cm, self)) 228 | self.customMarkers.append(cm) 229 | except CustomMarker.CustomMarkerException: 230 | self.log.error("Invalid CustomMarker data for parentRatingKey %s" % (self.media.parentRatingKey)) 231 | except CustomMarker.CustomMarkerDurationException: 232 | self.log.error("Invalid CustomMarker data for parentRatingKey %s, negative value start/end but API not reporting duration" % (self.media.parentRatingKey)) 233 | if str(self.media.parentRatingKey) in custom.offsets: 234 | self.leftOffset = custom.offsets[str(self.media.parentRatingKey)].get(STARTKEY, self.leftOffset) 235 | self.rightOffset = custom.offsets[str(self.media.parentRatingKey)].get(ENDKEY, self.rightOffset) 236 | self.offsetTags = custom.offsets[str(self.media.parentRatingKey)].get(TAGKEY, self.offsetTags) 237 | if str(self.media.parentRatingKey) in custom.tags: 238 | self.tags = custom.tags[str(self.media.parentRatingKey)] 239 | if str(self.media.parentRatingKey) in custom.mode: 240 | self.mode = Settings.MODE_MATCHER.get(custom.mode[str(self.media.parentRatingKey)], self.mode) 241 | 242 | if str(self.media.ratingKey) in custom.markers: 243 | filtered = [x for x in self.customMarkers if x.cascade] 244 | if self.customMarkers != filtered: 245 | self.log.debug("Better ratingKey markers found, clearing %d previous marker(s)" % (len(self.customMarkers) - len(filtered))) 246 | self.customMarkers = filtered 247 | for markerdata in custom.markers[str(self.media.ratingKey)]: 248 | try: 249 | cm = CustomMarker(markerdata, self.media.ratingKey, self.media.duration, settings.mode) 250 | if cm not in self.customMarkers: 251 | self.log.debug("Found custom marker range %s entry for %s" % (cm, self)) 252 | self.customMarkers.append(cm) 253 | except CustomMarker.CustomMarkerException: 254 | self.log.error("Invalid CustomMarker data for ratingKey %s" % (self.media.ratingKey)) 255 | except CustomMarker.CustomMarkerDurationException: 256 | self.log.error("Invalid CustomMarker data for ratingKey %s, negative value start/end but API not reporting duration" % (self.media.ratingKey)) 257 | if str(self.media.ratingKey) in custom.offsets: 258 | self.leftOffset = custom.offsets[str(self.media.ratingKey)].get(STARTKEY, self.leftOffset) 259 | self.rightOffset = custom.offsets[str(self.media.ratingKey)].get(ENDKEY, self.rightOffset) 260 | self.offsetTags = custom.offsets[str(self.media.ratingKey)].get(TAGKEY, self.offsetTags) 261 | if str(self.media.ratingKey) in custom.tags: 262 | self.tags = custom.tags[str(self.media.ratingKey)] 263 | if str(self.media.ratingKey) in custom.mode: 264 | self.mode = Settings.MODE_MATCHER.get(custom.mode[str(self.media.ratingKey)], self.mode) 265 | 266 | if self.player.title in custom.mode: 267 | self.mode = Settings.MODE_MATCHER.get(custom.mode[self.player.title], self.mode) 268 | elif self.clientIdentifier in custom.mode: 269 | self.mode = Settings.MODE_MATCHER.get(custom.mode[self.clientIdentifier], self.mode) 270 | 271 | if self.player.title in custom.offsets: 272 | self.commandDelay = custom.offsets[self.player.title].get("command", self.commandDelay) 273 | elif self.clientIdentifier in custom.offsets: 274 | self.commandDelay = custom.offsets[self.clientIdentifier].get("command", self.commandDelay) 275 | 276 | if not self.skipnext and custom.allowedSkipNext and (self.player.title in custom.allowedSkipNext or self.clientIdentifier in custom.allowedSkipNext): 277 | self.skipnext = True 278 | elif self.skipnext and custom.allowedSkipNext and (self.player.title not in custom.allowedSkipNext and self.clientIdentifier not in custom.allowedSkipNext): 279 | self.skipnext = False 280 | elif self.skipnext and custom.blockedSkipNext and (self.player.title in custom.blockedSkipNext or self.clientIdentifier in custom.blockedSkipNext): 281 | self.skipnext = False 282 | 283 | self.tags = [x.lower() for x in self.tags] 284 | self.playerTags = custom.tags.get(self.player.machineIdentifier, custom.tags.get(self.player.product, [])) 285 | if self.playerTags: 286 | self.playerTags = [x.lower() for x in self.playerTags] 287 | self.log.debug("Found a special set of tags %s for player %s %s, filtering tags" % (self.playerTags, self.player.product, self.player.machineIdentifier)) 288 | self.tags = [x for x in self.tags if x in self.playerTags] 289 | 290 | if self.leftOffset: 291 | self.log.debug("Custom start offset value of %dms found for %s" % (self.leftOffset, self)) 292 | if self.rightOffset: 293 | self.log.debug("Custom end offset value of %dms found for %s" % (self.rightOffset, self)) 294 | if self.tags != settings.tags: 295 | self.log.debug("Custom tags value of %s found for %s" % (self.tags, self)) 296 | if self.offsetTags != settings.offsetTags: 297 | self.log.debug("Custom offset tags value of %s found for %s" % (self.offsetTags, self)) 298 | if self.mode != settings.mode: 299 | self.log.debug("Custom mode value of %s found for %s" % (self.mode, self)) 300 | if self.commandDelay: 301 | self.log.debug("Custom command delay value of %dms found for %s" % (self.commandDelay, self)) 302 | if self.skipnext != settings.skipnext: 303 | self.log.debug("Custom skipNext value of %s found for %s" % (self.skipnext, self)) 304 | 305 | if not hasattr(self.media, 'markers') and not self.customOnly: 306 | # Allow markers to be loaded on non-standard media (currently only loaded for episodes) 307 | try: 308 | self.media.markers = self.media.findItems(self.media._data, media.Marker) 309 | except: 310 | self.log.debug("Exception trying to load markers on non-standard media") 311 | 312 | if self.playerTags: 313 | self.log.debug("Filtering custom markers based on playerTags %s, add 'custom' or a specified 'type' to the definition to keep them" % (self.playerTags)) 314 | self.customMarkers = [x for x in self.customMarkers if x.type.lower() in self.playerTags] 315 | 316 | self.updateMarkers() 317 | 318 | if hasattr(self.media, 'chapters') and not self.customOnly and len(self.media.chapters) > 0: 319 | self.lastchapter = self.media.chapters[-1] 320 | 321 | def updateMarkers(self) -> None: 322 | if hasattr(self.media, 'markers') and not self.customOnly: 323 | self.markers = [x for x in self.media.markers if x.type and (x.type.lower() in self.tags or "%s:%s" % (MARKERPREFIX, x.type.lower()) in self.tags)] 324 | 325 | if hasattr(self.media, 'chapters') and not self.customOnly: 326 | self.chapters = [x for x in self.media.chapters if x.title and (x.title.lower() in self.tags or "%s:%s" % (CHAPTERPREFIX, x.title.lower()) in self.tags)] 327 | 328 | def __repr__(self) -> str: 329 | base = "%d [%d]" % (self.plexsession.sessionKey, self.media.ratingKey) 330 | if hasattr(self.media, "title"): 331 | if hasattr(self.media, "grandparentTitle") and hasattr(self.media, "seasonEpisode"): 332 | return "%s (%s %s - %s) %s|%s" % (base, self.media.grandparentTitle, self.media.seasonEpisode, self.media.title, self.player.title, self.clientIdentifier) 333 | return "%s (%s) %s|%s" % (base, self.media.title, self.player.title, self.clientIdentifier) 334 | return "%s %s|%s" % (base, self.player.title, self.clientIdentifier) 335 | 336 | @property 337 | def hasContent(self) -> bool: 338 | return len(self.chapters + self.markers + self.customMarkers) > 0 339 | 340 | @staticmethod 341 | def getSessionClientIdentifier(sessionKey: str, clientIdentifier: str) -> str: 342 | return "%s-%s" % (sessionKey, clientIdentifier) 343 | 344 | @property 345 | def pasIdentifier(self) -> str: 346 | return MediaWrapper.getSessionClientIdentifier(self.plexsession.sessionKey, self.clientIdentifier) 347 | 348 | @property 349 | def seeking(self) -> bool: 350 | return self.seekTarget > 0 351 | 352 | @property 353 | def sinceLastUpdate(self) -> float: 354 | return (datetime.now() - self.lastUpdate).total_seconds() 355 | 356 | @property 357 | def sinceLastAlert(self) -> float: 358 | return (datetime.now() - self.lastAlert).total_seconds() 359 | 360 | @property 361 | def viewOffset(self) -> int: 362 | if self.state != PLAYINGKEY: 363 | return self._viewOffset 364 | vo = self._viewOffset + round((datetime.now() - self.lastUpdate).total_seconds() * 1000) 365 | return vo if vo <= (self.media.duration or vo) else self.media.duration 366 | 367 | def seekTo(self, offset: int, player: PlexClient) -> None: 368 | self.plexsession.viewOffset = self.viewOffset 369 | self.seekOrigin = rd(self._viewOffset) 370 | self.seekTarget = rd(offset) 371 | self.lastUpdate = datetime.now() 372 | self._viewOffset = offset 373 | player.seekTo(offset) 374 | self.plexsession.viewOffset = offset 375 | 376 | def badSeek(self) -> None: 377 | self.state = BUFFERINGKEY 378 | self._viewOffset = self.plexsession.viewOffset 379 | # self.seekOrigin = 0 380 | # self.seekTarget = 0 381 | self.lastUpdate = datetime.now() 382 | 383 | def updateOffset(self, offset: int, state: str) -> None: 384 | self.lastAlert = datetime.now() 385 | 386 | if self.seeking: 387 | if self.seekOrigin < offset < self.seekTarget or state in [PAUSEDKEY, STOPPEDKEY]: 388 | self.log.debug("Rejecting %d [%s] update session %s, alert is out of date" % (offset, state, self)) 389 | return 390 | elif offset < self.seekOrigin: 391 | self.log.debug("Seeking but new offset is earlier than the old one for session %s [%s], updating data and assuming user manual seek" % (self, state)) 392 | else: 393 | self.log.debug("Recent seek successful, server offset update %d meets/exceeds target %d [%s]" % (offset, self.seekTarget, state)) 394 | 395 | self.log.debug("Updating session %s [%s] viewOffset %d, old %d, diff %dms (%ds since last update)" % (self, state, offset, self.viewOffset, (offset - self.viewOffset), (datetime.now() - self.lastUpdate).total_seconds())) 396 | 397 | self.state = state 398 | self.seekOrigin = 0 399 | self.seekTarget = 0 400 | self._viewOffset = offset 401 | self.plexsession.viewOffset = offset 402 | self.lastUpdate = datetime.now() 403 | if not self.ended and state in [PAUSEDKEY, STOPPEDKEY] and offset >= rd(self.media.duration * DURATION_TOLERANCE): 404 | self.ended = True 405 | 406 | def updateVolume(self, volume: int, previousVolume: int, lowering: bool) -> bool: 407 | self.cachedVolume = previousVolume 408 | self.loweringVolume = lowering 409 | return volume != previousVolume 410 | -------------------------------------------------------------------------------- /resources/server.py: -------------------------------------------------------------------------------- 1 | from plexapi import VERSION as PLEXAPIVERSION 2 | from plexapi.server import PlexServer 3 | from plexapi.myplex import MyPlexAccount 4 | from resources.log import getLogger 5 | from resources.settings import Settings 6 | from typing import Tuple, Dict 7 | from ssl import CERT_NONE 8 | from pkg_resources import parse_version 9 | import requests 10 | import logging 11 | 12 | MINVERSION = "4.12" 13 | 14 | 15 | def getPlexServer(settings: Settings, logger: logging.Logger = None) -> Tuple[PlexServer, dict]: 16 | log = logger or getLogger(__name__) 17 | 18 | if not settings.username and not settings.address: 19 | log.error("No plex server settings specified, please update your configuration file") 20 | return None, None 21 | 22 | if parse_version(PLEXAPIVERSION) < parse_version(MINVERSION): 23 | log.error("PlexAutoSkip requires version %s please update to %s or greater, current version is %s" % (MINVERSION, MINVERSION, PLEXAPIVERSION)) 24 | return None, None 25 | 26 | plex: PlexServer = None 27 | sslopt: Dict = None 28 | session: requests.Session = None 29 | 30 | if settings.ignore_certs: 31 | sslopt = {"cert_reqs": CERT_NONE} 32 | session = requests.Session() 33 | session.verify = False 34 | requests.packages.urllib3.disable_warnings() 35 | 36 | log.info("Connecting to Plex server...") 37 | if settings.username and settings.servername: 38 | try: 39 | account = None 40 | if settings.token: 41 | try: 42 | account = MyPlexAccount(username=settings.username, token=settings.token, session=session) 43 | except: 44 | log.debug("Unable to connect using token, falling back to password") 45 | account = None 46 | if settings.password and not account: 47 | try: 48 | account = MyPlexAccount(username=settings.username, password=settings.password, session=session) 49 | except: 50 | log.debug("Unable to connect using username/password") 51 | account = None 52 | if account: 53 | plex = account.resource(settings.servername).connect() 54 | if plex: 55 | log.info("Connected to Plex server %s using plex.tv account" % (plex.friendlyName)) 56 | except: 57 | log.exception("Error connecting to plex.tv account") 58 | 59 | if not plex and settings.address and settings.port and settings.token: 60 | protocol = "https://" if settings.ssl else "http://" 61 | try: 62 | plex = PlexServer(protocol + settings.address + ':' + str(settings.port), settings.token, session=session) 63 | log.info("Connected to Plex server %s using server settings" % (plex.friendlyName)) 64 | except: 65 | log.exception("Error connecting to Plex server") 66 | elif plex and settings.address and settings.token: 67 | log.debug("Connected to server using plex.tv account, ignoring manual server settings") 68 | 69 | return plex, sslopt 70 | -------------------------------------------------------------------------------- /resources/settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import logging 4 | import sys 5 | import json 6 | from resources.customEntries import CustomEntries 7 | from resources.log import getLogger 8 | from enum import Enum 9 | from plexapi.server import PlexServer 10 | 11 | 12 | class FancyConfigParser(configparser.ConfigParser, object): 13 | def getlist(self, section, option, vars=None, separator=",", default=[], lower=True, replace=[' '], modifier=None): 14 | value = self.get(section, option, vars=vars) 15 | 16 | if not isinstance(value, str) and isinstance(value, list): 17 | return value 18 | 19 | if value == '': 20 | return list(default) 21 | 22 | value = value.split(separator) 23 | 24 | for r in replace: 25 | value = [x.replace(r, '') for x in value] 26 | if lower: 27 | value = [x.lower() for x in value] 28 | 29 | value = [x.strip() for x in value] 30 | 31 | if modifier: 32 | value = [modifier(x) for x in value] 33 | return value 34 | 35 | 36 | class Settings: 37 | CONFIG_DEFAULT = "config.ini" 38 | CUSTOM_DEFAULT = "custom.json" 39 | CONFIG_DIRECTORY = "./config" 40 | RESOURCE_DIRECTORY = "./resources" 41 | RELATIVE_TO_ROOT = "../" 42 | ENV_CONFIG_VAR = "PAS_CONFIG" 43 | 44 | @property 45 | def CONFIG_RELATIVEPATH(self) -> str: 46 | return os.path.join(self.CONFIG_DIRECTORY, self.CONFIG_DEFAULT) 47 | 48 | DEFAULTS = { 49 | "Plex.tv": { 50 | "username": "", 51 | "password": "", 52 | "token": "", 53 | "servername": "", 54 | }, 55 | "Server": { 56 | "address": "", 57 | "ssl": True, 58 | "port": 32400, 59 | }, 60 | "Security": { 61 | "ignore-certs": False 62 | }, 63 | "Skip": { 64 | "mode": "skip", 65 | "tags": "intro, commercial, advertisement, credits", 66 | "types": "movie, episode", 67 | "ignored-libraries": "", 68 | "last-chapter": 0.0, 69 | "unwatched": True, 70 | "first-episode-series": "Watched", 71 | "first-episode-season": "Always", 72 | "first-safe-tags": "", 73 | "last-episode-series": "Watched", 74 | "last-episode-season": "Always", 75 | "last-safe-tags": "", 76 | "next": False 77 | }, 78 | "Binge": { 79 | "ignore-skip-for": 0, 80 | "safe-tags": "", 81 | "same-show-only": False, 82 | "skip-next-max": 0, 83 | }, 84 | "Offsets": { 85 | "start": 3000, 86 | "end": 1000, 87 | "command": 500, 88 | "tags": "intro" 89 | }, 90 | "Volume": { 91 | "low": 0, 92 | "high": 100 93 | } 94 | } 95 | 96 | CUSTOM_DEFAULTS = { 97 | "markers": {}, 98 | "offsets": {}, 99 | "tags": {}, 100 | "allowed": { 101 | 'users': [], 102 | 'clients': [], 103 | 'keys': [], 104 | 'skip-next': [] 105 | }, 106 | "blocked": { 107 | 'users': [], 108 | 'clients': [], 109 | 'keys': [], 110 | 'skip-next': [] 111 | }, 112 | "clients": {}, 113 | "mode": {} 114 | } 115 | 116 | class MODE_TYPES(Enum): 117 | SKIP = 0 118 | VOLUME = 1 119 | 120 | MODE_MATCHER = { 121 | "skip": MODE_TYPES.SKIP, 122 | "volume": MODE_TYPES.VOLUME, 123 | "mute": MODE_TYPES.VOLUME 124 | } 125 | 126 | class SKIP_TYPES(Enum): 127 | NEVER = 0 128 | WATCHED = 1 129 | ALWAYS = 2 130 | 131 | SKIP_MATCHER = { 132 | "never": SKIP_TYPES.NEVER, 133 | "watched": SKIP_TYPES.WATCHED, 134 | "played": SKIP_TYPES.WATCHED, 135 | "always": SKIP_TYPES.ALWAYS, 136 | "all": SKIP_TYPES.ALWAYS, 137 | "true": SKIP_TYPES.ALWAYS, 138 | "false": SKIP_TYPES.NEVER, 139 | True: SKIP_TYPES.ALWAYS, 140 | False: SKIP_TYPES.NEVER 141 | } 142 | 143 | def __init__(self, configFile: str = None, loadCustom: bool = True, logger: logging.Logger = None) -> None: 144 | self.log: logging.Logger = logger or logging.getLogger(__name__) 145 | 146 | self.username: str = None 147 | self.password: str = None 148 | self.servername: str = None 149 | self.token: str = None 150 | self.address: str = None 151 | self.ssl: bool = False 152 | self.port: int = 32400 153 | self.ignore_certs: bool = False 154 | self.tags: list = [] 155 | self.skiplastchapter: float = 0.0 156 | self.skipunwatched: bool = False 157 | self.skipE01: Settings.SKIP_TYPES = Settings.SKIP_TYPES.ALWAYS 158 | self.skipS01E01: Settings.SKIP_TYPES = Settings.SKIP_TYPES.ALWAYS 159 | self.firstsafetags: list = [] 160 | self.skiplastepisodeseason: Settings.SKIP_TYPES = Settings.SKIP_TYPES.ALWAYS 161 | self.skiplastepisodeseries: Settings.SKIP_TYPES = Settings.SKIP_TYPES.ALWAYS 162 | self.lastsafetags: list = [] 163 | self.binge: int = 0 164 | self.bingesafetags: list = [] 165 | self.bingesameshowonly: bool = False 166 | self.sessionLength: int = 120 167 | self.skipnext: bool = False 168 | self.skipnextmax: int = 0 169 | self.leftOffset: int = 0 170 | self.rightOffset: int = 0 171 | self.offsetTags: list = [] 172 | self.commandDelay: int = 0 173 | self.customEntries: CustomEntries = None 174 | 175 | self._configFile: str = None 176 | 177 | self.log.info(sys.executable) 178 | if sys.version_info.major == 2: 179 | self.log.warning("Python 2 is not officially supported, use with caution") 180 | 181 | rootpath = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), self.RELATIVE_TO_ROOT)) 182 | 183 | defaultConfigFile = os.path.normpath(os.path.join(rootpath, self.CONFIG_RELATIVEPATH)) 184 | envConfigFile = os.environ.get(self.ENV_CONFIG_VAR) 185 | 186 | if envConfigFile and os.path.exists(os.path.realpath(envConfigFile)): 187 | configFile = os.path.realpath(envConfigFile) 188 | self.log.debug("%s environment variable override found." % (self.ENV_CONFIG_VAR)) 189 | elif not configFile: 190 | configFile = defaultConfigFile 191 | self.log.debug("Loading default config file.") 192 | 193 | if os.path.isdir(configFile): 194 | configFile = os.path.realpath(os.path.join(configFile, self.CONFIG_RELATIVEPATH)) 195 | self.log.debug("Configuration file specified is a directory, joining with %s." % (self.CONFIG_DEFAULT)) 196 | 197 | self.log.info("Loading config file %s." % configFile) 198 | 199 | config: FancyConfigParser = FancyConfigParser() 200 | if os.path.isfile(configFile): 201 | config.read(configFile) 202 | 203 | write = False 204 | # Make sure all sections and all keys for each section are present 205 | for s in self.DEFAULTS: 206 | if not config.has_section(s): 207 | config.add_section(s) 208 | write = True 209 | for k in self.DEFAULTS[s]: 210 | if not config.has_option(s, k): 211 | config.set(s, k, str(self.DEFAULTS[s][k])) 212 | write = True 213 | if write: 214 | Settings.writeConfig(config, configFile, self.log) 215 | self._configFile = configFile 216 | 217 | self.readConfig(config) 218 | 219 | if loadCustom: 220 | data = {} 221 | _, ext = os.path.splitext(self.CUSTOM_DEFAULT) 222 | for root, _, files in os.walk(os.path.dirname(configFile)): 223 | for filename in files: 224 | fullpath = os.path.join(root, filename) 225 | if os.path.isfile(fullpath) and os.path.splitext(filename)[1] == ext: 226 | Settings.merge(data, Settings.loadCustom(fullpath, self.log)) 227 | else: 228 | continue 229 | if not data: 230 | Settings.merge(data, Settings.loadCustom(os.path.join(os.path.dirname(configFile), self.CUSTOM_DEFAULT), self.log)) 231 | 232 | self.customEntries = CustomEntries(data, self.log) 233 | 234 | @staticmethod 235 | def loadCustom(customFile: str, logger: logging.Logger = None) -> dict: 236 | log = logger or getLogger(__name__) 237 | data = dict(Settings.CUSTOM_DEFAULTS) 238 | if not os.path.exists(customFile): 239 | Settings.writeCustom(Settings.CUSTOM_DEFAULTS, customFile, log) 240 | elif os.path.exists(customFile): 241 | try: 242 | with open(customFile, encoding='utf-8') as f: 243 | data = json.load(f) 244 | except: 245 | log.exception("Found custom file %s but failed to load, using defaults" % (customFile)) 246 | 247 | write = False 248 | # Make sure default entries are present to prevent exceptions 249 | for k in Settings.CUSTOM_DEFAULTS: 250 | if k not in data: 251 | data[k] = {} 252 | write = True 253 | for sk in Settings.CUSTOM_DEFAULTS[k]: 254 | if sk not in data[k]: 255 | data[k][sk] = [] 256 | write = True 257 | if write: 258 | Settings.writeCustom(data, customFile, log) 259 | log.info("Loading custom JSON file %s" % customFile) 260 | return data 261 | 262 | @staticmethod 263 | def merge(d1: dict, d2: dict) -> None: 264 | for k in d2: 265 | if k in d1 and isinstance(d1[k], dict) and isinstance(d2[k], dict): 266 | Settings.merge(d1[k], d2[k]) 267 | elif k in d1 and isinstance(d1[k], list) and isinstance(d2[k], list): 268 | d1[k].extend(d2[k]) 269 | else: 270 | d1[k] = d2[k] 271 | 272 | @staticmethod 273 | def writeConfig(config: configparser.ConfigParser, cfgfile: str, logger: logging.Logger = None) -> None: 274 | log = logger or getLogger(__name__) 275 | if not os.path.isdir(os.path.dirname(cfgfile)): 276 | os.makedirs(os.path.dirname(cfgfile)) 277 | try: 278 | fp = open(cfgfile, "w") 279 | config.write(fp) 280 | fp.close() 281 | except PermissionError: 282 | log.exception("Error writing to %s due to permissions" % (cfgfile)) 283 | except IOError: 284 | log.exception("Error writing to %s" % (cfgfile)) 285 | 286 | @staticmethod 287 | def writeCustom(data: dict, cfgfile: str, logger: logging.Logger = None) -> None: 288 | log = logger or getLogger(__name__) 289 | try: 290 | with open(cfgfile, 'w', encoding='utf-8') as cf: 291 | json.dump(data, cf, indent=4) 292 | except PermissionError: 293 | log.exception("Error writing to %s due to permissions" % (cfgfile)) 294 | except IOError: 295 | log.exception("Error writing to %s" % (cfgfile)) 296 | 297 | def readConfig(self, config: FancyConfigParser) -> None: 298 | self.username = config.get("Plex.tv", "username") 299 | self.password = config.get("Plex.tv", "password", raw=True) 300 | self.servername = config.get("Plex.tv", "servername") 301 | self.token = config.get("Plex.tv", "token", raw=True) 302 | 303 | self.address = config.get("Server", "address") 304 | for prefix in ['http://', 'https://']: 305 | if self.address.startswith(prefix): 306 | self.address = self.address[len(prefix):] 307 | while self.address.endswith("/"): 308 | self.address = self.address[:1] 309 | self.ssl = config.getboolean("Server", "ssl") 310 | self.port = config.getint("Server", "port") 311 | 312 | self.ignore_certs = config.getboolean("Security", "ignore-certs") 313 | 314 | self.mode = self.MODE_MATCHER.get(config.get("Skip", "mode").lower(), self.MODE_TYPES.SKIP) 315 | self.tags = config.getlist("Skip", "tags", replace=[]) 316 | self.types = config.getlist("Skip", "types") 317 | self.ignoredlibraries = config.getlist("Skip", "ignored-libraries", replace=[]) 318 | self.skipunwatched = config.getboolean("Skip", "unwatched") 319 | self.skiplastchapter = config.getfloat("Skip", "last-chapter") 320 | try: 321 | self.skipS01E01 = self.SKIP_MATCHER.get(config.getboolean("Skip", "first-episode-series")) # Legacy bool support 322 | except ValueError: 323 | self.skipS01E01 = self.SKIP_MATCHER.get(config.get("Skip", "first-episode-series").lower(), self.SKIP_TYPES.ALWAYS) 324 | try: 325 | self.skipE01 = self.SKIP_MATCHER.get(config.getboolean("Skip", "first-episode-season")) # Legacy bool support 326 | except ValueError: 327 | self.skipE01 = self.SKIP_MATCHER.get(config.get("Skip", "first-episode-season").lower(), self.SKIP_TYPES.ALWAYS) 328 | self.firstsafetags = config.getlist("Skip", "first-safe-tags", replace=[]) 329 | self.skiplastepisodeseries = self.SKIP_MATCHER.get(config.get("Skip", "last-episode-series").lower(), self.SKIP_TYPES.ALWAYS) 330 | self.skiplastepisodeseason = self.SKIP_MATCHER.get(config.get("Skip", "last-episode-season").lower(), self.SKIP_TYPES.ALWAYS) 331 | self.lastsafetags = config.getlist("Skip", "last-safe-tags", replace=[]) 332 | self.skipnext = config.getboolean("Skip", "next") 333 | 334 | self.binge = config.getint("Binge", "ignore-skip-for") 335 | self.bingesafetags = config.getlist("Binge", "safe-tags", replace=[]) 336 | self.bingesameshowonly = config.getboolean("Binge", "same-show-only") 337 | self.skipnextmax = config.getint("Binge", "skip-next-max") 338 | 339 | self.leftOffset = config.getint("Offsets", "start") 340 | self.rightOffset = config.getint("Offsets", "end") 341 | self.commandDelay = config.getint("Offsets", "command") 342 | self.offsetTags = config.getlist("Offsets", "tags", replace=[]) 343 | 344 | self.volumelow = config.getint("Volume", "low") 345 | self.volumehigh = config.getint("Volume", "high") 346 | 347 | for v in [self.volumelow, self.volumehigh]: 348 | if v < 0: 349 | v = 0 350 | if v > 100: 351 | v = 100 352 | 353 | @staticmethod 354 | def replaceWithGUIDs(data, server: PlexServer, ratingKeyLookup: dict, logger: logging.Logger = None) -> None: 355 | log = logger or getLogger(__name__) 356 | c = CustomEntries(data, logger=log) 357 | c.convertToGuids(server, ratingKeyLookup) 358 | 359 | @staticmethod 360 | def replaceWithRatingKeys(data, server: PlexServer, guidLookup: dict, logger: logging.Logger = None) -> None: 361 | log = logger or getLogger(__name__) 362 | c = CustomEntries(data, logger=log) 363 | c.convertToRatingKeys(server, guidLookup) 364 | -------------------------------------------------------------------------------- /resources/skipper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import logging 4 | import time 5 | import os 6 | from resources.settings import Settings 7 | from resources.customEntries import CustomEntries 8 | from resources.sslAlertListener import SSLAlertListener 9 | from resources.mediaWrapper import Media, MediaWrapper, PLAYINGKEY, STOPPEDKEY, PAUSEDKEY, BUFFERINGKEY, DURATION_TOLERANCE, GRANDPARENTRATINGKEY, PARENTRATINGKEY, rd 10 | from resources.binge import BingeSessions 11 | from resources.log import getLogger 12 | from xml.etree.ElementTree import ParseError 13 | from urllib3.exceptions import ReadTimeoutError 14 | from requests.exceptions import ReadTimeout 15 | from socket import timeout 16 | from plexapi.exceptions import BadRequest, NotFound 17 | from plexapi.client import PlexClient 18 | from plexapi.server import PlexServer 19 | from plexapi.playqueue import PlayQueue 20 | from plexapi.base import PlexSession 21 | from threading import Thread 22 | from typing import Dict, List 23 | from pkg_resources import parse_version 24 | 25 | 26 | class Skipper(): 27 | TROUBLESHOOT_URL = "https://github.com/mdhiggins/PlexAutoSkip/wiki/Troubleshooting" 28 | ERRORS = { 29 | "FrameworkException: Unable to find player with identifier": "BadRequest Error, see %s#badrequest-error" % TROUBLESHOOT_URL, 30 | "HTTPError: HTTP Error 403: Forbidden": "Forbidden Error, see %s#forbidden-error" % TROUBLESHOOT_URL, 31 | "(404) not_found": "404 Error, see %s#badrequest-error" % TROUBLESHOOT_URL 32 | } 33 | 34 | CREDIT_SKIP_FIX = { 35 | "Plex for Roku": 1500 36 | } 37 | 38 | # :( CustomEntries: 51 | return self.settings.customEntries 52 | 53 | def __init__(self, server: PlexServer, settings: Settings, logger: logging.Logger = None) -> None: 54 | self.server = server 55 | self.settings = settings 56 | self.log = logger or getLogger(__name__) 57 | self.verbose = os.environ.get("PAS_VERBOSE", "").lower() == "true" 58 | 59 | self.media_sessions: Dict[str, MediaWrapper] = {} 60 | self.delete: List[str] = [] 61 | self.ignored: List[str] = [] 62 | self.reconnect: bool = False 63 | self.bingeSessions = BingeSessions(self.settings, self.log) 64 | 65 | self.log.debug("%s init with leftOffset %d rightOffset %d" % (self.__class__.__name__, self.settings.leftOffset, self.settings.rightOffset)) 66 | self.log.debug("Offset tags %s" % (self.settings.offsetTags)) 67 | self.log.debug("Operating in %s mode" % (self.settings.mode)) 68 | self.log.debug("Skip tags %s" % (self.settings.tags)) 69 | self.log.debug("Skip first episode series %s" % (self.settings.skipS01E01)) 70 | self.log.debug("Skip first episode season %s" % (self.settings.skipE01)) 71 | self.log.debug("Skip last episode series %s" % (self.settings.skiplastepisodeseries)) 72 | self.log.debug("Skip last episode season %s" % (self.settings.skiplastepisodeseason)) 73 | self.log.debug("Skip last chapter %s" % (self.settings.skiplastchapter)) 74 | self.log.debug("Binge ignore skip for length %s" % (self.settings.binge)) 75 | 76 | if settings.customEntries.needsGuidResolution: 77 | self.log.debug("Custom entries contain GUIDs that need ratingKey resolution") 78 | settings.customEntries.convertToRatingKeys(server) 79 | 80 | self.log.info("Skipper initiated and ready") 81 | 82 | def getMediaSession(self, sessionKey: str) -> PlexSession: 83 | try: 84 | return next(iter([session for session in self.server.sessions() if session.sessionKey == sessionKey]), None) 85 | except KeyboardInterrupt: 86 | raise 87 | except: 88 | self.log.exception("getDataFromSessions Error") 89 | return None 90 | 91 | def start(self, sslopt: dict = None) -> None: 92 | self.listener = SSLAlertListener(self.server, self.processAlert, self.error, sslopt=sslopt, logger=self.log) 93 | self.log.debug("Starting listener") 94 | self.listener.start() 95 | self.reconnect = self.listener.is_alive() 96 | while self.listener.is_alive(): 97 | try: 98 | for session in list(self.media_sessions.values()): 99 | self.checkMedia(session) 100 | self.bingeSessions.clean() 101 | time.sleep(1) 102 | except KeyboardInterrupt: 103 | self.log.debug("Stopping listener") 104 | self.reconnect = False 105 | self.listener.stop() 106 | break 107 | else: 108 | self.log.error("Connection lost") 109 | if self.reconnect: 110 | self.start(sslopt) 111 | 112 | def checkMedia(self, mediaWrapper: MediaWrapper) -> None: 113 | if mediaWrapper.sinceLastAlert > self.TIMEOUT: 114 | self.log.debug("Session %s hasn't been updated in %d seconds" % (mediaWrapper, self.TIMEOUT)) 115 | self.removeSession(mediaWrapper) 116 | 117 | if mediaWrapper.state == BUFFERINGKEY: 118 | return 119 | 120 | leftOffset = mediaWrapper.leftOffset or self.settings.leftOffset 121 | rightOffset = mediaWrapper.rightOffset or self.settings.rightOffset 122 | 123 | self.checkMediaSkip(mediaWrapper, leftOffset, rightOffset) 124 | self.checkMediaVolume(mediaWrapper, leftOffset, rightOffset) 125 | 126 | if mediaWrapper.skipnext and mediaWrapper.ended and (mediaWrapper.viewOffset >= rd(mediaWrapper.media.duration * DURATION_TOLERANCE)): 127 | self.log.info("Found ended session %s that has reached the end of its duration %d with viewOffset %d with skip-next enabled, will skip to next" % (mediaWrapper, mediaWrapper.media.duration, mediaWrapper.viewOffset)) 128 | self.seekTo(mediaWrapper, mediaWrapper.media.duration) 129 | elif mediaWrapper.ended: 130 | self.log.debug("Session %s has been marked as ended with viewOffset %d and state %s, removing" % (mediaWrapper, mediaWrapper.viewOffset, mediaWrapper.state)) 131 | self.removeSession(mediaWrapper) 132 | 133 | def checkMediaSkip(self, mediaWrapper: MediaWrapper, leftOffset: int, rightOffset: int) -> None: 134 | if mediaWrapper.state != PLAYINGKEY: 135 | return 136 | 137 | skipMarkers = [m for m in mediaWrapper.customMarkers if m.mode == Settings.MODE_TYPES.SKIP] 138 | for marker in skipMarkers: 139 | if marker.start <= mediaWrapper.viewOffset < rd(marker.end): 140 | self.log.info("Found a custom marker for media %s with range %d-%d and viewOffset %d (%d)" % (mediaWrapper, marker.start, marker.end, mediaWrapper.viewOffset, marker.key)) 141 | self.seekTo(mediaWrapper, marker.end) 142 | return 143 | 144 | if mediaWrapper.mode != Settings.MODE_TYPES.SKIP: 145 | return 146 | 147 | if self.settings.skiplastchapter and mediaWrapper.lastchapter and (mediaWrapper.lastchapter.start / mediaWrapper.media.duration) > self.settings.skiplastchapter: 148 | if mediaWrapper.lastchapter and mediaWrapper.lastchapter.start <= mediaWrapper.viewOffset < rd(mediaWrapper.lastchapter.end): 149 | self.log.info("Found a valid last chapter for media %s with range %d-%d and viewOffset %d with skip-last-chapter enabled" % (mediaWrapper, mediaWrapper.lastchapter.start, mediaWrapper.lastchapter.end, mediaWrapper.viewOffset)) 150 | self.seekTo(mediaWrapper, mediaWrapper.media.duration) 151 | return 152 | 153 | for chapter in mediaWrapper.chapters: 154 | if chapter.start <= mediaWrapper.viewOffset < rd(chapter.end): 155 | self.log.info("Found skippable chapter %s for media %s with range %d-%d and viewOffset %d" % (chapter.title, mediaWrapper, chapter.start, chapter.end, mediaWrapper.viewOffset)) 156 | self.seekTo(mediaWrapper, chapter.end) 157 | return 158 | 159 | for marker in mediaWrapper.markers: 160 | lo = leftOffset if marker.type.lower() in mediaWrapper.offsetTags else 0 161 | ro = rightOffset if marker.type.lower() in mediaWrapper.offsetTags else 0 162 | 163 | start = marker.start if marker.start < lo else (marker.start + lo) 164 | if (start) <= mediaWrapper.viewOffset < rd(marker.end): 165 | self.log.info("Found skippable marker %s for media %s with range %d(+%d)-%d(+%d) and viewOffset %d" % (marker.type, mediaWrapper, marker.start, lo, marker.end, ro, mediaWrapper.viewOffset)) 166 | self.seekTo(mediaWrapper, marker.end + ro) 167 | return 168 | 169 | def checkMediaVolume(self, mediaWrapper: MediaWrapper, leftOffset: int, rightOffset: int) -> None: 170 | if mediaWrapper.state != PLAYINGKEY: 171 | return 172 | 173 | shouldLower = self.shouldLowerMediaVolume(mediaWrapper, leftOffset, rightOffset) 174 | if not mediaWrapper.loweringVolume and shouldLower: 175 | self.log.info("Moving from normal volume to low volume viewOffset %d which is a low volume area for media %s, lowering volume to %d" % (mediaWrapper.viewOffset, mediaWrapper, self.settings.volumelow)) 176 | self.setVolume(mediaWrapper, self.settings.volumelow, shouldLower) 177 | return 178 | elif mediaWrapper.loweringVolume and not shouldLower: 179 | self.log.info("Moving from lower volume to normal volume viewOffset %d for media %s, raising volume to %d" % (mediaWrapper.viewOffset, mediaWrapper, mediaWrapper.cachedVolume)) 180 | self.setVolume(mediaWrapper, mediaWrapper.cachedVolume, shouldLower) 181 | return 182 | 183 | def shouldLowerMediaVolume(self, mediaWrapper: MediaWrapper, leftOffset: int, rightOffset: int) -> bool: 184 | customVolumeMarkers = [m for m in mediaWrapper.customMarkers if m.mode == Settings.MODE_TYPES.VOLUME] 185 | for marker in customVolumeMarkers: 186 | if marker.start <= mediaWrapper.viewOffset < marker.end: 187 | self.log.debug("Inside a custom marker for media %s with range %d-%d and viewOffset %d (%d), volume should be low" % (mediaWrapper, marker.start, marker.end, mediaWrapper.viewOffset, marker.key)) 188 | return True 189 | 190 | if mediaWrapper.mode != Settings.MODE_TYPES.VOLUME: 191 | return False 192 | 193 | if self.settings.skiplastchapter and mediaWrapper.lastchapter and (mediaWrapper.lastchapter.start / mediaWrapper.media.duration) > self.settings.skiplastchapter: 194 | if mediaWrapper.lastchapter and mediaWrapper.lastchapter.start <= mediaWrapper.viewOffset <= mediaWrapper.lastchapter.end: 195 | self.log.debug("Inside a valid last chapter for media %s with range %d-%d and viewOffset %d with skip-last-chapter enabled, volume should be low" % (mediaWrapper, mediaWrapper.lastchapter.start, mediaWrapper.lastchapter.end, mediaWrapper.viewOffset)) 196 | return True 197 | 198 | for chapter in mediaWrapper.chapters: 199 | if chapter.start <= mediaWrapper.viewOffset < chapter.end: 200 | self.log.debug("Inside chapter %s for media %s with range %d-%d and viewOffset %d, volume should be low" % (chapter.title, mediaWrapper, chapter.start, chapter.end, mediaWrapper.viewOffset)) 201 | return True 202 | 203 | for marker in mediaWrapper.markers: 204 | lo = leftOffset if marker.type.lower() in mediaWrapper.offsetTags else 0 205 | ro = rightOffset if marker.type.lower() in mediaWrapper.offsetTags else 0 206 | if (marker.start + lo) <= mediaWrapper.viewOffset < (marker.end + ro): 207 | self.log.debug("Inside marker %s for media %s with range %d(+%d)-%d(+%d) and viewOffset %d, volume should be low" % (marker.type, mediaWrapper, marker.start, lo, marker.end, ro, mediaWrapper.viewOffset)) 208 | return True 209 | return False 210 | 211 | def seekTo(self, mediaWrapper: MediaWrapper, targetOffset: int) -> None: 212 | t = Thread(target=self._seekTo, args=(mediaWrapper, targetOffset,)) 213 | t.start() 214 | 215 | def _seekTo(self, mediaWrapper: MediaWrapper, targetOffset: int) -> None: 216 | try: 217 | self.seekPlayerTo(mediaWrapper.player, mediaWrapper, targetOffset) 218 | except (ReadTimeout, ReadTimeoutError, timeout): 219 | self.log.debug("TimeoutError, removing from cache to prevent false triggers, will be restored with next sync") 220 | self.removeSession(mediaWrapper) 221 | except: 222 | self.log.exception("Exception, removing from cache to prevent false triggers, will be restored with next sync") 223 | self.removeSession(mediaWrapper) 224 | 225 | def seekPlayerTo(self, player: PlexClient, mediaWrapper: MediaWrapper, targetOffset: int, pq: PlayQueue = None, server: PlexServer = None) -> bool: 226 | if not player: 227 | return False 228 | 229 | try: 230 | try: 231 | if mediaWrapper.skipnext and targetOffset >= mediaWrapper.media.duration: 232 | return self.skipPlayerTo(player, mediaWrapper, pq, server) 233 | else: 234 | if mediaWrapper.media.duration and targetOffset >= (mediaWrapper.media.duration - self.CREDIT_SKIP_FIX.get(player.product, 0)): 235 | self.log.debug("TargetOffset %d is greater or equal to duration of media %d(-%d), adjusting to match" % (targetOffset, mediaWrapper.media.duration, self.CREDIT_SKIP_FIX.get(player.product, 0))) 236 | targetOffset = mediaWrapper.media.duration - self.CREDIT_SKIP_FIX.get(player.product, 0) 237 | 238 | if targetOffset <= mediaWrapper.viewOffset: 239 | self.log.debug("TargetOffset %d is less than or equal to current viewOffset %d, ignoring" % (targetOffset, mediaWrapper.viewOffset)) 240 | return False 241 | 242 | self.log.info("Seeking %s player playing %s from %d to %d" % (player.product, mediaWrapper, mediaWrapper.viewOffset, targetOffset)) 243 | mediaWrapper.seekTo(targetOffset, player) 244 | return True 245 | except ParseError: 246 | self.log.debug("ParseError, seems to be certain players but still functional, continuing") 247 | return True 248 | except BadRequest as br: 249 | self.logErrorMessage(br, "BadRequest exception seekPlayerTo") 250 | mediaWrapper.badSeek() 251 | return False 252 | # return self.seekPlayerTo(self.recoverPlayer(player), mediaWrapper, targetOffset, pq, server) 253 | except NotFound as nf: 254 | self.logErrorMessage(nf, "NotFound exception seekPlayerTo") 255 | mediaWrapper.badSeek() 256 | return False 257 | # return self.seekPlayerTo(self.recoverPlayer(player), mediaWrapper, targetOffset, pq, server) 258 | except: 259 | raise 260 | 261 | def skipPlayerTo(self, player: PlexClient, mediaWrapper: MediaWrapper, pq: PlayQueue, server: PlexServer) -> bool: 262 | self.removeSession(mediaWrapper) 263 | self.ignoreSession(mediaWrapper) 264 | 265 | if self.bingeSessions.blockSkipNext(mediaWrapper): 266 | self.log.debug("Maximum skipNext achieved, stopping playback") 267 | player.stop() 268 | return True 269 | 270 | server = server or mediaWrapper.server 271 | if mediaWrapper.plexsession.user != server.myPlexAccount(): 272 | try: 273 | self.log.debug("Creating new server session with user %s" % (mediaWrapper.plexsession._username)) 274 | server = server.switchUser(mediaWrapper.plexsession._username) 275 | except: 276 | self.log.exception("Unable to create new server instance to maintain current user") 277 | 278 | if not pq: 279 | try: 280 | current = PlayQueue.get(self.server, mediaWrapper.playQueueID) 281 | if current.items[-1] != mediaWrapper.media: 282 | nextItem: Media = current[current.items.index(mediaWrapper.media) + 1] 283 | pq = PlayQueue.create(server, list(current.items), nextItem) 284 | self.log.debug("Creating new PlayQueue %d with start item %s" % (pq.playQueueID, nextItem)) 285 | else: 286 | self.log.debug("No more items in PlayQueue %d, at the end" % (current.playQueueID)) 287 | except Exception as e: 288 | self.log.exception("") 289 | self.log.debug("Seek target is the end but unable to get existing PlayQueue %d (%s) data from server" % (mediaWrapper.playQueueID, mediaWrapper.media.playQueueItemID)) 290 | if self.verbose: 291 | self.log.debug(e) 292 | if mediaWrapper.media.type == "episode": 293 | self.log.debug("Attempting to create a new PlayQueue using remaining episodes") 294 | try: 295 | episodes = mediaWrapper.media.show().episodes() 296 | if episodes and episodes[-1] != mediaWrapper.media: 297 | self.log.debug("Generating new PlayQueue using remaining episodes in series") 298 | startItemIndex = episodes.index(mediaWrapper.media) + 1 299 | startItem = episodes[startItemIndex] 300 | self.log.debug("New queue contains %d items, selecting %s with index %s" % (len(episodes), startItem, startItemIndex)) 301 | pq = PlayQueue.create(server, episodes, startItem) 302 | else: 303 | self.log.debug("No remaining episodes in series to build a PlayQueue") 304 | except: 305 | self.log.exception("Unable to create new PlayQueue for %s" % (mediaWrapper)) 306 | 307 | if mediaWrapper.media.type == "episode" and (not pq or not pq.items): 308 | try: 309 | data = server.query(mediaWrapper.media.show()._details_key) 310 | items = mediaWrapper.media.findItems(data, rtag='OnDeck') 311 | if items: 312 | self.log.debug("Generating new PlayQueue using on deck episodes in series") 313 | items = [mediaWrapper.media] + items 314 | pq = PlayQueue.create(server, items, items[1]) 315 | else: 316 | self.log.debug("No on deck episodes found to build a PlayQueue") 317 | except: 318 | self.log.exception("Unable to create new on deck PlayQueue for %s" % (mediaWrapper)) 319 | 320 | if not pq or not pq.items: 321 | self.log.warning("No available PlayQueue data %d (%s), using seekTo to go to media end" % (mediaWrapper.playQueueID, mediaWrapper.media.playQueueItemID)) 322 | mediaWrapper.seekTo(mediaWrapper.media.duration - self.CREDIT_SKIP_FIX.get(player.product, 0), player) 323 | return True 324 | 325 | if pq.items[-1] == mediaWrapper.media: 326 | self.log.debug("Seek target is the end but no more items in the PlayQueue, using seekTo to prevent loop") 327 | mediaWrapper.seekTo(mediaWrapper.media.duration - self.CREDIT_SKIP_FIX.get(player.product, 0), player) 328 | else: 329 | commandDelay = mediaWrapper.commandDelay or self.settings.commandDelay 330 | time.sleep(commandDelay / 1000) 331 | player.stop() 332 | time.sleep(commandDelay / 1000) 333 | player.playMedia(pq) 334 | return True 335 | 336 | def setVolume(self, mediaWrapper: MediaWrapper, volume: int, lowering: bool) -> None: 337 | t = Thread(target=self._setVolume, args=(mediaWrapper, volume, lowering)) 338 | t.start() 339 | 340 | def _setVolume(self, mediaWrapper: MediaWrapper, volume: int, lowering: bool) -> None: 341 | try: 342 | self.setPlayerVolume(mediaWrapper.player, mediaWrapper, volume, lowering) 343 | except (ReadTimeout, ReadTimeoutError, timeout): 344 | self.log.debug("TimeoutError, removing from cache to prevent false triggers, will be restored with next sync") 345 | self.removeSession(mediaWrapper) 346 | except: 347 | self.log.exception("Exception, removing from cache to prevent false triggers, will be restored with next sync") 348 | self.removeSession(mediaWrapper) 349 | 350 | def setPlayerVolume(self, player: PlexClient, mediaWrapper: MediaWrapper, volume: int, lowering: bool) -> bool: 351 | if not player: 352 | return False 353 | try: 354 | try: 355 | previousVolume = self.settings.volumehigh if lowering else self.settings.volumelow 356 | if player.timeline and player.timeline.volume is not None: 357 | previousVolume = player.timeline.volume 358 | else: 359 | self.log.debug("Unable to access timeline data for player %s to cache previous volume value, will restore to %d" % (player.product, previousVolume)) 360 | self.log.info("Setting %s player volume playing %s from %d to %d" % (player.product, mediaWrapper, previousVolume, volume)) 361 | mediaWrapper.updateVolume(volume, previousVolume, lowering) 362 | player.setVolume(volume) 363 | return True 364 | except ParseError: 365 | self.log.debug("ParseError, seems to be certain players but still functional, continuing") 366 | return True 367 | except BadRequest as br: 368 | self.logErrorMessage(br, "BadRequest exception setPlayerVolume") 369 | return False 370 | except NotFound as nf: 371 | self.logErrorMessage(nf, "NotFound exception setPlayerVolume") 372 | return False 373 | except: 374 | raise 375 | 376 | def safeVersion(self, version) -> str: 377 | return version.split("-")[0] 378 | 379 | def validPlayer(self, player: PlexClient) -> bool: 380 | bad = self.BROKEN_CLIENTS.get(player.product) 381 | if bad and player.version and parse_version(self.safeVersion(player.version)) >= parse_version(bad): 382 | self.log.error("Bad %s version %s due to Plex team removing 'Advertise as Player/Plex Companion' functionality. Please visit %s#notice to review this issue and voice your support on the Plex forums for this feature to be restored" % (player.product, player.version, self.TROUBLESHOOT_URL)) 383 | return False 384 | 385 | if player and (player._proxyThroughServer or player._baseurl): 386 | return True 387 | return False 388 | 389 | def processAlert(self, data: dict) -> None: 390 | if data['type'] == 'playing': 391 | sessionKey = int(data['PlaySessionStateNotification'][0]['sessionKey']) 392 | clientIdentifier = data['PlaySessionStateNotification'][0]['clientIdentifier'] 393 | pasIdentifier = MediaWrapper.getSessionClientIdentifier(sessionKey, clientIdentifier) 394 | playQueueID = int(data['PlaySessionStateNotification'][0].get('playQueueID', 0)) 395 | 396 | if pasIdentifier in self.ignored: 397 | if self.verbose: 398 | self.log.debug("Ignoring session %s" % pasIdentifier) 399 | return 400 | 401 | try: 402 | state = data['PlaySessionStateNotification'][0]['state'] 403 | viewOffset = int(data['PlaySessionStateNotification'][0]['viewOffset']) 404 | 405 | if pasIdentifier not in self.media_sessions: 406 | mediaSession = self.getMediaSession(sessionKey) 407 | if self.verbose: 408 | if mediaSession and mediaSession.session and mediaSession.player: 409 | self.log.debug("Alert for %s with state %s viewOffset %d playQueueID %d location %s user %s player IP %s" % (pasIdentifier, state, viewOffset, playQueueID, mediaSession.session.location, mediaSession._username, mediaSession.player.address)) 410 | elif mediaSession and mediaSession.session: 411 | self.log.debug("Alert for %s with state %s viewOffset %d playQueueID %d location %s user %s" % (pasIdentifier, state, viewOffset, playQueueID, mediaSession.session.location, mediaSession._username)) 412 | else: 413 | self.log.debug("Alert for %s with state %s viewOffset %d playQueueID %d but no session data" % (pasIdentifier, state, viewOffset, playQueueID)) 414 | if mediaSession and mediaSession.session and mediaSession.session.location == 'lan': 415 | wrapper = MediaWrapper(mediaSession, clientIdentifier, state, playQueueID, self.server, settings=self.settings, custom=self.customEntries, logger=self.log) 416 | if not self.blockedClientUser(wrapper): 417 | if self.shouldAdd(wrapper): 418 | self.addSession(wrapper) 419 | else: 420 | if len(wrapper.customMarkers) > 0: 421 | wrapper.customOnly = True 422 | self.addSession(wrapper) 423 | else: 424 | self.ignoreSession(wrapper) 425 | else: 426 | self.ignoreSession(wrapper) 427 | else: 428 | mediaSession = self.media_sessions[pasIdentifier] 429 | mediaSession.updateOffset(viewOffset, state=state) 430 | if not mediaSession.ended and state in [STOPPEDKEY, PAUSEDKEY] and not self.getMediaSession(sessionKey): 431 | self.media_sessions[pasIdentifier].ended = True 432 | self.bingeSessions.update(mediaSession) 433 | except KeyboardInterrupt: 434 | raise 435 | except: 436 | self.log.exception("Unexpected error getting data from session alert") 437 | 438 | def blockedClientUser(self, mediaWrapper: MediaWrapper) -> bool: 439 | session = mediaWrapper.plexsession 440 | 441 | # Users 442 | if session._username in self.customEntries.blockedUsers: 443 | self.log.debug("Blocking %s based on blocked user in %s" % (mediaWrapper, session._username)) 444 | return True 445 | if self.customEntries.allowedUsers and session._username not in self.customEntries.allowedUsers: 446 | self.log.debug("Blocking %s based on no allowed user in %s" % (mediaWrapper, session._username)) 447 | return True 448 | elif self.customEntries.allowedUsers: 449 | self.log.debug("Allowing %s based on allowed user in %s" % (mediaWrapper, session._username)) 450 | 451 | # Clients/players 452 | if self.customEntries.allowedClients and (mediaWrapper.player.title not in self.customEntries.allowedClients and mediaWrapper.clientIdentifier not in self.customEntries.allowedClients): 453 | self.log.debug("Blocking %s based on no allowed player %s %s" % (mediaWrapper, mediaWrapper.player.title, mediaWrapper.clientIdentifier)) 454 | return True 455 | elif self.customEntries.allowedClients: 456 | self.log.debug("Allowing %s based on allowed player %s %s" % (mediaWrapper, mediaWrapper.player.title, mediaWrapper.clientIdentifier)) 457 | if self.customEntries.blockedClients and (mediaWrapper.player.title in self.customEntries.blockedClients or mediaWrapper.clientIdentifier in self.customEntries.blockedClients): 458 | self.log.debug("Blocking %s based on blocked player %s %s" % (mediaWrapper, mediaWrapper.player.title, mediaWrapper.clientIdentifier)) 459 | return True 460 | return False 461 | 462 | def shouldAdd(self, mediaWrapper: MediaWrapper) -> bool: 463 | media = mediaWrapper.media 464 | 465 | if mediaWrapper.media.type not in self.settings.types: 466 | self.log.debug("Blocking %s of type %s as its not on the approved type list %s" % (mediaWrapper, media.type, self.settings.types)) 467 | return False 468 | 469 | if media.librarySectionTitle and media.librarySectionTitle.lower() in self.settings.ignoredlibraries: 470 | self.log.debug("Blocking %s in library %s as its library is on the ignored list %s" % (mediaWrapper, media.librarySectionTitle, self.settings.ignoredlibraries)) 471 | return False 472 | 473 | # Keys 474 | allowed = False 475 | if media.ratingKey in self.customEntries.allowedKeys: 476 | self.log.debug("Allowing %s for ratingKey %s" % (mediaWrapper, media.ratingKey)) 477 | allowed = True 478 | if media.ratingKey in self.customEntries.blockedKeys: 479 | self.log.debug("Blocking %s for ratingKey %s" % (mediaWrapper, media.ratingKey)) 480 | return False 481 | if hasattr(media, PARENTRATINGKEY): 482 | if media.parentRatingKey in self.customEntries.allowedKeys: 483 | self.log.debug("Allowing %s for parentRatingKey %s" % (mediaWrapper, media.parentRatingKey)) 484 | allowed = True 485 | if media.parentRatingKey in self.customEntries.blockedKeys: 486 | self.log.debug("Blocking %s for parentRatingKey %s" % (mediaWrapper, media.parentRatingKey)) 487 | return False 488 | if hasattr(media, GRANDPARENTRATINGKEY): 489 | if media.grandparentRatingKey in self.customEntries.allowedKeys: 490 | self.log.debug("Allowing %s for grandparentRatingKey %s" % (mediaWrapper, media.grandparentRatingKey)) 491 | allowed = True 492 | if media.grandparentRatingKey in self.customEntries.blockedKeys: 493 | self.log.debug("Blocking %s for grandparentRatingKey %s" % (mediaWrapper, media.grandparentRatingKey)) 494 | return False 495 | if self.customEntries.allowedKeys and not allowed: 496 | self.log.debug("Blocking %s, not on allowed list" % (mediaWrapper)) 497 | return False 498 | 499 | # Watched 500 | if not self.settings.skipunwatched and not media.isWatched: 501 | self.log.debug("Blocking %s, unwatched and skip-unwatched is %s" % (mediaWrapper, self.settings.skipunwatched)) 502 | return False 503 | return True 504 | 505 | def firstAdjust(self, mediaWrapper: MediaWrapper) -> None: 506 | media = mediaWrapper.media 507 | 508 | if hasattr(media, "episodeNumber"): 509 | if media.episodeNumber == 1: 510 | if self.settings.skipE01 == Settings.SKIP_TYPES.NEVER: 511 | self.log.debug("Erasing tags %s, first episode in season and skip-first-episode-season is %s" % (mediaWrapper, self.settings.skipE01)) 512 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.firstsafetags] 513 | mediaWrapper.updateMarkers() 514 | elif self.settings.skipE01 == Settings.SKIP_TYPES.WATCHED and not media.isWatched: 515 | self.log.debug("Erasing tags %s, first episode in season and skip-first-episode-season is %s and isWatched %s" % (mediaWrapper, self.settings.skipE01, media.isWatched)) 516 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.firstsafetags] 517 | mediaWrapper.updateMarkers() 518 | if hasattr(media, "seasonNumber") and media.seasonNumber == 1 and media.episodeNumber == 1: 519 | if self.settings.skipS01E01 == Settings.SKIP_TYPES.NEVER: 520 | self.log.debug("Erasing tags %s, first episode in series and skip-first-episode-series is %s" % (mediaWrapper, self.settings.skipS01E01)) 521 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.firstsafetags] 522 | mediaWrapper.updateMarkers() 523 | elif self.settings.skipS01E01 == Settings.SKIP_TYPES.WATCHED and not media.isWatched: 524 | self.log.debug("Erasing tags %s, first episode in series and skip-first-episode-series is %s and isWatched %s" % (mediaWrapper, self.settings.skipS01E01, media.isWatched)) 525 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.firstsafetags] 526 | mediaWrapper.updateMarkers() 527 | 528 | def lastAdjust(self, mediaWrapper: MediaWrapper) -> None: 529 | media = mediaWrapper.media 530 | 531 | if hasattr(media, "episodeNumber") and hasattr(media, "seasonNumber"): 532 | series = self.server.fetchItem(media.grandparentRatingKey) 533 | curr_season = series.season(season=media.seasonNumber) 534 | last_season = series.seasons()[-1] 535 | if media.episodeNumber == curr_season.episodes()[-1].episodeNumber: 536 | if self.settings.skiplastepisodeseason == Settings.SKIP_TYPES.NEVER: 537 | self.log.debug("Erasing tags %s, last episode in season and skip-last-episode-season is %s" % (mediaWrapper, self.settings.skiplastepisodeseason)) 538 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.lastsafetags] 539 | mediaWrapper.updateMarkers() 540 | elif self.settings.skiplastepisodeseason == Settings.SKIP_TYPES.WATCHED and not media.isWatched: 541 | self.log.debug("Erasing tags %s, last episode in season and skip-last-episode-season is %s and isWatched %s" % (mediaWrapper, self.settings.skiplastepisodeseason, media.isWatched)) 542 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.lastsafetags] 543 | mediaWrapper.updateMarkers() 544 | if media.seasonNumber == last_season.seasonNumber and media.episodeNumber == last_season.episodes()[-1].episodeNumber: 545 | if self.settings.skiplastepisodeseries == Settings.SKIP_TYPES.NEVER: 546 | self.log.debug("Erasing tags %s, last episode in series and skip-last-episode-series is %s" % (mediaWrapper, self.settings.skiplastepisodeseries)) 547 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.lastsafetags] 548 | mediaWrapper.updateMarkers() 549 | elif self.settings.skiplastepisodeseries == Settings.SKIP_TYPES.WATCHED and not media.isWatched: 550 | self.log.debug("Erasing tags %s, last episode in series and skip-last-episode-series is %s and isWatched %s" % (mediaWrapper, self.settings.skiplastepisodeseries, media.isWatched)) 551 | mediaWrapper.tags = [t for t in mediaWrapper.tags if t in self.settings.lastsafetags] 552 | mediaWrapper.updateMarkers() 553 | 554 | def addSession(self, mediaWrapper: MediaWrapper) -> None: 555 | if mediaWrapper.customOnly: 556 | self.log.info("Found blocked session %s viewOffset %d %s on %s (proxying: %s), using custom markers only, sessions: %d" % (mediaWrapper, mediaWrapper.plexsession.viewOffset, mediaWrapper.plexsession._username, mediaWrapper.player.product, mediaWrapper.player._proxyThroughServer, len(self.media_sessions))) 557 | else: 558 | self.log.info("Found new session %s viewOffset %d %s on %s (proxying: %s), sessions: %d" % (mediaWrapper, mediaWrapper.plexsession.viewOffset, mediaWrapper.plexsession._username, mediaWrapper.player.product, mediaWrapper.player._proxyThroughServer, len(self.media_sessions))) 559 | if mediaWrapper.player and self.validPlayer(mediaWrapper.player): 560 | self.purgeOldSessions(mediaWrapper) 561 | self.bingeSessions.update(mediaWrapper) 562 | self.firstAdjust(mediaWrapper) 563 | self.lastAdjust(mediaWrapper) 564 | self.checkMedia(mediaWrapper) 565 | self.media_sessions[mediaWrapper.pasIdentifier] = mediaWrapper 566 | else: 567 | self.log.info("Session %s has no accessible player, it will be ignored" % (mediaWrapper)) 568 | self.ignoreSession(mediaWrapper) 569 | 570 | def ignoreSession(self, mediaWrapper: MediaWrapper) -> None: 571 | self.purgeOldSessions(mediaWrapper) 572 | self.ignored.append(mediaWrapper.pasIdentifier) 573 | self.ignored = self.ignored[-self.IGNORED_CAP:] 574 | self.log.debug("Ignoring session %s %s, ignored: %d" % (mediaWrapper, mediaWrapper.plexsession._username, len(self.ignored))) 575 | 576 | def purgeOldSessions(self, mediaWrapper: MediaWrapper) -> None: 577 | for sessionMediaWrapper in list(self.media_sessions.values()): 578 | if sessionMediaWrapper.clientIdentifier == mediaWrapper.player.machineIdentifier: 579 | self.log.info("Session %s shares player (%s) with new session %s, deleting old session %s" % (sessionMediaWrapper, mediaWrapper.player.machineIdentifier, mediaWrapper, sessionMediaWrapper.plexsession.sessionKey)) 580 | self.removeSession(sessionMediaWrapper) 581 | break 582 | 583 | def removeSession(self, mediaWrapper: MediaWrapper): 584 | if mediaWrapper.pasIdentifier in self.media_sessions: 585 | del self.media_sessions[mediaWrapper.pasIdentifier] 586 | self.log.debug("Deleting session %s, sessions: %d" % (mediaWrapper, len(self.media_sessions))) 587 | 588 | def error(self, data: dict) -> None: 589 | self.log.error(data) 590 | 591 | def logErrorMessage(self, exception: Exception, default: str) -> None: 592 | for e in self.ERRORS: 593 | if e in exception.args[0]: 594 | self.log.error(self.ERRORS[e]) 595 | return 596 | self.log.exception("%s, see %s" % (default, self.TROUBLESHOOT_URL)) 597 | -------------------------------------------------------------------------------- /resources/sslAlertListener.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from plexapi.alert import AlertListener 3 | from plexapi.server import PlexServer 4 | 5 | 6 | class SSLAlertListener(AlertListener): 7 | """ Override class for PlexAPI AlertListener to allow SSL options to be passed to WebSocket 8 | 9 | Parameters: 10 | server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. 11 | callback (func): Callback function to call on received messages. The callback function 12 | will be sent a single argument 'data' which will contain a dictionary of data 13 | received from the server. :samp:`def my_callback(data): ...` 14 | callbackError (func): Callback function to call on errors. The callback function 15 | will be sent a single argument 'error' which will contain the Error object. 16 | :samp:`def my_callback(error): ...` 17 | sslopt (dict): ssl socket optional dict. 18 | :samp:`{"cert_reqs": ssl.CERT_NONE}` 19 | """ 20 | def __init__(self, server: PlexServer, callback=None, callbackError=None, sslopt=None, logger=None) -> None: 21 | self.log = logger or logging.getLogger(__name__) 22 | try: 23 | super(SSLAlertListener, self).__init__(server, callback, callbackError) 24 | except TypeError: 25 | self.log.error("AlertListener error detected, you may need to update your version of PlexAPI python package, attempting backwards compatibility") 26 | super(SSLAlertListener, self).__init__(server, callback) 27 | self._sslopt = sslopt 28 | 29 | def run(self) -> None: 30 | try: 31 | import websocket 32 | except ImportError: 33 | return 34 | # create the websocket connection 35 | url = self._server.url(self.key, includeToken=True).replace('http', 'ws') 36 | self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError) 37 | self._ws.run_forever(sslopt=self._sslopt) 38 | -------------------------------------------------------------------------------- /setup/config.ini.sample: -------------------------------------------------------------------------------- 1 | [Plex.tv] 2 | username = 3 | password = 4 | token = 5 | servername = 6 | 7 | [Server] 8 | address = 9 | ssl = True 10 | port = 32400 11 | 12 | [Security] 13 | ignore-certs = False 14 | 15 | [Skip] 16 | mode = skip 17 | tags = intro, commercial, advertisement, credits 18 | types = movie, episode 19 | ignored-libraries = 20 | last-chapter = 0.0 21 | unwatched = True 22 | first-episode-series = Watched 23 | first-episode-season = Always 24 | first-safe-tags = 25 | last-episode-series = Watched 26 | last-episode-season = Always 27 | last-safe-tags = 28 | next = False 29 | 30 | [Binge] 31 | ignore-skip-for = 0 32 | safe-tags = 33 | same-show-only = False 34 | skip-next-max = 0 35 | 36 | [Offsets] 37 | start = 3000 38 | end = 1000 39 | delay = 500 40 | tags = intro 41 | 42 | [Volume] 43 | low = 0 44 | high = 100 45 | -------------------------------------------------------------------------------- /setup/custom.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "markers": { 3 | "9999999": 4 | [ 5 | { 6 | "start": 0, 7 | "end": 20000 8 | } 9 | ] 10 | }, 11 | "offsets": { 12 | }, 13 | "tags": { 14 | }, 15 | "allowed": { 16 | "users": [ 17 | ], 18 | "clients": [ 19 | ], 20 | "keys": [ 21 | ], 22 | "skip-next": [ 23 | ] 24 | }, 25 | "blocked": { 26 | "users": [ 27 | ], 28 | "clients": [ 29 | ], 30 | "keys": [ 31 | ], 32 | "skip-next": [ 33 | ] 34 | }, 35 | "clients": { 36 | }, 37 | "mode": { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /setup/logging.ini.sample: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys = root 3 | 4 | [handlers] 5 | keys = consoleHandler, fileHandler 6 | 7 | [formatters] 8 | keys = simpleFormatter, minimalFormatter 9 | 10 | [logger_root] 11 | level = DEBUG 12 | handlers = consoleHandler, fileHandler 13 | 14 | [handler_consoleHandler] 15 | class = StreamHandler 16 | level = INFO 17 | formatter = minimalFormatter 18 | args = (sys.stdout,) 19 | 20 | [handler_fileHandler] 21 | class = handlers.RotatingFileHandler 22 | level = INFO 23 | formatter = simpleFormatter 24 | args = ('%(logfilename)s', 'a', 100000, 3, 'utf-8') 25 | 26 | [formatter_simpleFormatter] 27 | format = %(asctime)s - %(name)s - %(levelname)s - %(message)s 28 | datefmt = %Y-%m-%d %H:%M:%S 29 | 30 | [formatter_minimalFormatter] 31 | format = %(levelname)s - %(message)s 32 | datefmt = 33 | 34 | -------------------------------------------------------------------------------- /setup/requirements.txt: -------------------------------------------------------------------------------- 1 | plexapi >= 4.12 2 | requests 3 | websocket-client 4 | --------------------------------------------------------------------------------