├── .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 |
23 |
24 | NA_map_example
25 |
26 | 
27 |
28 |
29 |
30 |
31 | EU_map_example
32 |
33 | 
34 |
35 |
36 |
37 |
38 | World_map_example
39 |
40 | 
41 |
42 |
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 |

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