├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── README.md ├── config.ini ├── fun ├── playlist_manager.py ├── plex_lifx_color_theme.pyw └── plexapi_haiku.py ├── killstream ├── kill_else_if_buffering.py ├── kill_stream.py ├── limiterr.py ├── limiterr_readme.md └── readme.md ├── maps ├── EU_map_example.PNG ├── NA_map_example.PNG ├── World_map_example.PNG ├── geojson_example.geojson ├── ips_to_maps.py ├── readme.md └── requirements.txt ├── notify ├── find_unwatched_notify.py ├── notify_delay.py ├── notify_fav_tv_all_movie.py ├── notify_newip.py ├── notify_recently_aired.py ├── notify_user_favorites.py ├── notify_user_newip.py ├── top_concurrent_notify.py └── twitter_notify.py ├── reporting ├── added_to_plex.py ├── check_play.py ├── check_plex_log.py ├── drive_check.py ├── library_play_days.py ├── plays_by_library.py ├── server_compare.py ├── streaming_service_availability.py ├── userplays_weekly_reporting.py ├── watched_percentages.py └── weekly_stats_reporting.py ├── requirements.txt ├── scriptHeaderTemplate.txt ├── setup.cfg └── utility ├── add_label_recently_added.py ├── bypass_auth_name.py ├── delete_watched_TV.py ├── enable_disable_all_guest_access.py ├── find_plex_meta.py ├── find_unwatched.py ├── get_serial_transcoders.py ├── gmusic_playlists_to_plex.py ├── grab_gdrive_media.py ├── hide_episode_spoilers.py ├── library_growth.py ├── lock_unlock_poster_art.py ├── mark_multiepisode_watched.py ├── media_manager.py ├── merge_multiepisodes.py ├── music_folder_collections.py ├── off_deck.py ├── plex_api_invite.py ├── plex_api_parental_control.py ├── plex_api_poster_pull.py ├── plex_api_share.py ├── plex_api_show_settings.py ├── plex_dance.py ├── plex_imgur_dl.py ├── plex_popular_playlist.py ├── plex_theme_songs.py ├── plexapi_delete_playlists.py ├── purge_removed_plex_friends.py ├── recently_added_collection.py ├── refresh_next_episode.py ├── remove_inactive_users.py ├── remove_movie_collections.py ├── remove_watched_movies.py ├── rename_seasons.py ├── select_tmdb_poster.py ├── spoilers.png ├── stream_limiter_ban_email.py ├── sync_watch_status.py └── tautulli_friendly_name_to_ombi_alias_sync.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: Issue 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | **Provide logs** 15 | 16 | Tautulli logs - Please follow [Tautulli's guide](https://github.com/Tautulli/Tautulli-Wiki/wiki/Asking-for-Support#how-can-i-share-my-logs) for sharing logs. Please provide a link to a gist or pastebin. 17 | 18 | PlexAPI logs - Create or find your logs location for [PlexAPI](https://python-plexapi.readthedocs.io/en/latest/configuration.html). Please follow [Tautulli's guide](https://github.com/Tautulli/Tautulli-Wiki/wiki/Asking-for-Support#how-can-i-share-my-logs) for sharing logs. Please provide a link to a gist or pastebin. 19 | 20 | **Link to script with bug/issue** 21 | 22 | **To Reproduce** 23 | 24 | Steps to reproduce the behavior: 25 | 1. Go to '...' 26 | 2. Click on '....' 27 | 3. Scroll down to '....' 28 | 4. See error 29 | 30 | **Expected behavior** 31 | A clear and concise description of what you expected to happen. 32 | 33 | **Screenshots** 34 | If applicable, add screenshots to help explain your problem. 35 | 36 | **Desktop (please complete the following information):** 37 | - OS: [e.g. Linux, Windows, Docker] 38 | - Python Version [e.g. 22] 39 | - PlexAPI version [e.g. 4.1.0] 40 | 41 | **Additional context** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: Request 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request an improvement on an existing script? Please link to script.** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [plexapi] 2 | container_size = 50 3 | timeout = 30 4 | 5 | [auth] 6 | myplex_username = 7 | myplex_password = 8 | server_baseurl = http://127.0.0.1:32400 9 | server_token = 10 | tautulli_baseurl = http://127.0.0.1:8181 11 | tautulli_apikey = -------------------------------------------------------------------------------- /fun/plexapi_haiku.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | https://gist.github.com/blacktwin/4ccb79c7d01a95176b8e88bf4890cd2b 6 | """ 7 | from __future__ import print_function 8 | from __future__ import unicode_literals 9 | 10 | from plexapi.server import PlexServer 11 | import random 12 | import re 13 | 14 | baseurl = 'http://localhost:32400' 15 | token = 'xxxxx' 16 | plex = PlexServer(baseurl, token) 17 | 18 | 19 | LIBRARIES_LST = ['Movies', 'TV Shows'] 20 | 21 | 22 | def sylco(word): 23 | # pulled from https://github.com/eaydin/sylco/blob/master/sylco.py 24 | word = word.lower() 25 | 26 | # exception_add are words that need extra syllables 27 | # exception_del are words that need less syllables 28 | 29 | exception_add = ['serious', 'crucial'] 30 | exception_del = ['fortunately', 'unfortunately'] 31 | 32 | co_one = ['cool', 'coach', 'coat', 'coal', 'count', 'coin', 'coarse', 'coup', 'coif', 'cook', 'coign', 'coiffe', 33 | 'coof', 'court'] 34 | co_two = ['coapt', 'coed', 'coinci'] 35 | 36 | pre_one = ['preach'] 37 | 38 | syls = 0 # added syllable number 39 | disc = 0 # discarded syllable number 40 | 41 | # 1) if letters < 3 : return 1 42 | if len(word) <= 3: 43 | syls = 1 44 | return syls 45 | 46 | # 2) if doesn't end with "ted" or "tes" or "ses" or "ied" or "ies", discard "es" and "ed" at the end. 47 | # if it has only 1 vowel or 1 set of consecutive vowels, discard. (like "speed", "fled" etc.) 48 | 49 | if word[-2:] == "es" or word[-2:] == "ed": 50 | doubleAndtripple_1 = len(re.findall(r'[eaoui][eaoui]', word)) 51 | if doubleAndtripple_1 > 1 or len(re.findall(r'[eaoui][^eaoui]', word)) > 1: 52 | if word[-3:] == "ted" or \ 53 | word[-3:] == "tes" or \ 54 | word[-3:] == "ses" or \ 55 | word[-3:] == "ied" or \ 56 | word[-3:] == "ies": 57 | pass 58 | else: 59 | disc += 1 60 | 61 | # 3) discard trailing "e", except where ending is "le" 62 | 63 | le_except = ['whole', 'mobile', 'pole', 'male', 'female', 'hale', 'pale', 'tale', 'sale', 'aisle', 'whale', 'while'] 64 | 65 | if word[-1:] == "e": 66 | if word[-2:] == "le" and word not in le_except: 67 | pass 68 | 69 | else: 70 | disc += 1 71 | 72 | # 4) check if consecutive vowels exists, triplets or pairs, count them as one. 73 | 74 | doubleAndtripple = len(re.findall(r'[eaoui][eaoui]', word)) 75 | tripple = len(re.findall(r'[eaoui][eaoui][eaoui]', word)) 76 | disc += doubleAndtripple + tripple 77 | 78 | # 5) count remaining vowels in word. 79 | numVowels = len(re.findall(r'[eaoui]', word)) 80 | 81 | # 6) add one if starts with "mc" 82 | if word[:2] == "mc": 83 | syls += 1 84 | 85 | # 7) add one if ends with "y" but is not surrouned by vowel 86 | if word[-1:] == "y" and word[-2] not in "aeoui": 87 | syls += 1 88 | 89 | # 8) add one if "y" is surrounded by non-vowels and is not in the last word. 90 | 91 | for i, j in enumerate(word): 92 | if j == "y": 93 | if (i != 0) and (i != len(word) - 1): 94 | if word[i - 1] not in "aeoui" and word[i + 1] not in "aeoui": 95 | syls += 1 96 | 97 | # 9) if starts with "tri-" or "bi-" and is followed by a vowel, add one. 98 | 99 | if word[:3] == "tri" and word[3] in "aeoui": 100 | syls += 1 101 | 102 | if word[:2] == "bi" and word[2] in "aeoui": 103 | syls += 1 104 | 105 | # 10) if ends with "-ian", should be counted as two syllables, except for "-tian" and "-cian" 106 | 107 | if word[-3:] == "ian": 108 | # and (word[-4:] != "cian" or word[-4:] != "tian") : 109 | if word[-4:] == "cian" or word[-4:] == "tian": 110 | pass 111 | else: 112 | syls += 1 113 | 114 | # 11) if starts with "co-" and is followed by a vowel, check if exists in the double syllable dictionary, if not, check if in single dictionary and act accordingly. 115 | 116 | if word[:2] == "co" and word[2] in 'eaoui': 117 | 118 | if word[:4] in co_two or word[:5] in co_two or word[:6] in co_two: 119 | syls += 1 120 | elif word[:4] in co_one or word[:5] in co_one or word[:6] in co_one: 121 | pass 122 | else: 123 | syls += 1 124 | 125 | # 12) if starts with "pre-" and is followed by a vowel, check if exists in the double syllable dictionary, if not, check if in single dictionary and act accordingly. 126 | 127 | if word[:3] == "pre" and word[3] in 'eaoui': 128 | if word[:6] in pre_one: 129 | pass 130 | else: 131 | syls += 1 132 | 133 | # 13) check for "-n't" and cross match with dictionary to add syllable. 134 | 135 | negative = ["doesn't", "isn't", "shouldn't", "couldn't", "wouldn't"] 136 | 137 | if word[-3:] == "n't": 138 | if word in negative: 139 | syls += 1 140 | else: 141 | pass 142 | 143 | # 14) Handling the exceptional words. 144 | 145 | if word in exception_del: 146 | disc += 1 147 | 148 | if word in exception_add: 149 | syls += 1 150 | 151 | # calculate the output 152 | return numVowels - disc + syls 153 | 154 | 155 | def check_roman(word): 156 | roman_pattern = r"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$" 157 | 158 | if (re.search(roman_pattern, word.strip(), re.I)): 159 | # print(word) 160 | return True 161 | else: 162 | # print(word) 163 | return False 164 | 165 | 166 | def ran_words(sections_lst): 167 | word_site = [line.split() for line in sections_lst] 168 | WORDS = [item for sublist in word_site for item in sublist] 169 | 170 | ran_word = random.choice(WORDS) 171 | ran_word = ''.join(e for e in ran_word if e.isalpha() and not check_roman(ran_word)) 172 | sy_cnt = sylco(ran_word) 173 | word_cnt = {ran_word: sy_cnt} 174 | return word_cnt 175 | 176 | 177 | def hi_build(sections_lst, cnt): 178 | dd = ran_words(sections_lst) 179 | while sum(dd.values()) < cnt: 180 | try: 181 | up = ran_words(sections_lst) 182 | dd.update(up) 183 | if sum(dd.values()) == cnt: 184 | return [dd] 185 | else: 186 | if sum(dd.values()) > cnt: 187 | dd = {} 188 | except Exception: 189 | pass 190 | return [dd] 191 | 192 | 193 | sections_lst = [] 194 | for x in LIBRARIES_LST: 195 | sections = plex.library.section(x).all() 196 | sections_lst += [section.title for section in sections] 197 | 198 | m_lst = hi_build(sections_lst, 5) + hi_build(sections_lst, 7) + hi_build(sections_lst, 5) 199 | # to see word and syllable count uncomment below print. 200 | # print(m_lst) 201 | 202 | stanz1 = ' '.join(m_lst[0].keys()) 203 | stanz2 = ' '.join(m_lst[1].keys()) 204 | stanz3 = ' '.join(m_lst[2].keys()) 205 | 206 | lines = stanz1, stanz2, stanz3 207 | lines = '\n'.join(lines) 208 | print('') 209 | print(lines.lower()) 210 | print('') 211 | -------------------------------------------------------------------------------- /killstream/kill_else_if_buffering.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | If server admin stream is experiencing buffering and there are concurrent transcode streams from 6 | another user, kill concurrent transcode stream that has the lowest percent complete. Message in 7 | kill stream will list why it was killed ('Server Admin's stream take priority and this user has X 8 | concurrent streams'). Message will also include an approximation of when the other concurrent stream 9 | will finish, stream that is closest to finish will be used. 10 | 11 | Tautulli > Settings > Notification Agents > Scripts > Bell icon: 12 | [X] Notify on buffer warning 13 | 14 | Tautulli > Settings > Notification Agents > Scripts > Gear icon: 15 | Buffer Warnings: kill_else_if_buffering.py 16 | 17 | """ 18 | from __future__ import print_function 19 | from __future__ import division 20 | from __future__ import unicode_literals 21 | 22 | from builtins import str 23 | from past.utils import old_div 24 | import requests 25 | from operator import itemgetter 26 | import unicodedata 27 | from plexapi.server import PlexServer 28 | 29 | 30 | # ## EDIT THESE SETTINGS ## 31 | PLEX_TOKEN = 'xxxx' 32 | PLEX_URL = 'http://localhost:32400' 33 | 34 | DEFAULT_REASON = 'Server Admin\'s stream takes priority and {user}(you) has {x} concurrent streams.' \ 35 | ' {user}\'s stream of {video} is {time}% complete. Should be finished in {comp} minutes. ' \ 36 | 'Try again then.' 37 | 38 | ADMIN_USER = ('Admin') # Additional usernames can be added ('Admin', 'user2') 39 | # ## 40 | 41 | sess = requests.Session() 42 | sess.verify = False 43 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 44 | 45 | 46 | def kill_session(sess_key, message): 47 | for session in plex.sessions(): 48 | # Check for users stream 49 | username = session.usernames[0] 50 | if session.sessionKey == sess_key: 51 | title = str(session.grandparentTitle + ' - ' if session.type == 'episode' else '') + session.title 52 | title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').translate(None, "'") 53 | session.stop(reason=message) 54 | print('Terminated {user}\'s stream of {title} to prioritize admin stream.'.format(user=username, 55 | title=title)) 56 | 57 | 58 | def add_to_dictlist(d, key, val): 59 | if key not in d: 60 | d[key] = [val] 61 | else: 62 | d[key].append(val) 63 | 64 | 65 | def main(): 66 | user_dict = {} 67 | 68 | for session in plex.sessions(): 69 | if session.transcodeSessions: 70 | trans_dec = session.transcodeSessions[0].videoDecision 71 | username = session.usernames[0] 72 | if trans_dec == 'transcode' and username not in ADMIN_USER: 73 | sess_key = session.sessionKey 74 | percent_comp = int((float(session.viewOffset) / float(session.duration)) * 100) 75 | time_to_comp = old_div(old_div(int(int(session.duration) - int(session.viewOffset)), 1000), 60) 76 | title = str(session.grandparentTitle + ' - ' if session.type == 'episode' else '') + session.title 77 | title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').translate(None, "'") 78 | add_to_dictlist(user_dict, username, [sess_key, percent_comp, title, username, time_to_comp]) 79 | 80 | # Remove users with only 1 stream. Targeting users with multiple concurrent streams 81 | filtered_dict = {key: value for key, value in user_dict.items() 82 | if len(value) != 1} 83 | 84 | # Find who to kill and who will be finishing first. 85 | if filtered_dict: 86 | for users in filtered_dict.values(): 87 | to_kill = min(users, key=itemgetter(1)) 88 | to_finish = max(users, key=itemgetter(1)) 89 | 90 | MESSAGE = DEFAULT_REASON.format(user=to_finish[3], x=len(filtered_dict.values()[0]), 91 | video=to_finish[2], time=to_finish[1], comp=to_finish[4]) 92 | 93 | print(MESSAGE) 94 | kill_session(to_kill[0], MESSAGE) 95 | 96 | 97 | if __name__ == '__main__': 98 | main() 99 | -------------------------------------------------------------------------------- /killstream/limiterr_readme.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Killing streams is a Plex Pass only feature. So these scripts will **only** work for Plex Pass users. 4 | 5 | ## `limitter.py` examples: 6 | 7 | ### Limit user to an amount of Plays of a show during a time of day 8 | _For users falling asleep while autoplaying a show_ :sleeping:\ 9 | Triggers: Playback Start 10 | Conditions: \[ `Current Hour` | `is` | `22 or 23 or 0 or 1` \] 11 | 12 | Arguments: 13 | ``` 14 | --jbop limit --username {username} --sessionId {session_id} --grandparent_rating_key {grandparent_rating_key} --limit plays=3 --delay 60 --killMessage "You sleeping?" 15 | ``` 16 | 17 | ### Limit user to total Plays/Watches and send a notification to agent 1 18 | _Completed play sessions_ \ 19 | Triggers: Playback Start 20 | 21 | Arguments: 22 | ``` 23 | --jbop watch --username {username} --sessionId {session_id} --limit plays=3 --notify 1 --killMessage "You have met your limit of 3 watches." 24 | ``` 25 | 26 | ### Limit user to total Plays/Watches in a specific library (Movies) 27 | _Completed play sessions_ \ 28 | Triggers: Playback Start 29 | 30 | Arguments: 31 | ``` 32 | --jbop watch --username {username} --sessionId {session_id} --limit plays=3 --section Movies --killMessage "You have met your limit of 3 watches." 33 | ``` 34 | 35 | ### Limit user to total time watching 36 | 37 | Triggers: Playback Start 38 | 39 | Arguments: 40 | ``` 41 | --jbop time --username {username} --sessionId {session_id} --limit days=3 --limit hours=10 --killMessage "You have met your limit of 3 days and 10 hours." 42 | ``` 43 | 44 | 45 | ### Limit user to total play sessions for the day 46 | 47 | Triggers: Playback Start 48 | 49 | Arguments: 50 | ``` 51 | --jbop plays --username {username} --sessionId {session_id} --days 0 --limit plays=3 --killMessage "You have met your limit of 3 play sessions." 52 | ``` 53 | 54 | ### Limit user to total time watching for the week, including duration of item starting 55 | 56 | Triggers: Playback Start 57 | 58 | Arguments: 59 | ``` 60 | --jbop time --username {username} --sessionId {session_id} --duration {duration} --days 7 --limit hours=10 --killMessage "You have met your weekly limit of 10 hours." 61 | ``` 62 | -------------------------------------------------------------------------------- /killstream/readme.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Killing streams is a Plex Pass only feature. So these scripts will **only** work for Plex Pass users. 4 | 5 | ## `kill_stream.py` examples: 6 | 7 | ### Kill transcodes 8 | 9 | Triggers: 10 | * Playback Start 11 | * Transcode Decision Change 12 | 13 | Conditions: \[ `Transcode Decision` | `is` | `transcode` \] 14 | 15 | Arguments: 16 | ``` 17 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Transcoding streams are not allowed.' 18 | ``` 19 | 20 | ### Kill non-local streams paused for a long time 21 | 22 | _The default values will kill anything paused for over 20 minutes, checking every 30 seconds._ 23 | 24 | Script Timeout: 0 _**Important!**_ 25 | Triggers: Playback Paused 26 | Conditions: \[ `Stream Local` | `is not` | `1` \] 27 | 28 | Arguments: 29 | ``` 30 | --jbop paused --sessionId {session_id} --killMessage 'Your stream was paused for over 20 minutes and has been automatically stopped for you.' 31 | ``` 32 | 33 | ### Kill streams paused for a custom time 34 | 35 | _This is an example of customizing the paused stream monitoring to check every 15 seconds, and kill any stream paused for over 5 minutes._ 36 | 37 | Script Timeout: 0 _**Important!**_ 38 | Triggers: Playback Paused 39 | 40 | Arguments: 41 | ``` 42 | --jbop paused --interval 15 --limit 300 --sessionId {session_id} --killMessage 'Your stream was paused for over 5 minutes and has been automatically stopped for you.' 43 | ``` 44 | 45 | ### Kill paused transcodes 46 | 47 | Triggers: Playback Paused 48 | Conditions: \[ `Transcode Decision` | `is` | `transcode` \] 49 | 50 | Arguments: 51 | ``` 52 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Paused streams are automatically stopped.' 53 | ``` 54 | 55 | ### Limit User stream count, kill last stream 56 | 57 | Triggers: Playback Start 58 | Conditions: \[ `User Streams` | `is greater than` | `3` \] 59 | 60 | Arguments: 61 | ``` 62 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'You are only allowed 3 streams.' 63 | ``` 64 | 65 | ### Limit User streams to one unique IP 66 | 67 | Triggers: User Concurrent Streams 68 | Settings: 69 | * Notifications & Newsletters > Show Advanced > `User Concurrent Streams Notifications by IP Address` | `Checked` 70 | * Notifications & Newsletters > `User Concurrent Stream Threshold` | `2` 71 | 72 | Arguments: 73 | ``` 74 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'You are only allowed to stream from one location at a time.' 75 | ``` 76 | 77 | ### IP Whitelist 78 | 79 | Triggers: Playback Start 80 | Conditions: \[ `IP Address` | `is not` | `192.168.0.100 or 192.168.0.101` \] 81 | 82 | Arguments: 83 | ``` 84 | --jbop stream --username {username} --sessionId {session_id} --killMessage '{ip_address} is not allowed to access {server_name}.' 85 | ``` 86 | 87 | ### Kill by platform 88 | 89 | Triggers: Playback Start 90 | Conditions: \[ `Platform` | `is` | `Roku or Android` \] 91 | 92 | Arguments: 93 | ``` 94 | --jbop stream --username {username} --sessionId {session_id} --killMessage '{platform} is not allowed on {server_name}.' 95 | ``` 96 | 97 | ### Kill transcode by library 98 | 99 | Triggers: 100 | * Playback Start 101 | * Transcode Decision Change 102 | 103 | Conditions: 104 | * \[ `Transcode Decision` | `is` | `transcode` \] 105 | * \[ `Library Name` | `is` | `4K Movies` \] 106 | 107 | Arguments: 108 | ``` 109 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Transcoding streams are not allowed from the 4K Movies library.' 110 | ``` 111 | 112 | ### Kill transcode by original resolution 113 | 114 | Triggers: 115 | * Playback Start 116 | * Transcode Decision Change 117 | 118 | Conditions: 119 | * \[ `Transcode Decision` | `is` | `transcode` \] 120 | * \[ `Video Resolution` | `is` | `1080 or 720`\] 121 | 122 | Arguments: 123 | ``` 124 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Transcoding streams are not allowed for {stream_video_resolution}p streams.' 125 | ``` 126 | 127 | ### Kill transcode by bitrate 128 | 129 | Triggers: 130 | * Playback Start 131 | * Transcode Decision Change 132 | 133 | Conditions: 134 | * \[ `Transcode Decision` | `is` | `transcode` \] 135 | * \[ `Bitrate` | `is greater than` | `4000` \] 136 | 137 | Arguments: 138 | ``` 139 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Transcoding streams are not allowed from over 4 Mbps (Yours: {stream_bitrate}).' 140 | ``` 141 | 142 | ### Kill by hours of the day 143 | 144 | _Kills any streams during 9 AM to 10 AM._ 145 | 146 | Triggers: Playback Start 147 | Conditions: \[ `Timestamp` | `begins with` | `09 or 10` \] 148 | Arguments: 149 | ``` 150 | --jbop stream --username {username} --sessionId {session_id} --killMessage '{server_name} is unavailable between 9 and 10 AM.' 151 | ``` 152 | 153 | ### Kill non local streams 154 | 155 | Triggers: Playback Start 156 | Conditions: \[ `Stream Local` | `is not` | `1` \] 157 | Arguments: 158 | ``` 159 | --jbop stream --username {username} --sessionId {session_id} --killMessage '{server_name} only allows local streams.' 160 | ``` 161 | 162 | ### Kill transcodes and send a notification to agent 1 163 | 164 | Triggers: 165 | * Playback Start 166 | * Transcode Decision Change 167 | 168 | Conditions: \[ `Transcode Decision` | `is` | `transcode` \] 169 | 170 | Arguments: 171 | ``` 172 | --jbop stream --username {username} --sessionId {session_id} --notify 1 --killMessage 'Transcoding streams are not allowed.' 173 | ``` 174 | 175 | ### Kill transcodes using the default message 176 | 177 | Triggers: 178 | * Playback Start 179 | * Transcode Decision Change 180 | 181 | Conditions: \[ `Transcode Decision` | `is` | `transcode` \] 182 | 183 | Arguments: 184 | ``` 185 | --jbop stream --username {username} --sessionId {session_id} 186 | ``` 187 | 188 | ### Kill transcodes with a delay 189 | 190 | _This will wait 10 seconds before killing the stream._ 191 | 192 | Triggers: 193 | * Playback Start 194 | * Transcode Decision Change 195 | 196 | Conditions: \[ `Transcode Decision` | `is` | `transcode` \] 197 | 198 | Arguments: 199 | ``` 200 | --jbop stream --username {username} --sessionId {session_id} --killMessage 'Transcoding streams are not allowed.' --delay 10 201 | ``` 202 | 203 | ### Kill all of a user's streams with notification 204 | 205 | Triggers: Playback Start 206 | Conditions: \[ `Username` | `is` | `Bob` \] 207 | 208 | Arguments: 209 | ``` 210 | --jbop allStreams --userId {user_id} --notify 1 --killMessage 'Hey Bob, we need to talk!' 211 | ``` 212 | 213 | ### Rich Notifications (Discord or Slack) 214 | The following can be added to any of the above examples. 215 | 216 | #### How it Works 217 | 218 | Tautulli > Script Agent > Script > Tautulli > Webhook Agent > Discord/Slack 219 | 220 | 1. Tautulli's script agent is executed 221 | 1. Script executes 222 | 1. The script sends the JSON data to the webhook agent 223 | 1. Webhook agent passes the information to Discord/Slack 224 | 225 | #### Limitations 226 | * Due to [size](https://api.slack.com/docs/message-attachments#thumb_url) limitations by Slack. A thumbnail may not appear with every notification when using `--posterUrl {poster_url}`. 227 | * `allStreams` will not have poster images in the notifications. 228 | 229 | #### Required arguments 230 | 231 | * Discord: `--notify notifierID --richMessage discord` 232 | * Slack: `--notify notifierID --richMessage slack` 233 | 234 | **_Note: The notifierID must be a Webhook in Tautulli_** 235 | 236 | #### Optional arguments 237 | 238 | ```log 239 | --serverName {server_name} --plexUrl {plex_url} --posterUrl {poster_url} --richColor '#E5A00D' 240 | ``` 241 | 242 | #### Webhook Setup 243 | 1. Settings -> Notification Agents -> Add a new notification agent -> Webhook 244 | 1. For the **Webhook URL** enter your Slack or Discord webhook URL.
245 | Some examples: 246 | * Discord: [Intro to Webhooks](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 247 | * Slack: [Incoming Webhooks](https://api.slack.com/incoming-webhooks) 248 | 1. **Webhook Method** - `POST` 249 | 1. No triggers or any other configuration is needed. The script will pass the notifier the data to send to Discord/Slack. 250 | 251 |

252 | 253 | 254 |

255 | 256 | ### Debug 257 | 258 | Add `--debug` to enable debug logging. 259 | 260 | ### Conditions considerations 261 | 262 | #### Kill transcode variants 263 | 264 | All examples use \[ `Transcode Decision` | `is` | `transcode` \] which will kill any variant of transcoding. 265 | If you want to allow audio or container transcoding and only drop video transcodes, your condition would change to 266 | \[ `Video Decision` | `is` | `transcode` \] 267 | -------------------------------------------------------------------------------- /maps/EU_map_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacktwin/JBOPS/9177c8b00785e9a6a1cc111fee7cc86b102f5ac9/maps/EU_map_example.PNG -------------------------------------------------------------------------------- /maps/NA_map_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacktwin/JBOPS/9177c8b00785e9a6a1cc111fee7cc86b102f5ac9/maps/NA_map_example.PNG -------------------------------------------------------------------------------- /maps/World_map_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacktwin/JBOPS/9177c8b00785e9a6a1cc111fee7cc86b102f5ac9/maps/World_map_example.PNG -------------------------------------------------------------------------------- /maps/readme.md: -------------------------------------------------------------------------------- 1 | # Maps 2 | 3 | Maps are created with either Matplotlib/Basemap or as a geojson file on an anonymous gist. 4 | 5 | Choose which map type you'd like by using the `-l` argument: 6 | 7 | ``` 8 | -l , --location Map location. choices: (NA, EU, World, Geo) 9 | (default: NA) 10 | ``` 11 | 12 | # Requirements 13 | 14 | - [ ] [Matplotlib](https://matplotlib.org/1.2.1/users/installing.html) 15 | - [ ] [Basemap](https://matplotlib.org/basemap/users/installing.html) 16 | 17 | \* not required if creating geojson maps 18 | 19 | 20 | ## Matplotlib map examples: 21 | 22 | 43 | 44 | ## TODO LIST: 45 | 46 | - [x] Add check for user count in user_table to allow for greater than 25 users - [Pull](https://github.com/blacktwin/JBOPS/pull/3) 47 | - [x] If platform is missing from PLATFORM_COLORS use DEFAULT_COLOR - [Pull](https://github.com/blacktwin/JBOPS/pull/4) 48 | - [x] Add arg to allow for runs in headless (mpl.use("Agg")) 49 | - [x] Add pass on N/A values for Lon/Lat - [Pull](https://github.com/blacktwin/JBOPS/pull/2) 50 | 51 | ### Feature updates: 52 | 53 | - [ ] Add arg for legend (best, none, axes, top_left, top_center, etc.) 54 | - [ ] UI, toggles, interactive mode 55 | - [ ] Add arg to group legend items by city and/or region 56 | - [ ] Find server's external IP, geolocation. Allow custom location to override 57 | - [ ] Add arg for tracert visualization from server to client 58 | - [ ] Animate tracert visualization? gif? 59 | -------------------------------------------------------------------------------- /maps/requirements.txt: -------------------------------------------------------------------------------- 1 | #--------------------------------------------------------- 2 | # Potential requirements. 3 | # pip install -r requirements.txt 4 | #--------------------------------------------------------- 5 | requests 6 | matplotlib 7 | numpy 8 | basemap 9 | -------------------------------------------------------------------------------- /notify/find_unwatched_notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Find what was added TFRAME ago and not watched and notify admin using Tautulli. 6 | 7 | TAUTULLI_URL + delete_media_info_cache?section_id={section_id} 8 | """ 9 | from __future__ import print_function 10 | from __future__ import unicode_literals 11 | 12 | from builtins import object 13 | from builtins import str 14 | import requests 15 | import sys 16 | import time 17 | 18 | TFRAME = 1.577e+7 # ~ 6 months in seconds 19 | TODAY = time.time() 20 | 21 | # ## EDIT THESE SETTINGS ## 22 | TAUTULLI_APIKEY = '' # Your Tautulli API key 23 | TAUTULLI_URL = 'http://localhost:8183/' # Your Tautulli URL 24 | LIBRARY_NAMES = ['Movies', 'TV Shows'] # Name of libraries you want to check. 25 | SUBJECT_TEXT = "Tautulli Notification" 26 | NOTIFIER_ID = 12 # The email notification agent ID for Tautulli 27 | 28 | 29 | class LIBINFO(object): 30 | def __init__(self, data=None): 31 | d = data or {} 32 | self.added_at = d['added_at'] 33 | self.parent_rating_key = d['parent_rating_key'] 34 | self.play_count = d['play_count'] 35 | self.title = d['title'] 36 | self.rating_key = d['rating_key'] 37 | self.media_type = d['media_type'] 38 | 39 | 40 | class METAINFO(object): 41 | def __init__(self, data=None): 42 | d = data or {} 43 | self.added_at = d['added_at'] 44 | self.parent_rating_key = d['parent_rating_key'] 45 | self.title = d['title'] 46 | self.rating_key = d['rating_key'] 47 | self.media_type = d['media_type'] 48 | self.grandparent_title = d['grandparent_title'] 49 | media_info = d['media_info'][0] 50 | parts = media_info['parts'][0] 51 | self.file_size = parts['file_size'] 52 | self.file = parts['file'] 53 | 54 | 55 | def get_new_rating_keys(rating_key, media_type): 56 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 57 | payload = {'apikey': TAUTULLI_APIKEY, 58 | 'cmd': 'get_new_rating_keys', 59 | 'rating_key': rating_key, 60 | 'media_type': media_type} 61 | 62 | try: 63 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 64 | response = r.json() 65 | 66 | res_data = response['response']['data'] 67 | show = res_data['0'] 68 | episode_lst = [episode['rating_key'] for _, season in show['children'].items() for _, episode in 69 | season['children'].items()] 70 | 71 | return episode_lst 72 | 73 | except Exception as e: 74 | sys.stderr.write("Tautulli API 'get_new_rating_keys' request failed: {0}.".format(e)) 75 | 76 | 77 | def get_metadata(rating_key): 78 | # Get the metadata for a media item. 79 | payload = {'apikey': TAUTULLI_APIKEY, 80 | 'rating_key': rating_key, 81 | 'cmd': 'get_metadata'} 82 | 83 | try: 84 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 85 | response = r.json() 86 | res_data = response['response']['data'] 87 | return METAINFO(data=res_data) 88 | 89 | except Exception as e: 90 | sys.stderr.write("Tautulli API 'get_metadata' request failed: {0}.".format(e)) 91 | pass 92 | 93 | 94 | def get_library_media_info(section_id): 95 | # Get the data on the Tautulli media info tables. 96 | payload = {'apikey': TAUTULLI_APIKEY, 97 | 'section_id': section_id, 98 | 'cmd': 'get_library_media_info'} 99 | 100 | try: 101 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 102 | response = r.json() 103 | res_data = response['response']['data']['data'] 104 | return [LIBINFO(data=d) for d in res_data if d['play_count'] is None and (TODAY - int(d['added_at'])) > TFRAME] 105 | 106 | except Exception as e: 107 | sys.stderr.write("Tautulli API 'get_library_media_info' request failed: {0}.".format(e)) 108 | 109 | 110 | def get_libraries_table(): 111 | # Get the data on the Tautulli libraries table. 112 | payload = {'apikey': TAUTULLI_APIKEY, 113 | 'cmd': 'get_libraries_table'} 114 | 115 | try: 116 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 117 | response = r.json() 118 | 119 | res_data = response['response']['data']['data'] 120 | return [d['section_id'] for d in res_data if d['section_name'] in LIBRARY_NAMES] 121 | 122 | except Exception as e: 123 | sys.stderr.write("Tautulli API 'get_libraries_table' request failed: {0}.".format(e)) 124 | 125 | 126 | def send_notification(body_text): 127 | # Format notification text 128 | try: 129 | subject = SUBJECT_TEXT 130 | body = body_text 131 | except LookupError as e: 132 | sys.stderr.write("Unable to substitute '{0}' in the notification subject or body".format(e)) 133 | return None 134 | # Send the notification through Tautulli 135 | payload = {'apikey': TAUTULLI_APIKEY, 136 | 'cmd': 'notify', 137 | 'notifier_id': NOTIFIER_ID, 138 | 'subject': subject, 139 | 'body': body} 140 | 141 | try: 142 | r = requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 143 | response = r.json() 144 | 145 | if response['response']['result'] == 'success': 146 | sys.stdout.write("Successfully sent Tautulli notification.") 147 | else: 148 | raise Exception(response['response']['message']) 149 | except Exception as e: 150 | sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) 151 | return None 152 | 153 | 154 | show_lst = [] 155 | notify_lst = [] 156 | 157 | libraries = [lib for lib in get_libraries_table()] 158 | 159 | for library in libraries: 160 | try: 161 | library_media_info = get_library_media_info(library) 162 | for lib in library_media_info: 163 | try: 164 | if lib.media_type in ['show', 'episode']: 165 | # Need to find TV shows rating_key for episode. 166 | show_lst += get_new_rating_keys(lib.rating_key, lib.media_type) 167 | else: 168 | # Find movie rating_key. 169 | show_lst += [int(lib.rating_key)] 170 | except Exception as e: 171 | print("Rating_key failed: {e}".format(e=e)) 172 | 173 | except Exception as e: 174 | print("Library media info failed: {e}".format(e=e)) 175 | 176 | for show in show_lst: 177 | try: 178 | meta = get_metadata(str(show)) 179 | added = time.ctime(float(meta.added_at)) 180 | if meta.grandparent_title == '' or meta.media_type == 'movie': 181 | # Movies 182 | notify_lst += [u"
{x.title} ({x.rating_key}) was added {when} and has not been" 183 | u" watched.
File location: {x.file}

".format(x=meta, when=added)] 184 | else: 185 | # Shows 186 | notify_lst += [u"
{x.grandparent_title}: {x.title} ({x.rating_key}) was added {when} and has" 187 | u" not been watched.
File location: {x.file}

".format(x=meta, when=added)] 188 | 189 | except Exception as e: 190 | print("Metadata failed. Likely end of range: {e}".format(e=e)) 191 | 192 | if notify_lst: 193 | BODY_TEXT = """\ 194 | 195 | 196 | 197 |

Hi!
198 |
Below is the list of {LIBRARY_NAMES} that have not been watched.
199 |

200 | {notify_lst} 201 |
202 |

203 | 204 | 205 | """.format(notify_lst="\n".join(notify_lst).encode("utf-8"), LIBRARY_NAMES=" & ".join(LIBRARY_NAMES)) 206 | 207 | print(BODY_TEXT) 208 | send_notification(BODY_TEXT) 209 | else: 210 | print('Nothing to report.') 211 | exit() 212 | -------------------------------------------------------------------------------- /notify/notify_delay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Delay Notification Agent message for concurrent streams 6 | 7 | Arguments passed from Tautulli 8 | -u {user} -srv {server_name} 9 | You can add more arguments if you want more details in the email body 10 | 11 | Adding to Tautulli 12 | Tautulli > Settings > Notification Agents > Scripts > Bell icon: 13 | [X] Notify on concurrent streams 14 | Tautulli > Settings > Notification Agents > Scripts > Gear icon: 15 | User Concurrent Streams: notify_delay.py 16 | 17 | Tautulli Settings > Notification Agents > Scripts (Gear) > Script Timeout: 0 to disable or set to > 180 18 | """ 19 | from __future__ import print_function 20 | from __future__ import division 21 | from __future__ import unicode_literals 22 | 23 | from past.utils import old_div 24 | import requests 25 | import sys 26 | import argparse 27 | from time import sleep 28 | 29 | # ## EDIT THESE SETTINGS ## 30 | TAUTULLI_APIKEY = '' # Your Tautulli API key 31 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 32 | CONCURRENT_TOTAL = 2 33 | TIMEOUT = 180 34 | INTERVAL = 20 35 | 36 | NOTIFIER_ID = 10 # Notification notifier ID for Tautulli 37 | # Find Notification agent ID here: 38 | # https://github.com/JonnyWong16/plexpy/blob/master/API.md#notify 39 | 40 | SUBJECT_TEXT = 'Concurrent Streams from {p.user} on {p.plex_server}' 41 | BODY_TEXT = """\ 42 | 43 | 44 | 45 |

Hi!
46 | {p.user} has had {total} concurrent streams for longer than {time} minutes. 47 |

48 | 49 | 50 | """ 51 | 52 | 53 | def get_activity(): 54 | """Get the current activity on the PMS.""" 55 | payload = {'apikey': TAUTULLI_APIKEY, 56 | 'cmd': 'get_activity'} 57 | 58 | try: 59 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 60 | response = r.json() 61 | res_data = response['response']['data']['sessions'] 62 | return [d['user'] for d in res_data] 63 | 64 | except Exception as e: 65 | sys.stderr.write("Tautulli API 'get_activity' request failed: {0}.".format(e)) 66 | pass 67 | 68 | 69 | def send_notification(subject_text, body_text): 70 | """Format notification text.""" 71 | try: 72 | subject = subject_text.format(p=p, total=cc_total) 73 | body = body_text.format(p=p, total=cc_total, time=old_div(TIMEOUT, 60)) 74 | 75 | except LookupError as e: 76 | sys.stderr.write("Unable to substitute '{0}' in the notification subject or body".format(e)) 77 | return None 78 | # Send the notification through Tautulli 79 | payload = {'apikey': TAUTULLI_APIKEY, 80 | 'cmd': 'notify', 81 | 'notifier_id': NOTIFIER_ID, 82 | 'subject': subject, 83 | 'body': body} 84 | 85 | try: 86 | r = requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 87 | response = r.json() 88 | 89 | if response['response']['result'] == 'success': 90 | sys.stdout.write("Successfully sent Tautulli notification.") 91 | else: 92 | raise Exception(response['response']['message']) 93 | except Exception as e: 94 | sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) 95 | return None 96 | 97 | 98 | if __name__ == '__main__': 99 | parser = argparse.ArgumentParser() 100 | 101 | parser.add_argument('-u', '--user', action='store', default='', 102 | help='Username of the person watching the stream') 103 | parser.add_argument('-srv', '--plex_server', action='store', default='', 104 | help='The name of the Plex server') 105 | 106 | p = parser.parse_args() 107 | 108 | x = 0 109 | while x < TIMEOUT and x is not None: 110 | # check if user still has concurrent streams 111 | print('Checking concurrent stream count.') 112 | cc_total = get_activity().count(p.user) 113 | if cc_total >= CONCURRENT_TOTAL: 114 | print('{p.user} still has {total} concurrent streams.'.format(p=p, total=cc_total)) 115 | sleep(INTERVAL) 116 | x += INTERVAL 117 | else: 118 | print('Exiting, user no longer has concurrent streams.') 119 | exit() 120 | 121 | print('Concurrent stream monitoring timeout limit has been reached. Sending notification.') 122 | send_notification(SUBJECT_TEXT, BODY_TEXT) 123 | -------------------------------------------------------------------------------- /notify/notify_recently_aired.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Description: Notify only if recently aired/released 6 | Author: Blacktwin 7 | Requires: requests 8 | 9 | Enabling Scripts in Tautulli: 10 | Tautulli > Settings > Notification Agents > Add a Notification Agent > Script 11 | 12 | Configuration: 13 | Tautulli > Settings > Notification Agents > New Script > Configuration: 14 | 15 | Script Name: notify_recently_aired.py 16 | Set Script Timeout: Default 17 | Description: Notify only if recently aired/released 18 | Save 19 | 20 | Triggers: 21 | Tautulli > Settings > Notification Agents > New Script > Triggers: 22 | 23 | Check: Recently Added 24 | Save 25 | 26 | Conditions: 27 | Tautulli > Settings > Notification Agents > New Script > Conditions: 28 | 29 | Set Conditions: [{condition} | {operator} | {value} ] 30 | Save 31 | 32 | Script Arguments: 33 | Tautulli > Settings > Notification Agents > New Script > Script Arguments: 34 | 35 | Select: Recently Added 36 | Arguments: {air_date} or {release_date} {rating_key} 37 | 38 | Save 39 | Close 40 | 41 | Note: 42 | You'll need another notification agent to use for actually sending the notification. 43 | The notifier_id in the edit section will need to be this other notification agent you intend to use. 44 | It does not have to be an active notification agent, just setup. 45 | """ 46 | from __future__ import print_function 47 | from __future__ import unicode_literals 48 | import os 49 | import sys 50 | import requests 51 | from datetime import date 52 | from datetime import datetime 53 | 54 | TAUTULLI_URL = '' 55 | TAUTULLI_APIKEY = '' 56 | TAUTULLI_URL = os.getenv('TAUTULLI_URL', TAUTULLI_URL) 57 | TAUTULLI_APIKEY = os.getenv('TAUTULLI_APIKEY', TAUTULLI_APIKEY) 58 | 59 | # Edit 60 | date_format = "%Y-%m-%d" 61 | RECENT_DAYS = 3 62 | NOTIFIER_ID = 34 63 | # /Edit 64 | 65 | air_date = sys.argv[1] 66 | rating_key = int(sys.argv[2]) 67 | 68 | aired_date = datetime.strptime(air_date, date_format) 69 | today = date.today() 70 | delta = today - aired_date.date() 71 | 72 | 73 | def notify_recently_added(rating_key, notifier_id): 74 | # Get the metadata for a media item. 75 | payload = {'apikey': TAUTULLI_APIKEY, 76 | 'rating_key': rating_key, 77 | 'notifier_id': notifier_id, 78 | 'cmd': 'notify_recently_added'} 79 | 80 | try: 81 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 82 | response = r.json() 83 | sys.stdout.write(response["response"]["message"]) 84 | 85 | except Exception as e: 86 | sys.stderr.write("Tautulli API 'notify_recently_added' request failed: {0}.".format(e)) 87 | pass 88 | 89 | 90 | if delta.days < RECENT_DAYS: 91 | notify_recently_added(rating_key, NOTIFIER_ID) 92 | else: 93 | print("Not recent enough, no notification to be sent.") 94 | -------------------------------------------------------------------------------- /notify/notify_user_favorites.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Notify users of recently added episode to show that they have watched at least LIMIT times via email. 6 | Block users with IGNORE_LST. 7 | 8 | Arguments passed from Tautulli 9 | -sn {show_name} -ena {episode_name} -ssn {season_num00} -enu {episode_num00} -srv {server_name} -med {media_type} 10 | -pos {poster_url} -tt {title} -sum {summary} -lbn {library_name} -grk {grandparent_rating_key} 11 | You can add more arguments if you want more details in the email body 12 | 13 | Adding to Tautulli 14 | Tautulli > Settings > Notification Agents > Scripts > Bell icon: 15 | [X] Notify on recently added 16 | Tautulli > Settings > Notification Agents > Scripts > Gear icon: 17 | Recently Added: notify_user_favorite.py 18 | """ 19 | from __future__ import print_function 20 | from __future__ import unicode_literals 21 | 22 | from builtins import object 23 | import requests 24 | from email.mime.text import MIMEText 25 | import email.utils 26 | import smtplib 27 | import sys 28 | import argparse 29 | 30 | # ## EDIT THESE SETTINGS ## 31 | TAUTULLI_APIKEY = 'XXXXXXX' # Your Tautulli API key 32 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 33 | 34 | IGNORE_LST = [123456, 123456] # User_ids 35 | LIMIT = 3 36 | 37 | # Email settings 38 | name = '' # Your name 39 | sender = '' # From email address 40 | email_server = 'smtp.gmail.com' # Email server (Gmail: smtp.gmail.com) 41 | email_port = 587 # Email port (Gmail: 587) 42 | email_username = '' # Your email username 43 | email_password = '' # Your email password 44 | 45 | user_dict = {} 46 | 47 | 48 | class Users(object): 49 | def __init__(self, data=None): 50 | d = data or {} 51 | self.email = d['email'] 52 | self.user_id = d['user_id'] 53 | 54 | 55 | class UserHIS(object): 56 | def __init__(self, data=None): 57 | d = data or {} 58 | self.watched = d['watched_status'] 59 | self.title = d['full_title'] 60 | self.user = d['friendly_name'] 61 | self.user_id = d['user_id'] 62 | self.media = d['media_type'] 63 | self.rating_key = d['rating_key'] 64 | self.show_key = d['grandparent_rating_key'] 65 | 66 | 67 | def get_user(user_id): 68 | # Get the user list from Tautulli. 69 | payload = {'apikey': TAUTULLI_APIKEY, 70 | 'cmd': 'get_user', 71 | 'user_id': int(user_id)} 72 | 73 | try: 74 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 75 | response = r.json() 76 | res_data = response['response']['data'] 77 | return Users(data=res_data) 78 | 79 | except Exception as e: 80 | sys.stderr.write("Tautulli API 'get_user' request failed: {0}.".format(e)) 81 | 82 | 83 | def get_history(showkey): 84 | """Get the user history from Tautulli. 85 | 86 | Length matters! 87 | """ 88 | payload = {'apikey': TAUTULLI_APIKEY, 89 | 'cmd': 'get_history', 90 | 'grandparent_rating_key': showkey, 91 | 'length': 10000} 92 | 93 | try: 94 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 95 | response = r.json() 96 | res_data = response['response']['data']['data'] 97 | return [UserHIS(data=d) for d in res_data 98 | if d['watched_status'] == 1 and 99 | d['media_type'].lower() in ('episode', 'show')] 100 | 101 | except Exception as e: 102 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 103 | 104 | 105 | def add_to_dictlist(d, key, val): 106 | if key not in d: 107 | d[key] = [val] 108 | else: 109 | d[key].append(val) 110 | 111 | 112 | def get_email(show): 113 | history = get_history(show) 114 | 115 | [add_to_dictlist(user_dict, h.user_id, h.show_key) for h in history] 116 | # {user_id1: [grand_key, grand_key], user_id2: [grand_key]} 117 | 118 | for key, value in user_dict.items(): 119 | user_dict[key] = {x: value.count(x) for x in value} 120 | # Count how many times user watched show. History length matters! 121 | # {user_id1: {grand_key: 2}, user_id2: {grand_key: 1} 122 | 123 | email_lst = [] 124 | 125 | user_lst = user_dict.keys() 126 | 127 | for i in user_lst: 128 | try: 129 | if user_dict[i][show] >= LIMIT: 130 | g = get_user(i) 131 | if g.user_id not in IGNORE_LST: 132 | sys.stdout.write("Sending {g.user_id} email for %s.".format(g=g) % show) 133 | email_lst += [g.email] 134 | except Exception as e: 135 | sys.stderr.write("{0}".format(e)) 136 | pass 137 | return (email_lst) 138 | 139 | 140 | if __name__ == '__main__': 141 | parser = argparse.ArgumentParser() 142 | 143 | parser.add_argument('-ip', '--ip_address', action='store', default='', 144 | help='The IP address of the stream') 145 | parser.add_argument('-us', '--user', action='store', default='', 146 | help='Username of the person watching the stream') 147 | parser.add_argument('-uid', '--user_id', action='store', default='', 148 | help='User_ID of the person watching the stream') 149 | parser.add_argument('-med', '--media_type', action='store', default='', 150 | help='The media type of the stream') 151 | parser.add_argument('-tt', '--title', action='store', default='', 152 | help='The title of the media') 153 | parser.add_argument('-pf', '--platform', action='store', default='', 154 | help='The platform of the stream') 155 | parser.add_argument('-pl', '--player', action='store', default='', 156 | help='The player of the stream') 157 | parser.add_argument('-da', '--datestamp', action='store', default='', 158 | help='The date of the stream') 159 | parser.add_argument('-ti', '--timestamp', action='store', default='', 160 | help='The time of the stream') 161 | parser.add_argument('-sn', '--show_name', action='store', default='', 162 | help='The name of the TV show') 163 | parser.add_argument('-ena', '--episode_name', action='store', default='', 164 | help='The name of the episode') 165 | parser.add_argument('-ssn', '--season_num', action='store', default='', 166 | help='The season number of the TV show') 167 | parser.add_argument('-enu', '--episode_num', action='store', default='', 168 | help='The episode number of the TV show') 169 | parser.add_argument('-srv', '--plex_server', action='store', default='', 170 | help='The name of the Plex server') 171 | parser.add_argument('-pos', '--poster', action='store', default='', 172 | help='The poster url') 173 | parser.add_argument('-sum', '--summary', action='store', default='', 174 | help='The summary of the TV show') 175 | parser.add_argument('-lbn', '--library_name', action='store', default='', 176 | help='The name of the TV show') 177 | parser.add_argument('-grk', '--grandparent_rating_key', action='store', default='', 178 | help='The key of the TV show') 179 | 180 | p = parser.parse_args() 181 | 182 | email_subject = 'New episode for ' + p.show_name + ' is available on ' + p.plex_server # The email subject 183 | 184 | to = get_email(int(p.grandparent_rating_key)) 185 | 186 | # Detailed body for tv shows. You can add more arguments if you want more details in the email body 187 | show_html = """\ 188 | 189 | 190 | 191 |

Hi!
192 | {p.show_name} S{p.season_num} - E{p.episode_num} -- {p.episode_name} -- was recently added to 193 | {p.library_name} on PLEX 194 |

195 |
{p.summary}
196 |
Poster unavailable
197 |

198 | 199 | 200 | """.format(p=p) 201 | 202 | # ## Do not edit below ### 203 | message = MIMEText(show_html, 'html') 204 | message['Subject'] = email_subject 205 | message['From'] = email.utils.formataddr((name, sender)) 206 | 207 | mailserver = smtplib.SMTP(email_server, email_port) 208 | mailserver.starttls() 209 | mailserver.ehlo() 210 | mailserver.login(email_username, email_password) 211 | mailserver.sendmail(sender, to, message.as_string()) 212 | mailserver.quit() 213 | print('Email sent') 214 | -------------------------------------------------------------------------------- /notify/top_concurrent_notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | 6 | Description: Check Tautulli's most concurrent from home stats against current concurrent count. 7 | If greater notify using an existing agent. 8 | Author: Blacktwin 9 | Requires: requests 10 | 11 | Enabling Scripts in Tautulli: 12 | Tautulli > Settings > Notification Agents > Add a Notification Agent > Script 13 | 14 | Configuration: 15 | Tautulli > Settings > Notification Agents > New Script > Configuration: 16 | 17 | Script Name: Most Concurrent Record 18 | Set Script Timeout: {timeout} 19 | Description: New Most Concurrent Record 20 | Save 21 | 22 | Triggers: 23 | Tautulli > Settings > Notification Agents > New Script > Triggers: 24 | 25 | Check: Playback Start 26 | Save 27 | 28 | Conditions: 29 | Tautulli > Settings > Notification Agents > New Script > Conditions: 30 | 31 | Set Conditions: [{condition} | {operator} | {value} ] 32 | Save 33 | 34 | Script Arguments: 35 | Tautulli > Settings > Notification Agents > New Script > Script Arguments: 36 | 37 | Select: Playback Start 38 | Arguments: --streams {streams} --notifier notifierID 39 | 40 | *notifierID of the existing agent you want to use to send notification. 41 | 42 | 43 | Save 44 | Close 45 | 46 | Example: 47 | 48 | 49 | """ 50 | from __future__ import unicode_literals 51 | 52 | import os 53 | import sys 54 | import requests 55 | import argparse 56 | 57 | 58 | # ### EDIT SETTINGS ### 59 | 60 | TAUTULLI_URL = '' 61 | TAUTULLI_APIKEY = '' 62 | TAUTULLI_URL = os.getenv('TAUTULLI_URL', TAUTULLI_URL) 63 | TAUTULLI_APIKEY = os.getenv('TAUTULLI_APIKEY', TAUTULLI_APIKEY) 64 | VERIFY_SSL = False 65 | 66 | 67 | SUBJECT = 'New Record for Most Concurrent Streams!' 68 | BODY = 'New server record for most concurrent streams is now {}.' 69 | 70 | # ## CODE BELOW ## 71 | 72 | 73 | def get_home_stats(): 74 | # Get the homepage watch statistics. 75 | payload = {'apikey': TAUTULLI_APIKEY, 76 | 'cmd': 'get_home_stats'} 77 | 78 | try: 79 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 80 | response = r.json() 81 | res_data = response['response']['data'] 82 | most_concurrents = [rows for rows in res_data if rows['stat_id'] == 'most_concurrent'] 83 | concurrent_rows = most_concurrents[0]['rows'] 84 | return concurrent_rows 85 | 86 | except Exception as e: 87 | sys.stderr.write("Tautulli API 'get_home_stats' request failed: {0}.".format(e)) 88 | 89 | 90 | def notify(notifier_id, subject, body): 91 | """Call Tautulli's notify api endpoint""" 92 | payload = {'apikey': TAUTULLI_APIKEY, 93 | 'cmd': 'notify', 94 | 'notifier_id': notifier_id, 95 | 'subject': subject, 96 | 'body': body} 97 | 98 | try: 99 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 100 | response = r.json() 101 | res_data = response['response']['data'] 102 | return res_data 103 | 104 | except Exception as e: 105 | sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) 106 | 107 | 108 | 109 | if __name__ == '__main__': 110 | parser = argparse.ArgumentParser(description="Notification of new most concurrent streams count.", 111 | formatter_class=argparse.RawTextHelpFormatter) 112 | parser.add_argument('--streams', required=True, type=int, 113 | help='Current streams count from Tautulli.') 114 | parser.add_argument('--notifier', required=True, 115 | help='Tautulli notification ID to send notification to.') 116 | 117 | opts = parser.parse_args() 118 | 119 | most_concurrent = get_home_stats() 120 | for result in most_concurrent: 121 | if result['title'] == 'Concurrent Streams': 122 | if opts.streams > result['count']: 123 | notify(notifier_id=opts.notifier, subject=SUBJECT, body=BODY.format(opts.streams)) -------------------------------------------------------------------------------- /notify/twitter_notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | 1. Install the requests module for python. 6 | pip install requests 7 | pip install twitter 8 | 9 | Tautulli > Settings > Notification Agents > Scripts > Bell icon: 10 | [X] Notify on Recently Added 11 | Tautulli > Settings > Notification Agents > Scripts > Gear icon: 12 | Playback Recently Added: twitter_notify.py 13 | Tautulli > Settings > Notifications > Script > Script Arguments: 14 | -sn {show_name} -ena {episode_name} -ssn {season_num00} -enu {episode_num00} -dur {duration} 15 | -srv {server_name} -med {media_type} -tt {title} -purl {plex_url} -post {poster_url} 16 | 17 | https://gist.github.com/blacktwin/261c416dbed08291e6d12f6987d9bafa 18 | """ 19 | from __future__ import unicode_literals 20 | 21 | from twitter import Twitter, OAuth 22 | import argparse 23 | import requests 24 | import os 25 | 26 | # ## EDIT THESE SETTINGS ## 27 | TOKEN = '' 28 | TOKEN_SECRET = '' 29 | CONSUMER_KEY = '' 30 | CONSUMER_SECRET = '' 31 | 32 | TITLE_FIND = ['Friends'] # Title to ignore ['Snow White'] 33 | TWITTER_USER = ' @username' 34 | 35 | BODY_TEXT = '' 36 | MOVIE_TEXT = "New {media_type}: {title} [ duration: {duration} mins ] was recently added to PLEX" 37 | TV_TEXT = "New {media_type} of {show_name}: {title} [ S{season_num00}E{episode_num00} ] " \ 38 | "[ duration: {duration} mins ] was recently added to PLEX" 39 | 40 | 41 | if __name__ == '__main__': 42 | parser = argparse.ArgumentParser() 43 | 44 | parser.add_argument('-ip', '--ip_address', action='store', default='', 45 | help='The IP address of the stream') 46 | parser.add_argument('-us', '--user', action='store', default='', 47 | help='Username of the person watching the stream') 48 | parser.add_argument('-uid', '--user_id', action='store', default='', 49 | help='User_ID of the person watching the stream') 50 | parser.add_argument('-med', '--media_type', action='store', default='', 51 | help='The media type of the stream') 52 | parser.add_argument('-tt', '--title', action='store', default='', 53 | help='The title of the media') 54 | parser.add_argument('-pf', '--platform', action='store', default='', 55 | help='The platform of the stream') 56 | parser.add_argument('-pl', '--player', action='store', default='', 57 | help='The player of the stream') 58 | parser.add_argument('-da', '--datestamp', action='store', default='', 59 | help='The date of the stream') 60 | parser.add_argument('-ti', '--timestamp', action='store', default='', 61 | help='The time of the stream') 62 | parser.add_argument('-sn', '--show_name', action='store', default='', 63 | help='The name of the TV show') 64 | parser.add_argument('-ena', '--episode_name', action='store', default='', 65 | help='The name of the episode') 66 | parser.add_argument('-ssn', '--season_num', action='store', default='', 67 | help='The season number of the TV show') 68 | parser.add_argument('-enu', '--episode_num', action='store', default='', 69 | help='The episode number of the TV show') 70 | parser.add_argument('-srv', '--plex_server', action='store', default='', 71 | help='The name of the Plex server') 72 | parser.add_argument('-pos', '--poster', action='store', default='', 73 | help='The poster url') 74 | parser.add_argument('-sum', '--summary', action='store', default='', 75 | help='The summary of the TV show') 76 | parser.add_argument('-lbn', '--library_name', action='store', default='', 77 | help='The name of the TV show') 78 | parser.add_argument('-grk', '--grandparent_rating_key', action='store', default='', 79 | help='The key of the TV show') 80 | parser.add_argument('-purl', '--plex_url', action='store', default='', 81 | help='Url to Plex video') 82 | parser.add_argument('-dur', '--duration', action='store', default='', 83 | help='The time of the stream') 84 | 85 | p = parser.parse_args() 86 | 87 | if p.media_type == 'movie': 88 | BODY_TEXT = MOVIE_TEXT.format(media_type=p.media_type, title=p.title, duration=p.duration) 89 | elif p.media_type == 'episode': 90 | BODY_TEXT = TV_TEXT.format( 91 | media_type=p.media_type, show_name=p.show_name, title=p.title, 92 | season_num00=p.season_num, episode_num00=p.episode_num, 93 | duration=p.duration) 94 | else: 95 | exit() 96 | 97 | if p.title in TITLE_FIND: 98 | BODY_TEXT += TWITTER_USER 99 | 100 | t = Twitter(auth=OAuth(TOKEN, TOKEN_SECRET, CONSUMER_KEY, CONSUMER_SECRET)) 101 | 102 | filename = 'temp.jpg' 103 | request = requests.get(p.poster, stream=True) 104 | if request.status_code == 200: 105 | with open(filename, 'wb') as image: 106 | for chunk in request: 107 | image.write(chunk) 108 | 109 | t_upload = Twitter(domain='upload.twitter.com', 110 | auth=OAuth(TOKEN, TOKEN_SECRET, CONSUMER_KEY, CONSUMER_SECRET)) 111 | 112 | file = open(filename, 'rb') 113 | data = file.read() 114 | id_img1 = t_upload.media.upload(media=data)["media_id_string"] 115 | 116 | t.statuses.update(status=BODY_TEXT, media_ids=",".join([id_img1])) 117 | 118 | os.remove(filename) 119 | -------------------------------------------------------------------------------- /reporting/added_to_plex.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Find when media was added between STARTFRAME and ENDFRAME to Plex through Tautulli. 6 | 7 | Some Exceptions have been commented out to supress what is printed. 8 | Uncomment Exceptions if you run into problem and need to investigate. 9 | """ 10 | from __future__ import print_function 11 | from __future__ import unicode_literals 12 | 13 | from builtins import str 14 | from builtins import object 15 | import requests 16 | import sys 17 | import time 18 | 19 | STARTFRAME = 1480550400 # 2016, Dec 1 in seconds 20 | ENDFRAME = 1488326400 # 2017, March 1 in seconds 21 | 22 | TODAY = int(time.time()) 23 | LASTMONTH = int(TODAY - 2629743) # 2629743 = 1 month in seconds 24 | 25 | # Uncomment to change range to 1 month ago - Today 26 | # STARTFRAME = LASTMONTH 27 | # ENDFRAME = TODAY 28 | 29 | # ## EDIT THESE SETTINGS ## 30 | TAUTULLI_APIKEY = 'XXXXX' # Your Tautulli API key 31 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 32 | LIBRARY_NAMES = ['TV Shows', 'Movies'] # Names of your libraries you want to check. 33 | 34 | 35 | class LIBINFO(object): 36 | def __init__(self, data=None): 37 | d = data or {} 38 | self.added_at = d['added_at'] 39 | self.parent_rating_key = d['parent_rating_key'] 40 | self.title = d['title'] 41 | self.rating_key = d['rating_key'] 42 | self.media_type = d['media_type'] 43 | 44 | 45 | class METAINFO(object): 46 | def __init__(self, data=None): 47 | d = data or {} 48 | self.added_at = d['added_at'] 49 | self.parent_rating_key = d['parent_rating_key'] 50 | self.title = d['title'] 51 | self.rating_key = d['rating_key'] 52 | self.media_type = d['media_type'] 53 | self.grandparent_title = d['grandparent_title'] 54 | self.file_size = d['file_size'] 55 | 56 | 57 | def get_new_rating_keys(rating_key, media_type): 58 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 59 | payload = {'apikey': TAUTULLI_APIKEY, 60 | 'cmd': 'get_new_rating_keys', 61 | 'rating_key': rating_key, 62 | 'media_type': media_type} 63 | 64 | try: 65 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 66 | response = r.json() 67 | 68 | res_data = response['response']['data'] 69 | show = res_data['0'] 70 | episode_lst = [episode['rating_key'] for _, season in show['children'].items() for _, episode in 71 | season['children'].items()] 72 | 73 | return episode_lst 74 | 75 | except Exception as e: 76 | sys.stderr.write("Tautulli API 'get_new_rating_keys' request failed: {0}.".format(e)) 77 | 78 | 79 | def get_library_media_info(section_id): 80 | # Get the data on the Tautulli media info tables. Length matters! 81 | payload = {'apikey': TAUTULLI_APIKEY, 82 | 'section_id': section_id, 83 | 'order_dir ': 'asc', 84 | 'cmd': 'get_library_media_info', 85 | 'length': 10000000} 86 | 87 | try: 88 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 89 | response = r.json() 90 | 91 | res_data = response['response']['data']['data'] 92 | return [LIBINFO(data=d) for d in res_data] 93 | 94 | except Exception as e: 95 | sys.stderr.write("Tautulli API 'get_library_media_info' request failed: {0}.".format(e)) 96 | 97 | 98 | def get_metadata(rating_key): 99 | # Get the metadata for a media item. 100 | payload = {'apikey': TAUTULLI_APIKEY, 101 | 'rating_key': rating_key, 102 | 'cmd': 'get_metadata', 103 | 'media_info': True} 104 | 105 | try: 106 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 107 | response = r.json() 108 | 109 | res_data = response['response']['data'] 110 | if STARTFRAME <= int(res_data['added_at']) <= ENDFRAME: 111 | return METAINFO(data=res_data) 112 | 113 | except Exception as e: 114 | sys.stderr.write("Tautulli API 'get_metadata' request failed: {0}.".format(e)) 115 | 116 | 117 | def update_library_media_info(section_id): 118 | # Get the data on the Tautulli media info tables. 119 | payload = {'apikey': TAUTULLI_APIKEY, 120 | 'cmd': 'get_library_media_info', 121 | 'section_id': section_id, 122 | 'refresh': True} 123 | 124 | try: 125 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 126 | response = r.status_code 127 | if response != 200: 128 | print(r.content) 129 | 130 | except Exception as e: 131 | sys.stderr.write("Tautulli API 'update_library_media_info' request failed: {0}.".format(e)) 132 | 133 | 134 | def get_libraries_table(): 135 | # Get the data on the Tautulli libraries table. 136 | payload = {'apikey': TAUTULLI_APIKEY, 137 | 'cmd': 'get_libraries_table'} 138 | 139 | try: 140 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 141 | response = r.json() 142 | 143 | res_data = response['response']['data']['data'] 144 | return [d['section_id'] for d in res_data if d['section_name'] in LIBRARY_NAMES] 145 | 146 | except Exception as e: 147 | sys.stderr.write("Tautulli API 'get_libraries_table' request failed: {0}.".format(e)) 148 | 149 | 150 | show_lst = [] 151 | count_lst = [] 152 | size_lst = [] 153 | 154 | glt = [lib for lib in get_libraries_table()] 155 | 156 | # Updating media info for libraries. 157 | [update_library_media_info(i) for i in glt] 158 | 159 | for i in glt: 160 | try: 161 | gglm = get_library_media_info(i) 162 | for x in gglm: 163 | try: 164 | if x.media_type in ['show', 'episode']: 165 | # Need to find TV shows rating_key for episode. 166 | show_lst += get_new_rating_keys(x.rating_key, x.media_type) 167 | else: 168 | # Find movie rating_key. 169 | show_lst += [int(x.rating_key)] 170 | except Exception as e: 171 | print(("Rating_key failed: {e}").format(e=e)) 172 | 173 | except Exception as e: 174 | print(("Library media info failed: {e}").format(e=e)) 175 | 176 | # All rating_keys for episodes and movies. 177 | # Reserving order will put newest rating_keys first 178 | # print(sorted(show_lst, reverse=True)) 179 | 180 | for i in sorted(show_lst, reverse=True): 181 | try: 182 | x = get_metadata(str(i)) 183 | added = time.ctime(float(x.added_at)) 184 | count_lst += [x.media_type] 185 | size_lst += [int(x.file_size)] 186 | if x.grandparent_title == '' or x.media_type == 'movie': 187 | # Movies 188 | print(u"{x.title} ({x.rating_key}) was added {when}.".format(x=x, when=added)) 189 | else: 190 | # Shows 191 | print(u"{x.grandparent_title}: {x.title} ({x.rating_key}) was added {when}.".format(x=x, when=added)) 192 | 193 | except Exception: 194 | # Remove commented print below to investigate problems. 195 | # print("Metadata failed. Likely end of range: {e}").format(e=e) 196 | # Remove break if not finding files in range 197 | break 198 | 199 | print("There were {amount} files added between {start}:{end}".format( 200 | amount=len(count_lst), 201 | start=time.ctime(float(STARTFRAME)), 202 | end=time.ctime(float(ENDFRAME)))) 203 | print("Total movies: {}".format(count_lst.count('movie'))) 204 | print("Total shows: {}".format(count_lst.count('show') + count_lst.count('episode'))) 205 | print("Total size of files added: {}MB".format(sum(size_lst) >> 20)) 206 | -------------------------------------------------------------------------------- /reporting/check_play.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 1. Install the requests module for python. 5 | # pip install requests 6 | # 2. Add script arguments in Tautulli. 7 | # {user} {title} 8 | # Add to Playback Resume 9 | 10 | from __future__ import unicode_literals 11 | from builtins import object 12 | import requests 13 | import sys 14 | 15 | user = sys.argv[1] 16 | title = sys.argv[2] 17 | 18 | # ## EDIT THESE SETTINGS ## 19 | TAUTULLI_APIKEY = 'XXXXXXXXXX' # Your Tautulli API key 20 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 21 | NOTIFIER_ID = 10 # The notification notifier ID for Tautulli 22 | 23 | SUBJECT_TEXT = "Tautulli Notification" 24 | BODY_TEXT = """\ 25 | 26 | 27 | 28 |

Hi!
29 |
User %s has attempted to watch %s more than 3 times unsuccessfully.
30 |

31 | 32 | 33 | """ % (user, title) 34 | 35 | 36 | class UserHIS(object): 37 | def __init__(self, data=None): 38 | data = data or {} 39 | self.watched = [d['watched_status'] for d in data] 40 | 41 | 42 | def get_history(): 43 | """Get the history from Tautulli.""" 44 | payload = {'apikey': TAUTULLI_APIKEY, 45 | 'cmd': 'get_history', 46 | 'user': user, 47 | 'search': title} 48 | 49 | try: 50 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 51 | response = r.json() 52 | if response['response']['data']['recordsFiltered'] > 2: 53 | res_data = response['response']['data']['data'] 54 | return UserHIS(data=res_data) 55 | 56 | except Exception as e: 57 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 58 | 59 | 60 | def send_notification(): 61 | # Format notification text 62 | try: 63 | subject = SUBJECT_TEXT 64 | body = BODY_TEXT 65 | except LookupError as e: 66 | sys.stderr.write("Unable to substitute '{0}' in the notification subject or body".format(e)) 67 | return None 68 | # Send the notification through Tautulli 69 | payload = {'apikey': TAUTULLI_APIKEY, 70 | 'cmd': 'notify', 71 | 'notifier_id': NOTIFIER_ID, 72 | 'subject': subject, 73 | 'body': body} 74 | 75 | try: 76 | r = requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 77 | response = r.json() 78 | 79 | if response['response']['result'] == 'success': 80 | sys.stdout.write("Successfully sent Tautulli notification.") 81 | else: 82 | raise Exception(response['response']['message']) 83 | except Exception as e: 84 | sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) 85 | return None 86 | 87 | 88 | if __name__ == '__main__': 89 | hisy = get_history() 90 | 91 | if sum(hisy.watched) == 0: 92 | sys.stdout.write(user + ' has attempted to watch ' + title + ' more than 3 times unsuccessfully.') 93 | send_notification() 94 | -------------------------------------------------------------------------------- /reporting/check_plex_log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Run script by itself. Will look for WARN code followed by /library/metadata/ str in Plex logs. 6 | This is find files that are corrupt or having playback issues. 7 | I corrupted a file to test. 8 | """ 9 | from __future__ import print_function 10 | from __future__ import unicode_literals 11 | 12 | from builtins import object 13 | import requests 14 | import sys 15 | 16 | # ## EDIT THESE SETTINGS ## 17 | TAUTULLI_APIKEY = 'XXXXXXXX' # Your Tautulli API key 18 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 19 | 20 | lib_met = [] 21 | err_title = [] 22 | 23 | 24 | class PlexLOG(object): 25 | def __init__(self, data=None): 26 | self.error_msg = [] 27 | for e, f, g in data[0::1]: 28 | if f == 'WARN' and 'of key /library/metadata' in g: 29 | self.error_msg += [[f] + [g]] 30 | 31 | 32 | class UserHIS(object): 33 | def __init__(self, data=None): 34 | data = data or {} 35 | self.title = [d['full_title'] for d in data] 36 | 37 | 38 | def get_plex_log(): 39 | # Get the user IP list from Tautulli 40 | payload = {'apikey': TAUTULLI_APIKEY, 41 | 'cmd': 'get_plex_log'} 42 | 43 | try: 44 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 45 | response = r.json() 46 | res_data = response['response']['data']['data'] 47 | 48 | return PlexLOG(data=res_data) 49 | 50 | except Exception as e: 51 | sys.stderr.write("Tautulli API 'get_plex_log' request failed: {0}.".format(e)) 52 | 53 | 54 | def get_history(key): 55 | # Get the user IP list from Tautulli 56 | payload = {'apikey': TAUTULLI_APIKEY, 57 | 'cmd': 'get_history', 58 | 'rating_key': key} 59 | 60 | try: 61 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 62 | response = r.json() 63 | 64 | res_data = response['response']['data']['data'] 65 | return UserHIS(data=res_data) 66 | 67 | except Exception as e: 68 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 69 | 70 | 71 | if __name__ == '__main__': 72 | p_log = get_plex_log() 73 | for co, msg in p_log.error_msg: 74 | lib_met += [(msg.split('/library/metadata/'))[1].split(r'\n')[0]] 75 | for i in lib_met: 76 | his = get_history(int(i)) 77 | err_title += [x.encode('UTF8') for x in his.title] 78 | err_title = ''.join((set(err_title))) 79 | print(err_title + ' is having playback issues') 80 | -------------------------------------------------------------------------------- /reporting/drive_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import unicode_literals 5 | import psutil 6 | import requests 7 | 8 | # Drive letter to check if exists. 9 | drive = 'F:' 10 | 11 | disk = psutil.disk_partitions() 12 | 13 | TAUTULLI_URL = 'http://localhost:8182/' # Your Tautulli URL 14 | TAUTULLI_APIKEY = 'xxxxxx' # Enter your Tautulli API Key 15 | NOTIFIER_LST = [10, 11] # The Tautulli notifier notifier id found here: https://github.com/drzoidberg33/plexpy/blob/master/plexpy/notifiers.py#L43 16 | NOTIFY_SUBJECT = 'Tautulli' # The notification subject 17 | NOTIFY_BODY = 'The Plex disk {0} was not found'.format(drive) # The notification body 18 | 19 | disk_check = [True for i in disk if drive in i.mountpoint] 20 | 21 | if not disk_check: 22 | # Send the notification through Tautulli 23 | payload = { 24 | 'apikey': TAUTULLI_APIKEY, 25 | 'cmd': 'notify', 26 | 'subject': NOTIFY_SUBJECT, 27 | 'body': NOTIFY_BODY} 28 | 29 | for notifier in NOTIFIER_LST: 30 | payload['notifier_id'] = notifier 31 | requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 32 | else: 33 | pass 34 | -------------------------------------------------------------------------------- /reporting/library_play_days.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Use Tautulli to print plays by library from 0, 1, 7, or 30 days ago. 0 = total 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | -l [ ...], --libraries [ ...] 10 | Space separated list of case sensitive names to process. Allowed names are: 11 | (choices: All Library Names) 12 | -d [ ...], --days [ ...] 13 | Space separated list of case sensitive names to process. Allowed names are: 14 | (defaults: [0, 1, 7, 30]) 15 | (choices: 0, 1, 7, 30) 16 | 17 | Usage: 18 | plays_days.py -l "TV Shows" Movies -d 30 1 0 19 | Library: Movies 20 | Days: 1 : 30 : 0 21 | Plays: 5 : 83 : 384 22 | Library: TV Shows 23 | Days: 1 : 30 : 0 24 | Plays: 56 : 754 : 2899 25 | 26 | """ 27 | from __future__ import print_function 28 | from __future__ import unicode_literals 29 | 30 | from builtins import str 31 | import requests 32 | import sys 33 | import argparse 34 | 35 | # ## EDIT THESE SETTINGS ## 36 | 37 | TAUTULLI_APIKEY = 'xxxxx' # Your Tautulli API key 38 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 39 | 40 | OUTPUT = 'Library: {section}\nDays: {days}\nPlays: {plays}' 41 | 42 | # ## CODE BELOW ## 43 | 44 | 45 | def get_library_names(): 46 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 47 | payload = {'apikey': TAUTULLI_APIKEY, 48 | 'cmd': 'get_library_names'} 49 | 50 | try: 51 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 52 | response = r.json() 53 | # print(json.dumps(response, indent=4, sort_keys=True)) 54 | 55 | res_data = response['response']['data'] 56 | return [d for d in res_data] 57 | 58 | except Exception as e: 59 | sys.stderr.write("Tautulli API 'get_library_names' request failed: {0}.".format(e)) 60 | 61 | 62 | def get_library_watch_time_stats(section_id): 63 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 64 | payload = {'apikey': TAUTULLI_APIKEY, 65 | 'cmd': 'get_library_watch_time_stats', 66 | 'section_id': section_id} 67 | 68 | try: 69 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 70 | response = r.json() 71 | # print(json.dumps(response, indent=4, sort_keys=True)) 72 | 73 | res_data = response['response']['data'] 74 | return [d for d in res_data] 75 | 76 | except Exception as e: 77 | sys.stderr.write("Tautulli API 'get_library_watch_time_stats' request failed: {0}.".format(e)) 78 | 79 | 80 | def main(): 81 | 82 | lib_lst = [section['section_name'] for section in get_library_names()] 83 | days_lst = [0, 1, 7, 30] 84 | 85 | parser = argparse.ArgumentParser(description="Use Tautulli to pull plays by library", 86 | formatter_class=argparse.RawTextHelpFormatter) 87 | parser.add_argument('-l', '--libraries', nargs='+', type=str, default=lib_lst, choices=lib_lst, metavar='', 88 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 89 | '(defaults: %(default)s)\n (choices: %(choices)s)') 90 | parser.add_argument('-d', '--days', nargs='+', type=int, default=days_lst, choices=days_lst, metavar='', 91 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 92 | '(defaults: %(default)s)\n (choices: %(choices)s)') 93 | 94 | opts = parser.parse_args() 95 | 96 | for section in get_library_names(): 97 | sec_name = section['section_name'] 98 | if sec_name in opts.libraries: 99 | days = [] 100 | plays = [] 101 | section_id = section['section_id'] 102 | for stats in get_library_watch_time_stats(section_id): 103 | if stats['query_days'] in opts.days and stats['total_plays'] > 0: 104 | days.append(str(stats['query_days'])) 105 | plays.append(str(stats['total_plays'])) 106 | 107 | print(OUTPUT.format(section=sec_name, days=' : '.join(days), plays=' : '.join(plays))) 108 | 109 | 110 | if __name__ == "__main__": 111 | main() 112 | -------------------------------------------------------------------------------- /reporting/plays_by_library.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Use Tautulli to pull plays by library 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | -l [ ...], --libraries [ ...] 10 | Space separated list of case sensitive names to process. Allowed names are: 11 | (choices: All Library Names) 12 | 13 | 14 | Usage: 15 | plays_by_library.py -l "TV Shows" Movies 16 | TV Shows - Plays: 2859 17 | Movies - Plays: 379 18 | 19 | """ 20 | from __future__ import print_function 21 | from __future__ import unicode_literals 22 | 23 | import requests 24 | import sys 25 | import argparse 26 | # import json 27 | 28 | # ## EDIT THESE SETTINGS ## 29 | 30 | TAUTULLI_APIKEY = 'xxxxxx' # Your Tautulli API key 31 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 32 | 33 | OUTPUT = '{section} - Plays: {plays}' 34 | 35 | # ## CODE BELOW ## 36 | 37 | 38 | def get_libraries_table(sections=None): 39 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 40 | payload = {'apikey': TAUTULLI_APIKEY, 41 | 'cmd': 'get_libraries_table', 42 | 'order_column': 'plays'} 43 | 44 | try: 45 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 46 | response = r.json() 47 | # print(json.dumps(response, indent=4, sort_keys=True)) 48 | 49 | res_data = response['response']['data']['data'] 50 | if sections: 51 | return [d for d in res_data if d['section_name'] in sections] 52 | else: 53 | return [d for d in res_data if d['section_name']] 54 | 55 | except Exception as e: 56 | sys.stderr.write("Tautulli API 'get_libraries_table' request failed: {0}.".format(e)) 57 | 58 | 59 | def main(): 60 | 61 | lib_lst = [section['section_name'] for section in get_libraries_table()] 62 | 63 | parser = argparse.ArgumentParser(description="Use Tautulli to pull plays by library", 64 | formatter_class=argparse.RawTextHelpFormatter) 65 | parser.add_argument('-l', '--libraries', nargs='+', type=str, choices=lib_lst, metavar='', 66 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 67 | '(choices: %(choices)s)') 68 | 69 | opts = parser.parse_args() 70 | 71 | for section in get_libraries_table(opts.libraries): 72 | sec_name = section['section_name'] 73 | sec_plays = section['plays'] 74 | print(OUTPUT.format(section=sec_name, plays=sec_plays)) 75 | 76 | 77 | if __name__ == "__main__": 78 | main() 79 | -------------------------------------------------------------------------------- /reporting/streaming_service_availability.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Check media availability on streaming services. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | 8 | import argparse 9 | import os 10 | from plexapi import CONFIG 11 | from plexapi.server import PlexServer 12 | from plexapi.exceptions import BadRequest 13 | 14 | PLEX_URL = '' 15 | PLEX_TOKEN = '' 16 | 17 | # Environment Variables or PlexAPI Config 18 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) or CONFIG.data['auth'].get('server_baseurl') 19 | PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN) or CONFIG.data['auth'].get('server_token') 20 | 21 | 22 | def check_streaming_services(plex, libraries, services, available_only): 23 | if libraries: 24 | sections = [plex.library.section(library) for library in libraries] 25 | else: 26 | sections = [ 27 | section for section in plex.library.sections() 28 | if section.agent in {'tv.plex.agents.movie', 'tv.plex.agents.series'} 29 | ] 30 | 31 | for section in sections: 32 | print(f'{section.title}') 33 | 34 | for item in section.all(): 35 | try: 36 | availabilities = item.streamingServices() 37 | except BadRequest: 38 | continue 39 | 40 | if services: 41 | availabilities = [ 42 | availability for availability in availabilities 43 | if availability.title in services 44 | ] 45 | 46 | if available_only and not availabilities: 47 | continue 48 | 49 | if item.type == 'movie': 50 | subtitle = item.media[0].videoResolution 51 | subtitle = subtitle.upper() if subtitle == 'sd' else ((subtitle + 'p') if subtitle.isdigit() else '') 52 | else: 53 | subtitle = item.childCount 54 | subtitle = str(subtitle) + ' season' + ('s' if subtitle > 1 else '') 55 | 56 | print(f' └─ {item.title} ({item.year}) ({subtitle})') 57 | 58 | for availability in availabilities: 59 | title = availability.title 60 | quality = availability.quality 61 | offerType = availability.offerType.capitalize() 62 | priceDescription = (' ' + availability.priceDescription) if availability.priceDescription else '' 63 | print(f' └─ {title} ({quality} - {offerType}{priceDescription})') 64 | 65 | print() 66 | 67 | 68 | if __name__ == "__main__": 69 | parser = argparse.ArgumentParser() 70 | parser.add_argument( 71 | '--libraries', 72 | '-l', 73 | nargs='+', 74 | help=( 75 | 'Plex libraries to check (e.g. Movies, TV Shows, etc.). ' 76 | 'Default: All movie and tv show libraries using the Plex Movie or Plex TV Series agents.' 77 | ) 78 | ) 79 | parser.add_argument( 80 | '--services', 81 | '-s', 82 | nargs='+', 83 | help=( 84 | 'Streaming services to check (e.g. Netflix, Disney+, Amazon Prime Video, etc.). ' 85 | 'Note: Must be the exact name of the service as it appears in Plex. ' 86 | 'Default: All services.' 87 | ) 88 | ) 89 | parser.add_argument( 90 | '--available_only', 91 | '-a', 92 | action='store_true', 93 | help=( 94 | 'Only list media that is available on at least one streaming service.' 95 | ) 96 | ) 97 | opts = parser.parse_args() 98 | 99 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 100 | check_streaming_services(plex, **vars(opts)) 101 | -------------------------------------------------------------------------------- /reporting/userplays_weekly_reporting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Use Tautulli to count how many plays per user occurred this week. 6 | Notify via Tautulli Notification 7 | """ 8 | from __future__ import unicode_literals 9 | 10 | from builtins import object 11 | import requests 12 | import sys 13 | import time 14 | 15 | TODAY = int(time.time()) 16 | LASTWEEK = int(TODAY - 7 * 24 * 60 * 60) 17 | 18 | # ## EDIT THESE SETTINGS ## 19 | TAUTULLI_APIKEY = 'XXXXXX' # Your Tautulli API key 20 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 21 | SUBJECT_TEXT = "Tautulli Weekly Plays Per User" 22 | NOTIFIER_ID = 10 # The email notification notifier ID for Tautulli 23 | 24 | 25 | class UserHIS(object): 26 | def __init__(self, data=None): 27 | d = data or {} 28 | self.watched = d['watched_status'] 29 | self.title = d['full_title'] 30 | self.user = d['friendly_name'] 31 | self.user_id = d['user_id'] 32 | self.media = d['media_type'] 33 | self.rating_key = d['rating_key'] 34 | self.full_title = d['full_title'] 35 | self.date = d['date'] 36 | 37 | 38 | def get_history(): 39 | # Get the Tautulli history. Count matters!!! 40 | payload = {'apikey': TAUTULLI_APIKEY, 41 | 'cmd': 'get_history', 42 | 'length': 100000} 43 | 44 | try: 45 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 46 | response = r.json() 47 | 48 | res_data = response['response']['data']['data'] 49 | return [UserHIS(data=d) for d in res_data if d['watched_status'] == 1 and 50 | LASTWEEK < d['date'] < TODAY] 51 | 52 | except Exception as e: 53 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 54 | 55 | 56 | def send_notification(BODY_TEXT): 57 | # Format notification text 58 | try: 59 | subject = SUBJECT_TEXT 60 | body = BODY_TEXT 61 | except LookupError as e: 62 | sys.stderr.write("Unable to substitute '{0}' in the notification subject or body".format(e)) 63 | return None 64 | # Send the notification through Tautulli 65 | payload = {'apikey': TAUTULLI_APIKEY, 66 | 'cmd': 'notify', 67 | 'notifier_id': NOTIFIER_ID, 68 | 'subject': subject, 69 | 'body': body} 70 | 71 | try: 72 | r = requests.post(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 73 | response = r.json() 74 | 75 | if response['response']['result'] == 'success': 76 | sys.stdout.write("Successfully sent Tautulli notification.") 77 | else: 78 | raise Exception(response['response']['message']) 79 | except Exception as e: 80 | sys.stderr.write("Tautulli API 'notify' request failed: {0}.".format(e)) 81 | return None 82 | 83 | 84 | def add_to_dictlist(d, key, val): 85 | if key not in d: 86 | d[key] = [val] 87 | else: 88 | d[key].append(val) 89 | 90 | 91 | user_dict = {} 92 | notify_lst = [] 93 | 94 | [add_to_dictlist(user_dict, h.user, h.media) for h in get_history()] 95 | # Get count of media_type play in time frame 96 | for key, value in user_dict.items(): 97 | user_dict[key] = {x: value.count(x) for x in value} 98 | # Get total of all media_types play in time frame 99 | for key, value in user_dict.items(): 100 | user_dict[key].update({'total': sum(value.values())}) 101 | # Build email body contents 102 | for key, value in user_dict.items(): 103 | notify_lst += [u"
{} played a total of {} item(s) this week.
".format(key, user_dict[key]['total'])] 104 | 105 | 106 | BODY_TEXT = """\ 107 | 108 | 109 | 110 |

Hi!
111 |
Below is the list of plays per user this week ({start} - {end})
112 |

113 | {notify_lst} 114 |
115 |

116 | 117 | 118 | """.format( 119 | notify_lst="\n".join(notify_lst).encode("utf-8"), 120 | end=time.ctime(float(TODAY)), 121 | start=time.ctime(float(LASTWEEK))) 122 | 123 | send_notification(BODY_TEXT) 124 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | #--------------------------------------------------------- 2 | # Potential requirements. 3 | # pip install -r requirements.txt 4 | #--------------------------------------------------------- 5 | requests 6 | plexapi 7 | urllib3 -------------------------------------------------------------------------------- /scriptHeaderTemplate.txt: -------------------------------------------------------------------------------- 1 | """ 2 | Description: {description} 3 | Author: {author} 4 | Requires: {requirements} 5 | 6 | Enabling Scripts in Tautulli: 7 | Tautulli > Settings > Notification Agents > Add a Notification Agent > Script 8 | 9 | Configuration: 10 | Tautulli > Settings > Notification Agents > New Script > Configuration: 11 | 12 | Script Name: {script_name} 13 | Set Script Timeout: {timeout} 14 | Description: {Tautulli_description} 15 | Save 16 | 17 | Triggers: 18 | Tautulli > Settings > Notification Agents > New Script > Triggers: 19 | 20 | Check: {trigger} 21 | Save 22 | 23 | Conditions: 24 | Tautulli > Settings > Notification Agents > New Script > Conditions: 25 | 26 | Set Conditions: [{condition} | {operator} | {value} ] 27 | Save 28 | 29 | Script Arguments: 30 | Tautulli > Settings > Notification Agents > New Script > Script Arguments: 31 | 32 | Select: {trigger} 33 | Arguments: {arguments} 34 | 35 | Save 36 | Close 37 | 38 | Example: 39 | 40 | """ -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | ; Contains configuration for various linters 2 | 3 | ; E501: Disable line length limits (for now) 4 | ; W504: Require newlines after binary operators, use W503 for requiring the 5 | ; operators on the next line 6 | 7 | [flake8] 8 | ignore = E501,W504 9 | 10 | [pylama] 11 | ignore = E501,W504 12 | -------------------------------------------------------------------------------- /utility/add_label_recently_added.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Description: Automatically add a label to recently added items in your Plex library 6 | Author: /u/SwiftPanda16 7 | Requires: plexapi 8 | Usage: 9 | python add_label_recently_added.py --rating_key 1234 --label "Label" 10 | 11 | Tautulli script trigger: 12 | * Notify on recently added 13 | Tautulli script conditions: 14 | * Filter which media to add labels to using conditions. Examples: 15 | [ Media Type | is | movie ] 16 | [ Show Name | is | Game of Thrones ] 17 | [ Album Name | is | Reputation ] 18 | [ Video Resolution | is | 4k ] 19 | [ Genre | contains | horror ] 20 | Tautulli script arguments: 21 | * Recently Added: 22 | --rating_key {rating_key} --label "Label" 23 | ''' 24 | 25 | import argparse 26 | import os 27 | from plexapi.server import PlexServer 28 | 29 | 30 | # ## OVERRIDES - ONLY EDIT IF RUNNING SCRIPT WITHOUT TAUTULLI ## 31 | 32 | PLEX_URL = '' 33 | PLEX_TOKEN = '' 34 | 35 | # Environmental Variables 36 | PLEX_URL = PLEX_URL or os.getenv('PLEX_URL', PLEX_URL) 37 | PLEX_TOKEN = PLEX_TOKEN or os.getenv('PLEX_TOKEN', PLEX_TOKEN) 38 | 39 | 40 | def add_label_parent(plex, rating_key, label): 41 | item = plex.fetchItem(rating_key) 42 | 43 | if item.type in ('movie', 'show', 'album'): 44 | parent = item 45 | elif item.type in ('season', 'episode'): 46 | parent = item.show() 47 | elif item.type == 'track': 48 | parent = item.album() 49 | else: 50 | print(f"Cannot add label to '{item.title}' ({item.ratingKey}): Invalid media type '{item.type}'") 51 | return 52 | 53 | print(f"Adding label '{label}' to '{parent.title}' ({parent.ratingKey})") 54 | parent.addLabel(label) 55 | 56 | 57 | if __name__ == '__main__': 58 | parser = argparse.ArgumentParser() 59 | parser.add_argument('--rating_key', required=True, type=int) 60 | parser.add_argument('--label', required=True) 61 | opts = parser.parse_args() 62 | 63 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 64 | add_label_parent(plex, **vars(opts)) 65 | -------------------------------------------------------------------------------- /utility/bypass_auth_name.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Use Tautulli to pull last IP address from user and add to List of IP addresses and networks that are allowed without auth in Plex. 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | -u [ ...], --users [ ...] 10 | Space separated list of case sensitive names to process. Allowed names are: 11 | (choices: {List of all Plex users} ) 12 | (default: None) 13 | -c [], --clear [] Clear List of IP addresses and networks that are allowed without auth in Plex: 14 | (choices: None) 15 | (default: None) 16 | 17 | List of IP addresses is cleared before adding new IPs 18 | """ 19 | from __future__ import print_function 20 | from __future__ import unicode_literals 21 | 22 | import requests 23 | import argparse 24 | import sys 25 | 26 | 27 | # ## EDIT THESE SETTINGS ## 28 | PLEX_TOKEN = 'xxxx' 29 | PLEX_URL = 'http://localhost:32400' 30 | TAUTULLI_APIKEY = 'xxxx' # Your Tautulli API key 31 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 32 | 33 | 34 | def get_history(user_id): 35 | # Get the user history from Tautulli 36 | payload = {'apikey': TAUTULLI_APIKEY, 37 | 'cmd': 'get_history', 38 | 'user_id': user_id, 39 | 'length': 1} 40 | 41 | try: 42 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 43 | response = r.json() 44 | 45 | res_data = response['response']['data']['data'] 46 | return [d['ip_address'] for d in res_data] 47 | 48 | except Exception as e: 49 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 50 | 51 | 52 | def get_user_names(username): 53 | # Get the user names from Tautulli 54 | payload = {'apikey': TAUTULLI_APIKEY, 55 | 'cmd': 'get_user_names'} 56 | 57 | try: 58 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 59 | response = r.json() 60 | res_data = response['response']['data'] 61 | if username: 62 | return [d['user_id'] for d in res_data if d['friendly_name'] in username] 63 | else: 64 | return [d['friendly_name'] for d in res_data] 65 | 66 | except Exception as e: 67 | sys.stderr.write("Tautulli API 'get_user_names' request failed: {0}.".format(e)) 68 | 69 | 70 | def add_auth_bypass(net_str): 71 | headers = {"X-Plex-Token": PLEX_TOKEN} 72 | params = {"allowedNetworks": net_str} 73 | requests.put("{}/:/prefs".format(PLEX_URL), headers=headers, params=params) 74 | 75 | 76 | if __name__ == '__main__': 77 | 78 | user_lst = get_user_names('') 79 | parser = argparse.ArgumentParser(description="Use Tautulli to pull last IP address from user and add to List of " 80 | "IP addresses and networks that are allowed without auth in Plex.", 81 | formatter_class=argparse.RawTextHelpFormatter) 82 | parser.add_argument('-u', '--users', nargs='+', type=str, choices=user_lst, metavar='', 83 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 84 | '(choices: %(choices)s) \n(default: %(default)s)') 85 | parser.add_argument('-c', '--clear', nargs='?', default=None, metavar='', 86 | help='Clear List of IP addresses and networks that are allowed without auth in Plex: \n' 87 | '(default: %(default)s)') 88 | 89 | opts = parser.parse_args() 90 | 91 | if opts.clear and opts.users is None: 92 | print('Clearing List of IP addresses and networks that are allowed without auth in Plex.') 93 | add_auth_bypass('') 94 | elif opts.clear and len(opts.users) == 1: 95 | print('Clearing List of IP addresses and networks that are allowed without auth in Plex.') 96 | add_auth_bypass('') 97 | user_id = get_user_names(opts.users) 98 | user_ip = get_history(user_id) 99 | print('Adding {} to List of IP addresses and networks that are allowed without auth in Plex.' 100 | .format(''.join(user_ip))) 101 | add_auth_bypass(user_ip) 102 | elif opts.clear and len(opts.users) > 1: 103 | print('Clearing List of IP addresses and networks that are allowed without auth in Plex.') 104 | add_auth_bypass('') 105 | userid_lst = [get_user_names(user_names) for user_names in opts.users] 106 | userip_lst = [get_history(user_id) for user_id in userid_lst] 107 | flat_list = [item for sublist in userip_lst for item in sublist] 108 | print('Adding {} to List of IP addresses and networks that are allowed without auth in Plex.' 109 | .format(', '.join(flat_list))) 110 | add_auth_bypass(', '.join(flat_list)) 111 | else: 112 | print('I don\'t know what else you want.') 113 | -------------------------------------------------------------------------------- /utility/delete_watched_TV.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | From a list of TV shows, check if users in a list has watched shows episodes. 6 | If all users in list have watched an episode of listed show, then delete episode. 7 | 8 | Add deletion via Plex. 9 | """ 10 | from __future__ import print_function 11 | from __future__ import unicode_literals 12 | 13 | from builtins import object 14 | import requests 15 | import sys 16 | import os 17 | 18 | # ## EDIT THESE SETTINGS ## 19 | TAUTULLI_APIKEY = 'xxxxx' # Your Tautulli API key 20 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 21 | SHOW_LST = [123456, 123456, 123456, 123456] # Show rating keys. 22 | USER_LST = ['Sam', 'Jakie', 'Blacktwin'] # Name of users 23 | 24 | 25 | class METAINFO(object): 26 | def __init__(self, data=None): 27 | d = data or {} 28 | self.title = d['title'] 29 | media_info = d['media_info'][0] 30 | parts = media_info['parts'][0] 31 | self.file = parts['file'] 32 | self.media_type = d['media_type'] 33 | self.grandparent_title = d['grandparent_title'] 34 | 35 | 36 | def get_metadata(rating_key): 37 | # Get the metadata for a media item. 38 | payload = {'apikey': TAUTULLI_APIKEY, 39 | 'rating_key': rating_key, 40 | 'cmd': 'get_metadata', 41 | 'media_info': True} 42 | 43 | try: 44 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 45 | response = r.json() 46 | 47 | res_data = response['response']['data'] 48 | return METAINFO(data=res_data) 49 | 50 | except Exception as e: 51 | sys.stderr.write("Tautulli API 'get_metadata' request failed: {0}.".format(e)) 52 | pass 53 | 54 | 55 | def get_history(user, show, start, length): 56 | # Get the Tautulli history. 57 | payload = {'apikey': TAUTULLI_APIKEY, 58 | 'cmd': 'get_history', 59 | 'user': user, 60 | 'grandparent_rating_key': show, 61 | 'start': start, 62 | 'length': length} 63 | 64 | try: 65 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 66 | response = r.json() 67 | 68 | res_data = response['response']['data']['data'] 69 | return [d['rating_key'] for d in res_data if d['watched_status'] == 1] 70 | 71 | except Exception as e: 72 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 73 | 74 | 75 | meta_dict = {} 76 | meta_lst = [] 77 | delete_lst = [] 78 | 79 | count = 25 80 | for user in USER_LST: 81 | for show in SHOW_LST: 82 | start = 0 83 | while True: 84 | # Getting all watched history for listed users and shows 85 | history = get_history(user, show, start, count) 86 | try: 87 | if all([history]): 88 | start += count 89 | for rating_key in history: 90 | # Getting metadata of what was watched 91 | meta = get_metadata(rating_key) 92 | if not any(d['title'] == meta.title for d in meta_lst): 93 | meta_dict = { 94 | 'title': meta.title, 95 | 'file': meta.file, 96 | 'type': meta.media_type, 97 | 'grandparent_title': meta.grandparent_title, 98 | 'watched_by': [user] 99 | } 100 | meta_lst.append(meta_dict) 101 | else: 102 | for d in meta_lst: 103 | if d['title'] == meta.title: 104 | d['watched_by'].append(user) 105 | continue 106 | elif not all([history]): 107 | break 108 | 109 | start += count 110 | except Exception as e: 111 | print(e) 112 | pass 113 | 114 | 115 | for meta_dict in meta_lst: 116 | if set(USER_LST) == set(meta_dict['watched_by']): 117 | print("{} {} has been watched by {}".format( 118 | meta_dict['grandparent_title'].encode('UTF-8'), 119 | meta_dict['title'].encode('UTF-8'), 120 | " & ".join(USER_LST))) 121 | print("Removing {}".format(meta_dict['file'])) 122 | os.remove(meta_dict['file']) 123 | -------------------------------------------------------------------------------- /utility/enable_disable_all_guest_access.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Enable or disable all users remote access to Tautulli. 5 | 6 | Author: DirtyCajunRice 7 | Requires: requests, python3.6+ 8 | """ 9 | from __future__ import print_function 10 | from __future__ import unicode_literals 11 | 12 | from requests import Session 13 | from json.decoder import JSONDecodeError 14 | 15 | ENABLE_REMOTE_ACCESS = True 16 | 17 | TAUTULLI_URL = '' 18 | TAUTULLI_API_KEY = '' 19 | 20 | # Do not edit past this line # 21 | session = Session() 22 | session.params = {'apikey': TAUTULLI_API_KEY} 23 | formatted_url = f'{TAUTULLI_URL}/api/v2' 24 | 25 | request = session.get(formatted_url, params={'cmd': 'get_users'}) 26 | 27 | tautulli_users = None 28 | try: 29 | tautulli_users = request.json()['response']['data'] 30 | except JSONDecodeError: 31 | exit("Error talking to Tautulli API, please check your TAUTULLI_URL") 32 | 33 | allow_guest = 1 if ENABLE_REMOTE_ACCESS else 0 34 | string_representation = 'Enabled' if ENABLE_REMOTE_ACCESS else 'Disabled' 35 | 36 | users_to_change = [user for user in tautulli_users if user['allow_guest'] != allow_guest] 37 | 38 | if users_to_change: 39 | for user in users_to_change: 40 | # Redefine ALL params because of Tautulli edit_user API bug 41 | params = { 42 | 'cmd': 'edit_user', 43 | 'user_id': user['user_id'], 44 | 'friendly_name': user['friendly_name'], 45 | 'custom_thumb': user['custom_thumb'], 46 | 'keep_history': user['keep_history'], 47 | 'allow_guest': allow_guest 48 | } 49 | changed_user = session.get(formatted_url, params=params) 50 | print(f"{string_representation} guest access for {user['friendly_name']}") 51 | else: 52 | print(f'No users to {string_representation.lower()[:-1]}') 53 | -------------------------------------------------------------------------------- /utility/find_plex_meta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Find location of Plex metadata. 6 | 7 | find_plex_meta.py -s adventure 8 | pulls all titles with adventure in the title 9 | or 10 | find_plex_meta.py -s adventure -m movie 11 | pulls all movie titles with adventure in the title 12 | ''' 13 | from __future__ import print_function 14 | from __future__ import unicode_literals 15 | 16 | 17 | from plexapi.server import PlexServer, CONFIG 18 | # pip install plexapi 19 | import os 20 | import re 21 | import hashlib 22 | import argparse 23 | import requests 24 | 25 | # ## Edit ## 26 | PLEX_URL = '' 27 | PLEX_TOKEN = '' 28 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 29 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 30 | # Change directory based on your os see: 31 | # https://support.plex.tv/hc/en-us/articles/202915258-Where-is-the-Plex-Media-Server-data-directory-located- 32 | PLEX_LOCAL_TV_PATH = os.path.join(os.getenv('LOCALAPPDATA'), 'Plex Media Server\Metadata\TV Shows') 33 | PLEX_LOCAL_MOVIE_PATH = os.path.join(os.getenv('LOCALAPPDATA'), 'Plex Media Server\Metadata\Movies') 34 | PLEX_LOCAL_ALBUM_PATH = os.path.join(os.getenv('LOCALAPPDATA'), 'Plex Media Server\Metadata\Albums') 35 | # ## /Edit ## 36 | 37 | sess = requests.Session() 38 | # Ignore verifying the SSL certificate 39 | sess.verify = False # '/path/to/certfile' 40 | # If verify is set to a path to a directory, 41 | # the directory must have been processed using the c_rehash utility supplied 42 | # with OpenSSL. 43 | if sess.verify is False: 44 | # Disable the warning that the request is insecure, we know that... 45 | import urllib3 46 | 47 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 48 | 49 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 50 | 51 | 52 | def hash_to_path(hash_str, path, title, media_type, artist=None): 53 | full_hash = hashlib.sha1(hash_str).hexdigest() 54 | hash_path = '{}\{}{}'.format(full_hash[0], full_hash[1::1], '.bundle') 55 | full_path = os.path.join(path, hash_path, 'Contents') 56 | if artist: 57 | output = "{}'s {} titled: {}\nPath: {}".format(artist, media_type, title, full_path) 58 | else: 59 | output = "{} titled: {}\nPath: {}".format(media_type.title(), title, full_path) 60 | print(output) 61 | 62 | 63 | def get_plex_hash(search, mediatype=None): 64 | for searched in plex.search(search, mediatype=mediatype): 65 | # Need to find guid. 66 | if searched.type == 'show': 67 | # Get tvdb_if from first episode 68 | db_id = searched.episodes()[0].guid 69 | # Find str to pop 70 | str_pop = '/{}'.format(re.search(r'\/(.*)\?', db_id.split('//')[1]).group(1)) 71 | # Create string to hash 72 | hash_str = db_id.replace(str_pop, '') 73 | hash_to_path(hash_str, PLEX_LOCAL_TV_PATH, searched.title, searched.type) 74 | 75 | elif searched.type == 'movie': 76 | # Movie guid is good to hash 77 | hash_to_path(searched.guid, PLEX_LOCAL_MOVIE_PATH, searched.title, searched.type) 78 | 79 | elif searched.type == 'album': 80 | # if guid starts with local need to remove anything after id before hashing 81 | if searched.tracks()[0].guid.startswith('local'): 82 | local_id = searched.tracks()[0].guid.split('/')[2] 83 | hash_str = 'local://{}'.format(local_id) 84 | else: 85 | hash_str = searched.tracks()[0].guid.replace('/1?lang=en', '?lang=en') 86 | # print(searched.__dict__.items()) 87 | hash_to_path(hash_str, PLEX_LOCAL_ALBUM_PATH, searched.title, searched.type, searched.parentTitle) 88 | 89 | elif searched.type == 'artist': 90 | # If artist check over each album 91 | for albums in searched.albums(): 92 | get_plex_hash(albums.title, 'album') 93 | else: 94 | pass 95 | 96 | 97 | if __name__ == '__main__': 98 | parser = argparse.ArgumentParser(description="Helping navigate Plex's locally stored data.") 99 | parser.add_argument('-s', '--search', required=True, help='Search Plex for title.') 100 | parser.add_argument('-m', '--media_type', help='Plex media_type to refine search for title.', 101 | choices=['show', 'movie', 'episode', 'album', 'track', 'artist']) 102 | opts = parser.parse_args() 103 | if opts.media_type: 104 | get_plex_hash(opts.search, opts.media_type) 105 | else: 106 | get_plex_hash(opts.search) 107 | -------------------------------------------------------------------------------- /utility/find_unwatched.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Find what was added TFRAME ago and not watched using Tautulli. 6 | """ 7 | from __future__ import print_function 8 | from __future__ import unicode_literals 9 | 10 | from builtins import input 11 | from builtins import str 12 | from builtins import object 13 | import requests 14 | import sys 15 | import time 16 | import os 17 | 18 | TFRAME = 1.577e+7 # ~ 6 months in seconds 19 | TODAY = time.time() 20 | 21 | 22 | # ## EDIT THESE SETTINGS ## 23 | TAUTULLI_APIKEY = 'XXXXXX' # Your Tautulli API key 24 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 25 | LIBRARY_NAMES = ['My TV Shows', 'My Movies'] # Name of libraries you want to check. 26 | 27 | 28 | class LIBINFO(object): 29 | def __init__(self, data=None): 30 | d = data or {} 31 | self.added_at = d['added_at'] 32 | self.parent_rating_key = d['parent_rating_key'] 33 | self.play_count = d['play_count'] 34 | self.title = d['title'] 35 | self.rating_key = d['rating_key'] 36 | self.media_type = d['media_type'] 37 | 38 | 39 | class METAINFO(object): 40 | def __init__(self, data=None): 41 | d = data or {} 42 | self.added_at = d['added_at'] 43 | self.parent_rating_key = d['parent_rating_key'] 44 | self.title = d['title'] 45 | self.rating_key = d['rating_key'] 46 | self.media_type = d['media_type'] 47 | self.grandparent_title = d['grandparent_title'] 48 | media_info = d['media_info'][0] 49 | parts = media_info['parts'][0] 50 | self.file_size = parts['file_size'] 51 | self.file = parts['file'] 52 | 53 | 54 | def get_new_rating_keys(rating_key, media_type): 55 | # Get a list of new rating keys for the PMS of all of the item's parent/children. 56 | payload = {'apikey': TAUTULLI_APIKEY, 57 | 'cmd': 'get_new_rating_keys', 58 | 'rating_key': rating_key, 59 | 'media_type': media_type} 60 | 61 | try: 62 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 63 | response = r.json() 64 | 65 | res_data = response['response']['data'] 66 | show = res_data['0'] 67 | episode_lst = [episode['rating_key'] for _, season in show['children'].items() for _, episode in 68 | season['children'].items()] 69 | 70 | return episode_lst 71 | 72 | except Exception as e: 73 | sys.stderr.write("Tautulli API 'get_new_rating_keys' request failed: {0}.".format(e)) 74 | 75 | 76 | def get_metadata(rating_key): 77 | # Get the metadata for a media item. 78 | payload = {'apikey': TAUTULLI_APIKEY, 79 | 'rating_key': rating_key, 80 | 'cmd': 'get_metadata', 81 | 'media_info': True} 82 | 83 | try: 84 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 85 | response = r.json() 86 | 87 | res_data = response['response']['data'] 88 | return METAINFO(data=res_data) 89 | 90 | except Exception: 91 | # sys.stderr.write("Tautulli API 'get_metadata' request failed: {0}.".format(e)) 92 | pass 93 | 94 | 95 | def get_library_media_info(section_id): 96 | # Get the data on the Tautulli media info tables. 97 | payload = {'apikey': TAUTULLI_APIKEY, 98 | 'section_id': section_id, 99 | 'cmd': 'get_library_media_info', 100 | 'length': 10000} 101 | 102 | try: 103 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 104 | response = r.json() 105 | 106 | res_data = response['response']['data']['data'] 107 | return [LIBINFO(data=d) for d in res_data if d['play_count'] is None and (TODAY - int(d['added_at'])) > TFRAME] 108 | 109 | except Exception as e: 110 | sys.stderr.write("Tautulli API 'get_library_media_info' request failed: {0}.".format(e)) 111 | 112 | 113 | def get_libraries_table(): 114 | # Get the data on the Tautulli libraries table. 115 | payload = {'apikey': TAUTULLI_APIKEY, 116 | 'cmd': 'get_libraries_table'} 117 | 118 | try: 119 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 120 | response = r.json() 121 | 122 | res_data = response['response']['data']['data'] 123 | return [d['section_id'] for d in res_data if d['section_name'] in LIBRARY_NAMES] 124 | 125 | except Exception as e: 126 | sys.stderr.write("Tautulli API 'get_libraries_table' request failed: {0}.".format(e)) 127 | 128 | 129 | def delete_files(tmp_lst): 130 | del_file = input('Delete all unwatched files? (yes/no)').lower() 131 | if del_file.startswith('y'): 132 | for x in tmp_lst: 133 | print("Removing {}".format(x)) 134 | os.remove(x) 135 | else: 136 | print('Ok. doing nothing.') 137 | 138 | 139 | show_lst = [] 140 | path_lst = [] 141 | 142 | glt = [lib for lib in get_libraries_table()] 143 | 144 | for i in glt: 145 | try: 146 | gglm = get_library_media_info(i) 147 | for x in gglm: 148 | try: 149 | if x.media_type in ['show', 'episode']: 150 | # Need to find TV shows rating_key for episode. 151 | show_lst += get_new_rating_keys(x.rating_key, x.media_type) 152 | else: 153 | # Find movie rating_key. 154 | show_lst += [int(x.rating_key)] 155 | except Exception as e: 156 | print(("Rating_key failed: {e}").format(e=e)) 157 | 158 | except Exception as e: 159 | print(("Library media info failed: {e}").format(e=e)) 160 | 161 | # Remove reverse sort if you want the oldest keys first. 162 | for i in sorted(show_lst, reverse=True): 163 | try: 164 | x = get_metadata(str(i)) 165 | added = time.ctime(float(x.added_at)) 166 | if x.grandparent_title == '' or x.media_type == 'movie': 167 | # Movies 168 | print(u"{x.title} ({x.rating_key}) was added {when} and has not been " 169 | u"watched. \n File location: {x.file}".format(x=x, when=added)) 170 | else: 171 | # Shows 172 | print(u"{x.grandparent_title}: {x.title} ({x.rating_key}) was added {when} and has " 173 | u"not been watched. \n File location: {x.file}".format(x=x, when=added)) 174 | path_lst += [x.file] 175 | 176 | except Exception as e: 177 | print(("Metadata failed. Likely end of range: {e}").format(e=e)) 178 | 179 | 180 | delete_files(path_lst) 181 | -------------------------------------------------------------------------------- /utility/get_serial_transcoders.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Get a list of "Serial Transcoders". 5 | 6 | Author: DirtyCajunRice 7 | Requires: requests, plexapi, python3.6+ 8 | """ 9 | from __future__ import print_function 10 | from __future__ import division 11 | from __future__ import unicode_literals 12 | from past.utils import old_div 13 | from requests import Session 14 | from plexapi.server import CONFIG 15 | from datetime import date, timedelta 16 | from json.decoder import JSONDecodeError 17 | 18 | TAUTULLI_URL = '' 19 | TAUTULLI_API_KEY = '' 20 | 21 | PAST_DAYS = 7 22 | THRESHOLD_PERCENT = 50 23 | 24 | # Do not edit past this line # 25 | 26 | TAUTULLI_URL = TAUTULLI_URL or CONFIG.data['auth'].get('tautulli_baseurl') 27 | TAUTULLI_API_KEY = TAUTULLI_API_KEY or CONFIG.data['auth'].get('tautulli_apikey') 28 | 29 | TODAY = date.today() 30 | START_DATE = TODAY - timedelta(days=PAST_DAYS) 31 | 32 | SESSION = Session() 33 | SESSION.params = {'apikey': TAUTULLI_API_KEY} 34 | FORMATTED_URL = f'{TAUTULLI_URL}/api/v2' 35 | 36 | PARAMS = {'cmd': 'get_history', 'grouping': 1, 'order_column': 'date', 'length': 1000} 37 | 38 | REQUEST = None 39 | try: 40 | REQUEST = SESSION.get(FORMATTED_URL, params=PARAMS).json()['response']['data']['data'] 41 | except JSONDecodeError: 42 | exit("Error talking to Tautulli API, please check your TAUTULLI_URL") 43 | 44 | HISTORY = [play for play in REQUEST if date.fromtimestamp(play['started']) >= START_DATE] 45 | 46 | USERS = {} 47 | for play in HISTORY: 48 | if not USERS.get(play['user_id']): 49 | USERS.update( 50 | { 51 | play['user_id']: { 52 | 'direct play': 0, 53 | 'copy': 0, 54 | 'transcode': 0 55 | } 56 | } 57 | ) 58 | USERS[play['user_id']][play['transcode_decision']] += 1 59 | 60 | PARAMS = {'cmd': 'get_user', 'user_id': 0} 61 | for user, counts in USERS.items(): 62 | TOTAL_PLAYS = counts['transcode'] + counts['direct play'] + counts['copy'] 63 | TRANSCODE_PERCENT = round(old_div(counts['transcode'] * 100, TOTAL_PLAYS), 2) 64 | if TRANSCODE_PERCENT >= THRESHOLD_PERCENT: 65 | PARAMS['user_id'] = user 66 | NAUGHTY = SESSION.get(FORMATTED_URL, params=PARAMS).json()['response']['data'] 67 | print(f"{NAUGHTY['friendly_name']} is a serial transocde offender above the threshold at {TRANSCODE_PERCENT}%") 68 | -------------------------------------------------------------------------------- /utility/gmusic_playlists_to_plex.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Description: Pull Playlists from Google Music and create Playlist in Plex 6 | Author: Blacktwin, pjft, sdlynx 7 | Requires: gmusicapi, plexapi, requests 8 | 9 | 10 | Example: 11 | 12 | """ 13 | 14 | 15 | from plexapi.server import PlexServer, CONFIG 16 | from gmusicapi import Mobileclient 17 | 18 | import requests 19 | requests.packages.urllib3.disable_warnings() 20 | 21 | PLEX_URL = '' 22 | PLEX_TOKEN = '' 23 | MUSIC_LIBRARY_NAME = 'Music' 24 | 25 | ## CODE BELOW ## 26 | 27 | if not PLEX_URL: 28 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl') 29 | if not PLEX_TOKEN: 30 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token') 31 | 32 | # Connect to Plex Server 33 | sess = requests.Session() 34 | sess.verify = False 35 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 36 | 37 | # Connect to Google Music, if not authorized prompt to authorize 38 | # See https://unofficial-google-music-api.readthedocs.io/en/latest/reference/mobileclient.html 39 | # for more information 40 | mc = Mobileclient() 41 | if not mc.oauth_login(device_id=Mobileclient.FROM_MAC_ADDRESS): 42 | mc.perform_oauth() 43 | GGMUSICLIST = mc.get_all_songs() 44 | PLEX_MUSIC_LIBRARY = plex.library.section(MUSIC_LIBRARY_NAME) 45 | 46 | def round_down(num, divisor): 47 | """ 48 | Parameters 49 | ---------- 50 | num (int,str): Number to round down 51 | divisor (int): Rounding digit 52 | 53 | Returns 54 | ------- 55 | Rounded down int 56 | """ 57 | num = int(num) 58 | return num - (num%divisor) 59 | 60 | 61 | def compare(ggmusic, pmusic): 62 | """ 63 | Parameters 64 | ---------- 65 | ggmusic (dict): Contains track data from Google Music 66 | pmusic (object): Plex item found from search 67 | 68 | Returns 69 | ------- 70 | pmusic (object): Matched Plex item 71 | """ 72 | title = str(ggmusic['title'].encode('ascii', 'ignore')) 73 | album = str(ggmusic['album'].encode('ascii', 'ignore')) 74 | tracknum = int(ggmusic['trackNumber']) 75 | duration = int(ggmusic['durationMillis']) 76 | 77 | # Check if track numbers match 78 | if int(pmusic.index) == int(tracknum): 79 | return [pmusic] 80 | # If not track number, check track title and album title 81 | elif title == pmusic.title and (album == pmusic.parentTitle or 82 | album.startswith(pmusic.parentTitle)): 83 | return [pmusic] 84 | # Check if track duration match 85 | elif round_down(duration, 1000) == round_down(pmusic.duration, 1000): 86 | return [pmusic] 87 | # Lastly, check if title matches 88 | elif title == pmusic.title: 89 | return [pmusic] 90 | 91 | def get_ggmusic(trackId): 92 | for ggmusic in GGMUSICLIST: 93 | if ggmusic['id'] == trackId: 94 | return ggmusic 95 | 96 | def main(): 97 | for pl in mc.get_all_user_playlist_contents(): 98 | playlistName = pl['name'] 99 | # Check for existing Plex Playlists, skip if exists 100 | if playlistName in [x.title for x in plex.playlists()]: 101 | print("Playlist: ({}) already available, skipping...".format(playlistName)) 102 | else: 103 | playlistContent = [] 104 | shareToken = pl['shareToken'] 105 | # Go through tracks in Google Music Playlist 106 | for ggmusicTrackInfo in pl['tracks']: 107 | ggmusic = get_ggmusic(ggmusicTrackInfo['trackId']) 108 | title = str(ggmusic['title']) 109 | album = str(ggmusic['album']) 110 | artist = str(ggmusic['artist']) 111 | # Search Plex for Album title and Track title 112 | albumTrackSearch = PLEX_MUSIC_LIBRARY.searchTracks( 113 | **{'album.title': album, 'track.title': title}) 114 | # Check results 115 | if len(albumTrackSearch) == 1: 116 | playlistContent += albumTrackSearch 117 | if len(albumTrackSearch) > 1: 118 | for pmusic in albumTrackSearch: 119 | albumTrackFound = compare(ggmusic, pmusic) 120 | if albumTrackFound: 121 | playlistContent += albumTrackFound 122 | break 123 | # Nothing found from Album title and Track title 124 | if not albumTrackSearch or len(albumTrackSearch) == 0: 125 | # Search Plex for Track title 126 | trackSearch = PLEX_MUSIC_LIBRARY.searchTracks( 127 | **{'track.title': title}) 128 | if len(trackSearch) == 1: 129 | playlistContent += trackSearch 130 | if len(trackSearch) > 1: 131 | for pmusic in trackSearch: 132 | trackFound = compare(ggmusic, pmusic) 133 | if trackFound: 134 | playlistContent += trackFound 135 | break 136 | # Nothing found from Track title 137 | if not trackSearch or len(trackSearch) == 0: 138 | # Search Plex for Artist 139 | artistSearch = PLEX_MUSIC_LIBRARY.searchTracks( 140 | **{'artist.title': artist}) 141 | for pmusic in artistSearch: 142 | artistFound = compare(ggmusic, pmusic) 143 | if artistFound: 144 | playlistContent += artistFound 145 | break 146 | if not artistSearch or len(artistSearch) == 0: 147 | print(u"Could not find in Plex:\n\t{} - {} {}".format(artist, album, title)) 148 | if len(playlistContent) != 0: 149 | print("Adding Playlist: {}".format(playlistName)) 150 | print("Google Music Playlist: {}, has {} tracks. {} tracks were added to Plex.".format( 151 | playlistName, len(pl['tracks']), len(playlistContent))) 152 | plex.createPlaylist(playlistName, playlistContent) 153 | else: 154 | print("Could not find any matching tracks in Plex for {}".format(playlistName)) 155 | 156 | main() 157 | -------------------------------------------------------------------------------- /utility/grab_gdrive_media.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | https://gist.github.com/blacktwin/f435aa0ccd498b0840d2407d599bf31d 6 | """ 7 | from __future__ import print_function 8 | from __future__ import unicode_literals 9 | 10 | from builtins import input 11 | import os 12 | import httplib2 13 | 14 | # pip install --upgrade google-api-python-client 15 | from oauth2client.file import Storage 16 | from googleapiclient.discovery import build 17 | from oauth2client.client import OAuth2WebServerFlow 18 | 19 | # Copy your credentials from the console 20 | # https://console.developers.google.com 21 | CLIENT_ID = '' 22 | CLIENT_SECRET = '' 23 | OUT_PATH = '' # Output Path 24 | 25 | OAUTH_SCOPE = 'https://www.googleapis.com/auth/drive' 26 | REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' 27 | CREDS_FILE = os.path.join(os.path.dirname(__file__), 'credentials.json') 28 | 29 | if not os.path.exists(OUT_PATH): 30 | os.makedirs(OUT_PATH) 31 | 32 | storage = Storage(CREDS_FILE) 33 | credentials = storage.get() 34 | 35 | if credentials is None: 36 | # Run through the OAuth flow and retrieve credentials 37 | flow = OAuth2WebServerFlow(CLIENT_ID, CLIENT_SECRET, OAUTH_SCOPE, REDIRECT_URI) 38 | authorize_url = flow.step1_get_authorize_url() 39 | print('Go to the following link in your browser: ' + authorize_url) 40 | code = input('Enter verification code: ').strip() 41 | credentials = flow.step2_exchange(code) 42 | storage.put(credentials) 43 | 44 | 45 | # Create an httplib2.Http object and authorize it with our credentials 46 | http = httplib2.Http() 47 | http = credentials.authorize(http) 48 | 49 | drive_service = build('drive', 'v2', http=http) 50 | 51 | 52 | def list_files(service): 53 | page_token = None 54 | while True: 55 | param = {} 56 | if page_token: 57 | param['pageToken'] = page_token 58 | 59 | files = service.files().list(**param).execute() 60 | for item in files['items']: 61 | yield item 62 | page_token = files.get('nextPageToken') 63 | if not page_token: 64 | break 65 | 66 | 67 | for item in list_files(drive_service): 68 | if (item.get('mimeType') == 'image/jpeg' or item.get('mimeType') == 'video/mp4') \ 69 | and (item.get('originalFilename').endswith(('.jpg', '.mp4'))): 70 | try: 71 | video_path = OUT_PATH + "\\" + "Video" 72 | if not os.path.isdir(video_path): 73 | os.mkdir(video_path) 74 | picture_path = OUT_PATH + "\\" + "Pictures" 75 | if not os.path.isdir(picture_path): 76 | os.mkdir(picture_path) 77 | 78 | if item.get('mimeType') == 'image/jpeg' and item.get('originalFilename').endswith('.jpg'): 79 | year_date = picture_path + "\\" + item['createdDate'][:4] 80 | elif item.get('mimeType') == 'video/mp4' and item.get('originalFilename').endswith('.mp4'): 81 | year_date = video_path + "\\" + item['createdDate'][:4] 82 | 83 | md_date = year_date + "\\" + item['createdDate'][5:10] 84 | 85 | if not os.path.isdir(year_date): 86 | os.mkdir(year_date) 87 | if not os.path.isdir(md_date): 88 | os.mkdir(md_date) 89 | outfile = os.path.join(md_date, '%s' % item['title']) 90 | download_url = None 91 | if 'mimeType' in item and 'image/jpeg' in item['mimeType'] or 'video/mp4' in item['mimeType']: 92 | download_url = item['downloadUrl'] 93 | else: 94 | print('ERROR getting %s' % item.get('title')) 95 | print(item) 96 | print(dir(item)) 97 | if download_url: 98 | print("downloading %s" % item.get('title')) 99 | resp, content = drive_service._http.request(download_url) 100 | if resp.status == 200: 101 | if os.path.isfile(outfile): 102 | print("ERROR, %s already exist" % outfile) 103 | else: 104 | with open(outfile, 'wb') as f: 105 | f.write(content) 106 | print("OK.") 107 | else: 108 | print('ERROR downloading %s' % item.get('title')) 109 | except Exception as e: 110 | print(e) 111 | -------------------------------------------------------------------------------- /utility/hide_episode_spoilers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Automatically change episode artwork in Plex to hide spoilers. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi, requests 7 | # Tautulli script trigger: 8 | # * Notify on recently added 9 | # * Notify on watched (optional - to remove the artwork after being watched) 10 | # Tautulli script conditions: 11 | # * Condition {1}: 12 | # [Media Type | is | show or season or episode] 13 | # * Condition {2} (optional): 14 | # [ Library Name | is | DVR ] 15 | # [ Show Namme | is | Game of Thrones ] 16 | # Tautulli script arguments: 17 | # * Recently Added: 18 | # To use an image file (can be image in the same directory as this script, or full path to an image): 19 | # --rating_key {rating_key} --image spoilers.png 20 | # To blur the episode artwork (optional blur in pixels): 21 | # --rating_key {rating_key} --blur 25 22 | # To add a prefix to the summary (optional string prefix): 23 | # --rating_key {rating_key} --summary_prefix "** SPOILERS **" 24 | # To upload the episode artwork instead of creating a local asset (optional, for when the script cannot access the media folder): 25 | # --rating_key {rating_key} --blur 25 --upload 26 | # * Watched (optional): 27 | # To remove the local asset episode artwork: 28 | # --rating_key {rating_key} --remove 29 | # To remove the uploaded episode artwork 30 | # --rating_key {rating_key} --remove --upload 31 | # Note: 32 | # * "Use local assets" must be enabled for the library in Plex (Manage Library > Edit > Advanced > Use local assets). 33 | 34 | import argparse 35 | import os 36 | import requests 37 | import shutil 38 | from plexapi.server import PlexServer 39 | 40 | PLEX_URL = '' 41 | PLEX_TOKEN = '' 42 | 43 | # Environmental Variables 44 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) 45 | PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN) 46 | 47 | 48 | def modify_episode_artwork(plex, rating_key, image=None, blur=None, summary_prefix=None, remove=False, upload=False): 49 | item = plex.fetchItem(rating_key) 50 | 51 | if item.type == 'show': 52 | episodes = item.episodes() 53 | elif item.type == 'season': 54 | episodes = item.episodes() 55 | elif item.type == 'episode': 56 | episodes = [item] 57 | else: 58 | print('Only media type show, season, or episode is supported: ' 59 | '{item.title} ({item.ratingKey}) is media type {item.type}.'.format(item=item)) 60 | return 61 | 62 | for episode in episodes: 63 | for part in episode.iterParts(): 64 | episode_filepath = part.file 65 | episode_folder = os.path.dirname(episode_filepath) 66 | episode_filename = os.path.splitext(os.path.basename(episode_filepath))[0] 67 | 68 | if remove: 69 | if upload: 70 | # Unlock and select the first poster 71 | episode.unlockPoster().posters()[0].select() 72 | else: 73 | # Find image files with the same name as the episode 74 | for filename in os.listdir(episode_folder): 75 | if filename.startswith(episode_filename) and filename.endswith(('.jpg', '.png')): 76 | # Delete the episode artwork image file 77 | os.remove(os.path.join(episode_folder, filename)) 78 | 79 | # Unlock the summary so it will get updated on refresh 80 | episode.editSummary(episode.summary, locked=False) 81 | continue 82 | 83 | if image: 84 | if upload: 85 | # Upload the image to the episode artwork 86 | episode.uploadPoster(filepath=image) 87 | else: 88 | # File path to episode artwork using the same episode file name 89 | episode_artwork = os.path.splitext(episode_filepath)[0] + os.path.splitext(image)[1] 90 | # Copy the image to the episode artwork 91 | shutil.copy2(image, episode_artwork) 92 | 93 | elif blur: 94 | # File path to episode artwork using the same episode file name 95 | episode_artwork = os.path.splitext(episode_filepath)[0] + '.png' 96 | # Get the blurred artwork 97 | image_url = plex.transcodeImage( 98 | episode.thumbUrl, 99 | height=270, 100 | width=480, 101 | blur=blur, 102 | imageFormat='png' 103 | ) 104 | r = requests.get(image_url, stream=True) 105 | if r.status_code == 200: 106 | r.raw.decode_content = True 107 | if upload: 108 | # Upload the image to the episode artwork 109 | episode.uploadPoster(filepath=r.raw) 110 | else: 111 | # Copy the image to the episode artwork 112 | with open(episode_artwork, 'wb') as f: 113 | shutil.copyfileobj(r.raw, f) 114 | 115 | if summary_prefix and not episode.summary.startswith(summary_prefix): 116 | # Use a zero-width space (\u200b) for blank lines 117 | episode.editSummary(summary_prefix + '\n\u200b\n' + episode.summary) 118 | 119 | # Refresh metadata for the episode 120 | episode.refresh() 121 | 122 | 123 | if __name__ == "__main__": 124 | parser = argparse.ArgumentParser() 125 | parser.add_argument('--rating_key', required=True, type=int) 126 | parser.add_argument('--image') 127 | parser.add_argument('--blur', type=int, default=25) 128 | parser.add_argument('--summary_prefix', nargs='?', const='** SPOILERS **') 129 | parser.add_argument('--remove', action='store_true') 130 | parser.add_argument('--upload', action='store_true') 131 | opts = parser.parse_args() 132 | 133 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 134 | modify_episode_artwork(plex, **vars(opts)) 135 | -------------------------------------------------------------------------------- /utility/library_growth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Check Plex library locations growth over time using added date. 6 | Check Plex, Tautulli, OS for added time, last updated, originally availableAt, played dates 7 | """ 8 | 9 | import argparse 10 | import datetime 11 | import sys 12 | from plexapi.server import PlexServer 13 | from plexapi.server import CONFIG 14 | import requests 15 | import matplotlib.pyplot as plt 16 | import matplotlib.ticker as plticker 17 | from collections import Counter 18 | from matplotlib import rcParams 19 | rcParams.update({'figure.autolayout': True}) 20 | 21 | PLEX_URL ='' 22 | PLEX_TOKEN = '' 23 | TAUTULLI_URL = '' 24 | TAUTULLI_APIKEY = '' 25 | 26 | # Using CONFIG file 27 | if not PLEX_TOKEN: 28 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token') 29 | if not PLEX_URL: 30 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl') 31 | if not TAUTULLI_URL: 32 | TAUTULLI_URL = CONFIG.data['auth'].get('tautulli_baseurl') 33 | if not TAUTULLI_APIKEY: 34 | TAUTULLI_APIKEY = CONFIG.data['auth'].get('tautulli_apikey') 35 | 36 | VERIFY_SSL = False 37 | 38 | sess = requests.Session() 39 | sess.verify = False 40 | 41 | 42 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 43 | sections = [x for x in plex.library.sections() if x.type not in ['artist', 'photo']] 44 | sections_dict = {x.key: x.title for x in sections} 45 | 46 | 47 | def graph_setup(): 48 | fig, axs = plt.subplots(3) 49 | fig.set_size_inches(14, 12) 50 | 51 | return axs 52 | 53 | 54 | def exclusions(all_true, select, all_items): 55 | """ 56 | Parameters 57 | ---------- 58 | all_true: bool 59 | All of something (allLibraries, allPlaylists, allUsers) 60 | select: list 61 | List from arguments (user, playlists, libraries) 62 | all_items: list or dict 63 | List or Dictionary of all possible somethings 64 | 65 | Returns 66 | ------- 67 | output: list or dict 68 | List of what was included/excluded 69 | """ 70 | output = '' 71 | if isinstance(all_items, list): 72 | output = [] 73 | if all_true and not select: 74 | output = all_items 75 | elif not all_true and select: 76 | for item in all_items: 77 | if isinstance(item, str): 78 | return select 79 | else: 80 | if item.title in select: 81 | output.append(item) 82 | elif all_true and select: 83 | for x in select: 84 | all_items.remove(x) 85 | output = all_items 86 | 87 | elif isinstance(all_items, dict): 88 | output = {} 89 | if all_true and not select: 90 | output = all_items 91 | elif not all_true and select: 92 | for key, value in all_items.items(): 93 | if value in select: 94 | output[key] = value 95 | elif all_true and select: 96 | for key, value in all_items.items(): 97 | if value not in select: 98 | output[key] = value 99 | 100 | return output 101 | 102 | 103 | def plex_growth(section, axs): 104 | library = plex.library.sectionByID(section) 105 | 106 | allthem = library.all() 107 | 108 | allAddedAt = [x.addedAt.date() for x in allthem if x.addedAt] 109 | y = range(len(allAddedAt)) 110 | axs[0].plot(sorted(allAddedAt), y) 111 | axs[0].set_title('Plex {} Library Growth'.format(library.title)) 112 | 113 | 114 | def plex_released(section, axs): 115 | 116 | library = plex.library.sectionByID(section) 117 | allthem = library.all() 118 | 119 | originallyAvailableAt = [x.originallyAvailableAt.date().strftime('%Y') 120 | for x in allthem if x.originallyAvailableAt] 121 | counts = Counter(sorted(originallyAvailableAt)) 122 | 123 | axs[1].bar(list(counts.keys()), list(counts.values())) 124 | loc = plticker.MultipleLocator(base=5.0) # this locator puts ticks at regular intervals 125 | axs[1].xaxis.set_major_locator(loc) 126 | axs[1].set_title('Plex {} Library Released Date'.format(library.title)) 127 | 128 | releasedGenres = {} 129 | genres = [] 130 | for x in allthem: 131 | if x.originallyAvailableAt: 132 | releaseYear = x.originallyAvailableAt.date().strftime('%Y') 133 | if releasedGenres.get(releaseYear): 134 | for genre in x.genres: 135 | releasedGenres[releaseYear].append(genre.tag) 136 | genres.append(genre.tag) 137 | else: 138 | for genre in x.genres: 139 | releasedGenres[releaseYear] = [genre.tag] 140 | genres.append(genre.tag) 141 | 142 | labels = sorted(list(set(genres))) 143 | for year, genre in sorted(releasedGenres.items()): 144 | yearGenre = Counter(sorted(genre)) 145 | genresCounts = list(yearGenre.values()) 146 | for i in range(len(yearGenre)): 147 | axs[2].bar(year, genresCounts, bottom=sum(genresCounts[:i])) 148 | 149 | loc = plticker.MultipleLocator(base=5.0) # this locator puts ticks at regular intervals 150 | axs[2].xaxis.set_major_locator(loc) 151 | axs[2].legend(labels, bbox_to_anchor=(0, -0.25, 1., .102), loc='lower center', 152 | ncol=12, mode="expand", borderaxespad=0.) 153 | axs[2].set_title('Plex {} Library Released Date (Genre)'.format(library.title)) 154 | # plt.tight_layout() 155 | 156 | 157 | 158 | if __name__ == '__main__': 159 | parser = argparse.ArgumentParser(description="Show library growth.", 160 | formatter_class=argparse.RawTextHelpFormatter) 161 | parser.add_argument('--libraries', nargs='+', choices=sections_dict.values(), metavar='', 162 | help='Space separated list of case sensitive names to process. Allowed names are:\n' 163 | 'Choices: %(choices)s') 164 | parser.add_argument('--allLibraries', default=False, action='store_true', 165 | help='Select all libraries.') 166 | 167 | opts = parser.parse_args() 168 | # Defining libraries 169 | libraries = exclusions(opts.allLibraries, opts.libraries, sections_dict) 170 | 171 | for library in libraries: 172 | library_title = sections_dict.get(library) 173 | print("Starting {}".format(library_title)) 174 | graph = graph_setup() 175 | plex_growth(library, graph) 176 | plex_released(library, graph) 177 | plt.savefig('{}_library_growth.png'.format(library_title), bbox_inches='tight', dpi=100) 178 | # plt.show() -------------------------------------------------------------------------------- /utility/lock_unlock_poster_art.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Automatically lock/unlock posters and artwork in a Plex. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | # 8 | # Examples: 9 | # Lock poster for a specific rating key: 10 | # python lock_unlock_poster_art.py --rating_key 12345 --lock poster 11 | # 12 | # Unlock artwork for a specific rating key: 13 | # python lock_unlock_poster_art.py --rating_key 12345 --unlock art 14 | # 15 | # Lock all posters in "Movies" and "TV Shows" (Note: TV show libraries include season posters/artwork): 16 | # python lock_unlock_poster_art.py --libraries "Movies" "TV Shows" --lock poster 17 | # 18 | # Lock all artwork in "Anime": 19 | # python lock_unlock_poster_art.py --libraries "Anime" --lock art 20 | # 21 | # Lock all posters and artwork in "Movies" and "TV Shows": 22 | # python lock_unlock_poster_art.py --libraries "Movies" "TV Shows" --lock poster --lock art 23 | # 24 | # Unlock all posters and artwork in "Music" (Note: Music libraries include album covers/artwork): 25 | # python lock_unlock_poster_art.py --libraries "Music" --unlock poster --unlock art 26 | 27 | import argparse 28 | import os 29 | from plexapi.server import PlexServer 30 | 31 | PLEX_URL = '' 32 | PLEX_TOKEN = '' 33 | 34 | # Environmental Variables 35 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) 36 | PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN) 37 | 38 | 39 | def lock_unlock(plex, rating_key=None, libraries=None, lock=None, unlock=None): 40 | if libraries is None: 41 | libraries = [] 42 | if lock is None: 43 | lock = [] 44 | if unlock is None: 45 | unlock = [] 46 | 47 | if rating_key: 48 | item = plex.fetchItem(rating_key) 49 | lock_unlock_items([item], lock, unlock) 50 | if item.type == 'show': 51 | lock_unlock_items(item.seasons(), lock, unlock) 52 | elif item.type == 'artist': 53 | lock_unlock_items(item.albums(), lock, unlock) 54 | 55 | else: 56 | for lib in libraries: 57 | library = plex.library.section(lib) 58 | lock_unlock_library(library, lock, unlock) 59 | if library.type == 'show': 60 | lock_unlock_library(library, lock, unlock, libtype='season') 61 | elif library.type == 'artist': 62 | lock_unlock_library(library, lock, unlock, libtype='album') 63 | 64 | 65 | def lock_unlock_items(items, lock, unlock): 66 | for item in items: 67 | if 'poster' in lock: 68 | item.lockPoster() 69 | if 'art' in lock: 70 | item.lockArt() 71 | if 'poster' in unlock: 72 | item.unlockPoster() 73 | if 'art' in unlock: 74 | item.unlockArt() 75 | 76 | 77 | def lock_unlock_library(library, lock, unlock, libtype=None): 78 | if 'poster' in lock: 79 | library.lockAllField('thumb', libtype=libtype) 80 | if 'art' in lock: 81 | library.lockAllField('art', libtype=libtype) 82 | if 'poster' in unlock: 83 | library.unlockAllField('thumb', libtype=libtype) 84 | if 'art' in unlock: 85 | library.unlockAllField('art', libtype=libtype) 86 | 87 | 88 | if __name__ == "__main__": 89 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 90 | sections = [library.title for library in plex.library.sections()] 91 | lock_options = {'poster', 'art'} 92 | 93 | parser = argparse.ArgumentParser() 94 | parser.add_argument('--rating_key', type=int) 95 | parser.add_argument('--libraries', nargs='+', choices=sections) 96 | parser.add_argument('--lock', choices=lock_options, action='append') 97 | parser.add_argument('--unlock', choices=lock_options, action='append') 98 | opts = parser.parse_args() 99 | 100 | lock_unlock(plex, **vars(opts)) 101 | -------------------------------------------------------------------------------- /utility/mark_multiepisode_watched.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Automatically mark a multi-episode file as watched in Plex. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | # Tautulli script trigger: 8 | # * Notify on watched 9 | # Tautulli script conditions: 10 | # * Condition {1}: 11 | # [ Media Type | is | episode ] 12 | # * Condition {2} (optional): 13 | # [ Username | is | username ] 14 | # Tautulli script arguments: 15 | # * Watched: 16 | # --rating_key {rating_key} --filename {filename} 17 | 18 | from __future__ import print_function 19 | from __future__ import unicode_literals 20 | from builtins import str 21 | import argparse 22 | import os 23 | from plexapi.server import PlexServer 24 | 25 | PLEX_URL = '' 26 | PLEX_TOKEN = '' 27 | 28 | # Environmental Variables 29 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) 30 | PLEX_USER_TOKEN = os.getenv('PLEX_USER_TOKEN', PLEX_TOKEN) 31 | 32 | 33 | if __name__ == "__main__": 34 | parser = argparse.ArgumentParser() 35 | parser.add_argument('--rating_key', required=True, type=int) 36 | parser.add_argument('--filename', required=True) 37 | opts = parser.parse_args() 38 | 39 | plex = PlexServer(PLEX_URL, PLEX_USER_TOKEN) 40 | 41 | for episode in plex.fetchItem(opts.rating_key).season().episodes(): 42 | if episode.ratingKey == opts.rating_key: 43 | continue 44 | if any(opts.filename in part.file for media in episode.media for part in media.parts): 45 | print("Marking multi-episode file '{grandparentTitle} - S{parentIndex}E{index}' as watched.".format( 46 | grandparentTitle=episode.grandparentTitle.encode('UTF-8'), 47 | parentIndex=str(episode.parentIndex).zfill(2), 48 | index=str(episode.index).zfill(2))) 49 | episode.markWatched() 50 | -------------------------------------------------------------------------------- /utility/merge_multiepisodes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Description: Automatically merge multi-episode files in Plex into a single entry. 6 | Author: /u/SwiftPanda16 7 | Requires: plexapi, pillow (optional) 8 | Notes: 9 | * All episodes **MUST** be organized correctly according to Plex's "Multiple Episodes in a Single File". 10 | https://support.plex.tv/articles/naming-and-organizing-your-tv-show-files/#toc-4 11 | 12 | * Episode titles, summaries, and tags will be appended to the first episode of the group. 13 | 14 | * Without re-numbering will keep the episode number of the first episode of each group. 15 | 16 | * Re-numbering starts at the first group episode's number and increments by one. Skipping numbers is not supported. 17 | * e.g. s01e01-e02, s01e03, s01e04, s01e05-e06 --> s01e01, s01e02, s01e03, s01e04 18 | * e.g. s02e05-e06, s01e07-e08, s02e09-e10 --> s02e05, s02e06, s02e07 19 | * e.g. s03e01-e02, s03e04, s03e07-e08 --> s03e01, s03e02, s03e03 (s03e03, s03e05, so3e06 skipped) 20 | 21 | * To revert the changes and split the episodes again, the show must be removed and re-added to Plex (aka Plex Dance). 22 | 23 | Usage: 24 | * Without renumbering episodes: 25 | python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" 26 | 27 | * With renumbering episodes: 28 | python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" --renumber 29 | 30 | * With renumbering episodes and composite thumb: 31 | python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" --renumber --composite-thumb 32 | ''' 33 | 34 | import argparse 35 | import functools 36 | import io 37 | import math 38 | import os 39 | import requests 40 | from collections import defaultdict 41 | from plexapi.server import PlexServer 42 | 43 | try: 44 | from PIL import Image, ImageDraw 45 | hasPIL = True 46 | except ImportError: 47 | hasPIL = False 48 | 49 | 50 | # ## EDIT SETTINGS ## 51 | 52 | PLEX_URL = '' 53 | PLEX_TOKEN = '' 54 | 55 | # Composite Thumb Settings 56 | WIDTH, HEIGHT = 640, 360 # 16:9 aspect ratio 57 | LINE_ANGLE = 25 # degrees 58 | LINE_THICKNESS = 10 59 | 60 | 61 | # Environmental Variables 62 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) 63 | PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN) 64 | 65 | 66 | def group_episodes(plex, library, show, renumber, composite_thumb): 67 | show = plex.library.section(library).get(show) 68 | 69 | for season in show.seasons(): 70 | groups = defaultdict(list) 71 | startIndex = None 72 | 73 | for episode in season.episodes(): 74 | groups[episode.locations[0]].append(episode) 75 | if startIndex is None: 76 | startIndex = episode.index 77 | 78 | for index, (first, *episodes) in enumerate(groups.values(), start=startIndex): 79 | title = first.title + ' / ' 80 | titleSort = first.titleSort + ' / ' 81 | summary = first.summary + '\n\n' 82 | writers = [] 83 | directors = [] 84 | 85 | for episode in episodes: 86 | title += episode.title + ' / ' 87 | titleSort += episode.titleSort + ' / ' 88 | summary += episode.summary + '\n\n' 89 | writers.extend([writer.tag for writer in episode.writers]) 90 | directors.extend([director.tag for director in episode.directors]) 91 | 92 | if episodes: 93 | if composite_thumb: 94 | firstImgFile = download_image( 95 | plex.transcodeImage(first.thumbUrl, width=WIDTH, height=HEIGHT) 96 | ) 97 | lastImgFile = download_image( 98 | plex.transcodeImage(episodes[-1].thumbUrl, width=WIDTH, height=HEIGHT) 99 | ) 100 | compImgFile = create_composite_thumb(firstImgFile, lastImgFile) 101 | first.uploadPoster(filepath=compImgFile) 102 | 103 | merge(first, episodes) 104 | 105 | first.batchEdits() \ 106 | .editTitle(title[:-3]) \ 107 | .editSortTitle(titleSort[:-3]) \ 108 | .editSummary(summary[:-2]) \ 109 | .editContentRating(first.contentRating) \ 110 | .editOriginallyAvailable(first.originallyAvailableAt) \ 111 | .addWriter(writers) \ 112 | .addDirector(directors) \ 113 | 114 | if renumber: 115 | first._edits['index.value'] = index 116 | first._edits['index.locked'] = 1 117 | 118 | first.saveEdits() 119 | 120 | 121 | def merge(first, episodes): 122 | key = '%s/merge?ids=%s' % (first.key, ','.join([str(r.ratingKey) for r in episodes])) 123 | first._server.query(key, method=first._server._session.put) 124 | 125 | 126 | def download_image(url): 127 | r = requests.get(url, stream=True) 128 | r.raw.decode_content = True 129 | return r.raw 130 | 131 | 132 | def create_composite_thumb(firstImgFile, lastImgFile): 133 | mask, line = create_masks() 134 | 135 | # Open and crop first image 136 | firstImg = Image.open(firstImgFile) 137 | width, height = firstImg.size 138 | firstImg = firstImg.crop( 139 | ( 140 | (width - WIDTH) // 2, 141 | (height - HEIGHT) // 2, 142 | (width + WIDTH) // 2, 143 | (height + HEIGHT) // 2 144 | ) 145 | ) 146 | 147 | # Open and crop last image 148 | lastImg = Image.open(lastImgFile) 149 | width, height = lastImg.size 150 | lastImg = lastImg.crop( 151 | ( 152 | (width - WIDTH) // 2, 153 | (height - HEIGHT) // 2, 154 | (width + WIDTH) // 2, 155 | (height + HEIGHT) // 2 156 | ) 157 | ) 158 | 159 | # Create composite image 160 | comp = Image.composite(line, Image.composite(firstImg, lastImg, mask), line) 161 | 162 | # Return composite image as file-like object 163 | compImgFile = io.BytesIO() 164 | comp.save(compImgFile, format='jpeg') 165 | compImgFile.seek(0) 166 | return compImgFile 167 | 168 | 169 | @functools.lru_cache(maxsize=None) 170 | def create_masks(): 171 | scale = 3 # For line anti-aliasing 172 | offset = HEIGHT // 2 * math.tan(LINE_ANGLE * math.pi / 180) 173 | 174 | # Create diagonal mask 175 | mask = Image.new('L', (WIDTH, HEIGHT), 0) 176 | draw = ImageDraw.Draw(mask) 177 | draw.polygon( 178 | ( 179 | (0, 0), 180 | (WIDTH // 2 + offset, 0), 181 | (WIDTH // 2 - offset, HEIGHT), 182 | (0, HEIGHT) 183 | ), 184 | fill=255 185 | ) 186 | 187 | # Create diagonal line (use larger image then scale down with anti-aliasing) 188 | line = Image.new('L', (scale * WIDTH, scale * HEIGHT), 0) 189 | draw = ImageDraw.Draw(line) 190 | draw.line( 191 | ( 192 | (scale * (WIDTH // 2 + offset), -scale), 193 | (scale * (WIDTH // 2 - offset), scale * (HEIGHT + 1)) 194 | ), 195 | fill=255, 196 | width=scale * LINE_THICKNESS 197 | ) 198 | line = line.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) 199 | 200 | return mask, line 201 | 202 | 203 | if __name__ == '__main__': 204 | parser = argparse.ArgumentParser() 205 | parser.add_argument('--library', required=True) 206 | parser.add_argument('--show', required=True) 207 | parser.add_argument('--renumber', action='store_true') 208 | parser.add_argument('--composite_thumb', action='store_true') 209 | opts = parser.parse_args() 210 | 211 | if opts.composite_thumb and not hasPIL: 212 | print('PIL is not installed. Please install `pillow` to create composite thumbnails.') 213 | exit(1) 214 | 215 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 216 | group_episodes(plex, **vars(opts)) 217 | -------------------------------------------------------------------------------- /utility/music_folder_collections.py: -------------------------------------------------------------------------------- 1 | """ 2 | audiobooks / 3 | -- book1 / 4 | -- book1 - chapter1.mp3 ... 5 | -- series1 / 6 | -- book1 / 7 | -- book1 - chapter1.mp3 ... 8 | -- book2 / 9 | -- book2 - chapter1.mp3 ... 10 | 11 | In this structure use series1 to add all the series' books into a colleciton. 12 | 13 | """ 14 | 15 | from plexapi.server import PlexServer 16 | 17 | PLEX_URL = '' 18 | PLEX_TOKEN = '' 19 | 20 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 21 | 22 | COLLECTIONAME = 'My Fav Series' 23 | TOPLEVELFOLDERNAME = 'Series Name' 24 | LIBRARYNAME = 'Audio Books' 25 | 26 | abLibrary = plex.library.section(LIBRARYNAME) 27 | 28 | albums = [] 29 | for folder in abLibrary.folders(): 30 | if folder.title == TOPLEVELFOLDERNAME: 31 | for series in folder.allSubfolders(): 32 | trackKey = series.key 33 | try: 34 | track = plex.fetchItem(trackKey) 35 | albumKey = track.parentKey 36 | album = plex.fetchItem(albumKey) 37 | albums.append(album) 38 | except Exception: 39 | # print('{} contains additional subfolders that were likely captured. \n[{}].' 40 | # .format(series.title, ', '.join([x.title for x in series.allSubfolders()]))) 41 | pass 42 | 43 | for album in list(set(albums)): 44 | print('Adding {} to collection {}.'.format(album.title, COLLECTIONAME)) 45 | album.addCollection(COLLECTIONAME) -------------------------------------------------------------------------------- /utility/off_deck.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Removes Shows from Continue Watching. 5 | 6 | Author: Blacktwin 7 | Requires: requests, plexapi 8 | 9 | Example: 10 | python off_deck.py 11 | - Display what shows are on admin's Continue Watching 12 | 13 | python off_deck.py --user Steve 14 | - Display what shows are on Steve's Continue Watching 15 | 16 | python off_deck.py --shows "The Simpsons" Seinfeld 17 | - The Simpsons and Seinfeld Episodes will be removed from admin's Continue Watching 18 | 19 | python off_deck.py --user Steve --shows "The Simpsons" Seinfeld 20 | - The Simpsons and Seinfeld Episodes will be removed from Steve's Continue Watching 21 | 22 | python off_deck.py --playlists "Favorite Shows!" 23 | - Any Episode found in admin's "Favorite Shows" playlist will be remove from Continue Watching 24 | 25 | python off_deck.py --user Steve --playlists "Favorite Shows!" SleepMix 26 | - Any Episode found in Steve's "Favorite Shows" or SleepMix playlist will be remove from Continue Watching 27 | 28 | """ 29 | from __future__ import print_function 30 | from __future__ import unicode_literals 31 | 32 | import requests 33 | import argparse 34 | from plexapi.server import PlexServer, CONFIG 35 | 36 | PLEX_URL = '' 37 | PLEX_TOKEN = '' 38 | 39 | if not PLEX_URL: 40 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', '') 41 | 42 | if not PLEX_TOKEN: 43 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', '') 44 | 45 | sess = requests.Session() 46 | # Ignore verifying the SSL certificate 47 | sess.verify = False # '/path/to/certfile' 48 | # If verify is set to a path to a directory, 49 | # the directory must have been processed using the c_rehash utility supplied 50 | # with OpenSSL. 51 | if sess.verify is False: 52 | # Disable the warning that the request is insecure, we know that... 53 | import urllib3 54 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 55 | 56 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 57 | account = plex.myPlexAccount() 58 | 59 | 60 | def remove_from_cw(server, ratingKey): 61 | key = '/actions/removeFromContinueWatching?ratingKey=%s&' % ratingKey 62 | server.query(key, method=server._session.put) 63 | 64 | 65 | if __name__ == '__main__': 66 | 67 | parser = argparse.ArgumentParser(description="Remove items from Continue Watching.", 68 | formatter_class=argparse.RawTextHelpFormatter) 69 | parser.add_argument('--shows', nargs='+', 70 | help='Shows to be removed from Continue Watching.') 71 | parser.add_argument('--user', nargs='?', 72 | help='User whose Continue Watching will be modified.') 73 | parser.add_argument('--playlists', nargs='+', 74 | help='Shows in playlist to be removed from Continue Watching') 75 | parser.add_argument('--markWatched', action='store_true', 76 | help='Mark episode as watched after removing from Continue Watching') 77 | 78 | opts = parser.parse_args() 79 | 80 | to_remove = [] 81 | 82 | if opts.user: 83 | user_acct = account.user(opts.user) 84 | plex_server = PlexServer(PLEX_URL, user_acct.get_token(plex.machineIdentifier)) 85 | else: 86 | plex_server = plex 87 | 88 | onDeck = [item for item in plex_server.library.onDeck() if item.type == 'episode'] 89 | 90 | if opts.shows and not opts.playlists: 91 | for show in opts.shows: 92 | searched_show = plex_server.search(show, mediatype='show')[0] 93 | if searched_show.title == show: 94 | to_remove += searched_show.episodes() 95 | elif not opts.shows and opts.playlists: 96 | for pl in plex_server.playlists(): 97 | if pl.title in opts.playlists: 98 | to_remove += pl.items() 99 | else: 100 | for item in onDeck: 101 | print('{}: S{:02}E{:02} {}'.format(item.grandparentTitle, int(item.parentIndex), 102 | int(item.index), item.title)) 103 | 104 | for item in onDeck: 105 | if item in to_remove: 106 | print('Removing {}: S{:02}E{:02} {} from Continue Watching'.format( 107 | item.grandparentTitle, int(item.parentIndex), int(item.index), item.title)) 108 | # item.removeFromContinueWatching() 109 | remove_from_cw(plex_server, item.ratingKey) 110 | if opts.markWatched: 111 | print('Marking as watched!') 112 | item.markPlayed() -------------------------------------------------------------------------------- /utility/plex_api_invite.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Invite new users to share Plex libraries. 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --user [] Enter a valid username(s) or email address(s) for user to be invited. 10 | --libraries [ ...] 11 | Space separated list of case sensitive names to process. Allowed names are: 12 | (choices: All library names) 13 | --allLibraries Select all libraries. 14 | --sync Allow user to sync content 15 | --camera Allow user to upload photos 16 | --channel Allow user to utilize installed channels 17 | --movieRatings Add rating restrictions to movie library types 18 | --movieLabels Add label restrictions to movie library types 19 | --tvRatings Add rating restrictions to show library types 20 | --tvLabels Add label restrictions to show library types 21 | --musicLabels Add label restrictions to music library types 22 | 23 | Usage: 24 | 25 | plex_api_invite.py --user USER --libraries Movies 26 | - Shared libraries: ['Movies'] with USER 27 | 28 | plex_api_invite.py --user USER --libraries Movies "TV Shows" 29 | - Shared libraries: ['Movies', 'TV Shows'] with USER 30 | * Double Quote libraries with spaces 31 | 32 | plex_api_invite.py --allLibraries --user USER 33 | - Shared all libraries with USER. 34 | 35 | plex_api_invite.py --libraries Movies --user USER --movieRatings G, PG-13 36 | - Share Movie library with USER but restrict them to only G and PG-13 titles. 37 | 38 | """ 39 | from __future__ import print_function 40 | from __future__ import unicode_literals 41 | 42 | from plexapi.server import PlexServer, CONFIG 43 | import argparse 44 | import requests 45 | 46 | PLEX_URL = '' 47 | PLEX_TOKEN = '' 48 | 49 | if not PLEX_URL: 50 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', '') 51 | 52 | if not PLEX_TOKEN: 53 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', '') 54 | 55 | sess = requests.Session() 56 | # Ignore verifying the SSL certificate 57 | sess.verify = False # '/path/to/certfile' 58 | # If verify is set to a path to a directory, 59 | # the directory must have been processed using the c_rehash utility supplied 60 | # with OpenSSL. 61 | if sess.verify is False: 62 | # Disable the warning that the request is insecure, we know that... 63 | import urllib3 64 | 65 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 66 | 67 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 68 | 69 | sections_lst = [x.title for x in plex.library.sections()] 70 | ratings_lst = ['G', 'PG', 'PG-13', 'R', 'NC-17', 'TV-G', 'TV-Y', 'TV-Y7', 'TV-14', 'TV-PG', 'TV-MA'] 71 | 72 | 73 | def invite(user, sections, allowSync, camera, channels, filterMovies, filterTelevision, filterMusic): 74 | plex.myPlexAccount().inviteFriend(user=user, server=plex, sections=sections, allowSync=allowSync, 75 | allowCameraUpload=camera, allowChannels=channels, filterMovies=filterMovies, 76 | filterTelevision=filterTelevision, filterMusic=filterMusic) 77 | print('Invited {user} to share libraries: \n{sections}'.format(sections=sections, user=user)) 78 | if allowSync is True: 79 | print('Sync: Enabled') 80 | if camera is True: 81 | print('Camera Upload: Enabled') 82 | if channels is True: 83 | print('Plugins: Enabled') 84 | if filterMovies: 85 | print('Movie Filters: {}'.format(filterMovies)) 86 | if filterTelevision: 87 | print('Show Filters: {}'.format(filterTelevision)) 88 | if filterMusic: 89 | print('Music Filters: {}'.format(filterMusic)) 90 | 91 | 92 | if __name__ == "__main__": 93 | 94 | parser = argparse.ArgumentParser(description="Invite new users to share Plex libraries.", 95 | formatter_class=argparse.RawTextHelpFormatter) 96 | parser.add_argument('--user', nargs='+', required=True, 97 | help='Enter a valid username(s) or email address(s) for user to be invited.') 98 | parser.add_argument('--libraries', nargs='+', default=False, choices=sections_lst, metavar='', 99 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 100 | '(choices: %(choices)s') 101 | parser.add_argument('--allLibraries', default=False, action='store_true', 102 | help='Select all libraries.') 103 | parser.add_argument('--sync', default=None, action='store_true', 104 | help='Use to allow user to sync content.') 105 | parser.add_argument('--camera', default=None, action='store_true', 106 | help='Use to allow user to upload photos.') 107 | parser.add_argument('--channels', default=None, action='store_true', 108 | help='Use to allow user to utilize installed channels.') 109 | parser.add_argument('--movieRatings', nargs='+', choices=ratings_lst, metavar='', 110 | help='Use to add rating restrictions to movie library types.') 111 | parser.add_argument('--movieLabels', nargs='+', 112 | help='Use to add label restrictions for movie library types.') 113 | parser.add_argument('--tvRatings', nargs='+', choices=ratings_lst, metavar='', 114 | help='Use to add rating restrictions for show library types.') 115 | parser.add_argument('--tvLabels', nargs='+', 116 | help='Use to add label restrictions for show library types.') 117 | parser.add_argument('--musicLabels', nargs='+', 118 | help='Use to add label restrictions for music library types.') 119 | 120 | opts = parser.parse_args() 121 | libraries = '' 122 | 123 | # Plex Pass additional share options 124 | sync = None 125 | camera = None 126 | channels = None 127 | filterMovies = None 128 | filterTelevision = None 129 | filterMusic = None 130 | try: 131 | if opts.sync: 132 | sync = opts.sync 133 | if opts.camera: 134 | camera = opts.camera 135 | if opts.channels: 136 | channels = opts.channels 137 | if opts.movieLabels or opts.movieRatings: 138 | filterMovies = {} 139 | if opts.movieLabels: 140 | filterMovies['label'] = opts.movieLabels 141 | if opts.movieRatings: 142 | filterMovies['contentRating'] = opts.movieRatings 143 | if opts.tvLabels or opts.tvRatings: 144 | filterTelevision = {} 145 | if opts.tvLabels: 146 | filterTelevision['label'] = opts.tvLabels 147 | if opts.tvRatings: 148 | filterTelevision['contentRating'] = opts.tvRatings 149 | if opts.musicLabels: 150 | filterMusic = {} 151 | filterMusic['label'] = opts.musicLabels 152 | except AttributeError: 153 | print('No Plex Pass moving on...') 154 | 155 | # Defining libraries 156 | if opts.allLibraries and not opts.libraries: 157 | libraries = sections_lst 158 | elif not opts.allLibraries and opts.libraries: 159 | libraries = opts.libraries 160 | elif opts.allLibraries and opts.libraries: 161 | # If allLibraries is used then any libraries listed will be excluded 162 | for library in opts.libraries: 163 | sections_lst.remove(library) 164 | libraries = sections_lst 165 | 166 | for user in opts.user: 167 | invite(user, libraries, sync, camera, channels, 168 | filterMovies, filterTelevision, filterMusic) 169 | -------------------------------------------------------------------------------- /utility/plex_api_parental_control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Set as cron or task for times of allowing and not allowing user access to server. 6 | Unsharing will kill any current stream from user before unsharing. 7 | 8 | Share or unshare libraries. 9 | 10 | optional arguments: 11 | -h, --help show this help message and exit 12 | -s [], --share [] To share or to unshare.: 13 | (choices: share, share_all, unshare) 14 | -u [], --user [] Space separated list of case sensitive names to process. Allowed names are: 15 | (choices: All users names) 16 | -l [ ...], --libraries [ ...] 17 | Space separated list of case sensitive names to process. Allowed names are: 18 | (choices: All library names) 19 | (default: All Libraries) 20 | 21 | Usage: 22 | 23 | plex_api_share.py -s share -u USER -l Movies 24 | - Shared libraries: ['Movies'] with USER 25 | 26 | plex_api_share.py -s share -u USER -l Movies "TV Shows" 27 | - Shared libraries: ['Movies', 'TV Shows'] with USER 28 | * Double Quote libraries with spaces 29 | 30 | plex_api_share.py -s share_all -u USER 31 | - Shared all libraries with USER. 32 | 33 | plex_api_share.py -s unshare -u USER 34 | - Kill users current stream. 35 | - Unshared all libraries with USER. 36 | - USER is still exists as a Friend or Home User 37 | 38 | """ 39 | from __future__ import print_function 40 | from __future__ import unicode_literals 41 | 42 | 43 | import argparse 44 | import requests 45 | from time import sleep 46 | from plexapi.server import PlexServer, CONFIG 47 | 48 | MESSAGE = "GET TO BED!" 49 | 50 | PLEX_URL = '' 51 | PLEX_TOKEN = '' 52 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 53 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 54 | 55 | sess = requests.Session() 56 | # Ignore verifying the SSL certificate 57 | sess.verify = False # '/path/to/certfile' 58 | # If verify is set to a path to a directory, 59 | # the directory must have been processed using the c_rehash utility supplied 60 | # with OpenSSL. 61 | if sess.verify is False: 62 | # Disable the warning that the request is insecure, we know that... 63 | import urllib3 64 | 65 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 66 | 67 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 68 | 69 | user_lst = [x.title for x in plex.myPlexAccount().users()] 70 | sections_lst = [x.title for x in plex.library.sections()] 71 | 72 | 73 | def share(user, libraries): 74 | plex.myPlexAccount().updateFriend(user=user, server=plex, sections=libraries) 75 | print('Shared libraries: {libraries} with {user}.'.format(libraries=libraries, user=user)) 76 | 77 | 78 | def unshare(user, libraries): 79 | plex.myPlexAccount().updateFriend(user=user, server=plex, removeSections=True, sections=libraries) 80 | print('Unshared all libraries from {user}.'.format(libraries=libraries, user=user)) 81 | 82 | 83 | def kill_session(user): 84 | for session in plex.sessions(): 85 | # Check for users stream 86 | if session.usernames[0] in user: 87 | title = (session.grandparentTitle + ' - ' if session.type == 'episode' else '') + session.title 88 | print('{user} is watching {title} and it\'s past their bedtime. Killing stream.'.format( 89 | user=user, title=title)) 90 | session.stop(reason=MESSAGE) 91 | 92 | 93 | if __name__ == "__main__": 94 | 95 | parser = argparse.ArgumentParser(description="Share or unshare libraries.", 96 | formatter_class=argparse.RawTextHelpFormatter) 97 | parser.add_argument('-s', '--share', nargs='?', type=str, required=True, 98 | choices=['share', 'share_all', 'unshare'], metavar='', 99 | help='To share or to unshare.: \n (choices: %(choices)s)') 100 | parser.add_argument('-u', '--user', nargs='?', type=str, required=True, choices=user_lst, metavar='', 101 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 102 | '(choices: %(choices)s)') 103 | parser.add_argument('-l', '--libraries', nargs='+', default='', choices=sections_lst, metavar='', 104 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 105 | '(choices: %(choices)s \n(default: All Libraries)') 106 | 107 | opts = parser.parse_args() 108 | 109 | if opts.share == 'share': 110 | share(opts.user, opts.libraries) 111 | elif opts.share == 'share_all': 112 | share(opts.user, sections_lst) 113 | elif opts.share == 'unshare': 114 | kill_session(opts.user) 115 | sleep(5) 116 | unshare(opts.user, sections_lst) 117 | else: 118 | print('I don\'t know what else you want.') 119 | -------------------------------------------------------------------------------- /utility/plex_api_poster_pull.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Pull Movie and TV Show poster images from Plex. 5 | 6 | Saves the poster images to Movie and TV Show directories in scripts working 7 | directory. 8 | 9 | Author: Blacktwin 10 | Requires: plexapi 11 | 12 | Example: 13 | python plex_api_poster_pull.py 14 | 15 | """ 16 | from __future__ import print_function 17 | from __future__ import unicode_literals 18 | 19 | from future import standard_library 20 | standard_library.install_aliases() 21 | from plexapi.server import PlexServer, CONFIG 22 | import requests 23 | import re 24 | import os 25 | import urllib.request, urllib.parse, urllib.error 26 | 27 | library_name = ['Movies', 'TV Shows'] # Your library names 28 | 29 | PLEX_URL = '' 30 | PLEX_TOKEN = '' 31 | if not PLEX_URL: 32 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', '') 33 | 34 | if not PLEX_TOKEN: 35 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', '') 36 | 37 | 38 | sess = requests.Session() 39 | # Ignore verifying the SSL certificate 40 | sess.verify = False # '/path/to/certfile' 41 | # If verify is set to a path to a directory, 42 | # the directory must have been processed using the c_rehash utility supplied 43 | # with OpenSSL. 44 | if sess.verify is False: 45 | # Disable the warning that the request is insecure, we know that... 46 | import urllib3 47 | 48 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 49 | 50 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 51 | 52 | # Create paths for Movies and TV Shows inside current directory 53 | movie_path = '{}/Movies'.format(os.path.dirname(__file__)) 54 | if not os.path.isdir(movie_path): 55 | os.mkdir(movie_path) 56 | 57 | show_path = '{}/TV Shows'.format(os.path.dirname(__file__)) 58 | if not os.path.isdir(show_path): 59 | os.mkdir(show_path) 60 | 61 | 62 | # Get all movies or shows from LIBRARY_NAME 63 | for library in library_name: 64 | for child in plex.library.section(library).all(): 65 | # Clean names of special characters 66 | name = re.sub('\W+', ' ', child.title) 67 | # Add (year) to name 68 | name = '{} ({})'.format(name, child.year) 69 | # Pull URL for poster 70 | thumb_url = '{}{}?X-Plex-Token={}'.format(PLEX_URL, child.thumb, PLEX_TOKEN) 71 | # Select path based on media_type 72 | if child.type == 'movie': 73 | image_path = u'{}/{}.jpg'.format(movie_path, name) 74 | elif child.type == 'show': 75 | image_path = u'{}/{}.jpg'.format(show_path, name) 76 | # Check if file already exists 77 | if os.path.isfile(image_path): 78 | print("ERROR, %s already exist" % image_path) 79 | else: 80 | # Save to directory 81 | urllib.request.urlretrieve(thumb_url, image_path) 82 | -------------------------------------------------------------------------------- /utility/plex_api_show_settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Change show deletion settings by library. 6 | 7 | Keep: (section) 8 | autoDeletionItemPolicyUnwatchedLibrary 9 | 5 - keep 5 episode 10 | -30 - keep episodes from last 30 days 11 | 0 - keep all episodes 12 | 13 | [0, 5, 3, 1, -3, -7,-30] 14 | 15 | Delete episodes after watching: (section) 16 | autoDeletionItemPolicyWatchedLibrary=7 17 | 18 | [0, 1, 7] 19 | 20 | Example: 21 | python plex_api_show_settings.py --libraries "TV Shows" --watched 7 22 | - Delete episodes after watching After 1 week 23 | 24 | python plex_api_show_settings.py --libraries "TV Shows" --unwatched -7 25 | - Keep Episodesfrom the past 7 days 26 | """ 27 | from __future__ import print_function 28 | from __future__ import unicode_literals 29 | import argparse 30 | import requests 31 | from plexapi.server import PlexServer, CONFIG 32 | 33 | PLEX_URL = '' 34 | PLEX_TOKEN = '' 35 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 36 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 37 | 38 | # Allowed days/episodes to keep or delete 39 | WATCHED_LST = [0, 1, 7] 40 | UNWATCHED_LST = [0, 5, 3, 1, -3, -7, -30] 41 | 42 | sess = requests.Session() 43 | # Ignore verifying the SSL certificate 44 | sess.verify = False # '/path/to/certfile' 45 | # If verify is set to a path to a directory, 46 | # the directory must have been processed using the c_rehash utility supplied 47 | # with OpenSSL. 48 | if sess.verify is False: 49 | # Disable the warning that the request is insecure, we know that... 50 | import urllib3 51 | 52 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 53 | 54 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 55 | 56 | sections_lst = [x.title for x in plex.library.sections() if x.type == 'show'] 57 | 58 | 59 | def set_show(rating_key, action, number): 60 | 61 | path = '{}/prefs'.format(rating_key) 62 | try: 63 | params = {'X-Plex-Token': PLEX_TOKEN, 64 | action: number 65 | } 66 | 67 | r = requests.put(PLEX_URL + path, params=params, verify=False) 68 | print(r.url) 69 | except Exception as e: 70 | print('Error: {}'.format(e)) 71 | 72 | 73 | if __name__ == '__main__': 74 | 75 | parser = argparse.ArgumentParser(description="Change show deletion settings by library.", 76 | formatter_class=argparse.RawTextHelpFormatter) 77 | parser.add_argument('--libraries', nargs='+', default=False, choices=sections_lst, metavar='', 78 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 79 | '(choices: %(choices)s)') 80 | parser.add_argument('--watched', nargs='?', default=False, choices=WATCHED_LST, metavar='', 81 | help='Keep: Set the maximum number of unwatched episodes to keep for the show. \n' 82 | '(choices: %(choices)s)') 83 | parser.add_argument('--unwatched', nargs='?', default=False, choices=UNWATCHED_LST, metavar='', 84 | help='Delete episodes after watching: ' 85 | 'Choose how quickly episodes are removed after the server admin has watched them. \n' 86 | '(choices: %(choices)s)') 87 | 88 | opts = parser.parse_args() 89 | 90 | if opts.watched: 91 | setting = 'autoDeletionItemPolicyWatchedLibrary' 92 | number = opts.watched 93 | if opts.unwatched: 94 | setting = 'autoDeletionItemPolicyUnwatchedLibrary' 95 | number = opts.unwatched 96 | 97 | for libary in opts.libraries: 98 | shows = plex.library.section(libary).all() 99 | 100 | for show in shows: 101 | set_show(show.key, setting, number) 102 | -------------------------------------------------------------------------------- /utility/plex_dance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Description: Do the Plex Dance! 6 | Author: Blacktwin, SwiftPanda16 7 | Requires: plexapi, requests 8 | 9 | Original Dance moves 10 | 1. Move all files for the media item out of the directory your Library is looking at, 11 | so Plex will not “see” it anymore 12 | 2. Scan the library (to detect changes) 13 | 3. Empty trash 14 | 4. Clean bundles 15 | 5. Double check naming schema and move files back 16 | 6. Scan the library 17 | 18 | https://forums.plex.tv/t/the-plex-dance/197064 19 | 20 | Script Dance moves 21 | 1. Create .plexignore file in affected items library root 22 | .plexignore will contain: 23 | 24 | # Ignoring below file for Plex Dance 25 | *Item_Root/* 26 | 27 | - if .plexignore file already exists in library root, append contents 28 | 2. Scan the library 29 | 3. Empty trash 30 | 4. Clean bundles 31 | 5. Remove or restore .plexignore 32 | 6. Scan the library 33 | 7. Optimize DB 34 | 35 | Example: 36 | Dance with rating key 110645 37 | plex_dance.py --ratingKey 110645 38 | 39 | From Unraid host OS 40 | plex_dance.py --ratingKey 110645 --path /mnt/user 41 | 42 | *Dancing only works with Show or Movie rating keys 43 | 44 | **After Dancing, if you use Tautulli the rating key of the dancing item will have changed. 45 | Please use this script to update your Tautulli database with the new rating key 46 | https://gist.github.com/JonnyWong16/f554f407832076919dc6864a78432db2 47 | """ 48 | from __future__ import print_function 49 | from __future__ import unicode_literals 50 | 51 | from plexapi.server import PlexServer 52 | from plexapi.server import CONFIG 53 | import requests 54 | import argparse 55 | import time 56 | import os 57 | 58 | # Using CONFIG file 59 | PLEX_URL = '' 60 | PLEX_TOKEN = '' 61 | 62 | IGNORE_FILE = "# Ignoring below file for Plex Dance\n{}" 63 | 64 | if not PLEX_TOKEN: 65 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token') 66 | if not PLEX_URL: 67 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl') 68 | 69 | session = requests.Session() 70 | # Ignore verifying the SSL certificate 71 | session.verify = False # '/path/to/certfile' 72 | # If verify is set to a path to a directory, 73 | # the directory must have been processed using the c_rehash utility supplied 74 | # with OpenSSL. 75 | if session.verify is False: 76 | # Disable the warning that the request is insecure, we know that... 77 | import urllib3 78 | 79 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 80 | 81 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=session) 82 | 83 | 84 | def section_path(section, filepath): 85 | for location in section.locations: 86 | if filepath.startswith(location): 87 | return location 88 | 89 | 90 | def refresh_section(sectionID): 91 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=session) 92 | section = plex.library.sectionByID(sectionID) 93 | section.update() 94 | time.sleep(10) 95 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=session) 96 | section = plex.library.sectionByID(sectionID) 97 | while section.refreshing is True: 98 | time.sleep(10) 99 | print("Waiting for library to finish refreshing to continue dance.") 100 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=session) 101 | section = plex.library.sectionByID(sectionID) 102 | 103 | 104 | if __name__ == '__main__': 105 | 106 | parser = argparse.ArgumentParser(description="Do the Plex Dance!", 107 | formatter_class=argparse.RawTextHelpFormatter) 108 | parser.add_argument('--ratingKey', nargs="?", type=int, required=True, 109 | help='Rating key of item that needs to dance.') 110 | parser.add_argument('--path', nargs="?", type=str, 111 | help='Prefix path for libraries behind mount points.\n' 112 | 'Example: /mnt/user Resolves: /mnt/user/library_root') 113 | 114 | opts = parser.parse_args() 115 | 116 | item = plex.fetchItem(opts.ratingKey) 117 | item.reload() 118 | sectionID = item.librarySectionID 119 | section = plex.library.sectionByID(sectionID) 120 | old_plexignore = '' 121 | 122 | if item.type == 'movie': 123 | item_file = item.media[0].parts[0].file 124 | locations = os.path.split(item.locations[0]) 125 | item_root = os.path.split(locations[0])[1] 126 | library_root = section_path(section, locations[0]) 127 | elif item.type == 'show': 128 | locations = os.path.split(item.locations[0]) 129 | item_root = locations[1] 130 | library_root = section_path(section, locations[0]) 131 | else: 132 | print("Media type not supported.") 133 | exit() 134 | 135 | library_root = opts.path + library_root if opts.path else library_root 136 | plexignore = IGNORE_FILE.format('*' + item_root + '/*') 137 | item_ignore = os.path.join(library_root, '.plexignore') 138 | # Check for existing .plexignore file in library root 139 | if os.path.exists(item_ignore): 140 | # If file exists append new ignore params and store old params 141 | with open(item_ignore, 'a+') as old_file: 142 | old_plexignore = old_file.readlines() 143 | old_file.write('\n' + plexignore) 144 | 145 | # 1. Create .plexignore file 146 | print("Creating .plexignore file for dancing.") 147 | with open(item_ignore, 'w') as f: 148 | f.write(plexignore) 149 | # 2. Scan library 150 | print("Refreshing library of dancing item.") 151 | refresh_section(sectionID) 152 | # 3. Empty library trash 153 | print("Emptying Trash from library.") 154 | section.emptyTrash() 155 | time.sleep(5) 156 | # 4. Clean library bundles 157 | print("Cleaning Bundles from library.") 158 | plex.library.cleanBundles() 159 | time.sleep(5) 160 | # 5. Remove or restore .plexignore 161 | if old_plexignore: 162 | print("Replacing new .plexignore with old .plexignore.") 163 | with open(item_ignore, 'w') as new_file: 164 | new_file.writelines(old_plexignore) 165 | else: 166 | print("Removing .plexignore file from dancing directory.") 167 | os.remove(item_ignore) 168 | # 6. Scan library 169 | print("Refreshing library of dancing item.") 170 | refresh_section(sectionID) 171 | # 7. Optimize DB 172 | print("Optimizing library database.") 173 | plex.library.optimize() 174 | -------------------------------------------------------------------------------- /utility/plex_imgur_dl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Pull poster images from Imgur and places them inside Shows root folder. 6 | /path/to/show/Show.jpg 7 | 8 | Skips download if showname.jpg exists or if show does not exist. 9 | 10 | """ 11 | from __future__ import print_function 12 | from __future__ import unicode_literals 13 | 14 | from future import standard_library 15 | standard_library.install_aliases() 16 | from builtins import object 17 | import requests 18 | import urllib.request, urllib.parse, urllib.error 19 | import os 20 | 21 | 22 | # ## Edit ## 23 | 24 | # Imgur info 25 | CLIENT_ID = 'xxxxx' # Tautulli Settings > Notifications > Imgur Client ID 26 | ALBUM_ID = '7JeSw' # http://imgur.com/a/7JeSw <--- 7JeSw is the ablum_id 27 | 28 | # Local info 29 | SHOW_PATH = 'D:\\Shows\\' 30 | 31 | # ## /Edit ## 32 | 33 | 34 | class IMGURINFO(object): 35 | def __init__(self, data=None): 36 | d = data or {} 37 | self.link = d['link'] 38 | self.description = d['description'] 39 | 40 | 41 | def get_imgur(): 42 | url = "https://api.imgur.com/3/album/{ALBUM_ID}/images".format(ALBUM_ID=ALBUM_ID) 43 | headers = {'authorization': 'Client-ID {}'.format(CLIENT_ID)} 44 | r = requests.get(url, headers=headers) 45 | imgur_dump = r.json() 46 | return[IMGURINFO(data=d) for d in imgur_dump['data']] 47 | 48 | 49 | for x in get_imgur(): 50 | # Check if Show directory exists 51 | if os.path.exists(os.path.join(SHOW_PATH, x.description)): 52 | # Check if Show poster (show.jpg) exists 53 | if os.path.exists((os.path.join(SHOW_PATH, x.description, x.description))): 54 | print("Poster for {} was already downloaded or filename already exists, skipping.".format(x.description)) 55 | else: 56 | print("Downloading poster for {}.".format(x.description)) 57 | urllib.request.urlretrieve(x.link, '{}.jpg'.format((os.path.join(SHOW_PATH, x.description, x.description)))) 58 | else: 59 | print("{} - {} did not match your library.".format(x.description, x.link)) 60 | -------------------------------------------------------------------------------- /utility/plex_popular_playlist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Build playlist from popular tracks. 5 | 6 | optional arguments: 7 | -h, --help show this help message and exit 8 | --name [] Name your playlist 9 | --libraries [ ...] Space separated list of case sensitive names to process. Allowed names are: 10 | (choices: ALL MUSIC LIBRARIES)* 11 | --artists [ ...] Space separated list of case sensitive names to process. Allowed names are: 12 | (choices: ALL ARTIST NAMES) 13 | --tracks [] Specify the track length you would like the playlist. 14 | --random [] Randomly select N artists. 15 | 16 | * LIBRARY_EXCLUDE are excluded from libraries choice. 17 | 18 | """ 19 | from __future__ import print_function 20 | from __future__ import unicode_literals 21 | 22 | 23 | import requests 24 | from plexapi.server import PlexServer, CONFIG 25 | import argparse 26 | import random 27 | 28 | # Edit 29 | PLEX_URL = '' 30 | PLEX_TOKEN = '' 31 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 32 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 33 | 34 | LIBRARY_EXCLUDE = ['Audio Books', 'Podcasts', 'Soundtracks'] 35 | DEFAULT_NAME = 'Popular Music Playlist' 36 | 37 | # /Edit 38 | 39 | sess = requests.Session() 40 | # Ignore verifying the SSL certificate 41 | sess.verify = False # '/path/to/certfile' 42 | # If verify is set to a path to a directory, 43 | # the directory must have been processed using the c_rehash utility supplied 44 | # with OpenSSL. 45 | if sess.verify is False: 46 | # Disable the warning that the request is insecure, we know that... 47 | import urllib3 48 | 49 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 50 | 51 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 52 | 53 | music_sections = [x.title for x in plex.library.sections() if x.type == 'artist' and x.title not in LIBRARY_EXCLUDE] 54 | 55 | artist_lst = [] 56 | for section in music_sections: 57 | artist_lst += [x.title.encode('utf-8') for x in plex.library.section(section).all() if section not in LIBRARY_EXCLUDE] 58 | 59 | 60 | def fetch(path): 61 | url = PLEX_URL 62 | 63 | header = {'Accept': 'application/json'} 64 | params = {'X-Plex-Token': PLEX_TOKEN, 65 | 'includePopularLeaves': '1' 66 | } 67 | 68 | r = requests.get(url + path, headers=header, params=params, verify=False) 69 | return r.json()['MediaContainer']['Metadata'][0]['PopularLeaves']['Metadata'] 70 | 71 | 72 | def build_tracks(music_lst): 73 | ratingKey_lst = [] 74 | track_lst = [] 75 | 76 | for artist in music_lst: 77 | try: 78 | ratingKey_lst += fetch('/library/metadata/{}'.format(artist.ratingKey)) 79 | for tracks in ratingKey_lst: 80 | track_lst.append(plex.fetchItem(int(tracks['ratingKey']))) 81 | except KeyError as e: 82 | print('Artist: {} does not have any popular tracks listed.'.format(artist.title)) 83 | print('Error: {}'.format(e)) 84 | 85 | return track_lst 86 | 87 | 88 | if __name__ == "__main__": 89 | 90 | parser = argparse.ArgumentParser(description="Build playlist from popular tracks.", 91 | formatter_class=argparse.RawTextHelpFormatter) 92 | parser.add_argument('--name', nargs='?', default=DEFAULT_NAME, metavar='', 93 | help='Name your playlist') 94 | parser.add_argument('--libraries', nargs='+', default=False, choices=music_sections, metavar='', 95 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 96 | '(choices: %(choices)s)') 97 | parser.add_argument('--artists', nargs='+', default=False, choices=artist_lst, metavar='', 98 | help='Space separated list of case sensitive names to process. Allowed names are: \n' 99 | '(choices: %(choices)s)') 100 | 101 | parser.add_argument('--tracks', nargs='?', default=False, type=int, metavar='', 102 | help='Specify the track length you would like the playlist.') 103 | parser.add_argument('--random', nargs='?', default=False, type=int, metavar='', 104 | help='Randomly select N artists.') 105 | 106 | opts = parser.parse_args() 107 | playlist = [] 108 | 109 | if opts.libraries and not opts.artists and not opts.random: 110 | for section in opts.libraries: 111 | playlist += build_tracks(plex.library.section(section).all()) 112 | 113 | elif opts.libraries and opts.artists and not opts.random: 114 | for artist in opts.artists: 115 | for section in opts.libraries: 116 | artist_objects = [x for x in plex.library.section(section).all() if x.title == artist] 117 | playlist += build_tracks(artist_objects) 118 | 119 | elif not opts.libraries and opts.artists and not opts.random: 120 | for artist in opts.artists: 121 | for section in music_sections: 122 | artist_objects = [x for x in plex.library.section(section).all() if x.title == artist] 123 | playlist += build_tracks(artist_objects) 124 | 125 | elif not opts.libraries and not opts.artists and opts.random: 126 | rand_artists = random.sample((artist_lst), opts.random) 127 | for artist in rand_artists: 128 | for section in music_sections: 129 | artist_objects = [x for x in plex.library.section(section).all() if x.title == artist] 130 | playlist += build_tracks(artist_objects) 131 | 132 | if opts.tracks and opts.random: 133 | playlist = random.sample((playlist), opts.tracks) 134 | 135 | elif opts.tracks and not opts.random: 136 | playlist = playlist[:opts.tracks] 137 | 138 | # Create Playlist 139 | plex.createPlaylist(opts.name, playlist) 140 | -------------------------------------------------------------------------------- /utility/plex_theme_songs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Download theme songs from Plex TV Shows. 5 | 6 | Theme songs are mp3 and named by shows as displayed by Plex. 7 | Songs are saved in a 'Theme Songs' directory located in script's path. 8 | 9 | """ 10 | from __future__ import unicode_literals 11 | 12 | 13 | from future import standard_library 14 | standard_library.install_aliases() 15 | from plexapi.server import PlexServer, CONFIG 16 | # pip install plexapi 17 | import os 18 | import re 19 | import urllib.request, urllib.parse, urllib.error 20 | import requests 21 | 22 | # ## Edit ## 23 | PLEX_URL = '' 24 | PLEX_TOKEN = '' 25 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 26 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 27 | 28 | TV_LIBRARY = 'TV Shows' # Name of your TV Show library 29 | # ## /Edit ## 30 | 31 | sess = requests.Session() 32 | # Ignore verifying the SSL certificate 33 | sess.verify = False # '/path/to/certfile' 34 | # If verify is set to a path to a directory, 35 | # the directory must have been processed using the c_rehash utility supplied 36 | # with OpenSSL. 37 | if sess.verify is False: 38 | # Disable the warning that the request is insecure, we know that... 39 | import urllib3 40 | 41 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 42 | 43 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 44 | 45 | # Theme Songs url 46 | themes_url = 'http://tvthemes.plexapp.com/{}.mp3' 47 | 48 | # Create /Theme Songs/ directory in same path as script. 49 | out_path = os.path.join(os.path.dirname(__file__), 'Theme Songs') 50 | if not os.path.isdir(out_path): 51 | os.mkdir(out_path) 52 | 53 | # Get episodes from TV Shows 54 | for show in plex.library.section(TV_LIBRARY).all(): 55 | # Remove special characters from name 56 | filename = '{}.mp3'.format(re.sub('\W+', ' ', show.title)) 57 | # Set output path 58 | theme_path = os.path.join(out_path, filename) 59 | # Get tvdb_if from first episode, no need to go through all episodes 60 | tvdb_id = show.episodes()[0].guid.split('/')[2] 61 | # Download theme song to output path 62 | urllib.request.urlretrieve(themes_url.format(tvdb_id), theme_path) 63 | -------------------------------------------------------------------------------- /utility/plexapi_delete_playlists.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Delete all playlists from Plex. 5 | 6 | 7 | """ 8 | from __future__ import unicode_literals 9 | 10 | from plexapi.server import PlexServer 11 | 12 | baseurl = 'http://localhost:32400' 13 | token = 'XXXXXXXX' 14 | plex = PlexServer(baseurl, token) 15 | 16 | 17 | for playlist in plex.playlists(): 18 | playlist.delete() 19 | -------------------------------------------------------------------------------- /utility/purge_removed_plex_friends.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Purge Tautulli users that no longer exist as a friend in Plex. 5 | 6 | Author: DirtyCajunRice 7 | Requires: requests, plexapi, python3.6+ 8 | """ 9 | from __future__ import print_function 10 | from __future__ import unicode_literals 11 | 12 | from requests import Session 13 | from plexapi.server import CONFIG 14 | from json.decoder import JSONDecodeError 15 | from plexapi.myplex import MyPlexAccount 16 | 17 | TAUTULLI_URL = '' 18 | TAUTULLI_API_KEY = '' 19 | 20 | PLEX_USERNAME = '' 21 | PLEX_PASSWORD = '' 22 | 23 | # Do you want to back up the database before deleting? 24 | BACKUP_DB = True 25 | 26 | # Do not edit past this line # 27 | 28 | # Grab config vars if not set in script 29 | TAUTULLI_URL = TAUTULLI_URL or CONFIG.data['auth'].get('tautulli_baseurl') 30 | TAUTULLI_API_KEY = TAUTULLI_API_KEY or CONFIG.data['auth'].get('tautulli_apikey') 31 | PLEX_USERNAME = PLEX_USERNAME or CONFIG.data['auth'].get('myplex_username') 32 | PLEX_PASSWORD = PLEX_PASSWORD or CONFIG.data['auth'].get('myplex_password') 33 | 34 | account = MyPlexAccount(PLEX_USERNAME, PLEX_PASSWORD) 35 | 36 | session = Session() 37 | session.params = {'apikey': TAUTULLI_API_KEY} 38 | formatted_url = f'{TAUTULLI_URL}/api/v2' 39 | 40 | request = session.get(formatted_url, params={'cmd': 'get_user_names'}) 41 | 42 | tautulli_users = None 43 | try: 44 | tautulli_users = request.json()['response']['data'] 45 | except JSONDecodeError: 46 | exit("Error talking to Tautulli API, please check your TAUTULLI_URL") 47 | 48 | plex_friend_ids = [friend.id for friend in account.users()] 49 | plex_friend_ids.extend((0, int(account.id))) 50 | removed_users = [user for user in tautulli_users if user['user_id'] not in plex_friend_ids] 51 | 52 | if BACKUP_DB: 53 | backup = session.get(formatted_url, params={'cmd': 'backup_db'}) 54 | 55 | if removed_users: 56 | for user in removed_users: 57 | removed_user = session.get(formatted_url, params={'cmd': 'delete_user', 'user_id': user['user_id']}) 58 | print(f"Removed {user['friendly_name']} from Tautulli") 59 | else: 60 | print('No users to remove') 61 | -------------------------------------------------------------------------------- /utility/recently_added_collection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Automatically add a movie to a collection based on release date. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | # Tautulli script trigger: 8 | # * Notify on recently added 9 | # Tautulli script conditions: 10 | # * Filter which media to add to collection. 11 | # [ Media Type | is | movie ] 12 | # [ Library Name | is | Movies ] 13 | # Tautulli script arguments: 14 | # * Recently Added: 15 | # --rating_key {rating_key} --collection "New Releases" --days 180 16 | 17 | from __future__ import print_function 18 | from __future__ import unicode_literals 19 | import argparse 20 | import os 21 | from datetime import datetime, timedelta 22 | from plexapi.server import PlexServer 23 | 24 | PLEX_URL = '' 25 | PLEX_TOKEN = '' 26 | 27 | # Environmental Variables 28 | PLEX_URL = os.getenv('PLEX_URL', PLEX_URL) 29 | PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN) 30 | 31 | 32 | if __name__ == "__main__": 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument('--rating_key', required=True, type=int) 35 | parser.add_argument('--collection', required=True) 36 | parser.add_argument('--days', required=True, type=int) 37 | opts = parser.parse_args() 38 | 39 | threshold_date = datetime.now() - timedelta(days=opts.days) 40 | 41 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 42 | 43 | movie = plex.fetchItem(opts.rating_key) 44 | 45 | if movie.originallyAvailableAt >= threshold_date: 46 | movie.addCollection(opts.collection) 47 | print("Added collection '{}' to '{}'.".format(opts.collection, movie.title.encode('UTF-8'))) 48 | 49 | for m in movie.section().search(collection=opts.collection): 50 | if m.originallyAvailableAt < threshold_date: 51 | m.removeCollection(opts.collection) 52 | print("Removed collection '{}' from '{}'.".format(opts.collection, m.title.encode('UTF-8'))) 53 | -------------------------------------------------------------------------------- /utility/refresh_next_episode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Refresh the next episode of show once current episode is watched. 5 | 6 | Check Tautulli's Watched Percent in Tautulli > Settings > General 7 | 8 | 1. Tautulli > Settings > Notification Agents > Script > Script Triggers: 9 | [√] watched 10 | 2. Tautulli > Settings > Notification Agents > Script > Gear icon: 11 | Enter the "Script Folder" where you save the script. 12 | Select "refresh_next_episode.py" in "Script File". 13 | Save 14 | 3. Tautulli > Settings > Notification Agents > Script > Script Arguments > Watched: 15 | {show_name} {episode_num00} {season_num00} 16 | 17 | """ 18 | from __future__ import print_function 19 | from __future__ import unicode_literals 20 | 21 | import requests 22 | import sys 23 | from plexapi.server import PlexServer, CONFIG 24 | # pip install plexapi 25 | 26 | 27 | PLEX_URL = '' 28 | PLEX_TOKEN = '' 29 | PLEX_URL = CONFIG.data['auth'].get('server_baseurl', PLEX_URL) 30 | PLEX_TOKEN = CONFIG.data['auth'].get('server_token', PLEX_TOKEN) 31 | 32 | sess = requests.Session() 33 | # Ignore verifying the SSL certificate 34 | sess.verify = False # '/path/to/certfile' 35 | # If verify is set to a path to a directory, 36 | # the directory must have been processed using the c_rehash utility supplied 37 | # with OpenSSL. 38 | if sess.verify is False: 39 | # Disable the warning that the request is insecure, we know that... 40 | import urllib3 41 | 42 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 43 | 44 | plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess) 45 | 46 | show_name = sys.argv[1] 47 | next_ep_num = int(sys.argv[2]) 48 | season_num = int(sys.argv[3]) 49 | TV_LIBRARY = 'My TV Shows' # Name of your TV Shows library 50 | 51 | current_season = season_num - 1 52 | 53 | # Get all seasons from Show 54 | all_seasons = plex.library.section(TV_LIBRARY).get(show_name).seasons() 55 | 56 | try: 57 | # Get all episodes from current season of Show 58 | all_eps = all_seasons[current_season].episodes() 59 | # Refresh the next episode 60 | all_eps[next_ep_num].refresh() 61 | except IndexError: 62 | try: 63 | # End of season go to next season 64 | all_eps = all_seasons[season_num].episodes() 65 | # Refresh the next season's first episode 66 | all_eps[0].refresh() 67 | except IndexError: 68 | print('End of series') 69 | -------------------------------------------------------------------------------- /utility/remove_inactive_users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Unshare or Remove users who have been inactive for X days. Prints out last seen for all users. 5 | 6 | Just run. 7 | 8 | Comment out `remove_friend(username)` and `unshare(username)` to test. 9 | """ 10 | from __future__ import print_function 11 | from __future__ import unicode_literals 12 | from sys import exit 13 | from requests import Session 14 | from datetime import datetime 15 | from plexapi.server import PlexServer, CONFIG 16 | 17 | 18 | # EDIT THESE SETTINGS # 19 | PLEX_URL = '' 20 | PLEX_TOKEN = '' 21 | TAUTULLI_URL = '' 22 | TAUTULLI_APIKEY = '' 23 | 24 | REMOVE_LIMIT = 30 # Days 25 | UNSHARE_LIMIT = 15 # Days 26 | 27 | USERNAME_IGNORE = ['user1', 'username2'] 28 | IGNORE_NEVER_SEEN = True 29 | DRY_RUN = True 30 | # EDIT THESE SETTINGS # 31 | 32 | # CODE BELOW # 33 | 34 | PLEX_URL = PLEX_URL or CONFIG.data['auth'].get('server_baseurl') 35 | PLEX_TOKEN = PLEX_TOKEN or CONFIG.data['auth'].get('server_token') 36 | TAUTULLI_URL = TAUTULLI_URL or CONFIG.data['auth'].get('tautulli_baseurl') 37 | TAUTULLI_APIKEY = TAUTULLI_APIKEY or CONFIG.data['auth'].get('tautulli_apikey') 38 | USERNAME_IGNORE = [username.lower() for username in USERNAME_IGNORE] 39 | SESSION = Session() 40 | # Ignore verifying the SSL certificate 41 | SESSION.verify = False # '/path/to/certfile' 42 | # If verify is set to a path to a directory, 43 | # the directory must have been processed using the c_rehash utility supplied with OpenSSL. 44 | if not SESSION.verify: 45 | # Disable the warning that the request is insecure, we know that... 46 | from urllib3 import disable_warnings 47 | from urllib3.exceptions import InsecureRequestWarning 48 | disable_warnings(InsecureRequestWarning) 49 | 50 | SERVER = PlexServer(baseurl=PLEX_URL, token=PLEX_TOKEN, session=SESSION) 51 | ACCOUNT = SERVER.myPlexAccount() 52 | SECTIONS = [section.title for section in SERVER.library.sections()] 53 | PLEX_USERS = {user.id: user.title for user in ACCOUNT.users()} 54 | PLEX_USERS.update({int(ACCOUNT.id): ACCOUNT.title}) 55 | IGNORED_UIDS = [uid for uid, username in PLEX_USERS.items() if username.lower() in USERNAME_IGNORE] 56 | IGNORED_UIDS.extend((int(ACCOUNT.id), 0)) 57 | # Get the Tautulli history. 58 | PARAMS = { 59 | 'cmd': 'get_users_table', 60 | 'order_column': 'last_seen', 61 | 'order_dir': 'asc', 62 | 'length': 200, 63 | 'apikey': TAUTULLI_APIKEY 64 | } 65 | TAUTULLI_USERS = [] 66 | try: 67 | GET = SESSION.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=PARAMS).json()['response']['data']['data'] 68 | for user in GET: 69 | if user['user_id'] in IGNORED_UIDS: 70 | continue 71 | elif IGNORE_NEVER_SEEN and not user['last_seen']: 72 | continue 73 | TAUTULLI_USERS.append(user) 74 | except Exception as e: 75 | exit("Tautulli API 'get_users_table' request failed. Error: {}.".format(e)) 76 | 77 | 78 | def time_format(total_seconds): 79 | # Display user's last history entry 80 | days = total_seconds // 86400 81 | hours = (total_seconds - days * 86400) // 3600 82 | minutes = (total_seconds - days * 86400 - hours * 3600) // 60 83 | seconds = total_seconds - days * 86400 - hours * 3600 - minutes * 60 84 | result = ("{} day{}, ".format(days, "s" if days != 1 else "") if days else "") + \ 85 | ("{} hour{}, ".format(hours, "s" if hours != 1 else "") if hours else "") + \ 86 | ("{} minute{}, ".format(minutes, "s" if minutes != 1 else "") if minutes else "") + \ 87 | ("{} second{}, ".format(seconds, "s" if seconds != 1 else "") if seconds else "") 88 | return result.strip().rstrip(',') 89 | 90 | 91 | NOW = datetime.today() 92 | for user in TAUTULLI_USERS: 93 | OUTPUT = [] 94 | USERNAME = user['friendly_name'] 95 | UID = user['user_id'] 96 | if not user['last_seen']: 97 | TOTAL_SECONDS = None 98 | OUTPUT = '{} has never used the server'.format(USERNAME) 99 | else: 100 | TOTAL_SECONDS = int((NOW - datetime.fromtimestamp(user['last_seen'])).total_seconds()) 101 | OUTPUT = '{} was last seen {} ago'.format(USERNAME, time_format(TOTAL_SECONDS)) 102 | 103 | if UID not in PLEX_USERS.keys(): 104 | print('{}, and exists in Tautulli but does not exist in Plex. Skipping.'.format(OUTPUT)) 105 | continue 106 | 107 | TOTAL_SECONDS = TOTAL_SECONDS or 86400 * UNSHARE_LIMIT 108 | if TOTAL_SECONDS >= (REMOVE_LIMIT * 86400): 109 | if DRY_RUN: 110 | print('{}, and would be removed.'.format(OUTPUT)) 111 | else: 112 | print('{}, and has reached their shareless threshold. Removing.'.format(OUTPUT)) 113 | ACCOUNT.removeFriend(PLEX_USERS[UID]) 114 | elif TOTAL_SECONDS >= (UNSHARE_LIMIT * 86400): 115 | if DRY_RUN: 116 | print('{}, and would unshare libraries.'.format(OUTPUT)) 117 | else: 118 | 119 | for server in ACCOUNT.user(PLEX_USERS[UID]).servers: 120 | if server.machineIdentifier == SERVER.machineIdentifier and server.sections(): 121 | print('{}, and has reached their inactivity limit. Unsharing.'.format(OUTPUT)) 122 | ACCOUNT.updateFriend(PLEX_USERS[UID], SERVER, SECTIONS, removeSections=True) 123 | else: 124 | print("{}, has already been unshared, but has not reached their shareless threshold." 125 | "Skipping.".format(OUTPUT)) 126 | -------------------------------------------------------------------------------- /utility/remove_movie_collections.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Removes ALL collections from ALL movies. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | 8 | from __future__ import unicode_literals 9 | from plexapi.server import PlexServer 10 | 11 | ### EDIT SETTINGS ### 12 | 13 | PLEX_URL = "http://localhost:32400" 14 | PLEX_TOKEN = "xxxxxxxxxx" 15 | MOVIE_LIBRARY_NAME = "Movies" 16 | 17 | 18 | ## CODE BELOW ## 19 | 20 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 21 | 22 | for movie in plex.library.section(MOVIE_LIBRARY_NAME).all(): 23 | movie.removeCollection([c.tag for c in movie.collections]) 24 | -------------------------------------------------------------------------------- /utility/remove_watched_movies.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Find and delete Movies that have been watched by a list of users. 5 | 6 | Deletion is prompted 7 | """ 8 | from __future__ import print_function 9 | from __future__ import unicode_literals 10 | 11 | from builtins import input 12 | from builtins import object 13 | import requests 14 | import sys 15 | import os 16 | import shutil 17 | 18 | 19 | # ## EDIT THESE SETTINGS ## 20 | TAUTULLI_APIKEY = 'xxxxxxxx' # Your Tautulli API key 21 | TAUTULLI_URL = 'http://localhost:8181/' # Your Tautulli URL 22 | LIBRARY_NAMES = ['My Movies'] # Whatever your movie libraries are called. 23 | USER_LST = ['Joe', 'Alex'] # Name of users 24 | 25 | 26 | class UserHIS(object): 27 | def __init__(self, data=None): 28 | d = data or {} 29 | self.rating_key = d['rating_key'] 30 | 31 | 32 | class METAINFO(object): 33 | def __init__(self, data=None): 34 | d = data or {} 35 | self.title = d['title'] 36 | media_info = d['media_info'][0] 37 | parts = media_info['parts'][0] 38 | self.file = parts['file'] 39 | 40 | 41 | def get_metadata(rating_key): 42 | # Get the metadata for a media item. 43 | payload = {'apikey': TAUTULLI_APIKEY, 44 | 'rating_key': rating_key, 45 | 'cmd': 'get_metadata', 46 | 'media_info': True} 47 | 48 | try: 49 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 50 | response = r.json() 51 | 52 | res_data = response['response']['data'] 53 | if res_data['library_name'] in LIBRARY_NAMES: 54 | return METAINFO(data=res_data) 55 | 56 | except Exception as e: 57 | sys.stderr.write("Tautulli API 'get_metadata' request failed: {0}.".format(e)) 58 | pass 59 | 60 | 61 | def get_history(user, start, length): 62 | # Get the Tautulli history. 63 | payload = {'apikey': TAUTULLI_APIKEY, 64 | 'cmd': 'get_history', 65 | 'user': user, 66 | 'media_type': 'movie', 67 | 'start': start, 68 | 'length': length} 69 | 70 | try: 71 | r = requests.get(TAUTULLI_URL.rstrip('/') + '/api/v2', params=payload) 72 | response = r.json() 73 | 74 | res_data = response['response']['data']['data'] 75 | return [UserHIS(data=d) for d in res_data if d['watched_status'] == 1] 76 | 77 | except Exception as e: 78 | sys.stderr.write("Tautulli API 'get_history' request failed: {0}.".format(e)) 79 | 80 | 81 | def delete_files(tmp_lst): 82 | del_file = input('Delete all watched files? (yes/no)').lower() 83 | if del_file.startswith('y'): 84 | for x in tmp_lst: 85 | print("Removing {}".format(os.path.dirname(x))) 86 | shutil.rmtree(os.path.dirname(x)) 87 | else: 88 | print('Ok. doing nothing.') 89 | 90 | 91 | movie_dict = {} 92 | movie_lst = [] 93 | delete_lst = [] 94 | 95 | count = 25 96 | for user in USER_LST: 97 | start = 0 98 | while True: 99 | # Getting all watched history for listed users 100 | history = get_history(user, start, count) 101 | try: 102 | if all([history]): 103 | start += count 104 | for h in history: 105 | # Getting metadata of what was watched 106 | movies = get_metadata(h.rating_key) 107 | if not any(d['title'] == movies.title for d in movie_lst): 108 | movie_dict = { 109 | 'title': movies.title, 110 | 'file': movies.file, 111 | 'watched_by': [user] 112 | } 113 | movie_lst.append(movie_dict) 114 | else: 115 | for d in movie_lst: 116 | if d['title'] == movies.title: 117 | d['watched_by'].append(user) 118 | continue 119 | elif not all([history]): 120 | break 121 | 122 | start += count 123 | except Exception as e: 124 | print(e) 125 | pass 126 | 127 | for movie_dict in movie_lst: 128 | if set(USER_LST) == set(movie_dict['watched_by']): 129 | print(u"{} has been watched by {}".format(movie_dict['title'], " & ".join(USER_LST))) 130 | delete_lst.append(movie_dict['file']) 131 | 132 | delete_files(delete_lst) 133 | -------------------------------------------------------------------------------- /utility/rename_seasons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Rename season title for TV shows on Plex. 5 | # Author: /u/SwiftPanda16 6 | # Requires: plexapi 7 | 8 | from __future__ import print_function 9 | from __future__ import unicode_literals 10 | from plexapi.server import PlexServer 11 | 12 | 13 | ### EDIT SETTINGS ### 14 | 15 | PLEX_URL = "http://localhost:32400" 16 | PLEX_TOKEN = "xxxxxxxxxx" 17 | 18 | TV_SHOW_LIBRARY = "TV Shows" 19 | TV_SHOW_NAME = "Sailor Moon" 20 | SEASON_MAPPINGS = { 21 | "Season 1": "Sailor Moon", # Season 1 will be renamed to Sailor Moon 22 | "Season 2": "Sailor Moon R", # Season 2 will be renamed to Sailor Moon R 23 | "Season 3": "Sailor Moon S", # etc. 24 | "Season 4": "Sailor Moon SuperS", 25 | "Season 5": "Sailor Moon Sailor Stars", 26 | "Bad Season Title": "", # Blank string "" to reset season title 27 | } 28 | 29 | 30 | ## CODE BELOW ## 31 | 32 | def main(): 33 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 34 | 35 | show = plex.library.section(TV_SHOW_LIBRARY).get(TV_SHOW_NAME) 36 | print("Found TV show '{}' in the '{}' library on Plex.".format(TV_SHOW_NAME, TV_SHOW_LIBRARY)) 37 | 38 | for season in show.seasons(): 39 | old_season_title = season.title 40 | new_season_title = SEASON_MAPPINGS.get(season.title) 41 | if new_season_title: 42 | season.edit(**{"title.value": new_season_title, "title.locked": 1}) 43 | print("'{}' renamed to '{}'.".format(old_season_title, new_season_title)) 44 | elif new_season_title == "": 45 | season.edit(**{"title.value": new_season_title, "title.locked": 0}) 46 | print("'{}' reset to '{}'.".format(old_season_title, season.reload().title)) 47 | else: 48 | print("No mapping for '{}'. Season not renamed.".format(old_season_title)) 49 | 50 | 51 | if __name__ == "__main__": 52 | main() 53 | 54 | print("Done.") 55 | -------------------------------------------------------------------------------- /utility/select_tmdb_poster.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Description: Selects the default TMDB poster if no poster is selected 6 | or the current poster is from Gracenote. 7 | Author: /u/SwiftPanda16 8 | Requires: plexapi 9 | Usage: 10 | * Change the posters for an entire library: 11 | python select_tmdb_poster.py --library "Movies" 12 | 13 | * Change the poster for a specific item: 14 | python select_tmdb_poster.py --rating_key 1234 15 | 16 | * By default locked posters are skipped. To update locked posters: 17 | python select_tmdb_poster.py --library "Movies" --include_locked 18 | 19 | Tautulli script trigger: 20 | * Notify on recently added 21 | Tautulli script conditions: 22 | * Filter which media to select the poster. Examples: 23 | [ Media Type | is | movie ] 24 | Tautulli script arguments: 25 | * Recently Added: 26 | --rating_key {rating_key} 27 | ''' 28 | 29 | import argparse 30 | import os 31 | import plexapi.base 32 | from plexapi.server import PlexServer 33 | plexapi.base.USER_DONT_RELOAD_FOR_KEYS.add('fields') 34 | 35 | 36 | # ## OVERRIDES - ONLY EDIT IF RUNNING SCRIPT WITHOUT TAUTULLI ## 37 | 38 | PLEX_URL = '' 39 | PLEX_TOKEN = '' 40 | 41 | # Environmental Variables 42 | PLEX_URL = PLEX_URL or os.getenv('PLEX_URL', PLEX_URL) 43 | PLEX_TOKEN = PLEX_TOKEN or os.getenv('PLEX_TOKEN', PLEX_TOKEN) 44 | 45 | 46 | def select_tmdb_poster_library(library, include_locked=False): 47 | for item in library.all(includeGuids=False): 48 | # Only reload for fields 49 | item.reload(**{k: 0 for k, v in item._INCLUDES.items()}) 50 | select_tmdb_poster_item(item, include_locked=include_locked) 51 | 52 | 53 | def select_tmdb_poster_item(item, include_locked=False): 54 | if item.isLocked('thumb') and not include_locked: 55 | print(f"Locked poster for {item.title}. Skipping.") 56 | return 57 | 58 | posters = item.posters() 59 | selected_poster = next((p for p in posters if p.selected), None) 60 | 61 | if selected_poster is None: 62 | print(f"WARNING: No poster selected for {item.title}.") 63 | else: 64 | skipping = ' Skipping.' if selected_poster.provider != 'gracenote' else '' 65 | print(f"Poster provider is '{selected_poster.provider}' for {item.title}.{skipping}") 66 | 67 | if selected_poster is None or selected_poster.provider == 'gracenote': 68 | # Fallback to first poster if no TMDB posters are available 69 | tmdb_poster = next((p for p in posters if p.provider == 'tmdb'), posters[0]) 70 | # Selecting the poster automatically locks it 71 | tmdb_poster.select() 72 | print(f"Selected {tmdb_poster.provider} poster for {item.title}.") 73 | 74 | 75 | if __name__ == '__main__': 76 | parser = argparse.ArgumentParser() 77 | parser.add_argument('--rating_key', type=int) 78 | parser.add_argument('--library') 79 | parser.add_argument('--include_locked', action='store_true') 80 | opts = parser.parse_args() 81 | 82 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 83 | 84 | if opts.rating_key: 85 | item = plex.fetchItem(opts.rating_key) 86 | select_tmdb_poster_item(item, opts.include_locked) 87 | elif opts.library: 88 | library = plex.library.section(opts.library) 89 | select_tmdb_poster_library(library, opts.include_locked) 90 | else: 91 | print("No --rating_key or --library specified. Exiting.") 92 | -------------------------------------------------------------------------------- /utility/spoilers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacktwin/JBOPS/9177c8b00785e9a6a1cc111fee7cc86b102f5ac9/utility/spoilers.png -------------------------------------------------------------------------------- /utility/tautulli_friendly_name_to_ombi_alias_sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Sync Tautulli friendly names with Ombi aliases (Tautulli as master). 5 | 6 | Author: DirtyCajunRice 7 | Requires: requests, python3.6+ 8 | """ 9 | from __future__ import print_function 10 | from __future__ import unicode_literals 11 | from requests import Session 12 | from plexapi.server import CONFIG 13 | from urllib3 import disable_warnings 14 | from urllib3.exceptions import InsecureRequestWarning 15 | 16 | OMBI_BASEURL = '' 17 | OMBI_APIKEY = '' 18 | 19 | TAUTULLI_BASEURL = '' 20 | TAUTULLI_APIKEY = '' 21 | 22 | # Dont Edit Below # 23 | TAUTULLI_BASEURL = TAUTULLI_BASEURL or CONFIG.data['auth'].get('tautulli_baseurl') 24 | TAUTULLI_APIKEY = TAUTULLI_APIKEY or CONFIG.data['auth'].get('tautulli_apikey') 25 | OMBI_BASEURL = OMBI_BASEURL or CONFIG.data['auth'].get('ombi_baseurl') 26 | OMBI_APIKEY = OMBI_APIKEY or CONFIG.data['auth'].get('ombi_apikey') 27 | 28 | disable_warnings(InsecureRequestWarning) 29 | SESSION = Session() 30 | SESSION.verify = False 31 | 32 | HEADERS = {'apiKey': OMBI_APIKEY} 33 | PARAMS = {'apikey': TAUTULLI_APIKEY, 'cmd': 'get_users'} 34 | 35 | TAUTULLI_USERS = SESSION.get('{}/api/v2'.format(TAUTULLI_BASEURL.rstrip('/')), params=PARAMS).json()['response']['data'] 36 | TAUTULLI_MAPPED = {user['username']: user['friendly_name'] for user in TAUTULLI_USERS 37 | if user['user_id'] != 0 and user['friendly_name']} 38 | OMBI_USERS = SESSION.get('{}/api/v1/Identity/Users'.format(OMBI_BASEURL.rstrip('/')), headers=HEADERS).json() 39 | 40 | for user in OMBI_USERS: 41 | if user['userName'] in TAUTULLI_MAPPED and user['alias'] != TAUTULLI_MAPPED[user['userName']]: 42 | print("{}'s alias in Tautulli ({}) is being updated in Ombi from {}".format( 43 | user['userName'], TAUTULLI_MAPPED[user['userName']], user['alias'] or 'empty' 44 | )) 45 | user['alias'] = TAUTULLI_MAPPED[user['userName']] 46 | put = SESSION.put('{}/api/v1/Identity'.format(OMBI_BASEURL.rstrip('/')), json=user, headers=HEADERS) 47 | if put.status_code != 200: 48 | print('Error updating {}'.format(user['userName'])) 49 | --------------------------------------------------------------------------------