Compare commits
240 Commits
v2.0.19-be
...
v2.1.0-bet
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b144ded87b | ||
![]() |
ef8c91ee56 | ||
![]() |
d76ded3ebe | ||
![]() |
c4fc94ea34 | ||
![]() |
ad61e23d92 | ||
![]() |
fcd7593764 | ||
![]() |
8465df5095 | ||
![]() |
95697a3367 | ||
![]() |
978ae7d8cb | ||
![]() |
366e8514b6 | ||
![]() |
45c646c062 | ||
![]() |
4b482938a1 | ||
![]() |
9699129a38 | ||
![]() |
5ef8947532 | ||
![]() |
f335ffa8d5 | ||
![]() |
793665d62a | ||
![]() |
7da5730c73 | ||
![]() |
1f587ed698 | ||
![]() |
1032fdfe7a | ||
![]() |
35e3f7dccc | ||
![]() |
909cbc90df | ||
![]() |
77ed94bbef | ||
![]() |
c260543586 | ||
![]() |
a4de63095f | ||
![]() |
817335b42e | ||
![]() |
818e7723ff | ||
![]() |
a69008e179 | ||
![]() |
91c647f9ae | ||
![]() |
36b80aa6d3 | ||
![]() |
c35fcc727c | ||
![]() |
749e1fcebe | ||
![]() |
80506b8541 | ||
![]() |
80df2b0fad | ||
![]() |
084732706d | ||
![]() |
2aff7713cd | ||
![]() |
683a782723 | ||
![]() |
5108e1bb09 | ||
![]() |
d8298a12eb | ||
![]() |
dec5931fd4 | ||
![]() |
71d79266f6 | ||
![]() |
d3f6812178 | ||
![]() |
042b48c1fd | ||
![]() |
38613f24fe | ||
![]() |
e23b1a0603 | ||
![]() |
90f3d597dc | ||
![]() |
d166b77ea9 | ||
![]() |
feb74b157f | ||
![]() |
4aeafdae2d | ||
![]() |
f12de78370 | ||
![]() |
d2415c92ea | ||
![]() |
646ca1d9fa | ||
![]() |
c8c93c69ab | ||
![]() |
2c8c20af02 | ||
![]() |
a877da3de8 | ||
![]() |
1b7cfd7f8a | ||
![]() |
3f7edc3635 | ||
![]() |
8fac54aa71 | ||
![]() |
244008d539 | ||
![]() |
502b807e45 | ||
![]() |
35914b9a48 | ||
![]() |
24ac34d5e2 | ||
![]() |
e1035a49fd | ||
![]() |
511f4a916b | ||
![]() |
1f10668838 | ||
![]() |
a9a08a959c | ||
![]() |
341f4040ff | ||
![]() |
e9a1b2ea38 | ||
![]() |
7f67213ff7 | ||
![]() |
e9bdbb863c | ||
![]() |
04641c7c63 | ||
![]() |
15cc96a005 | ||
![]() |
b712874ed2 | ||
![]() |
5b1ff402bc | ||
![]() |
eda0e73eb6 | ||
![]() |
a5807f21b4 | ||
![]() |
e3b71a729e | ||
![]() |
f810f50ea9 | ||
![]() |
2b0f83e036 | ||
![]() |
4977b3def1 | ||
![]() |
1cb5f0b635 | ||
![]() |
7e11af1fd0 | ||
![]() |
6f6fb485fe | ||
![]() |
964f24d6ab | ||
![]() |
1474f144fe | ||
![]() |
8d25b0c973 | ||
![]() |
50b37d6b3a | ||
![]() |
b9b82b23f7 | ||
![]() |
b6bd305694 | ||
![]() |
ebb287e1ee | ||
![]() |
bd3497b2bf | ||
![]() |
034f3ee308 | ||
![]() |
a946879fc1 | ||
![]() |
9f964b5a87 | ||
![]() |
2245e38d40 | ||
![]() |
c9618322c2 | ||
![]() |
960e147e10 | ||
![]() |
bbca0b3b42 | ||
![]() |
ed0b41cd19 | ||
![]() |
dc87591992 | ||
![]() |
1f7be7a4d5 | ||
![]() |
d05e80e573 | ||
![]() |
003e890844 | ||
![]() |
afa16cd656 | ||
![]() |
9aff61f670 | ||
![]() |
8b1c7df3ce | ||
![]() |
25355f29ce | ||
![]() |
09ea81ccd2 | ||
![]() |
28efaf73c7 | ||
![]() |
0057481efb | ||
![]() |
827b012978 | ||
![]() |
0e419695cf | ||
![]() |
46f26cc307 | ||
![]() |
46f7a92c97 | ||
![]() |
2a24ea4cdf | ||
![]() |
8e13bf4f93 | ||
![]() |
aa844b76fc | ||
![]() |
0e5bb7b188 | ||
![]() |
49a6cf8809 | ||
![]() |
2adad24684 | ||
![]() |
d4d5ff9de7 | ||
![]() |
33c2315384 | ||
![]() |
4577704f19 | ||
![]() |
a13d93f239 | ||
![]() |
5ac5b3cd29 | ||
![]() |
d104ec216c | ||
![]() |
32645c374e | ||
![]() |
d1f982847b | ||
![]() |
7770431b67 | ||
![]() |
edeb6ae4e4 | ||
![]() |
af3501a6a6 | ||
![]() |
0f39201774 | ||
![]() |
b73d2ff1f7 | ||
![]() |
6009fb24b6 | ||
![]() |
522684b2ab | ||
![]() |
feab16b351 | ||
![]() |
ee041db63d | ||
![]() |
2479533d07 | ||
![]() |
d045fd5834 | ||
![]() |
8407f27fed | ||
![]() |
b505286caf | ||
![]() |
feb762ce8b | ||
![]() |
8acdb5af83 | ||
![]() |
5af1294f71 | ||
![]() |
87d2d273d3 | ||
![]() |
b5c52ac71e | ||
![]() |
efe9a15f72 | ||
![]() |
525f1e4b0b | ||
![]() |
d18820b832 | ||
![]() |
7e024fd736 | ||
![]() |
c9c5989474 | ||
![]() |
ce9f96d3be | ||
![]() |
7362dd0bf4 | ||
![]() |
9905ebc144 | ||
![]() |
8f8010884b | ||
![]() |
37afd141be | ||
![]() |
a3643b4302 | ||
![]() |
02cfd8d9b7 | ||
![]() |
941ce439b4 | ||
![]() |
a08bce2073 | ||
![]() |
4e9c8322c3 | ||
![]() |
89bfe85be3 | ||
![]() |
98d994591c | ||
![]() |
a29bc7f4f9 | ||
![]() |
288f4c5f7f | ||
![]() |
a6bf78ed56 | ||
![]() |
8dbb05931e | ||
![]() |
ac8a712ff0 | ||
![]() |
39406c25c3 | ||
![]() |
48d7c2c54c | ||
![]() |
0217188274 | ||
![]() |
fd762e71de | ||
![]() |
4d5c3b6df0 | ||
![]() |
7df54e4d1b | ||
![]() |
5d085de9d3 | ||
![]() |
a8a4299086 | ||
![]() |
86f0e8425c | ||
![]() |
d2e879be4a | ||
![]() |
544114fffe | ||
![]() |
3b3e207b11 | ||
![]() |
84aad638ac | ||
![]() |
2bb691966e | ||
![]() |
8f5e788270 | ||
![]() |
7c43ea2f46 | ||
![]() |
8146e1e3cf | ||
![]() |
51b1ff6d4a | ||
![]() |
403e8dfbea | ||
![]() |
9d08717c83 | ||
![]() |
66167d5960 | ||
![]() |
624863d826 | ||
![]() |
d4b3810fbc | ||
![]() |
6056e1d3b9 | ||
![]() |
1a293d525f | ||
![]() |
b87eb68bdd | ||
![]() |
8620546d07 | ||
![]() |
a082109045 | ||
![]() |
559a9b393e | ||
![]() |
ae41b22e59 | ||
![]() |
754fd24421 | ||
![]() |
ab34a74210 | ||
![]() |
cfa6de4d91 | ||
![]() |
a5608c7a1e | ||
![]() |
88a7b52e51 | ||
![]() |
e444bad4de | ||
![]() |
5403b0b547 | ||
![]() |
51b5e615f5 | ||
![]() |
700547b63b | ||
![]() |
3f3d1962c7 | ||
![]() |
655a359ef4 | ||
![]() |
90647628c9 | ||
![]() |
681c3ed6e3 | ||
![]() |
7f255943c6 | ||
![]() |
b6e73b5dea | ||
![]() |
eacb7f6ae5 | ||
![]() |
7b300bb87e | ||
![]() |
a81ad27d85 | ||
![]() |
8eed14ff3b | ||
![]() |
82446acdf0 | ||
![]() |
88770b8805 | ||
![]() |
f9f05bbea3 | ||
![]() |
17dd767c22 | ||
![]() |
25b1dc6dd8 | ||
![]() |
b2b1277e37 | ||
![]() |
8e1a588ced | ||
![]() |
9eddfafeae | ||
![]() |
d24a922ccb | ||
![]() |
bbc6482c99 | ||
![]() |
36ff1fb674 | ||
![]() |
f0aa793262 | ||
![]() |
681627a656 | ||
![]() |
87c6ad66fb | ||
![]() |
4ab9eb3bfa | ||
![]() |
2d56ac027b | ||
![]() |
836c4293d6 | ||
![]() |
07092e8aa5 | ||
![]() |
66743c1401 | ||
![]() |
bfe34e060b | ||
![]() |
5ed4236a22 | ||
![]() |
868aeb3902 | ||
![]() |
cbcdac5b04 | ||
![]() |
d473bb3058 |
2
.gitignore
vendored
@@ -15,7 +15,9 @@
|
||||
release.lock
|
||||
version.lock
|
||||
logs/*
|
||||
backups/*
|
||||
cache/*
|
||||
newsletters/*
|
||||
*.mmdb
|
||||
|
||||
# HTTPS Cert/Key #
|
||||
|
15
API.md
@@ -401,6 +401,7 @@ Returns:
|
||||
"quality_profile": "Original",
|
||||
"rating": "7.8",
|
||||
"rating_key": "153037",
|
||||
"relay": 0,
|
||||
"section_id": "2",
|
||||
"session_id": "helf15l3rxgw01xxe0jf3l3d",
|
||||
"session_key": "27",
|
||||
@@ -1316,6 +1317,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1341,6 +1343,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1366,6 +1369,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1391,6 +1395,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1416,6 +1421,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1441,6 +1447,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1466,6 +1473,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1491,6 +1499,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1516,6 +1525,7 @@ Optional parameters:
|
||||
time_range (str): The number of months of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1664,7 +1674,8 @@ Optional parameters:
|
||||
remote (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
string: The unique PMS identifier
|
||||
json:
|
||||
{'identifier': '08u2phnlkdshf890bhdlksghnljsahgleikjfg9t'}
|
||||
```
|
||||
|
||||
|
||||
@@ -1777,6 +1788,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -1802,6 +1814,7 @@ Optional parameters:
|
||||
time_range (str): The number of days of data to return
|
||||
y_axis (str): "plays" or "duration"
|
||||
user_id (str): The user id to filter the data
|
||||
grouping (int): 0 or 1
|
||||
|
||||
Returns:
|
||||
json:
|
||||
|
128
CHANGELOG.md
@@ -1,5 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
## v2.1.0-beta (2018-04-07)
|
||||
|
||||
* Newsletters:
|
||||
* New: A completely new scheduled newsletter system.
|
||||
* Beautiful HTML formatted newsletter for recently added movies, TV shows, or music.
|
||||
* Send newsletters on a daily, weekly, or monthly schedule to your users.
|
||||
* Customize the number of days of recently added content and the libraries to include on the newsletter.
|
||||
* Add a custom message to be included on the newsletter.
|
||||
* Option to either send an HTML formatted email, or a link to a self-hosted newsletter on your own domain to any notification agent.
|
||||
* Notifications:
|
||||
* New: Ability to use self-hosted images on your own domain instead of using Imgur.
|
||||
|
||||
|
||||
## v2.0.28 (2018-04-02)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Homepage activity header text.
|
||||
|
||||
|
||||
## v2.0.27 (2018-04-02)
|
||||
|
||||
* Monitoring:
|
||||
* Change: Move activity refresh interval setting to the settings page.
|
||||
|
||||
|
||||
## v2.0.26-beta (2018-03-30)
|
||||
|
||||
* Monitoring:
|
||||
* New: Setting to change the refresh interval on the homepage.
|
||||
* Fix: Identify extras correctly on the activity cards.
|
||||
* Notifications:
|
||||
* Change: Send Telegram image and text separately if the caption is longer than 200 characters.
|
||||
* UI:
|
||||
* Fix: Error when clicking on synced playlist links.
|
||||
|
||||
|
||||
## v2.0.25 (2018-03-22)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Websocket not reconnecting causing activity monitoring and notifications to not work.
|
||||
* Fix: Error checking for synced streams without Plex Pass.
|
||||
|
||||
|
||||
## v2.0.24 (2018-03-18)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Fix stream data not showing for history recorded before v2.
|
||||
* Notifications:
|
||||
* Fix: Set all environment variables for scripts.
|
||||
* Change: Moved all notification agent instructions to the wiki.
|
||||
* Change: XBMC notification agent renamed to Kodi.
|
||||
* Change: OSX Notify notification agent renamed to macOS Notification Center.
|
||||
|
||||
|
||||
## v2.0.23-beta (2018-03-16)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Certain transcode stream showing incorrectly as direct play in history. Fix is not retroactive.
|
||||
* Notifications:
|
||||
* New: Added season/episode/album/track count to notification parameters.
|
||||
* New: Added "Value 3" setting for IFTTT notifications.
|
||||
* New: Set PLEX_URL, PLEX_TOKEN, TAUTULLI_URL, and TAUTULLI_APIKEY environment variables for scripts.
|
||||
* Fix: Notifications failing to send with invalid custom conditions json.
|
||||
* Fix: Email notifications failing with unicode username/passwords.
|
||||
* Change: Facebook Graph API version updated to v2.12.
|
||||
* UI:
|
||||
* New: Show the Plex Server URL in the settings.
|
||||
* Fix: Incorrect info displayed in the Tautulli login logs.
|
||||
* API:
|
||||
* Fix: API returning empty data if a message was in the original data.
|
||||
* Change: get_server_id command returns json instead of string.
|
||||
* Other:
|
||||
* Fix: Forgot git pull when changing branches in the web UI.
|
||||
|
||||
|
||||
## v2.0.22 (2018-03-10)
|
||||
|
||||
* Tautulli v2 release!
|
||||
|
||||
|
||||
## v2.0.22-beta (2018-03-09)
|
||||
|
||||
* Notifications:
|
||||
* Fix: Pushover notifications failing with priority 2 is set.
|
||||
* Fix: Expanding selectize box for some notification agent settings.
|
||||
* Other:
|
||||
* Fix: Update check failing when an update is available.
|
||||
* Fix: Item count incorrect for photo libraries.
|
||||
|
||||
|
||||
## v2.0.21-beta (2018-03-04)
|
||||
|
||||
* Monitoring:
|
||||
* New: Identify if a stream is using Plex Relay.
|
||||
* Change: Don't ping the Plex server if the websocket is disconnected.
|
||||
* Notifications:
|
||||
* Fix: Pause/resume state not being sent correctly in some instances.
|
||||
* Other:
|
||||
* New: Add Patreon donation method.
|
||||
* Fix: Catch failure to send analytics.
|
||||
* Fix: IP address connection lookup error when the country is missing.
|
||||
* Change: Updated all init scripts to Tautulli.
|
||||
* Change: Move database to tautulli.db.
|
||||
* Change: Move logs to tautulli.log.
|
||||
* Change: Move startup file to Tautulli.py.
|
||||
|
||||
|
||||
## v2.0.20-beta (2018-02-24)
|
||||
|
||||
* Notifications:
|
||||
* New: Add poster support for Pushover notifications.
|
||||
* New: Add poster support for Pushbullet notifications.
|
||||
* Fix: Incorrect Plex/Tautulli update notification parameter types.
|
||||
* Change: Poster and text sent as a single message for Telegram.
|
||||
* Change: Posters uploaded directly to Telegram without Imgur.
|
||||
* UI:
|
||||
* New: Add "Delete" button to synced items table on user pages.
|
||||
* Fix: Button spacing/positioning on mobile site.
|
||||
* Fix: Music statistic cards not using the fallback thumbnail.
|
||||
* Fix: Logo not showing up when using an SVG.
|
||||
* Change: Graphs now respect the "Group History" setting.
|
||||
* API:
|
||||
* New: Add grouping to graph API commands.
|
||||
* Other:
|
||||
* New: Added Google Analytics to collect installation metrics.
|
||||
* Fix: Reconnecting to the Plex server when server settings are not changed.
|
||||
|
||||
|
||||
## v2.0.19-beta (2018-02-16)
|
||||
|
||||
* Monitoring:
|
||||
|
235
PlexPy.py
@@ -21,239 +21,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from Tautulli import main
|
||||
|
||||
# Ensure lib added to path, before any other imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
|
||||
|
||||
import argparse
|
||||
import locale
|
||||
import signal
|
||||
import time
|
||||
|
||||
import plexpy
|
||||
from plexpy import config, database, logger, webstart
|
||||
|
||||
|
||||
# Register signals, such as CTRL + C
|
||||
signal.signal(signal.SIGINT, plexpy.sig_handler)
|
||||
signal.signal(signal.SIGTERM, plexpy.sig_handler)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Tautulli application entry point. Parses arguments, setups encoding and
|
||||
initializes the application.
|
||||
"""
|
||||
|
||||
# Fixed paths to Tautulli
|
||||
if hasattr(sys, 'frozen'):
|
||||
plexpy.FULL_PATH = os.path.abspath(sys.executable)
|
||||
else:
|
||||
plexpy.FULL_PATH = os.path.abspath(__file__)
|
||||
|
||||
plexpy.PROG_DIR = os.path.dirname(plexpy.FULL_PATH)
|
||||
plexpy.ARGS = sys.argv[1:]
|
||||
|
||||
# From sickbeard
|
||||
plexpy.SYS_PLATFORM = sys.platform
|
||||
plexpy.SYS_ENCODING = None
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
plexpy.SYS_ENCODING = locale.getpreferredencoding()
|
||||
except (locale.Error, IOError):
|
||||
pass
|
||||
|
||||
# for OSes that are poorly configured I'll just force UTF-8
|
||||
if not plexpy.SYS_ENCODING or plexpy.SYS_ENCODING in ('ANSI_X3.4-1968', 'US-ASCII', 'ASCII'):
|
||||
plexpy.SYS_ENCODING = 'UTF-8'
|
||||
|
||||
# Set up and gather command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description='A Python based monitoring and tracking tool for Plex Media Server.')
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--verbose', action='store_true', help='Increase console logging verbosity')
|
||||
parser.add_argument(
|
||||
'-q', '--quiet', action='store_true', help='Turn off console logging')
|
||||
parser.add_argument(
|
||||
'-d', '--daemon', action='store_true', help='Run as a daemon')
|
||||
parser.add_argument(
|
||||
'-p', '--port', type=int, help='Force Tautulli to run on a specified port')
|
||||
parser.add_argument(
|
||||
'--dev', action='store_true', help='Start Tautulli in the development environment')
|
||||
parser.add_argument(
|
||||
'--datadir', help='Specify a directory where to store your data files')
|
||||
parser.add_argument(
|
||||
'--config', help='Specify a config file to use')
|
||||
parser.add_argument(
|
||||
'--nolaunch', action='store_true', help='Prevent browser from launching on startup')
|
||||
parser.add_argument(
|
||||
'--pidfile', help='Create a pid file (only relevant when running as a daemon)')
|
||||
parser.add_argument(
|
||||
'--nofork', action='store_true', help='Start Tautulli as a service, do not fork when restarting')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
plexpy.VERBOSE = True
|
||||
if args.quiet:
|
||||
plexpy.QUIET = True
|
||||
|
||||
# Do an intial setup of the logger.
|
||||
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
|
||||
verbose=plexpy.VERBOSE)
|
||||
|
||||
if args.dev:
|
||||
plexpy.DEV = True
|
||||
logger.debug(u"Tautulli is running in the dev environment.")
|
||||
|
||||
if args.daemon:
|
||||
if sys.platform == 'win32':
|
||||
sys.stderr.write(
|
||||
"Daemonizing not supported under Windows, starting normally\n")
|
||||
else:
|
||||
plexpy.DAEMON = True
|
||||
plexpy.QUIET = True
|
||||
|
||||
if args.nofork:
|
||||
plexpy.NOFORK = True
|
||||
logger.info("Tautulli is running as a service, it will not fork when restarted.")
|
||||
|
||||
if args.pidfile:
|
||||
plexpy.PIDFILE = str(args.pidfile)
|
||||
|
||||
# If the pidfile already exists, plexpy may still be running, so
|
||||
# exit
|
||||
if os.path.exists(plexpy.PIDFILE):
|
||||
try:
|
||||
with open(plexpy.PIDFILE, 'r') as fp:
|
||||
pid = int(fp.read())
|
||||
os.kill(pid, 0)
|
||||
except IOError as e:
|
||||
raise SystemExit("Unable to read PID file: %s", e)
|
||||
except OSError:
|
||||
logger.warn("PID file '%s' already exists, but PID %d is " \
|
||||
"not running. Ignoring PID file." %
|
||||
(plexpy.PIDFILE, pid))
|
||||
else:
|
||||
# The pidfile exists and points to a live PID. plexpy may
|
||||
# still be running, so exit.
|
||||
raise SystemExit("PID file '%s' already exists. Exiting." %
|
||||
plexpy.PIDFILE)
|
||||
|
||||
# The pidfile is only useful in daemon mode, make sure we can write the
|
||||
# file properly
|
||||
if plexpy.DAEMON:
|
||||
plexpy.CREATEPID = True
|
||||
|
||||
try:
|
||||
with open(plexpy.PIDFILE, 'w') as fp:
|
||||
fp.write("pid\n")
|
||||
except IOError as e:
|
||||
raise SystemExit("Unable to write PID file: %s", e)
|
||||
else:
|
||||
logger.warn("Not running in daemon mode. PID file creation " \
|
||||
"disabled.")
|
||||
|
||||
# Determine which data directory and config file to use
|
||||
if args.datadir:
|
||||
plexpy.DATA_DIR = args.datadir
|
||||
else:
|
||||
plexpy.DATA_DIR = plexpy.PROG_DIR
|
||||
|
||||
if args.config:
|
||||
config_file = args.config
|
||||
else:
|
||||
config_file = os.path.join(plexpy.DATA_DIR, config.FILENAME)
|
||||
|
||||
# Try to create the DATA_DIR if it doesn't exist
|
||||
if not os.path.exists(plexpy.DATA_DIR):
|
||||
try:
|
||||
os.makedirs(plexpy.DATA_DIR)
|
||||
except OSError:
|
||||
raise SystemExit(
|
||||
'Could not create data directory: ' + plexpy.DATA_DIR + '. Exiting....')
|
||||
|
||||
# Make sure the DATA_DIR is writeable
|
||||
if not os.access(plexpy.DATA_DIR, os.W_OK):
|
||||
raise SystemExit(
|
||||
'Cannot write to the data directory: ' + plexpy.DATA_DIR + '. Exiting...')
|
||||
|
||||
# Put the database in the DATA_DIR
|
||||
plexpy.DB_FILE = os.path.join(plexpy.DATA_DIR, database.FILENAME)
|
||||
|
||||
if plexpy.DAEMON:
|
||||
plexpy.daemonize()
|
||||
|
||||
# Read config and start logging
|
||||
plexpy.initialize(config_file)
|
||||
|
||||
# Start the background threads
|
||||
plexpy.start()
|
||||
|
||||
# Force the http port if neccessary
|
||||
if args.port:
|
||||
http_port = args.port
|
||||
logger.info('Using forced web server port: %i', http_port)
|
||||
else:
|
||||
http_port = int(plexpy.CONFIG.HTTP_PORT)
|
||||
|
||||
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||
# and for CherryPy.
|
||||
if plexpy.CONFIG.ENABLE_HTTPS:
|
||||
try:
|
||||
import OpenSSL
|
||||
except ImportError:
|
||||
logger.warn("The pyOpenSSL module is missing. Install this " \
|
||||
"module to enable HTTPS. HTTPS will be disabled.")
|
||||
plexpy.CONFIG.ENABLE_HTTPS = False
|
||||
|
||||
# Try to start the server. Will exit here is address is already in use.
|
||||
web_config = {
|
||||
'http_port': http_port,
|
||||
'http_host': plexpy.CONFIG.HTTP_HOST,
|
||||
'http_root': plexpy.CONFIG.HTTP_ROOT,
|
||||
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
|
||||
'http_proxy': plexpy.CONFIG.HTTP_PROXY,
|
||||
'enable_https': plexpy.CONFIG.ENABLE_HTTPS,
|
||||
'https_cert': plexpy.CONFIG.HTTPS_CERT,
|
||||
'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN,
|
||||
'https_key': plexpy.CONFIG.HTTPS_KEY,
|
||||
'http_username': plexpy.CONFIG.HTTP_USERNAME,
|
||||
'http_password': plexpy.CONFIG.HTTP_PASSWORD,
|
||||
'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH
|
||||
}
|
||||
webstart.initialize(web_config)
|
||||
|
||||
# Open webbrowser
|
||||
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
|
||||
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, http_port,
|
||||
plexpy.CONFIG.HTTP_ROOT)
|
||||
|
||||
# Wait endlessy for a signal to happen
|
||||
while True:
|
||||
if not plexpy.SIGNAL:
|
||||
try:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
plexpy.SIGNAL = 'shutdown'
|
||||
else:
|
||||
logger.info('Received signal: %s', plexpy.SIGNAL)
|
||||
|
||||
if plexpy.SIGNAL == 'shutdown':
|
||||
plexpy.shutdown()
|
||||
elif plexpy.SIGNAL == 'restart':
|
||||
plexpy.shutdown(restart=True)
|
||||
elif plexpy.SIGNAL == 'checkout':
|
||||
plexpy.shutdown(restart=True, checkout=True)
|
||||
else:
|
||||
plexpy.shutdown(restart=True, update=True)
|
||||
|
||||
plexpy.SIGNAL = None
|
||||
|
||||
# Call main()
|
||||
# Call main() from Tautulli.py
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
267
Tautulli.py
Executable file
@@ -0,0 +1,267 @@
|
||||
#!/bin/sh
|
||||
''''which python >/dev/null 2>&1 && exec python "$0" "$@" # '''
|
||||
''''which python2 >/dev/null 2>&1 && exec python2 "$0" "$@" # '''
|
||||
''''which python2.7 >/dev/null 2>&1 && exec python2.7 "$0" "$@" # '''
|
||||
''''exec echo "Error: Python not found!" # '''
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of Tautulli.
|
||||
#
|
||||
# Tautulli is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Tautulli is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure lib added to path, before any other imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
|
||||
|
||||
import argparse
|
||||
import locale
|
||||
import signal
|
||||
import time
|
||||
|
||||
import plexpy
|
||||
from plexpy import config, database, logger, webstart
|
||||
|
||||
|
||||
# Register signals, such as CTRL + C
|
||||
signal.signal(signal.SIGINT, plexpy.sig_handler)
|
||||
signal.signal(signal.SIGTERM, plexpy.sig_handler)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Tautulli application entry point. Parses arguments, setups encoding and
|
||||
initializes the application.
|
||||
"""
|
||||
|
||||
# Fixed paths to Tautulli
|
||||
if hasattr(sys, 'frozen'):
|
||||
plexpy.FULL_PATH = os.path.abspath(sys.executable)
|
||||
else:
|
||||
plexpy.FULL_PATH = os.path.abspath(__file__)
|
||||
|
||||
plexpy.PROG_DIR = os.path.dirname(plexpy.FULL_PATH)
|
||||
plexpy.ARGS = sys.argv[1:]
|
||||
|
||||
# From sickbeard
|
||||
plexpy.SYS_PLATFORM = sys.platform
|
||||
plexpy.SYS_ENCODING = None
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
plexpy.SYS_LANGUAGE, plexpy.SYS_ENCODING = locale.getdefaultlocale()
|
||||
except (locale.Error, IOError):
|
||||
pass
|
||||
|
||||
# for OSes that are poorly configured I'll just force UTF-8
|
||||
if not plexpy.SYS_ENCODING or plexpy.SYS_ENCODING in ('ANSI_X3.4-1968', 'US-ASCII', 'ASCII'):
|
||||
plexpy.SYS_ENCODING = 'UTF-8'
|
||||
|
||||
# Set up and gather command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description='A Python based monitoring and tracking tool for Plex Media Server.')
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--verbose', action='store_true', help='Increase console logging verbosity')
|
||||
parser.add_argument(
|
||||
'-q', '--quiet', action='store_true', help='Turn off console logging')
|
||||
parser.add_argument(
|
||||
'-d', '--daemon', action='store_true', help='Run as a daemon')
|
||||
parser.add_argument(
|
||||
'-p', '--port', type=int, help='Force Tautulli to run on a specified port')
|
||||
parser.add_argument(
|
||||
'--dev', action='store_true', help='Start Tautulli in the development environment')
|
||||
parser.add_argument(
|
||||
'--datadir', help='Specify a directory where to store your data files')
|
||||
parser.add_argument(
|
||||
'--config', help='Specify a config file to use')
|
||||
parser.add_argument(
|
||||
'--nolaunch', action='store_true', help='Prevent browser from launching on startup')
|
||||
parser.add_argument(
|
||||
'--pidfile', help='Create a pid file (only relevant when running as a daemon)')
|
||||
parser.add_argument(
|
||||
'--nofork', action='store_true', help='Start Tautulli as a service, do not fork when restarting')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
plexpy.VERBOSE = True
|
||||
if args.quiet:
|
||||
plexpy.QUIET = True
|
||||
|
||||
# Do an intial setup of the logger.
|
||||
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
|
||||
verbose=plexpy.VERBOSE)
|
||||
|
||||
if args.dev:
|
||||
plexpy.DEV = True
|
||||
logger.debug(u"Tautulli is running in the dev environment.")
|
||||
|
||||
if args.daemon:
|
||||
if sys.platform == 'win32':
|
||||
sys.stderr.write(
|
||||
"Daemonizing not supported under Windows, starting normally\n")
|
||||
else:
|
||||
plexpy.DAEMON = True
|
||||
plexpy.QUIET = True
|
||||
|
||||
if args.nofork:
|
||||
plexpy.NOFORK = True
|
||||
logger.info("Tautulli is running as a service, it will not fork when restarted.")
|
||||
|
||||
if args.pidfile:
|
||||
plexpy.PIDFILE = str(args.pidfile)
|
||||
|
||||
# If the pidfile already exists, plexpy may still be running, so
|
||||
# exit
|
||||
if os.path.exists(plexpy.PIDFILE):
|
||||
try:
|
||||
with open(plexpy.PIDFILE, 'r') as fp:
|
||||
pid = int(fp.read())
|
||||
os.kill(pid, 0)
|
||||
except IOError as e:
|
||||
raise SystemExit("Unable to read PID file: %s", e)
|
||||
except OSError:
|
||||
logger.warn("PID file '%s' already exists, but PID %d is " \
|
||||
"not running. Ignoring PID file." %
|
||||
(plexpy.PIDFILE, pid))
|
||||
else:
|
||||
# The pidfile exists and points to a live PID. plexpy may
|
||||
# still be running, so exit.
|
||||
raise SystemExit("PID file '%s' already exists. Exiting." %
|
||||
plexpy.PIDFILE)
|
||||
|
||||
# The pidfile is only useful in daemon mode, make sure we can write the
|
||||
# file properly
|
||||
if plexpy.DAEMON:
|
||||
plexpy.CREATEPID = True
|
||||
|
||||
try:
|
||||
with open(plexpy.PIDFILE, 'w') as fp:
|
||||
fp.write("pid\n")
|
||||
except IOError as e:
|
||||
raise SystemExit("Unable to write PID file: %s", e)
|
||||
else:
|
||||
logger.warn("Not running in daemon mode. PID file creation " \
|
||||
"disabled.")
|
||||
|
||||
# Determine which data directory and config file to use
|
||||
if args.datadir:
|
||||
plexpy.DATA_DIR = args.datadir
|
||||
else:
|
||||
plexpy.DATA_DIR = plexpy.PROG_DIR
|
||||
|
||||
if args.config:
|
||||
config_file = args.config
|
||||
else:
|
||||
config_file = os.path.join(plexpy.DATA_DIR, config.FILENAME)
|
||||
|
||||
# Try to create the DATA_DIR if it doesn't exist
|
||||
if not os.path.exists(plexpy.DATA_DIR):
|
||||
try:
|
||||
os.makedirs(plexpy.DATA_DIR)
|
||||
except OSError:
|
||||
raise SystemExit(
|
||||
'Could not create data directory: ' + plexpy.DATA_DIR + '. Exiting....')
|
||||
|
||||
# Make sure the DATA_DIR is writeable
|
||||
if not os.access(plexpy.DATA_DIR, os.W_OK):
|
||||
raise SystemExit(
|
||||
'Cannot write to the data directory: ' + plexpy.DATA_DIR + '. Exiting...')
|
||||
|
||||
# Put the database in the DATA_DIR
|
||||
plexpy.DB_FILE = os.path.join(plexpy.DATA_DIR, database.FILENAME)
|
||||
|
||||
# Move 'plexpy.db' to 'tautulli.db'
|
||||
if os.path.isfile(os.path.join(plexpy.DATA_DIR, 'plexpy.db')) and \
|
||||
not os.path.isfile(os.path.join(plexpy.DATA_DIR, plexpy.DB_FILE)):
|
||||
try:
|
||||
os.rename(os.path.join(plexpy.DATA_DIR, 'plexpy.db'), plexpy.DB_FILE)
|
||||
except OSError as e:
|
||||
raise SystemExit("Unable to rename plexpy.db to tautulli.db: %s", e)
|
||||
|
||||
if plexpy.DAEMON:
|
||||
plexpy.daemonize()
|
||||
|
||||
# Read config and start logging
|
||||
plexpy.initialize(config_file)
|
||||
|
||||
# Start the background threads
|
||||
plexpy.start()
|
||||
|
||||
# Force the http port if neccessary
|
||||
if args.port:
|
||||
http_port = args.port
|
||||
logger.info('Using forced web server port: %i', http_port)
|
||||
else:
|
||||
http_port = int(plexpy.CONFIG.HTTP_PORT)
|
||||
|
||||
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||
# and for CherryPy.
|
||||
if plexpy.CONFIG.ENABLE_HTTPS:
|
||||
try:
|
||||
import OpenSSL
|
||||
except ImportError:
|
||||
logger.warn("The pyOpenSSL module is missing. Install this " \
|
||||
"module to enable HTTPS. HTTPS will be disabled.")
|
||||
plexpy.CONFIG.ENABLE_HTTPS = False
|
||||
|
||||
# Try to start the server. Will exit here is address is already in use.
|
||||
web_config = {
|
||||
'http_port': http_port,
|
||||
'http_host': plexpy.CONFIG.HTTP_HOST,
|
||||
'http_root': plexpy.CONFIG.HTTP_ROOT,
|
||||
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
|
||||
'http_proxy': plexpy.CONFIG.HTTP_PROXY,
|
||||
'enable_https': plexpy.CONFIG.ENABLE_HTTPS,
|
||||
'https_cert': plexpy.CONFIG.HTTPS_CERT,
|
||||
'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN,
|
||||
'https_key': plexpy.CONFIG.HTTPS_KEY,
|
||||
'http_username': plexpy.CONFIG.HTTP_USERNAME,
|
||||
'http_password': plexpy.CONFIG.HTTP_PASSWORD,
|
||||
'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH
|
||||
}
|
||||
webstart.initialize(web_config)
|
||||
|
||||
# Open webbrowser
|
||||
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
|
||||
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, http_port,
|
||||
plexpy.CONFIG.HTTP_ROOT)
|
||||
|
||||
# Wait endlessy for a signal to happen
|
||||
while True:
|
||||
if not plexpy.SIGNAL:
|
||||
try:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
plexpy.SIGNAL = 'shutdown'
|
||||
else:
|
||||
logger.info('Received signal: %s', plexpy.SIGNAL)
|
||||
|
||||
if plexpy.SIGNAL == 'shutdown':
|
||||
plexpy.shutdown()
|
||||
elif plexpy.SIGNAL == 'restart':
|
||||
plexpy.shutdown(restart=True)
|
||||
elif plexpy.SIGNAL == 'checkout':
|
||||
plexpy.shutdown(restart=True, checkout=True)
|
||||
else:
|
||||
plexpy.shutdown(restart=True, update=True)
|
||||
|
||||
plexpy.SIGNAL = None
|
||||
|
||||
# Call main()
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -15,7 +15,7 @@
|
||||
<meta name="author" content="">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
|
||||
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
|
||||
<link href="${http_root}css/plexpy.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
|
||||
${next.headIncludes()}
|
||||
@@ -47,11 +47,17 @@
|
||||
You are running an unknown version of Tautulli.<br />
|
||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||
</div>
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.COMMITS_BEHIND > 0 and plexpy.INSTALL_TYPE != 'win':
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
|
||||
<div id="updatebar" style="display: none;">
|
||||
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank">
|
||||
new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
|
||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||
</div>
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.INSTALL_TYPE != 'win':
|
||||
<div id="updatebar" style="display: none;">
|
||||
A <a href="${anon_url('https://github.com/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
|
||||
newer version</a> is available!<br />
|
||||
You are ${plexpy.COMMITS_BEHIND} commits behind.<br />
|
||||
newer version</a> of Tautulli is available!<br />
|
||||
You are ${plexpy.COMMITS_BEHIND} commit${'s' if plexpy.COMMITS_BEHIND > 1 else ''} behind.<br />
|
||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||
</div>
|
||||
% else:
|
||||
@@ -68,7 +74,7 @@
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="home" title="Tautulli">
|
||||
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 45px;"></object>
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse navbar-right" id="navbar-collapse-1">
|
||||
@@ -221,15 +227,23 @@ ${next.modalIncludes()}
|
||||
</div>
|
||||
</div>
|
||||
<ul id="donation_type" class="nav nav-pills" role="tablist" style="display: flex; justify-content: center; margin: 10px 0;">
|
||||
<li class="active"><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
|
||||
<li><a href="#flattr-donation" role="tab" data-toggle="tab">Flattr</a></li>
|
||||
<li class="active"><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
|
||||
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoin" data-name="Bitcoin" data-address="3FdfJAyNWU15Sf11U9FTgPHuP1hPz32eEN">Bitcoin</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoincash" data-name="Bitcoin Cash" data-address="1H2atabxAQGaFAWYQEiLkXKSnK9CZZvt2n">Bitcoin Cash</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="ethereum" data-name="Ethereum" data-address="0x77ae4c2b8de1a1ccfa93553db39971da58c873d3">Ethereum</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="litecoin" data-name="Litecoin" data-address="LWpPmUqQYHBhMV83XSCsHzPmKLhJt6r57J">Litecoin</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="paypal-donation" style="text-align: center">
|
||||
<div role="tabpanel" class="tab-pane active" id="patreon-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to Patreon.
|
||||
</p>
|
||||
<a href="${anon_url('https://www.patreon.com/bePatron?u=10078609')}" target="_blank">
|
||||
<img src="images/become_a_patron_button.png" alt="Become a Patron" height="40">
|
||||
</a>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="paypal-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to PayPal.
|
||||
</p>
|
||||
@@ -237,14 +251,6 @@ ${next.modalIncludes()}
|
||||
<img src="images/gold-rect-paypal-34px.png" alt="PayPal">
|
||||
</a>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="flattr-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to Flattr.
|
||||
</p>
|
||||
<a href="${anon_url('https://flattr.com/submit/auto?user_id=JonnyWong16&url=https://github.com/%s/%s&title=Tautulli&language=en_GB&tags=github&category=software' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">
|
||||
<img src="images/flattr-badge-large.png" alt="Flattr">
|
||||
</a>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="crypto-donation">
|
||||
<label>QR Code</label>
|
||||
<pre id="crypto_qr_code" style="text-align: center"></pre>
|
||||
@@ -311,17 +317,21 @@ ${next.modalIncludes()}
|
||||
complete: function (xhr, status) {
|
||||
var result = $.parseJSON(xhr.responseText);
|
||||
var msg = '';
|
||||
if (result.update === true) {
|
||||
msg = 'A <a href="' + result.compare_url + '" target="_blank">newer version</a> is available!<br />' +
|
||||
'You are '+ result.commits_behind + ' commits behind.<br />' +
|
||||
if (result.update === null) {
|
||||
msg = 'You are running an unknown version of Tautulli.<br />' +
|
||||
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
|
||||
$('#updatebar').html(msg).fadeIn();
|
||||
} else if (result.update === true && result.release === true) {
|
||||
msg = 'A <a href="' + result.release_url + '" target="_blank">new release (' + result.latest_release + ')</a> of Tautulli is available!<br />' +
|
||||
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
|
||||
$('#updatebar').html(msg).fadeIn();
|
||||
} else if (result.update === true && result.release === false) {
|
||||
msg = 'A <a href="' + result.compare_url + '" target="_blank">newer version</a> of Tautulli is available!<br />' +
|
||||
'You are '+ result.commits_behind + ' commit' + (result.commits_behind > 1 ? 's' : '') + ' behind.<br />' +
|
||||
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
|
||||
$('#updatebar').html(msg).fadeIn();
|
||||
} else if (result.update === false) {
|
||||
showMsg('<i class="fa fa-check"></i> ' + result.message, false, true, 2000);
|
||||
} else if (result.update === null) {
|
||||
msg = 'You are running an unknown version of Tautulli.<br />' +
|
||||
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
|
||||
$('#updatebar').html(msg).fadeIn();
|
||||
}
|
||||
|
||||
if (_callback) {
|
||||
|
@@ -26,7 +26,7 @@ DOCUMENTATION :: END
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Git Commit Hash:</td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}">${plexpy.CURRENT_VERSION}</a></td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}">${plexpy.CURRENT_VERSION}</a></td>
|
||||
</tr>
|
||||
% endif
|
||||
<tr>
|
||||
@@ -49,6 +49,10 @@ DOCUMENTATION :: END
|
||||
<td>Cache Directory:</td>
|
||||
<td>${plexpy.CONFIG.CACHE_DIR}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Newsletter Directory:</td>
|
||||
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>GeoLite2 Database:</td>
|
||||
% if plexpy.CONFIG.GEOIP_DB:
|
||||
|
@@ -359,11 +359,13 @@ table.display tr.shown + tr:hover {
|
||||
}
|
||||
table.display tr.shown + tr:hover a,
|
||||
table.display tr.shown + tr td:hover a,
|
||||
table.display tr.shown + tr td:hover a .fa,
|
||||
table.display tr.shown + tr .pagination > .active > a,
|
||||
table.display tr.shown + tr .pagination > .active > a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
table.display tr.shown + tr table[id^='history_child'] td:hover a,
|
||||
table.display tr.shown + tr table[id^='history_child'] td:hover a .fa,
|
||||
table.display tr.shown + tr table[id^='media_info_child'] > tr > td:hover a,
|
||||
table.display tr.shown + tr table[id^='media_info_child'] tr.shown + tr table[id^='media_info_child'] td:hover a {
|
||||
color: #cc7b19;
|
@@ -66,7 +66,6 @@ div.form-control .selectize-input {
|
||||
color: #fff;
|
||||
border: 0px solid #444;
|
||||
background: #555;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
background-color: #555;
|
||||
border-radius: 3px;
|
||||
@@ -92,6 +91,7 @@ div.form-control .selectize-input {
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
min-height: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
.input-group .selectize-control.form-control.selectize-pms-ip .selectize-input > div {
|
||||
max-width: 450px;
|
||||
@@ -125,8 +125,10 @@ div.form-control .selectize-input {
|
||||
padding-bottom: 2px !important;
|
||||
transition: background-color .3s;
|
||||
}
|
||||
.react-selectize.root-node .simple-value span {
|
||||
.react-selectize.root-node .simple-value span,
|
||||
.selectize-control.multi .selectize-input > div {
|
||||
padding-bottom: 2px !important;
|
||||
padding-left: 5px !important;
|
||||
}
|
||||
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
|
||||
content: "or";
|
||||
@@ -134,9 +136,6 @@ div.form-control .selectize-input {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
}
|
||||
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values.negative-operator .value-wrapper:not(:first-child):before {
|
||||
content: "and" !important;
|
||||
}
|
||||
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input {
|
||||
padding-top: 3px !important;
|
||||
padding-bottom: 3px !important;
|
||||
@@ -467,6 +466,18 @@ fieldset[disabled] .btn-bright.active {
|
||||
.btn-group select {
|
||||
margin-top: 0;
|
||||
}
|
||||
.input-group-addon-form {
|
||||
display: inline-block;
|
||||
line-height: 1.42857143;
|
||||
color: #e5e5e5;
|
||||
background-color: #3B3B3B;
|
||||
border: 1px solid transparent;
|
||||
border-top-right-radius: 3px !important;
|
||||
border-bottom-right-radius: 3px !important;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
#user-selection label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -745,7 +756,10 @@ a .users-poster-face:hover {
|
||||
transition: all .2s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard-activity-background-overlay {
|
||||
.dashboard-activity-background {
|
||||
background-color: #282828;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: nowrap;
|
||||
@@ -754,30 +768,13 @@ a .users-poster-face:hover {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
}
|
||||
.dashboard-activity-background {
|
||||
background-color: #282828;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
height: 235px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.40;
|
||||
-webkit-filter: blur(3px);
|
||||
-moz-filter: blur(3px);
|
||||
filter: blur(3px);
|
||||
-webkit-transition: background 1s linear;
|
||||
transition: background 1s linear;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
z-index: -1;
|
||||
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
}
|
||||
.dashboard-activity-poster-container {
|
||||
background-color: #282828;
|
||||
@@ -808,14 +805,14 @@ a .users-poster-face:hover {
|
||||
background-size: cover;
|
||||
height: 225px;
|
||||
width: 150px;
|
||||
-webkit-transition: background .2s ease-in-out;
|
||||
transition: background .2s ease-in-out;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.60;
|
||||
-webkit-filter: blur(3px);
|
||||
-moz-filter: blur(3px);
|
||||
filter: blur(3px);
|
||||
-webkit-transition: background .2s ease-in-out;
|
||||
transition: background .2s ease-in-out;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
.dashboard-activity-cover {
|
||||
@@ -1162,7 +1159,10 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
transition: all .2s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard-stats-background-overlay {
|
||||
.dashboard-stats-background {
|
||||
background-color: #282828;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: nowrap;
|
||||
@@ -1171,30 +1171,13 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
}
|
||||
.dashboard-stats-background {
|
||||
background-color: #282828;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.40;
|
||||
-webkit-filter: blur(3px);
|
||||
-moz-filter: blur(3px);
|
||||
filter: blur(3px);
|
||||
-webkit-transition: background .2s ease-in-out;
|
||||
transition: background .2s ease-in-out;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
z-index: -1;
|
||||
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
|
||||
}
|
||||
.dashboard-stats-background.flat {
|
||||
opacity: 1;
|
||||
@@ -1214,17 +1197,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
.dashboard-stats-poster {
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
height: 150px;
|
||||
width: 100px;
|
||||
-webkit-transition: background .2s ease-in-out;
|
||||
transition: background .2s ease-in-out;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
.dashboard-stats-poster-blur {
|
||||
background-color: #282828;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
@@ -1234,10 +1206,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
transition: background .2s ease-in-out;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.60;
|
||||
-webkit-filter: blur(3px);
|
||||
-moz-filter: blur(3px);
|
||||
filter: blur(3px);
|
||||
z-index: 2;
|
||||
}
|
||||
.dashboard-stats-cover {
|
||||
@@ -1419,7 +1387,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
}
|
||||
.dashboard-stats-info-item .sub-count {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
margin-left: 5px;
|
||||
color: #f9be03;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
@@ -1430,7 +1398,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
||||
}
|
||||
.dashboard-stats-info-item .sub-divider {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
margin-left: 5px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -2160,6 +2128,12 @@ a:hover .item-children-poster {
|
||||
top: 5px;
|
||||
left: 12px;
|
||||
}
|
||||
.settings-warning {
|
||||
color: #eb8600;
|
||||
}
|
||||
span.settings-warning {
|
||||
padding-left: 10px;
|
||||
}
|
||||
#menu_link_show_advanced_settings.active {
|
||||
color: #fff;
|
||||
background-color: #cc7b19;
|
||||
@@ -2372,21 +2346,6 @@ a .library-user-instance-box:hover {
|
||||
#watched-stats-days-selection label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#watched-stats-days {
|
||||
margin: 0;
|
||||
width: 75px;
|
||||
height: 34px;
|
||||
}
|
||||
#watched-stats-count {
|
||||
margin: 0;
|
||||
width: 75px;
|
||||
height: 34px;
|
||||
}
|
||||
#recently-added-count {
|
||||
margin: 0;
|
||||
width: 75px;
|
||||
height: 34px;
|
||||
}
|
||||
.home-padded-header {
|
||||
margin: 25px 0;
|
||||
height: 34px;
|
||||
@@ -2395,6 +2354,9 @@ a .library-user-instance-box:hover {
|
||||
margin-top: 9px;
|
||||
width: 175px;
|
||||
}
|
||||
.home-padded-header .button-bar {
|
||||
float: left;
|
||||
}
|
||||
.home-platforms {
|
||||
}
|
||||
.home-platforms ul,
|
||||
@@ -2985,6 +2947,9 @@ a .home-platforms-list-cover-face:hover
|
||||
.stacked-configs > li.new-notification-agent,
|
||||
.stacked-configs > li.notification-agent,
|
||||
.stacked-configs > li.add-notification-agent,
|
||||
.stacked-configs > li.new-newsletter-agent,
|
||||
.stacked-configs > li.newsletter-agent,
|
||||
.stacked-configs > li.add-newsletter-agent,
|
||||
.stacked-configs > li.mobile-device,
|
||||
.stacked-configs > li.add-mobile-device {
|
||||
cursor: pointer;
|
||||
@@ -3146,7 +3111,7 @@ div.dataTables_info {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.history-thumbnail-popover {
|
||||
z-index: 2;
|
||||
z-index: 2000;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
@@ -3316,6 +3281,48 @@ pre::-webkit-scrollbar-thumb {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media only screen
|
||||
and (min-device-width: 300px)
|
||||
and (max-device-width: 740px) {
|
||||
.header-bar {
|
||||
display: block;
|
||||
float: none !important;
|
||||
}
|
||||
.button-bar {
|
||||
float: left !important;
|
||||
clear: both;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.button-bar > div,
|
||||
.button-bar > button,
|
||||
.button-bar > span {
|
||||
float: left !important;
|
||||
clear: both !important;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.button-bar > div > button.btn {
|
||||
float: left !important;
|
||||
clear: both !important;
|
||||
}
|
||||
.home-padded-header .button-bar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
@media only screen
|
||||
and (min-device-width: 740px)
|
||||
and (max-device-width: 1024px) {
|
||||
.button-bar {
|
||||
float: right !important;
|
||||
}
|
||||
.button-bar > div > button.btn {
|
||||
float: left !important;
|
||||
clear: both !important;
|
||||
}
|
||||
.home-padded-header .button-bar {
|
||||
float: left !important;
|
||||
}
|
||||
}
|
||||
#search_form {
|
||||
width: 300px;
|
||||
padding: 8px 15px;
|
||||
@@ -3390,22 +3397,10 @@ pre::-webkit-scrollbar-thumb {
|
||||
.notification-params tr:nth-child(even) td {
|
||||
background-color: rgba(255,255,255,0.010);
|
||||
}
|
||||
#days-selection label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#graph-days {
|
||||
margin: 0;
|
||||
width: 75px;
|
||||
height: 34px;
|
||||
}
|
||||
#days-selection label,
|
||||
#months-selection label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#graph-months {
|
||||
margin: 0;
|
||||
width: 75px;
|
||||
height: 34px;
|
||||
}
|
||||
.card-sortable {
|
||||
height: 36px;
|
||||
padding: 0 20px 0 0;
|
||||
@@ -3639,43 +3634,77 @@ a:hover .overlay-refresh-image:hover {
|
||||
}
|
||||
#plexpy-notifiers-table .friendly_name,
|
||||
#notifier-config-modal span.notifier_id,
|
||||
#plexpy-newsletters-table .friendly_name,
|
||||
#newsletter-config-modal span.newsletter_id,
|
||||
#plexpy-mobile-devices-table .friendly_name,
|
||||
#mobile-device-config-modal span.notifier_id {
|
||||
color: #777;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs {
|
||||
#notifier-config-modal .nav-tabs,
|
||||
#newsletter-config-modal .nav-tabs {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 15px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs > li {
|
||||
#notifier-config-modal .nav-tabs > li,
|
||||
#newsletter-config-modal .nav-tabs > li {
|
||||
margin: 0 0 -1px 0;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs > li > a {
|
||||
#notifier-config-modal .nav-tabs > li > a,
|
||||
#newsletter-config-modal .nav-tabs > li > a {
|
||||
padding: 5px 10px;
|
||||
color: #737373;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs > li > a:hover {
|
||||
#notifier-config-modal .nav-tabs > li > a:hover,
|
||||
#newsletter-config-modal .nav-tabs > li > a:hover {
|
||||
border-color: #444;
|
||||
background: #222;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs > li.active > a,
|
||||
#notifier-config-modal .nav-tabs > li.active > a:hover,
|
||||
#notifier-config-modal .nav-tabs > li.active > a:focus {
|
||||
#notifier-config-modal .nav-tabs > li.active > a:focus,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a:hover,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a:focus {
|
||||
color: #fff;
|
||||
background: #222;
|
||||
}
|
||||
#notifier-config-modal .nav-tabs > li.active > a,
|
||||
#notifier-config-modal .nav-tabs > li.active > a:hover,
|
||||
#notifier-config-modal .nav-tabs > li.active > a:focus {
|
||||
#notifier-config-modal .nav-tabs > li.active > a:focus,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a:hover,
|
||||
#newsletter-config-modal .nav-tabs > li.active > a:focus {
|
||||
border: 1px solid #444;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
#newsletter-config-modal #custom_cron {
|
||||
display: inline-block;
|
||||
width: initial;
|
||||
height: 32px;
|
||||
margin-right: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
#newsletter-config-modal #cron-widget {
|
||||
display: inline-block;
|
||||
margin-top: 1px;
|
||||
}
|
||||
#newsletter-config-modal #cron-widget select.cron-select {
|
||||
width: initial;
|
||||
display: inline;
|
||||
height: 32px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=minute],
|
||||
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=hour] {
|
||||
display: none !important;
|
||||
}
|
||||
.git-group input.form-control {
|
||||
width: 50%;
|
||||
}
|
||||
.git-group select.form-control {
|
||||
width: 50%;
|
||||
height: 32px;
|
||||
}
|
||||
#changelog-modal .modal-body > h2 {
|
||||
margin-bottom: 10px;
|
||||
@@ -3822,6 +3851,90 @@ a:hover .overlay-refresh-image:hover {
|
||||
background-color: #107c10;
|
||||
background-image: url(../images/platforms/xbox.svg);
|
||||
}
|
||||
.platform-android-rgba {
|
||||
background-color: rgba(164, 202, 57, 0.40);
|
||||
}
|
||||
.platform-atv-rgba {
|
||||
background-color: rgba(133, 132, 135, 0.40);
|
||||
}
|
||||
.platform-chrome-rgba {
|
||||
background-color: rgba(237, 94, 80, 0.40);
|
||||
}
|
||||
.platform-chromecast-rgba {
|
||||
background-color: rgba(16, 164, 232, 0.40);
|
||||
}
|
||||
.platform-default-rgba {
|
||||
background-color: rgba(229, 160, 13, 0.40);
|
||||
}
|
||||
.platform-dlna-rgba {
|
||||
background-color: rgba(12, 177, 75, 0.40);
|
||||
}
|
||||
.platform-firefox-rgba {
|
||||
background-color: rgba(230, 120, 23, 0.40);
|
||||
}
|
||||
.platform-gtv-rgba {
|
||||
background-color: rgba(0, 139, 207, 0.40);
|
||||
}
|
||||
.platform-ie-rgba {
|
||||
background-color: rgba(0, 89, 158, 0.40);
|
||||
}
|
||||
.platform-ios-rgba {
|
||||
background-color: rgba(133, 132, 135, 0.40);
|
||||
}
|
||||
.platform-kodi-rgba {
|
||||
background-color: rgba(49, 175, 225, 0.40);
|
||||
}
|
||||
.platform-linux-rgba {
|
||||
background-color: rgba(23, 147, 208, 0.40);
|
||||
}
|
||||
.platform-macos-rgba {
|
||||
background-color: rgba(133, 132, 135, 0.40);
|
||||
}
|
||||
.platform-msedge-rgba {
|
||||
background-color: rgba(0, 120, 215, 0.40);
|
||||
}
|
||||
.platform-opera-rgba {
|
||||
background-color: rgba(255, 27, 45, 0.40);
|
||||
}
|
||||
.platform-playstation-rgba {
|
||||
background-color: rgba(3, 77, 162, 0.40);
|
||||
}
|
||||
.platform-plex-rgba {
|
||||
background-color: rgba(229, 160, 13, 0.40);
|
||||
}
|
||||
.platform-plexamp-rgba {
|
||||
background-color: rgba(229, 160, 13, 0.40);
|
||||
}
|
||||
.platform-roku-rgba {
|
||||
background-color: rgba(109, 60, 151, 0.40);
|
||||
}
|
||||
.platform-safari-rgba {
|
||||
background-color: rgba(0, 169, 236, 0.40);
|
||||
}
|
||||
.platform-samsung-rgba {
|
||||
background-color: rgba(3, 78, 162, 0.40);
|
||||
}
|
||||
.platform-synclounge-rgba {
|
||||
background-color: rgba(21, 25, 36, 0.40);
|
||||
}
|
||||
.platform-tivo-rgba {
|
||||
background-color: rgba(0, 167, 225, 0.40);
|
||||
}
|
||||
.platform-wiiu-rgba {
|
||||
background-color: rgba(3, 169, 244, 0.40);
|
||||
}
|
||||
.platform-windows-rgba {
|
||||
background-color: rgba(47, 192, 245, 0.40);
|
||||
}
|
||||
.platform-wp-rgba {
|
||||
background-color: rgba(104, 33, 122, 0.40);
|
||||
}
|
||||
.platform-xbmc-rgba {
|
||||
background-color: rgba(59, 72, 114, 0.40);
|
||||
}
|
||||
.platform-xbox-rgba {
|
||||
background-color: rgba(16, 124, 16, 0.40);
|
||||
}
|
||||
.library-movie {
|
||||
background-image: url(../images/libraries/movie.svg);
|
||||
}
|
||||
@@ -3922,3 +4035,48 @@ a:hover .overlay-refresh-image:hover {
|
||||
.stream-info tr:nth-child(even) td {
|
||||
background-color: rgba(255,255,255,0.010);
|
||||
}
|
||||
.number-input {
|
||||
margin: 0 !important;
|
||||
width: 55px !important;
|
||||
height: 34px !important;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.number-input::-webkit-inner-spin-button,
|
||||
.number-input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.newsletter-loader-container {
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.newsletter-loader-message {
|
||||
color: #282A2D;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 25%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.newsletter-loader {
|
||||
border: 5px solid #ccc;
|
||||
-webkit-animation: spin 1s linear infinite;
|
||||
animation: spin 1s linear infinite;
|
||||
border-top: 5px solid #282A2D;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
left: calc(50% - 25px);
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
a[data-tab-destination] {
|
||||
cursor: pointer;
|
||||
}
|
@@ -64,7 +64,7 @@ DOCUMENTATION :: END
|
||||
from collections import defaultdict
|
||||
from urllib import quote
|
||||
from plexpy import helpers
|
||||
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES
|
||||
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES, EXTRA_TYPES
|
||||
import plexpy
|
||||
%>
|
||||
<%
|
||||
@@ -79,20 +79,19 @@ DOCUMENTATION :: END
|
||||
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
|
||||
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}">
|
||||
<div class="dashboard-activity-container">
|
||||
<div class="dashboard-activity-background-overlay">
|
||||
% if data['channel_stream'] == 0:
|
||||
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${data['art']}&width=500&height=280&fallback=art&refresh=true);"></div>
|
||||
% else:
|
||||
% if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
|
||||
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${data['art']});"></div>
|
||||
% else:
|
||||
<!--Hacky solution to escape the image url until I come up with something better-->
|
||||
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${quote(data['art'] or data['thumb'])}&width=500&height=280&fallback=art&refresh=true&clip=true);"></div>
|
||||
% endif
|
||||
% endif
|
||||
<%
|
||||
if data['channel_stream'] == 0:
|
||||
background_url = 'pms_image_proxy?img=' + data['art'] + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true'
|
||||
else:
|
||||
if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
|
||||
background_url = data['art']
|
||||
else:
|
||||
background_url = 'pms_image_proxy?img=' + quote(data['art'] or data['thumb']) + '&width=500&height=280&fallback=art&refresh=true&clip=true'
|
||||
%>
|
||||
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${background_url});">
|
||||
<div class="dashboard-activity-poster-container hidden-xs">
|
||||
% if data['media_type'] == 'track':
|
||||
<div id="poster-${sk}-bg" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&fallback=cover&refresh=true);"></div>
|
||||
<div id="poster-${sk}-bg" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
|
||||
% endif
|
||||
% if data['channel_stream'] == 0:
|
||||
% if data['media_type'] == 'movie':
|
||||
@@ -108,7 +107,11 @@ DOCUMENTATION :: END
|
||||
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&fallback=cover&refresh=true);"></div>
|
||||
</a>
|
||||
% elif data['media_type'] in ('photo', 'clip'):
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
|
||||
% if data['extra_type']:
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['art'].replace('/art', '/thumb') or data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
|
||||
% else:
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb'] or data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
|
||||
% endif
|
||||
% else:
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/art.png);"></div>
|
||||
% endif
|
||||
@@ -117,7 +120,7 @@ DOCUMENTATION :: END
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(${data['channel_icon']});"></div>
|
||||
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(${data['channel_icon']});"></div>
|
||||
% else:
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div>
|
||||
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
|
||||
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div>
|
||||
% endif
|
||||
% endif
|
||||
@@ -279,16 +282,20 @@ DOCUMENTATION :: END
|
||||
<span id="location-${sk}">${data['location'].upper()}</span>:
|
||||
% if data['ip_address'] != 'N/A':
|
||||
<span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
|
||||
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
|
||||
<span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span>
|
||||
</a>
|
||||
<script>
|
||||
isPrivateIP("${data['ip_address']}").then(function () {
|
||||
$("#external_ip-${sk}").hide();
|
||||
}, function () {
|
||||
$("#external_ip-${sk}").show();
|
||||
});
|
||||
</script>
|
||||
% if data['relay']:
|
||||
<span data-toggle="tooltip" title="Plex Relay"><i class="fa fa-exclamation-circle"></i></span>
|
||||
% else:
|
||||
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
|
||||
<span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span>
|
||||
</a>
|
||||
<script>
|
||||
isPrivateIP("${data['ip_address']}").then(function () {
|
||||
$("#external_ip-${sk}").hide();
|
||||
}, function () {
|
||||
$("#external_ip-${sk}").show();
|
||||
});
|
||||
</script>
|
||||
% endif
|
||||
% else:
|
||||
N/A
|
||||
% endif
|
||||
@@ -297,14 +304,13 @@ DOCUMENTATION :: END
|
||||
<li class="dashboard-activity-info-item">
|
||||
<div class="sub-heading">Bandwidth</div>
|
||||
<div class="sub-value time-right">
|
||||
% if data['media_type'] != 'photo' and helpers.cast_to_int(data['bandwidth']):
|
||||
% if data['media_type'] != 'photo' and data['bandwidth'] != 'Unknown':
|
||||
<%
|
||||
bw = helpers.cast_to_int(data['bandwidth'])
|
||||
if bw != "Unknown":
|
||||
if bw > 1000:
|
||||
bw = str(round(bw / 1000.0, 1)) + ' Mbps'
|
||||
else:
|
||||
bw = str(bw) + ' kbps'
|
||||
if bw > 1000:
|
||||
bw = str(round(bw / 1000.0, 1)) + ' Mbps'
|
||||
else:
|
||||
bw = str(bw) + ' kbps'
|
||||
%>
|
||||
<span id="stream-bandwidth-${sk}">${bw}</span>
|
||||
<span id="streaming-brain-${sk}" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span>
|
||||
@@ -436,7 +442,12 @@ DOCUMENTATION :: END
|
||||
% elif data['media_type'] == 'photo':
|
||||
<span title="${data['title']}" class="sub-heading">${data['title']}</span>
|
||||
% else:
|
||||
<span title="${data['year']}" class="sub-heading">${data['year']}</span>
|
||||
% if data['extra_type']:
|
||||
<% extra_type = EXTRA_TYPES.get(data['extra_type'], data['sub_type'].capitalize()) %>
|
||||
<span title="${data['year']} (${extra_type})" class="sub-heading">${data['year']} (${extra_type})</span>
|
||||
% else:
|
||||
<span title="${data['year']}" class="sub-heading">${data['year']}</span>
|
||||
% endif
|
||||
% endif
|
||||
% elif data['channel_title']:
|
||||
<span title="${data['channel_title']}" class="sub-heading">${data['channel_title']}</span>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="header-bar">
|
||||
<span><i class="fa fa-bar-chart"></i> Graphs</span>
|
||||
</div>
|
||||
<div class="button-bar hidden-xs">
|
||||
<div class="button-bar">
|
||||
<div class="btn-group" id="user-selection">
|
||||
<label>
|
||||
<select name="graph-user" id="graph-user" class="btn" style="color: inherit;">
|
||||
@@ -39,12 +39,12 @@
|
||||
</div>
|
||||
<div class="input-group pull-right" style="width: 1px;" id="days-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control" name="graph-days" id="graph-days" value="${config['graph_days']}" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<input type="number" class="form-control number-input" name="graph-days" id="graph-days" value="${config['graph_days']}" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<span class="input-group-addon btn-dark inactive">days</span>
|
||||
</div>
|
||||
<div class="input-group pull-right" style="width: 1px;" id="months-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control" name="graph-months" id="graph-months" value="${config['graph_months']}" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
|
||||
<input type="number" class="form-control number-input" name="graph-months" id="graph-months" value="${config['graph_months']}" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
|
||||
<span class="input-group-addon btn-dark inactive">months</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -113,7 +113,7 @@
|
||||
// Load user ids and names (for the selector)
|
||||
$.ajax({
|
||||
url: 'get_user_names',
|
||||
type: 'get',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function (data) {
|
||||
var select = $('#history-user');
|
||||
@@ -130,6 +130,7 @@
|
||||
function loadHistoryTable(media_type, selected_user_id) {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
return {
|
||||
json_data: JSON.stringify(d),
|
||||
@@ -163,7 +164,7 @@
|
||||
}
|
||||
|
||||
var media_type = null;
|
||||
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}";
|
||||
var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
|
||||
loadHistoryTable(media_type, selected_user_id);
|
||||
|
||||
% if _session['user_group'] == 'admin':
|
||||
|
@@ -5,7 +5,15 @@
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="myModalLabel">
|
||||
<strong><span id="modal_header_ip_address">
|
||||
% if data.get('media_type'):
|
||||
<% h = {'episode': 'TV Show', 'track': 'Music'} %>
|
||||
<i class="fa fa-history"></i> ${h.get(data['media_type'], data['media_type'].title())} History for <span id="date-header">${data['start_date']}</span>
|
||||
% elif data.get('transcode_decision'):
|
||||
<% h = {'copy': 'Direct Stream'} %>
|
||||
<i class="fa fa-history"></i> ${h.get(data['transcode_decision'], data['transcode_decision'].title())} History for <span id="date-header">${data['start_date']}</span>
|
||||
% else:
|
||||
<i class="fa fa-history"></i> History for <span id="date-header">${data['start_date']}</span>
|
||||
% endif
|
||||
</span></strong>
|
||||
</h4>
|
||||
</div>
|
||||
@@ -13,11 +21,18 @@
|
||||
<table class="display history_table" id="history_table_modal" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="delete_row">Delete</th>
|
||||
<th align="left" id="date">Date</th>
|
||||
<th align="left" id="friendly_name">User</th>
|
||||
<th align="left" id="player">Player</th>
|
||||
<th align="left" id="ip_address">IP Address</th>
|
||||
<th align="left" id="platform">Platform</th>
|
||||
<th align="left" id="device">Player</th>
|
||||
<th align="left" id="title">Title</th>
|
||||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -28,28 +43,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="${http_root}js/tables/history_table_modal.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#date-header').html(moment('${data["start_date"]}','YYYY-MM-DD').format('ddd MMM Do YYYY'));
|
||||
history_table_modal_options.ajax = {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify(d),
|
||||
grouping: false,
|
||||
user_id: "${data['user_id']}",
|
||||
start_date: "${data['start_date']}",
|
||||
media_type: "${data.get('media_type')}",
|
||||
transcode_decision: "${data.get('transcode_decision')}"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
history_table = $('#history_table_modal').DataTable(history_table_options);
|
||||
history_table.columns([0, 3, 4, 8, 10, 11]).visible(false);
|
||||
|
||||
history_table = $('#history_table_modal').DataTable(history_table_modal_options);
|
||||
|
||||
clearSearchButton('history_table_modal', history_table);
|
||||
|
||||
$('#history-modal').on('shown.bs.modal', function() {
|
||||
history_table.columns.adjust().draw();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
% else:
|
||||
|
@@ -71,28 +71,27 @@ DOCUMENTATION :: END
|
||||
%>
|
||||
<div class="dashboard-stats-instance" id="stats-instance-${stat_id}" data-stat_id="${stat_id}">
|
||||
<div class="dashboard-stats-container">
|
||||
<div class="dashboard-stats-background-overlay">
|
||||
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
|
||||
% if row0['art']:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=${row0['art']}&width=500&height=280&fallback=art);"></div>
|
||||
% else:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);"></div>
|
||||
% endif
|
||||
% elif stat_id == 'top_platforms':
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']} no-image"></div>
|
||||
% else:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background flat"></div>
|
||||
% endif
|
||||
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
|
||||
% if row0['art']:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=${row0['art']}&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art);">
|
||||
% else:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);">
|
||||
% endif
|
||||
% elif stat_id == 'top_platforms':
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']}-rgba no-image">
|
||||
% else:
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background flat">
|
||||
% endif
|
||||
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
|
||||
<div class="dashboard-stats-poster-container hidden-xs">
|
||||
% if stat_id in ('top_music', 'popular_music'):
|
||||
<div id="stats-thumb-${stat_id}-bg" class="dashboard-stats-poster-blur" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&fallback=cover);"></div>
|
||||
<div id="stats-thumb-${stat_id}-bg" class="dashboard-stats-poster" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover);"></div>
|
||||
% endif
|
||||
<% type = 'cover' if stat_id in ('top_music', 'popular_music') else 'poster' %>
|
||||
<% height, type = ('300', 'cover') if stat_id in ('top_music', 'popular_music') else ('450', 'poster') %>
|
||||
<% href = 'info?rating_key={}'.format(row0['rating_key']) if row0['rating_key'] else '#' %>
|
||||
<a id="stats-thumb-url-${stat_id}" href="${href}" title="${row0['title']}">
|
||||
% if row0['thumb']:
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&fallback=${type});"></div>
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=${height}&fallback=${type});"></div>
|
||||
% else:
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(images/${type}.png);"></div>
|
||||
% endif
|
||||
@@ -200,7 +199,7 @@ DOCUMENTATION :: END
|
||||
}).addClass('platform-' + $(elem).data('platform'));
|
||||
$('#stats-background-' + stat_id).removeClass(function (index, className) {
|
||||
return (className.match (/(^|\s)platform-\S+/g) || []).join(' ');
|
||||
}).addClass('platform-' + $(elem).data('platform'));
|
||||
}).addClass('platform-' + $(elem).data('platform') + '-rgba');
|
||||
} else {
|
||||
if (rating_key) {
|
||||
href = 'info?rating_key=' + rating_key;
|
||||
@@ -209,15 +208,16 @@ DOCUMENTATION :: END
|
||||
}
|
||||
$('#stats-thumb-url-' + stat_id).attr('href', href).prop('title', $(elem).data('title'));
|
||||
if (art) {
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + art + '&width=500&height=280&fallback=art)');
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art)');
|
||||
} else {
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(images/art.png)');
|
||||
}
|
||||
if (thumb) {
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&fallback=' + fallback + ')');
|
||||
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&fallback=' + fallback + ')');
|
||||
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&opacity=60&background=282828&blur=3&fallback=' + fallback + ')');
|
||||
} else {
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(images/' + fallback + '.png)');
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(images/' + fallback + '.png)');
|
||||
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(images/' + fallback + '.png)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
BIN
data/interfaces/default/images/become_a_patron_button.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 2.2 KiB |
BIN
data/interfaces/default/images/libraries/artist.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
data/interfaces/default/images/libraries/movie.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
data/interfaces/default/images/libraries/photo.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
data/interfaces/default/images/libraries/playlist.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
data/interfaces/default/images/libraries/show.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
data/interfaces/default/images/libraries/video.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
data/interfaces/default/images/logo-tautulli-100.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
data/interfaces/default/images/logo-tautulli-45.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
data/interfaces/default/images/logo-tautulli-newsletter.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
data/interfaces/default/images/newsletter/newsletter-header.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
data/interfaces/default/images/newsletter/view-on-plex-cover.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
data/interfaces/default/images/newsletter/view-on-plex-flat.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 5.1 KiB |
@@ -22,7 +22,16 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div id="currentActivity">
|
||||
<% from plexpy import PLEX_SERVER_UP %>
|
||||
% if PLEX_SERVER_UP:
|
||||
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
|
||||
% else:
|
||||
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
|
||||
% if _session['user_group'] == 'admin':
|
||||
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,27 +40,29 @@
|
||||
<div class="col-md-12">
|
||||
<div class="home-padded-header padded-header">
|
||||
<h3 class="pull-left">Watch Statistics</h3>
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="watch-stats-toggles" style="margin-right: 3px">
|
||||
% if config['home_stats_type'] == 0:
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off" checked> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
% else:
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off" checked> Play Duration
|
||||
</label>
|
||||
% endif
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="watched-stats-days-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control" name="watched-stats-days" id="watched-stats-days" value="${config['home_stats_length']}" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<span class="input-group-addon btn-dark inactive">days</span>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="watch-stats-toggles" style="margin-right: 3px">
|
||||
% if config['home_stats_type'] == 0:
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off" checked> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
% else:
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off" checked> Play Duration
|
||||
</label>
|
||||
% endif
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="watched-stats-days-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control number-input" name="watched-stats-days" id="watched-stats-days" value="${config['home_stats_length']}" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<span class="input-group-addon btn-dark inactive">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +80,9 @@
|
||||
<div class="col-md-12">
|
||||
<div class="home-padded-header padded-header" id="library-statistics-header">
|
||||
<h3 class="pull-left">Library Statistics</h3>
|
||||
<span class="btn btn-dark active" style="cursor: default">${config['pms_name']}</span>
|
||||
<div class="button-bar">
|
||||
<span class="btn btn-dark active" style="cursor: default">${config['pms_name']}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,23 +107,25 @@
|
||||
<a href="#" id="recently-added-page-right" class="paginate btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="recently-added-toggles" style="margin-right: 3px">
|
||||
<label class="btn btn-dark active" id="recently-added-label-all">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-movies">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-movie" value="movie" autocomplete="off"> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-tv">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-show" value="show" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-music">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-music" value="artist" autocomplete="off"> Music
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
|
||||
<input type="number" class="form-control" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="100" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 100 items" />
|
||||
<span class="input-group-addon btn-dark inactive">items</span>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="recently-added-toggles" style="margin-right: 3px">
|
||||
<label class="btn btn-dark active" id="recently-added-label-all">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-movies">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-movie" value="movie" autocomplete="off"> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-tv">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-show" value="show" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-music">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-music" value="artist" autocomplete="off"> Music
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
|
||||
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
|
||||
<span class="input-group-addon btn-dark inactive">items</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,13 +146,13 @@
|
||||
<%def name="modalIncludes()">
|
||||
|
||||
% if _session['user_group'] == 'admin' and config['update_show_changelog']:
|
||||
<% from plexpy.common import VERSION_NUMBER %>
|
||||
<% from plexpy.common import RELEASE %>
|
||||
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title">Tautulli Updated to <strong>${VERSION_NUMBER}</strong></h4>
|
||||
<h4 class="modal-title">Tautulli Updated to <strong>${RELEASE}</strong></h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
</div>
|
||||
@@ -235,9 +250,10 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
% if 'current_activity' in config['home_sections']:
|
||||
<% from plexpy import PLEX_SERVER_UP %>
|
||||
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
|
||||
<script>
|
||||
var defaultHandler = {
|
||||
get: function(target, name) {
|
||||
@@ -260,6 +276,7 @@
|
||||
async: true,
|
||||
error: function (xhr, status, error) {
|
||||
console.log(status + ': ' + error);
|
||||
activity_ready = true;
|
||||
},
|
||||
complete: function (xhr, status) {
|
||||
$('#dashboard-checking-activity').remove();
|
||||
@@ -274,9 +291,9 @@
|
||||
|
||||
if (!(current_activity)) {
|
||||
% if _session['user_group'] == 'admin':
|
||||
var msg_settings = ' Verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
|
||||
var msg_settings = ' Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
|
||||
% else:
|
||||
var msg_settings = ''
|
||||
var msg_settings = '';
|
||||
% endif
|
||||
$('#currentActivityHeader').hide();
|
||||
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.' + msg_settings + '</div>');
|
||||
@@ -358,7 +375,7 @@
|
||||
if (s.media_type === 'track') {
|
||||
// Update if artist changed
|
||||
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) {
|
||||
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&fallback=art&refresh=true)');
|
||||
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
|
||||
$('#metadata-grandparent_title-' + key)
|
||||
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
|
||||
.attr('title', s.grandparent_title)
|
||||
@@ -367,7 +384,7 @@
|
||||
// Update cover if album changed
|
||||
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
|
||||
$('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
|
||||
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
|
||||
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&opacity=60&background=282828&blur=3&fallback=poster&refresh=true)');
|
||||
$('#poster-url-' + key)
|
||||
.attr('href', 'info?rating_key=' + s.parent_rating_key)
|
||||
.attr('title', s.parent_title);
|
||||
@@ -490,17 +507,15 @@
|
||||
|
||||
$('#location-' + key).html(s.location.toUpperCase());
|
||||
|
||||
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) {
|
||||
var bw = parseInt(s.bandwidth);
|
||||
if (bw !== "Unknown") {
|
||||
if (bw > 1000) {
|
||||
bw = (bw / 1000).toFixed(1) + ' Mbps';
|
||||
} else {
|
||||
bw = bw + ' kbps'
|
||||
}
|
||||
if (s.media_type !== 'photo' && s.bandwidth !== 'Unknown') {
|
||||
var bw = parseInt(s.bandwidth) || 0;
|
||||
if (bw > 1000) {
|
||||
bw = (bw / 1000).toFixed(1) + ' Mbps';
|
||||
} else {
|
||||
bw = bw + ' kbps'
|
||||
}
|
||||
$('#stream-bandwidth-' + key).html(bw);
|
||||
}
|
||||
};
|
||||
|
||||
// Update the stream progress times
|
||||
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
|
||||
@@ -572,7 +587,7 @@
|
||||
if (!(create_instances.length) && activity_ready) {
|
||||
getCurrentActivity();
|
||||
}
|
||||
}, 2000);
|
||||
}, ${config['home_refresh_interval'] * 1000});
|
||||
|
||||
setInterval(function(){
|
||||
$('.progress_time_offset').each(function () {
|
||||
@@ -587,7 +602,7 @@
|
||||
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
|
||||
var view_offset = parseInt($(this).data('view_offset'));
|
||||
var stream_duration = parseInt($(this).data('stream_duration'));
|
||||
var progress_percent = Math.min(Math.trunc(view_offset / stream_duration * 100), 100)
|
||||
var progress_percent = Math.min(Math.trunc(view_offset / stream_duration * 100), 100);
|
||||
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%')
|
||||
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
|
||||
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
|
||||
|
@@ -64,7 +64,7 @@ DOCUMENTATION :: END
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -521,6 +521,7 @@ DOCUMENTATION :: END
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
<p class="help-block">Note: All custom notification conditions will be bypassed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -546,12 +547,12 @@ DOCUMENTATION :: END
|
||||
function get_history() {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
grandparent_rating_key: "${data['rating_key']}",
|
||||
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
|
||||
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -562,12 +563,12 @@ DOCUMENTATION :: END
|
||||
function get_history() {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
parent_rating_key: "${data['rating_key']}",
|
||||
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
|
||||
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -578,12 +579,12 @@ DOCUMENTATION :: END
|
||||
function get_history() {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
rating_key: "${data['rating_key']}",
|
||||
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
|
||||
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
1
data/interfaces/default/js/jquery-cron-min.js
vendored
Normal file
@@ -292,7 +292,11 @@ function millisecondsToMinutes(ms, roundToMinute) {
|
||||
if (ms > 0) {
|
||||
var minutes = Math.floor(ms / 60000);
|
||||
var seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return (seconds == 60 ? (minutes+1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds);
|
||||
if (roundToMinute) {
|
||||
return (seconds >= 30 ? (minutes + 1) : minutes);
|
||||
} else {
|
||||
return (seconds == 60 ? (minutes + 1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds);
|
||||
}
|
||||
} else {
|
||||
if (roundToMinute) {
|
||||
return '0';
|
||||
|
@@ -270,7 +270,7 @@ history_table_options = {
|
||||
});
|
||||
|
||||
if ($('#row-edit-mode').hasClass('active')) {
|
||||
$('.delete-control').each(function () {
|
||||
$('.history_table .delete-control').each(function () {
|
||||
$(this).removeClass('hidden');
|
||||
});
|
||||
}
|
||||
@@ -290,7 +290,9 @@ history_table_options = {
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
showMsg(msg, false, false, 0);
|
||||
$('[data-toggle="tooltip"]').tooltip('destroy');
|
||||
$('[data-toggle="popover"]').popover('destroy');
|
||||
},
|
||||
"rowCallback": function (row, rowData, rowIndex) {
|
||||
if (rowData['group_count'] == 1) {
|
||||
@@ -464,7 +466,7 @@ function childTableOptions(rowData) {
|
||||
});
|
||||
|
||||
if ($('#row-edit-mode').hasClass('active')) {
|
||||
$('.delete-control').each(function () {
|
||||
$('.history_table .delete-control').each(function () {
|
||||
$(this).removeClass('hidden');
|
||||
});
|
||||
}
|
||||
|
@@ -113,7 +113,7 @@ login_log_table_options = {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$('.login_log_table').on('click', '> tbody > tr > td.modal-control-ip', function () {
|
||||
var tr = $(this).closest('tr');
|
||||
|
@@ -54,7 +54,7 @@ media_info_table_options = {
|
||||
} else if (rowData['media_type'] === 'album') {
|
||||
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Tracks"><i class="fa fa-plus-circle fa-fw"></i></span>';
|
||||
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + ' ' + date + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'photo' && rowData['parent_rating_key'] == '') {
|
||||
} else if (rowData['media_type'] === 'photo_album') {
|
||||
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Photos"><i class="fa fa-plus-circle fa-fw"></i></span>';
|
||||
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + ' ' + date + '</div></a></div>');
|
||||
} else {
|
||||
@@ -77,32 +77,44 @@ media_info_table_options = {
|
||||
if (rowData['media_type'] === 'movie') {
|
||||
if (rowData['year']) { parent_info = ' (' + rowData['year'] + ')'; }
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Movie"><i class="fa fa-film fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + parent_info + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + parent_info + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'show') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="TV Show"><i class="fa fa-television fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'season') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Season"><i class="fa fa-television fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 15px;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'episode') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">E' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">E' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 30px;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'artist') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Artist"><i class="fa fa-music fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'album') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Album"><i class="fa fa-music fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 15px;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'track') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">T' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>'
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">T' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 30px;">' + media_type + ' ' + thumb_popover + '</div></a></div>');
|
||||
} else if (rowData['media_type'] === 'photo_album') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo Album"><i class="fa fa-camera fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + ' ' + thumb_popover + '</div></div>');
|
||||
} else if (rowData['media_type'] === 'photo') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + ' ' + thumb_popover + '</div></div>');
|
||||
} else if (rowData['media_type'] === 'clip') {
|
||||
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>';
|
||||
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">' + rowData['title'] + '</span>';
|
||||
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + ' ' + thumb_popover + '</div></div>');
|
||||
} else {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
@@ -335,7 +347,7 @@ function childTableOptionsMedia(rowData) {
|
||||
case 'album':
|
||||
section_type = 'track';
|
||||
break;
|
||||
case 'photo':
|
||||
case 'photo_album':
|
||||
section_type = 'picture';
|
||||
break;
|
||||
}
|
||||
|
146
data/interfaces/default/js/tables/newsletter_logs.js
Normal file
@@ -0,0 +1,146 @@
|
||||
newsletter_log_table_options = {
|
||||
"destroy": true,
|
||||
"serverSide": true,
|
||||
"processing": false,
|
||||
"pagingType": "full_numbers",
|
||||
"order": [ 0, 'desc'],
|
||||
"pageLength": 50,
|
||||
"stateSave": true,
|
||||
"language": {
|
||||
"search":"Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
"emptyTable": "No log information available",
|
||||
"info" :"Showing _START_ to _END_ of _TOTAL_ lines",
|
||||
"infoEmpty": "Showing 0 to 0 of 0 lines",
|
||||
"infoFiltered": "(filtered from _MAX_ total lines)",
|
||||
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
|
||||
},
|
||||
"autoWidth": false,
|
||||
"scrollX": true,
|
||||
"columnDefs": [
|
||||
{
|
||||
"targets": [0],
|
||||
"data": "timestamp",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(moment(cellData, "X").format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
},
|
||||
"width": "10%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [1],
|
||||
"data": "newsletter_id",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "5%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [2],
|
||||
"data": "agent_name",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "5%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [3],
|
||||
"data": "notify_action",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "5%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [4],
|
||||
"data": "subject_text",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "23%"
|
||||
},
|
||||
{
|
||||
"targets": [5],
|
||||
"data": "body_text",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "35%"
|
||||
},
|
||||
{
|
||||
"targets": [6],
|
||||
"data": "start_date",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "5%"
|
||||
},
|
||||
{
|
||||
"targets": [7],
|
||||
"data": "end_date",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"width": "5%"
|
||||
},
|
||||
{
|
||||
"targets": [8],
|
||||
"data": "uuid",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
$(td).html('<a href="newsletter/' + rowData['uuid'] + '" target="_blank">' + cellData + '</a>');
|
||||
}
|
||||
},
|
||||
"width": "5%"
|
||||
},
|
||||
{
|
||||
"targets": [9],
|
||||
"data": "success",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData === 1) {
|
||||
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Newsletter Sent"><i class="fa fa-lg fa-fw fa-check"></i></span>');
|
||||
} else {
|
||||
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Newsletter Failed"><i class="fa fa-lg fa-fw fa-times"></i></span>');
|
||||
}
|
||||
},
|
||||
"searchable": false,
|
||||
"orderable": false,
|
||||
"className": "no-wrap",
|
||||
"width": "2%"
|
||||
},
|
||||
],
|
||||
"drawCallback": function (settings) {
|
||||
// Jump to top of page
|
||||
//$('html,body').scrollTop(0);
|
||||
$('#ajaxMsg').fadeOut();
|
||||
|
||||
// Create the tooltips.
|
||||
$('body').tooltip({
|
||||
selector: '[data-toggle="tooltip"]',
|
||||
container: 'body'
|
||||
});
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
};
|
@@ -86,7 +86,7 @@ notification_log_table_options = {
|
||||
"targets": [6],
|
||||
"data": "success",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData == 1) {
|
||||
if (cellData === 1) {
|
||||
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Sent"><i class="fa fa-lg fa-fw fa-check"></i></span>');
|
||||
} else {
|
||||
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Failed"><i class="fa fa-lg fa-fw fa-times"></i></span>');
|
||||
@@ -113,4 +113,4 @@ notification_log_table_options = {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -37,7 +37,6 @@ sync_table_options = {
|
||||
"data": "state",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData === 'pending') {
|
||||
$(td).addClass('currentlyWatching');
|
||||
$(td).html('Pending...');
|
||||
} else {
|
||||
$(td).html(cellData.toProperCase());
|
||||
@@ -66,7 +65,7 @@ sync_table_options = {
|
||||
"data": "sync_title",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
if (rowData['metadata_type'] !== '') {
|
||||
if (rowData['rating_key']) {
|
||||
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
|
||||
} else {
|
||||
$(td).html(cellData);
|
||||
@@ -74,7 +73,7 @@ sync_table_options = {
|
||||
}
|
||||
},
|
||||
"className": "datatable-wrap"
|
||||
},
|
||||
},
|
||||
{
|
||||
"targets": [4],
|
||||
"data": "metadata_type",
|
||||
@@ -139,14 +138,26 @@ sync_table_options = {
|
||||
// $('html,body').scrollTop(0);
|
||||
|
||||
$('#ajaxMsg').fadeOut();
|
||||
|
||||
if ($('#sync-row-edit-mode').hasClass('active')) {
|
||||
$('.sync_table .delete-control').each(function () {
|
||||
$(this).removeClass('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
},
|
||||
"preDrawCallback": function (settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
},
|
||||
"rowCallback": function (row, rowData, rowIndex) {
|
||||
if (rowData['state'] === 'pending') {
|
||||
$(row).addClass('current-activity-row');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$('#sync_table').on('click', 'td.delete-control > .edit-sync-toggles > button.delete-sync', function () {
|
||||
$('.sync_table').on('click', 'td.delete-control > .edit-sync-toggles > button.delete-sync', function () {
|
||||
var tr = $(this).parents('tr');
|
||||
var row = sync_table.row(tr);
|
||||
var rowData = row.data();
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -91,7 +91,7 @@
|
||||
json_data: JSON.stringify(d)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
libraries_list_table = $('#libraries_list_table').DataTable(libraries_list_table_options);
|
||||
var colvis = new $.fn.dataTable.ColVis(libraries_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] });
|
||||
|
@@ -30,7 +30,7 @@ DOCUMENTATION :: END
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -374,15 +374,15 @@ DOCUMENTATION :: END
|
||||
// Build watch history table
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
section_id: section_id,
|
||||
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
|
||||
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
history_table = $('#history_table-SID-${data["section_id"]}').DataTable(history_table_options);
|
||||
|
||||
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
|
||||
@@ -392,7 +392,13 @@ DOCUMENTATION :: END
|
||||
}
|
||||
|
||||
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
|
||||
loadHistoryTable();
|
||||
if (typeof(history_table) === 'undefined') {
|
||||
loadHistoryTable();
|
||||
}
|
||||
});
|
||||
|
||||
$("#refresh-history-list").click(function () {
|
||||
history_table.draw();
|
||||
});
|
||||
|
||||
% if _session['user_group'] == 'admin':
|
||||
@@ -400,7 +406,7 @@ DOCUMENTATION :: END
|
||||
// Build media info table
|
||||
media_info_table_options.ajax = {
|
||||
url: 'get_library_media_info',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
@@ -408,7 +414,7 @@ DOCUMENTATION :: END
|
||||
refresh: refresh_table
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options);
|
||||
|
||||
var colvis = new $.fn.dataTable.ColVis(media_info_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
|
||||
@@ -418,7 +424,9 @@ DOCUMENTATION :: END
|
||||
}
|
||||
|
||||
$('a[href="#tabs-mediainfo"]').on('shown.bs.tab', function() {
|
||||
loadMediaInfoTable();
|
||||
if (typeof(media_info_table) === 'undefined') {
|
||||
loadMediaInfoTable();
|
||||
}
|
||||
});
|
||||
|
||||
$("#refresh-media-info-table").click(function () {
|
||||
@@ -484,10 +492,6 @@ DOCUMENTATION :: END
|
||||
});
|
||||
% endif
|
||||
|
||||
$("#refresh-history-list").click(function () {
|
||||
history_table.draw();
|
||||
});
|
||||
|
||||
function recentlyWatched() {
|
||||
// Populate recently watched
|
||||
$.ajax({
|
||||
|
@@ -29,14 +29,13 @@ DOCUMENTATION :: END
|
||||
headers = {'movie': ('Movie Libraries', ('Movies', '', '')),
|
||||
'show': ('TV Show Libraries', ('Shows', 'Seasons', 'Episodes')),
|
||||
'artist': ('Music Libraries', ('Artists', 'Albums', 'Tracks')),
|
||||
'photo': ('Photo Libraries', ('Albums', '', 'Photos'))}
|
||||
'photo': ('Photo Libraries', ('Albums', 'Photos', 'Videos'))}
|
||||
%>
|
||||
% for section_type in types:
|
||||
% if section_type in data:
|
||||
<div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}">
|
||||
<div class="dashboard-stats-container">
|
||||
<div class="dashboard-stats-background-overlay">
|
||||
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&fallback=art);"></div>
|
||||
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art);">
|
||||
<div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div>
|
||||
<div class="dashboard-stats-info-container">
|
||||
<div id="library-stats-title-${section_type}" class="dashboard-stats-info-title">
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<meta name="author" content="">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
|
||||
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
|
||||
<link href="${http_root}css/plexpy.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="row">
|
||||
<div class="login-container">
|
||||
<div class="login-logo">
|
||||
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 100px;"></object>
|
||||
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="PlexPy">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
@@ -85,7 +85,7 @@
|
||||
dataType: 'json',
|
||||
statusCode: {
|
||||
200: function() {
|
||||
window.location = "${http_root}";
|
||||
window.location = "${redirect_uri or http_root}";
|
||||
},
|
||||
401: function() {
|
||||
$('#incorrect-login').show();
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
<style>
|
||||
td {word-break: break-all;}
|
||||
</style>
|
||||
@@ -21,9 +21,9 @@
|
||||
<span><i class="fa fa-list-alt"></i> Logs</span>
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group" id="plexpy-log-levels">
|
||||
<div class="btn-group" id="tautulli-log-levels">
|
||||
<label>
|
||||
<select name="plexpy-log-level-filter" id="plexpy-log-level-filter" class="btn" style="color: inherit;">
|
||||
<select name="tautulli-log-level-filter" id="tautulli-log-level-filter" class="btn" style="color: inherit;">
|
||||
<option value="">All log levels</option>
|
||||
<option disabled>────────────</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
@@ -45,28 +45,30 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-dark" id="download-plexpylog"><i class="fa fa-download"></i> Download logs</button>
|
||||
<button class="btn btn-dark" id="download-tautullilog"><i class="fa fa-download"></i> Download logs</button>
|
||||
<button class="btn btn-dark" id="download-plexserverlog" style="display: none;"><i class="fa fa-download"></i> Download logs</button>
|
||||
<button class="btn btn-dark" id="download-plexscannerlog" style="display: none;"><i class="fa fa-download"></i> Download logs</button>
|
||||
<button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear logs</button>
|
||||
<button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
|
||||
<button class="btn btn-dark" id="clear-newsletter-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
|
||||
<button class="btn btn-dark" id="clear-login-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='table-card-back'>
|
||||
<div>
|
||||
<ul id="log_tabs" class="nav nav-pills" role="tablist">
|
||||
<li role="presentation" class="active"><a id="plexpy-logs-btn" href="#tabs-plexpy_log" aria-controls="tabs-plexpy_log" role="tab" data-toggle="tab">Tautulli Logs</a></li>
|
||||
<li role="presentation"><a id="plexpy-api-logs-btn" href="#tabs-plexpy_api_log" aria-controls="tabs-plexpy_api_log" role="tab" data-toggle="tab">Tautulli API Logs</a></li>
|
||||
<li role="presentation" class="active"><a id="tautulli-logs-btn" href="#tabs-tautulli_log" aria-controls="tabs-tautulli_log" role="tab" data-toggle="tab">Tautulli Logs</a></li>
|
||||
<li role="presentation"><a id="tautulli-api-logs-btn" href="#tabs-tautulli_api_log" aria-controls="tabs-tautulli_api_log" role="tab" data-toggle="tab">Tautulli API Logs</a></li>
|
||||
<li role="presentation"><a id="plex-logs-btn" href="#tabs-plex_log" aria-controls="tabs-plex_log" role="tab" data-toggle="tab">Plex Media Server Logs</a></li>
|
||||
<li role="presentation"><a id="plex-scanner-logs-btn" href="#tabs-plex_scanner_log" aria-controls="tabs-plex_scanner_log" role="tab" data-toggle="tab">Plex Media Scanner Logs</a></li>
|
||||
<li role="presentation"><a id="plexpy-websocket-logs-btn" href="#tabs-plex_websocket_log" aria-controls="tabs-plex_websocket_log" role="tab" data-toggle="tab">Plex Websocket Logs</a></li>
|
||||
<li role="presentation"><a id="plex-websocket-logs-btn" href="#tabs-plex_websocket_log" aria-controls="tabs-plex_websocket_log" role="tab" data-toggle="tab">Plex Websocket Logs</a></li>
|
||||
<li role="presentation"><a id="notification-logs-btn" href="#tabs-notification_log" aria-controls="tabs-notification_log" role="tab" data-toggle="tab">Notification Logs</a></li>
|
||||
<li role="presentation"><a id="newsletter-logs-btn" href="#tabs-newsletter_log" aria-controls="tabs-newsletter_log" role="tab" data-toggle="tab">Newsletter Logs</a></li>
|
||||
<li role="presentation"><a id="login-logs-btn" href="#tabs-login_log" aria-controls="tabs-login_log" role="tab" data-toggle="tab">Login Logs</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-plexpy_log" data-logfile="plexpy">
|
||||
<table class="display" id="plexpy_log_table" width="100%">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-tautulli_log" data-logfile="tautulli">
|
||||
<table class="display" id="tautulli_log_table" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-tablet" align="left" id="timestamp">Timestamp</th>
|
||||
@@ -77,8 +79,8 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-plexpy_api_log" data-logfile="plexpy_api">
|
||||
<table class="display" id="plexpy_api_log_table" width="100%">
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-tautulli_api_log" data-logfile="tautulli_api">
|
||||
<table class="display" id="tautulli_api_log_table" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-tablet" align="left" id="timestamp">Timestamp</th>
|
||||
@@ -141,6 +143,25 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_log">
|
||||
<table class="display" id="newsletter_log_table" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" id="newsletter_timestamp">Timestamp</th>
|
||||
<th align="left" id="newsletter_newsletter_id">Newsletter ID</th>
|
||||
<th align="left" id="newsletter_agent_name">Agent</th>
|
||||
<th align="left" id="newsletter_notify_action">Action</th>
|
||||
<th align="left" id="newsletter_subject_text">Subject Text</th>
|
||||
<th align="left" id="newsletter_body_text">Body Text</th>
|
||||
<th align="left" id="newsletter_start_date">Start Date</th>
|
||||
<th align="left" id="newsletter_end_date">End Date</th>
|
||||
<th align="left" id="newsletter_uuid">UUID</th>
|
||||
<th align="left" id="newsletter_success"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-login_log">
|
||||
<table class="display login_log_table" id="login_log_table" width="100%">
|
||||
<thead>
|
||||
@@ -191,12 +212,13 @@
|
||||
<script src="${http_root}js/tables/logs.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/plex_logs.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/notification_logs.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/newsletter_logs.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/login_logs.js${cache_param}"></script>
|
||||
<script>
|
||||
|
||||
$(document).ready(function() {
|
||||
loadPlexPyLogs('plexpy', selected_log_level);
|
||||
clearSearchButton('plexpy_log_table', log_table);
|
||||
loadtautullilogs('tautulli', selected_log_level);
|
||||
clearSearchButton('tautulli_log_table', log_table);
|
||||
});
|
||||
|
||||
var log_levels = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
|
||||
@@ -227,10 +249,10 @@
|
||||
}
|
||||
|
||||
var selected_log_level = null;
|
||||
function loadPlexPyLogs(logfile, selected_log_level) {
|
||||
function loadtautullilogs(logfile, selected_log_level) {
|
||||
log_table_options.ajax = {
|
||||
url: "get_log",
|
||||
type: 'post',
|
||||
url: 'get_log',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
return {
|
||||
logfile: logfile,
|
||||
@@ -238,10 +260,10 @@
|
||||
log_level: selected_log_level
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
log_table = $('#' + logfile + '_log_table').DataTable(log_table_options);
|
||||
|
||||
$('#plexpy-log-level-filter').on('change', function () {
|
||||
$('#tautulli-log-level-filter').on('change', function () {
|
||||
selected_log_level = $(this).val() || null;
|
||||
log_table.draw();
|
||||
});
|
||||
@@ -249,131 +271,168 @@
|
||||
|
||||
function loadPlexLogs() {
|
||||
plex_log_table_options.ajax = {
|
||||
url: "get_plex_log?log_type=server"
|
||||
}
|
||||
url: 'get_plex_log?log_type=server',
|
||||
type: 'POST'
|
||||
};
|
||||
plex_log_table_options.initComplete = bindLogLevelFilter;
|
||||
plex_log_table = $('#plex_log_table').DataTable(plex_log_table_options);
|
||||
}
|
||||
|
||||
function loadPlexScannerLogs() {
|
||||
plex_log_table_options.ajax = {
|
||||
url: "get_plex_log?log_type=scanner"
|
||||
}
|
||||
url: 'get_plex_log?log_type=scanner',
|
||||
type: 'POST'
|
||||
};
|
||||
plex_log_table_options.initComplete = bindLogLevelFilter;
|
||||
plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options);
|
||||
}
|
||||
|
||||
function loadNotificationLogs() {
|
||||
notification_log_table_options.ajax = {
|
||||
url: "get_notification_log",
|
||||
url: 'get_notification_log',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
return {
|
||||
json_data: JSON.stringify(d)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options);
|
||||
}
|
||||
|
||||
function loadNewsletterLogs() {
|
||||
newsletter_log_table_options.ajax = {
|
||||
url: "get_newsletter_log",
|
||||
data: function (d) {
|
||||
return {
|
||||
json_data: JSON.stringify(d)
|
||||
};
|
||||
}
|
||||
};
|
||||
newsletter_log_table = $('#newsletter_log_table').DataTable(newsletter_log_table_options);
|
||||
}
|
||||
|
||||
function loadLoginLogs() {
|
||||
login_log_table_options.pageLength = 50;
|
||||
login_log_table_options.ajax = {
|
||||
url: "get_user_logins",
|
||||
url: 'get_user_logins',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
return {
|
||||
json_data: JSON.stringify(d)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
login_log_table = $('#login_log_table').DataTable(login_log_table_options);
|
||||
}
|
||||
|
||||
$("#plexpy-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").show();
|
||||
$("#tautulli-logs-btn").click(function () {
|
||||
$("#tautulli-log-levels").show();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").show();
|
||||
$("#download-plexpylog").show()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").show();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadPlexPyLogs('plexpy', selected_log_level);
|
||||
clearSearchButton('plexpy_log_table', log_table);
|
||||
loadtautullilogs('tautulli', selected_log_level);
|
||||
clearSearchButton('tautulli_log_table', log_table);
|
||||
});
|
||||
|
||||
$("#plexpy-api-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").show();
|
||||
$("#tautulli-api-logs-btn").click(function () {
|
||||
$("#tautulli-log-levels").show();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").show();
|
||||
$("#download-plexpylog").show()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").show();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadPlexPyLogs('plexpy_api', selected_log_level);
|
||||
clearSearchButton('plexpy_api_log_table', log_table);
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadtautullilogs('tautulli_api', selected_log_level);
|
||||
clearSearchButton('tautulli_api_log_table', log_table);
|
||||
});
|
||||
|
||||
$("#plexpy-websocket-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").show();
|
||||
$("#plex-websocket-logs-btn").click(function () {
|
||||
$("#tautulli-log-levels").show();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").show();
|
||||
$("#download-plexpylog").show()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").show();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadPlexPyLogs('plex_websocket', selected_log_level);
|
||||
loadtautullilogs('plex_websocket', selected_log_level);
|
||||
clearSearchButton('plex_websocket_log_table', log_table);
|
||||
});
|
||||
|
||||
$("#plex-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").hide();
|
||||
$("#tautulli-log-levels").hide();
|
||||
$("#plex-log-levels").show();
|
||||
$("#clear-logs").hide();
|
||||
$("#download-plexpylog").hide()
|
||||
$("#download-plexserverlog").show()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").hide();
|
||||
$("#download-plexserverlog").show();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadPlexLogs();
|
||||
clearSearchButton('plex_log_table', plex_log_table);
|
||||
});
|
||||
|
||||
$("#plex-scanner-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").hide();
|
||||
$("#tautulli-log-levels").hide();
|
||||
$("#plex-log-levels").show();
|
||||
$("#clear-logs").hide();
|
||||
$("#download-plexpylog").hide()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").show()
|
||||
$("#download-tautullilog").hide();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").show();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadPlexScannerLogs();
|
||||
clearSearchButton('plex_scanner_log_table', plex_scanner_log_table);
|
||||
});
|
||||
|
||||
$("#notification-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").hide();
|
||||
$("#tautulli-log-levels").hide();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").hide();
|
||||
$("#download-plexpylog").hide()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").hide();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").show();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").hide();
|
||||
loadNotificationLogs();
|
||||
clearSearchButton('notification_log_table', notification_log_table);
|
||||
});
|
||||
|
||||
$("#login-logs-btn").click(function () {
|
||||
$("#plexpy-log-levels").hide();
|
||||
$("#newsletter-logs-btn").click(function () {
|
||||
$("#tautulli-log-levels").hide();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").hide();
|
||||
$("#download-plexpylog").hide()
|
||||
$("#download-plexserverlog").hide()
|
||||
$("#download-plexscannerlog").hide()
|
||||
$("#download-tautullilog").hide();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").show();
|
||||
$("#clear-login-logs").hide();
|
||||
loadNewsletterLogs();
|
||||
clearSearchButton('newsletter_log_table', newsletter_log_table);
|
||||
});
|
||||
|
||||
$("#login-logs-btn").click(function () {
|
||||
$("#tautulli-log-levels").hide();
|
||||
$("#plex-log-levels").hide();
|
||||
$("#clear-logs").hide();
|
||||
$("#download-tautullilog").hide();
|
||||
$("#download-plexserverlog").hide();
|
||||
$("#download-plexscannerlog").hide();
|
||||
$("#clear-notify-logs").hide();
|
||||
$("#clear-newsletter-logs").hide();
|
||||
$("#clear-login-logs").show();
|
||||
loadLoginLogs();
|
||||
clearSearchButton('login_log_table', notification_log_table);
|
||||
@@ -384,8 +443,8 @@
|
||||
});
|
||||
|
||||
$("#clear-logs").click(function () {
|
||||
var logfile = $(".tab-pane.active").data('logfile')
|
||||
var title = $("#log_tabs li.active a").text()
|
||||
var logfile = $(".tab-pane.active").data('logfile');
|
||||
var title = $("#log_tabs li.active a").text();
|
||||
|
||||
$("#confirm-message").text("Are you sure you want to clear the " + title + "?");
|
||||
$('#confirm-modal').modal();
|
||||
@@ -397,7 +456,7 @@
|
||||
complete: function (xhr, status) {
|
||||
result = $.parseJSON(xhr.responseText);
|
||||
msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
if (result.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
|
||||
@@ -408,7 +467,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#download-plexpylog").click(function () {
|
||||
$("#download-tautullilog").click(function () {
|
||||
var logfile = $(".tab-pane.active").data('logfile');
|
||||
window.location.href = "download_log?logfile=" + logfile;
|
||||
});
|
||||
@@ -431,7 +490,7 @@
|
||||
complete: function (xhr, status) {
|
||||
result = $.parseJSON(xhr.responseText);
|
||||
msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
if (result.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
|
||||
@@ -442,6 +501,27 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#clear-newsletter-logs").click(function () {
|
||||
$("#confirm-message").text("Are you sure you want to clear the Tautulli Newsletter Logs?");
|
||||
$('#confirm-modal').modal();
|
||||
$('#confirm-modal').one('click', '#confirm-button', function () {
|
||||
$.ajax({
|
||||
url: 'delete_newsletter_log',
|
||||
type: 'POST',
|
||||
complete: function (xhr, status) {
|
||||
result = $.parseJSON(xhr.responseText);
|
||||
msg = result.message;
|
||||
if (result.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
|
||||
}
|
||||
newsletter_log_table.draw();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$("#clear-login-logs").click(function () {
|
||||
$("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?");
|
||||
$('#confirm-modal').modal();
|
||||
@@ -452,7 +532,7 @@
|
||||
complete: function (xhr, status) {
|
||||
result = $.parseJSON(xhr.responseText);
|
||||
msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
if (result.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
|
||||
@@ -473,10 +553,10 @@
|
||||
{
|
||||
clearInterval(timer);
|
||||
}
|
||||
if(refreshrate.value != 0)
|
||||
if(refreshrate.value !== 0)
|
||||
{
|
||||
timer = setInterval(function() {
|
||||
if ($("#tabs-plexpy_log").hasClass("active") || $("#tabs-plexpy_api_log").hasClass("active") || $("#tabs-plex_websocket_log").hasClass("active")) {
|
||||
if ($("#tabs-tautulli_log").hasClass("active") || $("#tabs-tautulli_api_log").hasClass("active") || $("#tabs-plex_websocket_log").hasClass("active")) {
|
||||
log_table.ajax.reload();
|
||||
} else if ($("#tabs-plex_log").hasClass("active")) {
|
||||
plex_log_table.ajax.reload();
|
||||
|
@@ -55,7 +55,7 @@ DOCUMENTATION :: END
|
||||
})
|
||||
}
|
||||
return deferred;
|
||||
}
|
||||
};
|
||||
|
||||
function checkQRAddress(url) {
|
||||
var parser = document.createElement('a');
|
||||
@@ -82,7 +82,7 @@ DOCUMENTATION :: END
|
||||
verifiedDevice = false;
|
||||
|
||||
getPlexPyURL().then(function (url) {
|
||||
checkQRAddress(url)
|
||||
checkQRAddress(url);
|
||||
|
||||
$.get('generate_api_key', { device: true }).then(function (token) {
|
||||
$('#api_qr_address').val(url);
|
||||
@@ -120,7 +120,7 @@ DOCUMENTATION :: END
|
||||
|
||||
$('#api_qr_address').change(function () {
|
||||
var url = $(this).val();
|
||||
checkQRAddress(url)
|
||||
checkQRAddress(url);
|
||||
|
||||
$('#api_qr_code').empty().qrcode({
|
||||
text: url + '|' + $('#api_qr_token').val()
|
||||
|
723
data/interfaces/default/newsletter_config.html
Normal file
@@ -0,0 +1,723 @@
|
||||
% if newsletter:
|
||||
<%!
|
||||
import json
|
||||
from plexpy import notifiers
|
||||
from plexpy.helpers import anon_url, checked
|
||||
|
||||
all_notifiers = sorted(notifiers.get_notifiers(), key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id']))
|
||||
email_notifiers = [n for n in all_notifiers if n['agent_name'] == 'email']
|
||||
email_notifiers = [{'id': 0, 'agent_label': 'New Email Configuration', 'friendly_name': ''}] + email_notifiers
|
||||
other_notifiers = [{'id': 0, 'agent_label': 'Select a Notification Agent', 'friendly_name': ''}] + all_notifiers
|
||||
%>
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings <small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']})</span></small></h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<ul class="nav nav-tabs list-unstyled" role="tablist">
|
||||
<li role="presentation" class="active"><a href="#tabs-newsletter_config" aria-controls="tabs-newsletter_config" role="tab" data-toggle="tab">Configuration</a></li>
|
||||
<li role="presentation"><a href="#tabs-newsletter_agent" aria-controls="tabs-newsletter_agent" role="tab" data-toggle="tab">Notification Agent</a></li>
|
||||
<li role="presentation"><a href="#tabs-newsletter_text" aria-controls="tabs-newsletter_text" role="tab" data-toggle="tab">Newsletter Text</a></li>
|
||||
<li role="presentation"><a href="#tabs-test_newsletter" aria-controls="tabs-test_newsletter" role="tab" data-toggle="tab">Test Newsletter</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<form action="set_newsletter_config" method="post" class="form" id="set_newsletter_config" data-parsley-validate>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-newsletter_config">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="checkbox" style="margin-bottom: 20px;">
|
||||
<label>
|
||||
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" ${checked(newsletter['active'])}> Enable the Newsletter
|
||||
</label>
|
||||
<input type="hidden" id="active_value" name="active" value="${newsletter['active']}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_cron">Schedule</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron">
|
||||
<option value="0" ${'selected' if newsletter['config']['custom_cron'] == 0 else ''}>Simple</option>
|
||||
<option value="1" ${'selected' if newsletter['config']['custom_cron'] == 1 else ''}>Custom</option>
|
||||
</select>
|
||||
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" />
|
||||
<div id="cron-widget"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
|
||||
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank">custom crontab</a>. Only standard cron values are valid.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
|
||||
<input type="hidden" id="newsletter_id" name="newsletter_id" value="${newsletter['id']}" />
|
||||
<input type="hidden" id="agent_id" name="agent_id" value="${newsletter['agent_id']}" />
|
||||
% for item in newsletter['config_options']:
|
||||
% if item['input_type'] == 'help':
|
||||
<div class="form-group">
|
||||
<label>${item['label']}</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'text' or item['input_type'] == 'password':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'number':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'button':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'checkbox':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
|
||||
</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
</div>
|
||||
% elif item['input_type'] == 'select':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
% for key, value in sorted(item['select_options'].iteritems()):
|
||||
% if key == item['value']:
|
||||
<option value="${key}" selected>${value}</option>
|
||||
% else:
|
||||
<option value="${key}">${value}</option>
|
||||
% endif
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'selectize':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<option value="select-all">Select All</option>
|
||||
<option value="remove-all">Remove All</option>
|
||||
% if isinstance(item['select_options'], dict):
|
||||
% for section, options in item['select_options'].iteritems():
|
||||
<optgroup label="${section}">
|
||||
% for option in sorted(options, key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
</optgroup>
|
||||
% endfor
|
||||
% else:
|
||||
<option value="border-all"></option>
|
||||
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
% endif
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% endif
|
||||
% endfor
|
||||
</div>
|
||||
<div class="col-md-12" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
|
||||
<div class="form-group">
|
||||
<label for="friendly_name">Description</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter a description to help identify this newsletter in the newsletters list.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agent">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" ${checked(newsletter['config']['formatted'])}> Send newsletter as an HTML formatted Email
|
||||
</label>
|
||||
<p class="help-block">Enable to send the newsletter as an HTML formatted Email. Disable to only send a subject and body message to a different notification agent.</p>
|
||||
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
|
||||
</div>
|
||||
<div class="form-group" id="email_notifier_select">
|
||||
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id">
|
||||
% for notifier in email_notifiers:
|
||||
<% selected = 'selected' if notifier['id'] == newsletter['email_config']['notifier_id'] else '' %>
|
||||
% if notifier['friendly_name']:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']} - ${notifier['friendly_name']})</option>
|
||||
% elif notifier['id']:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']})</option>
|
||||
% else:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']}</option>
|
||||
% endif
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Select an existing Email notification agent or enter a new configuration below.<br>
|
||||
Note: Make sure HTML support is enabled for the Email notification agent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group" id="other_notifier_select">
|
||||
<label for="newsletter_config_notifier_id">Notification Agent</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id">
|
||||
% for notifier in other_notifiers:
|
||||
<% selected = 'selected' if notifier['id'] == newsletter['config']['notifier_id'] else '' %>
|
||||
% if notifier['friendly_name']:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']} - ${notifier['friendly_name']})</option>
|
||||
% elif notifier['id']:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']})</option>
|
||||
% else:
|
||||
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']}</option>
|
||||
% endif
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Select an existing notification agent where the subject and body text will be sent.<br>
|
||||
Note: Self-hosted newsletters must be enabled under <a data-tab-destination="tabs-notifications" data-dismiss="modal" data-target="#newsletter_self_hosted">Newsletters</a> to include a link to the newsletter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newsletter-email-config" class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
|
||||
% for item in newsletter['email_config_options']:
|
||||
% if item['input_type'] == 'help':
|
||||
<div class="form-group">
|
||||
<label>${item['label']}</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'text' or item['input_type'] == 'password':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'number':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'button':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'checkbox' and item['name'] != 'newsletter_email_html_support':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
|
||||
</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
</div>
|
||||
% elif item['input_type'] == 'select':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
% for key, value in sorted(item['select_options'].iteritems()):
|
||||
% if key == item['value']:
|
||||
<option value="${key}" selected>${value}</option>
|
||||
% else:
|
||||
<option value="${key}">${value}</option>
|
||||
% endif
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% elif item['input_type'] == 'selectize':
|
||||
<div class="form-group">
|
||||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<option value="select-all">Select All</option>
|
||||
<option value="remove-all">Remove All</option>
|
||||
% if isinstance(item['select_options'], dict):
|
||||
% for section, options in item['select_options'].iteritems():
|
||||
<optgroup label="${section}">
|
||||
% for option in sorted(options, key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
</optgroup>
|
||||
% endfor
|
||||
% else:
|
||||
<option value="border-all"></option>
|
||||
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
% endif
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
</div>
|
||||
% endif
|
||||
% endfor
|
||||
<input type="hidden" id="newsletter_email_html_support" name="newsletter_email_html_support" value="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_text">
|
||||
<label>Newsletter Text</label>
|
||||
<p class="help-block">
|
||||
Set the custom formatted text for each type of notification.
|
||||
<a href="#newsletter-text-sub-modal" data-toggle="modal">Click here</a> for a list of available parameters which can be used.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
You can also add text modifiers to change the case or slice parameters with a list of items.
|
||||
<a href="#notify-text-modifiers-modal" data-toggle="modal">Click here</a> to view usage information.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Enter a custom subject line for the newsletter. Leave blank for default.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group" id="newsletter_body">
|
||||
<label for="body">Body</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="body" name="body" data-autoresize>${newsletter['body']}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Enter a custom body line for the newsletter notification. Leave blank for default.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="message" name="message" data-autoresize>${newsletter['message']}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Enter a custom message to include on the newsletter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-test_newsletter">
|
||||
<label>Preview Newsletter</label>
|
||||
<p class="help-block">
|
||||
Preview the ${newsletter['agent_label']} newsletter.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="button" class="btn btn-bright" id="preview_newsletter" name="preview_newsletter" value="Preview ${newsletter['agent_label']} Newsletter">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label>Test Newsletter</label>
|
||||
<p class="help-block">
|
||||
Test if the ${newsletter['agent_label']} newsletter is working. Check the <a href="logs">logs</a> for troubleshooting.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
Warning: This will send an actual newsletter to your notification agent!
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="button" class="btn btn-bright" id="test_newsletter" name="test_newsletter" value="Test ${newsletter['agent_label']} Newsletter">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="button" id="delete-newsletter-item" class="btn btn-danger btn-edit" style="float:left;" value="Delete">
|
||||
<input type="button" id="duplicate-newsletter-item" class="btn btn-dark btn-edit" style="float:left;" value="Duplicate">
|
||||
<input type="button" id="save-newsletter-item" class="btn btn-bright" value="Save">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="${http_root}js/jquery-cron-min.js"></script>
|
||||
<script>
|
||||
|
||||
$('#newsletter-config-modal').unbind('hidden.bs.modal');
|
||||
|
||||
var cron_widget = $('#cron-widget').cron({
|
||||
initial: '0 0 * * 0',
|
||||
classes: 'form-control cron-select',
|
||||
onChange: function() {
|
||||
$("#cron_value").val($(this).cron('value'));
|
||||
}
|
||||
});
|
||||
|
||||
if (${newsletter['config']['custom_cron']}) {
|
||||
$('#cron_value').val('${newsletter['cron']}');
|
||||
} else {
|
||||
try {
|
||||
cron_widget.cron('value', '${newsletter['cron']}');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function toggleCustomCron() {
|
||||
if ($('#custom_cron').val() === '1'){
|
||||
$('#cron-widget').hide();
|
||||
$('#cron_value').show();
|
||||
$('#simple_cron_message').hide();
|
||||
$('#custom_cron_message').show();
|
||||
} else {
|
||||
$('#cron-widget').show();
|
||||
$('#cron_value').hide();
|
||||
$('#simple_cron_message').show();
|
||||
$('#custom_cron_message').hide();
|
||||
}
|
||||
}
|
||||
toggleCustomCron();
|
||||
|
||||
$('#custom_cron').change(function () {
|
||||
toggleCustomCron();
|
||||
});
|
||||
|
||||
var $incl_libraries = $('#newsletter_config_incl_libraries').selectize({
|
||||
plugins: ['remove_button'],
|
||||
maxItems: null,
|
||||
render: {
|
||||
option: function(item) {
|
||||
if (item.value.endsWith('-all')) {
|
||||
return '<div class="' + item.value + '">' + item.text + '</div>'
|
||||
}
|
||||
return '<div>' + item.text + '</div>';
|
||||
}
|
||||
},
|
||||
onItemAdd: function(value) {
|
||||
if (value === 'select-all') {
|
||||
var all_keys = $.map(this.options, function(option){
|
||||
return option.value.endsWith('-all') ? null : option.value;
|
||||
});
|
||||
this.setValue(all_keys);
|
||||
} else if (value === 'remove-all') {
|
||||
this.clear();
|
||||
this.refreshOptions();
|
||||
this.positionDropdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
var incl_libraries = $incl_libraries[0].selectize;
|
||||
incl_libraries.setValue(${json.dumps(next((c['value'] for c in newsletter['config_options'] if c['name'] == 'newsletter_config_incl_libraries'), [])) | n});
|
||||
|
||||
function toggleEmailSelect () {
|
||||
if ($('#newsletter_config_formatted_checkbox').is(':checked')) {
|
||||
$('#newsletter_body').hide();
|
||||
$('#email_notifier_select').show();
|
||||
$('#other_notifier_select').hide();
|
||||
toggleNewEmailConfig();
|
||||
} else {
|
||||
$('#newsletter_body').show();
|
||||
$('#email_notifier_select').hide();
|
||||
$('#other_notifier_select').show();
|
||||
$('#newsletter-email-config').hide();
|
||||
}
|
||||
}
|
||||
toggleEmailSelect();
|
||||
|
||||
$('#newsletter_config_formatted_checkbox').change(function () {
|
||||
toggleEmailSelect();
|
||||
});
|
||||
|
||||
function toggleNewEmailConfig () {
|
||||
if ($('#newsletter_config_formatted_checkbox').is(':checked') && $('#newsletter_email_notifier_id').val() === '0') {
|
||||
$('#newsletter-email-config').show();
|
||||
} else {
|
||||
$('#newsletter-email-config').hide();
|
||||
}
|
||||
}
|
||||
toggleNewEmailConfig();
|
||||
|
||||
$('#newsletter_email_notifier_id').change(function () {
|
||||
toggleNewEmailConfig();
|
||||
});
|
||||
|
||||
var REGEX_EMAIL = '([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' +
|
||||
'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)';
|
||||
var $email_selectors = $('#newsletter_email_to, #newsletter_email_cc, #newsletter_email_bcc').selectize({
|
||||
plugins: ['remove_button'],
|
||||
maxItems: null,
|
||||
render: {
|
||||
item: function(item, escape) {
|
||||
return '<div>' +
|
||||
(item.text ? '<span class="item-text">' + escape(item.text) + '</span>' : '') +
|
||||
(item.value ? '<span class="item-value">' + escape(item.value) + '</span>' : '') +
|
||||
'</div>';
|
||||
},
|
||||
option: function(item, escape) {
|
||||
var label = item.text || item.value;
|
||||
var caption = item.text ? item.value : null;
|
||||
if (item.value.endsWith('-all')) {
|
||||
return '<div class="' + item.value + '">' + escape(label) + '</div>'
|
||||
}
|
||||
return '<div>' +
|
||||
escape(label) +
|
||||
(caption ? '<span class="caption">' + escape(caption) + '</span>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
},
|
||||
onItemAdd: function(value) {
|
||||
if (value === 'select-all') {
|
||||
var all_keys = $.map(this.options, function(option){
|
||||
return option.value.endsWith('-all') ? null : option.value;
|
||||
});
|
||||
this.setValue(all_keys);
|
||||
} else if (value === 'remove-all') {
|
||||
this.clear();
|
||||
this.refreshOptions();
|
||||
this.positionDropdown();
|
||||
}
|
||||
},
|
||||
createFilter: function(input) {
|
||||
var match, regex;
|
||||
|
||||
// email@address.com
|
||||
regex = new RegExp('^' + REGEX_EMAIL + '$', 'i');
|
||||
match = input.match(regex);
|
||||
if (match) return !this.options.hasOwnProperty(match[0]);
|
||||
|
||||
// user <email@address.com>
|
||||
regex = new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i');
|
||||
match = input.match(regex);
|
||||
if (match) return !this.options.hasOwnProperty(match[2]);
|
||||
|
||||
return false;
|
||||
},
|
||||
create: function(input) {
|
||||
if ((new RegExp('^' + REGEX_EMAIL + '$', 'i')).test(input)) {
|
||||
return {value: input};
|
||||
}
|
||||
var match = input.match(new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i'));
|
||||
if (match) {
|
||||
return {
|
||||
value : match[2],
|
||||
text : $.trim(match[1])
|
||||
};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
var email_to = $email_selectors[0].selectize;
|
||||
var email_cc = $email_selectors[1].selectize;
|
||||
var email_bcc = $email_selectors[2].selectize;
|
||||
email_to.setValue(${json.dumps(next((c['value'] for c in newsletter['email_config_options'] if c['name'] == 'newsletter_email_to'), [])) | n});
|
||||
email_cc.setValue(${json.dumps(next((c['value'] for c in newsletter['email_config_options'] if c['name'] == 'newsletter_email_cc'), [])) | n});
|
||||
email_bcc.setValue(${json.dumps(next((c['value'] for c in newsletter['email_config_options'] if c['name'] == 'newsletter_email_bcc'), [])) | n});
|
||||
|
||||
function reloadModal() {
|
||||
$.ajax({
|
||||
url: 'get_newsletter_config_modal',
|
||||
data: { newsletter_id: '${newsletter["id"]}' },
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function (xhr, status) {
|
||||
$('#newsletter-config-modal').html(xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveCallback(jqXHR) {
|
||||
if (jqXHR) {
|
||||
var result = $.parseJSON(jqXHR.responseText);
|
||||
var msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
|
||||
}
|
||||
}
|
||||
|
||||
getNewslettersTable();
|
||||
}
|
||||
|
||||
function deleteCallback() {
|
||||
$('#newsletter-config-modal').modal('hide');
|
||||
getNewslettersTable();
|
||||
}
|
||||
|
||||
function duplicateCallback(result) {
|
||||
// Set new newsletter id
|
||||
$('#newsletter_id').val(result.newsletter_id);
|
||||
// Clear friendly name
|
||||
$('#friendly_name').val("");
|
||||
|
||||
saveNewsletter();
|
||||
|
||||
$('#newsletter-config-modal').on('hidden.bs.modal', function () {
|
||||
loadNewsletterConfig(result.newsletter_id);
|
||||
});
|
||||
$('#newsletter-config-modal').modal('hide');
|
||||
}
|
||||
|
||||
function saveNewsletter() {
|
||||
// Trim all text inputs before saving
|
||||
$('input[type=text]').val(function(_, value) {
|
||||
return $.trim(value);
|
||||
});
|
||||
// Make sure simple cron value is set
|
||||
if ($('#custom_cron').val() === '0'){
|
||||
$("#cron_value").val(cron_widget.cron('value'));
|
||||
}
|
||||
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, true, saveCallback);
|
||||
}
|
||||
|
||||
$('#delete-newsletter-item').click(function () {
|
||||
var msg = 'Are you sure you want to delete this <strong>${newsletter["agent_label"]}</strong> newsletter?';
|
||||
var url = 'delete_newsletter';
|
||||
confirmAjaxCall(url, msg, { newsletter_id: '${newsletter["id"]}' }, null, deleteCallback);
|
||||
});
|
||||
|
||||
$('#duplicate-newsletter-item').click(function() {
|
||||
var msg = 'Are you sure you want to duplicate this <strong>${newsletter["agent_label"]}</strong> newsletter?';
|
||||
var url = 'add_newsletter_config';
|
||||
confirmAjaxCall(url, msg, { agent_id: '${newsletter["agent_id"]}' }, null, duplicateCallback);
|
||||
});
|
||||
|
||||
$('#save-newsletter-item').click(function () {
|
||||
saveNewsletter();
|
||||
});
|
||||
|
||||
$('#preview_newsletter').click(function () {
|
||||
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, previewNewsletter);
|
||||
});
|
||||
|
||||
$('#test_newsletter').click(function () {
|
||||
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, sendTestNewsletter);
|
||||
});
|
||||
|
||||
function previewNewsletter() {
|
||||
showMsg('<i class="fa fa-check"></i> Check pop-up blocker if no response.', false, true, 2000);
|
||||
window.open('newsletter_preview?newsletter_id=' + $('#newsletter_id').val());
|
||||
}
|
||||
|
||||
function sendTestNewsletter() {
|
||||
showMsg('<i class="fa fa-refresh fa-spin"></i> Sending Newsletter', false);
|
||||
$.ajax({
|
||||
url: 'send_newsletter',
|
||||
data: {
|
||||
newsletter_id: $('#newsletter_id').val(),
|
||||
notify_action: 'test'
|
||||
},
|
||||
cache: false,
|
||||
async: true,
|
||||
success: function (data) {
|
||||
if (data.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
|
||||
} else {
|
||||
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$("${', '.join(['#' + c['name'] for c in newsletter['config_options'] if c.get('refresh')])}").on('change', function () {
|
||||
// Reload modal to update certain fields
|
||||
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, reloadModal);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Never send checkbox values directly, always substitute value in hidden input.
|
||||
$('.checkboxes').click(function () {
|
||||
var configToggle = $(this).data('id');
|
||||
if ($(this).is(':checked')) {
|
||||
$('#'+configToggle).val(1);
|
||||
} else {
|
||||
$('#'+configToggle).val(0);
|
||||
}
|
||||
});
|
||||
|
||||
// auto resizing textarea for custom notification message body
|
||||
$('textarea[data-autoresize]').each(function () {
|
||||
var offset = this.offsetHeight - this.clientHeight;
|
||||
var resizeTextarea = function (el) {
|
||||
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
|
||||
};
|
||||
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
|
||||
});
|
||||
</script>
|
||||
% else:
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="newsletter-config-modal-header">Error</h4>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: center">
|
||||
<strong>
|
||||
<i class="fa fa-exclamation-circle"></i> Failed to retrieve newsletter configuration. Check the <a href="logs">logs</a> for more info.
|
||||
</strong>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
48
data/interfaces/default/newsletter_preview.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<%
|
||||
import urllib
|
||||
%>
|
||||
<!doctype html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Tautulli - ${title} | ${server_name}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="newsletter-loader-container">
|
||||
<div class="newsletter-loader-message">
|
||||
<div class="newsletter-loader"></div>
|
||||
<br>
|
||||
Generating Newsletter
|
||||
<br>
|
||||
Please wait, this may take a few minutes...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
var frame = $('<iframe></iframe>', {
|
||||
src: '${http_root}real_newsletter?${urllib.urlencode(kwargs) | n}',
|
||||
frameborder: '0',
|
||||
style: 'display: none; height: 100vh; width: 100vw;'
|
||||
});
|
||||
frame.on('load', function (e) {
|
||||
$(e.target).fadeIn();
|
||||
$('#loader').fadeOut();
|
||||
});
|
||||
$('body').append(frame);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
51
data/interfaces/default/newsletters_table.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<%doc>
|
||||
USAGE DOCUMENTATION :: PLEASE LEAVE THIS AT THE TOP OF THIS FILE
|
||||
|
||||
For Mako templating syntax documentation please visit: http://docs.makotemplates.org/en/latest/
|
||||
|
||||
Filename: newsletters_table.html
|
||||
Version: 0.1
|
||||
|
||||
DOCUMENTATION :: END
|
||||
</%doc>
|
||||
|
||||
<% from plexpy.newsletter_handler import NEWSLETTER_SCHED %>
|
||||
<ul class="stacked-configs list-unstyled">
|
||||
% for newsletter in sorted(newsletters_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])):
|
||||
<li class="newsletter-agent" data-id="${newsletter['id']}">
|
||||
<span>
|
||||
<span class="toggle-left trigger-tooltip ${'active' if newsletter['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Newsletter ${'active' if newsletter['active'] else 'inactive'}"><i class="fa fa-lg fa-newspaper-o"></i></span>
|
||||
% if newsletter['friendly_name']:
|
||||
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']} - ${newsletter['friendly_name']})</span>
|
||||
% else:
|
||||
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']})</span>
|
||||
% endif
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
|
||||
% if NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
|
||||
<% job = NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
|
||||
<script>
|
||||
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
|
||||
</script>
|
||||
% endif
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
% endfor
|
||||
<li class="add-newsletter-agent" id="add-newsletter-agent" data-target="#add-newsletter-modal" data-toggle="modal">
|
||||
<span>
|
||||
<span class="toggle-left"><i class="fa fa-lg fa-newspaper-o"></i></span> Add a new newsletter agent
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<script>
|
||||
// Load newsletter config modal
|
||||
$(".newsletter-agent").click(function () {
|
||||
var newsletter_id = $(this).data('id');
|
||||
loadNewsletterConfig(newsletter_id);
|
||||
});
|
||||
|
||||
$('.trigger-tooltip').tooltip();
|
||||
</script>
|
@@ -1,3 +1,4 @@
|
||||
% if notifier:
|
||||
<%!
|
||||
import json
|
||||
from plexpy import helpers, notifiers, users
|
||||
@@ -6,9 +7,6 @@
|
||||
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
|
||||
sorted(user_emails, key=lambda u: u['user'])
|
||||
%>
|
||||
% if notifier:
|
||||
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
|
||||
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -19,7 +17,7 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<ul class="nav nav-tabs list-unstyled" role="tablist">
|
||||
<li role="presentation" class="active"><a href="#tabs-config" aria-controls="tabs-config" role="tab" data-toggle="tab">Configuration</a></li>
|
||||
<li role="presentation" class="active"><a href="#tabs-notifier_config" aria-controls="tabs-notifier_config" role="tab" data-toggle="tab">Configuration</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li>
|
||||
@@ -28,7 +26,7 @@
|
||||
</div>
|
||||
<form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-config">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-notifier_config">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" />
|
||||
@@ -45,9 +43,6 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
% if item['name'] == 'osx_notify_app':
|
||||
<a href="javascript:void(0)" id="osxnotifyregister">Register</a>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
@@ -151,7 +146,7 @@
|
||||
% for action in available_notification_actions:
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> Notify on ${action['label']}
|
||||
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> ${action['label']}
|
||||
</label>
|
||||
<p class="help-block">${action['description'] | n}</p>
|
||||
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
|
||||
@@ -167,11 +162,11 @@
|
||||
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
|
||||
</p>
|
||||
<div id="condition-widget"></div>
|
||||
<input type="hidden" name="custom_conditions" id="custom_conditions" />
|
||||
<input type="hidden" id="custom_conditions" name="custom_conditions" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom_conditions_logic">Condition Logic</label>
|
||||
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" required />
|
||||
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" />
|
||||
<div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div>
|
||||
<p class="help-block">
|
||||
Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
|
||||
@@ -333,31 +328,16 @@
|
||||
$('#notifier-config-modal').unbind('hidden.bs.modal');
|
||||
|
||||
// Need this for setting conditions since conditions contain the character "
|
||||
$('#custom_conditions').val(${json.dumps(notifier["custom_conditions"]) | n});
|
||||
$('#custom_conditions').val(JSON.stringify(${json.dumps(notifier["custom_conditions"]) | n}));
|
||||
|
||||
$('#condition-widget').filterer({
|
||||
parameters: ${parameters | n},
|
||||
conditions: ${notifier["custom_conditions"] | n},
|
||||
parameters: ${json.dumps(parameters) | n},
|
||||
conditions: ${json.dumps(notifier["custom_conditions"]) | n},
|
||||
updateConditions: function(newConditions){
|
||||
$('#custom_conditions').val(JSON.stringify(newConditions));
|
||||
}
|
||||
});
|
||||
|
||||
function setNegativeOperator(select) {
|
||||
if (select.val() === 'does not contain' || select.val() === 'is not') {
|
||||
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').addClass('negative-operator');
|
||||
} else {
|
||||
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').removeClass('negative-operator');
|
||||
}
|
||||
}
|
||||
|
||||
$('#condition-widget select[name=operator]').each(function () {
|
||||
setNegativeOperator($(this));
|
||||
});
|
||||
$('#condition-widget').on('change', 'select[name=operator]', function () {
|
||||
setNegativeOperator($(this));
|
||||
});
|
||||
|
||||
function reloadModal() {
|
||||
$.ajax({
|
||||
url: 'get_notifier_config_modal',
|
||||
@@ -425,7 +405,7 @@
|
||||
$('#duplicate-notifier-item').click(function() {
|
||||
var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?';
|
||||
var url = 'add_notifier_config';
|
||||
confirmAjaxCall(url, msg, { agent_id: "${notifier['agent_id']}" }, null, duplicateCallback);
|
||||
confirmAjaxCall(url, msg, { agent_id: '${notifier["agent_id"]}' }, null, duplicateCallback);
|
||||
});
|
||||
|
||||
$('#save-notifier-item').click(function () {
|
||||
@@ -433,16 +413,30 @@
|
||||
});
|
||||
|
||||
% if notifier['agent_name'] == 'facebook':
|
||||
if (location.protocol !== 'https:') {
|
||||
$('#tabs-config .form-group:first').prepend(
|
||||
'<div class="form-group">' +
|
||||
'<label>Warning</label>' +
|
||||
'<p class="help-block" style="color: #eb8600;">Facebook requires HTTPS for authorization. ' +
|
||||
'Please enable HTTPS for Tautulli under <a data-tab-destination="tabs-web_interface" data-dismiss="modal" data-target="#enable_https">Web Interface</a>.</p>' +
|
||||
'</div>'
|
||||
);
|
||||
$('#facebook_redirect_uri').val('HTTPS not enabled');
|
||||
|
||||
} else {
|
||||
$('#facebook_redirect_uri').val(location.href.split('/settings')[0] + '/facebook_redirect');
|
||||
}
|
||||
|
||||
function disableFacebookRequest() {
|
||||
if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebookStep1').prop('disabled', false); }
|
||||
else { $('#facebook_facebookStep1').prop('disabled', true); }
|
||||
if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebook_auth').prop('disabled', false); }
|
||||
else { $('#facebook_facebook_auth').prop('disabled', true); }
|
||||
}
|
||||
disableFacebookRequest();
|
||||
$('#facebook_app_id, #facebook_app_secret').on('change', function () {
|
||||
disableFacebookRequest();
|
||||
});
|
||||
|
||||
$('#facebook_facebookStep1').click(function () {
|
||||
$('#facebook_facebook_auth').click(function () {
|
||||
// Remove trailing '/' from Facebook redirect URI
|
||||
if ($('#facebook_redirect_uri') && $('#facebook_redirect_uri').val().endsWith('/')) {
|
||||
$('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1));
|
||||
@@ -450,7 +444,7 @@
|
||||
|
||||
var facebook_token;
|
||||
$.ajax({
|
||||
url: 'facebookStep1',
|
||||
url: 'facebook_auth',
|
||||
data: {
|
||||
app_id: $('#facebook_app_id').val(),
|
||||
app_secret: $('#facebook_app_secret').val(),
|
||||
@@ -508,7 +502,7 @@
|
||||
});
|
||||
|
||||
% elif notifier['agent_name'] == 'osx':
|
||||
$('#osxnotifyregister').click(function () {
|
||||
$('#osx_notify_register').click(function () {
|
||||
var osx_notify_app = $('#osx_notify_app').val();
|
||||
$.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
|
||||
});
|
||||
@@ -606,6 +600,22 @@
|
||||
});
|
||||
});
|
||||
|
||||
% elif notifier['agent_name'] == 'pushover':
|
||||
function pushoverPriority() {
|
||||
if ($('#pushover_priority').val() == '2') {
|
||||
$('#pushover_retry').closest('.form-group').show();
|
||||
$('#pushover_expire').closest('.form-group').show();
|
||||
} else {
|
||||
$('#pushover_retry').closest('.form-group').hide();
|
||||
$('#pushover_expire').closest('.form-group').hide();
|
||||
}
|
||||
}
|
||||
|
||||
pushoverPriority();
|
||||
$('#pushover_priority').change( function () {
|
||||
pushoverPriority();
|
||||
});
|
||||
|
||||
% endif
|
||||
|
||||
function validateLogic() {
|
||||
@@ -736,11 +746,12 @@
|
||||
});
|
||||
|
||||
function sendTestNotification() {
|
||||
showMsg('<i class="fa fa-refresh fa-spin"></i> Sending Notification', false);
|
||||
if ('${notifier["agent_name"]}' !== 'browser') {
|
||||
$.ajax({
|
||||
url: 'send_notification',
|
||||
data: {
|
||||
notifier_id: '${notifier["id"]}',
|
||||
notifier_id: $('#notifier_id').val(),
|
||||
subject: $('#test_subject').val(),
|
||||
body: $('#test_body').val(),
|
||||
script: $('#test_script').val(),
|
||||
@@ -749,13 +760,11 @@
|
||||
},
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function (xhr, status) {
|
||||
if (xhr.responseText.indexOf('sent') > -1) {
|
||||
msg = '<i class="fa fa-check"></i> ' + xhr.responseText;
|
||||
showMsg(msg, false, true, 2000);
|
||||
success: function (data) {
|
||||
if (data.result === 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
|
||||
} else {
|
||||
msg = '<i class="fa fa-times"></i> ' + xhr.responseText;
|
||||
showMsg(msg, false, true, 2000, true);
|
||||
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -10,7 +10,7 @@ DOCUMENTATION :: END
|
||||
</%doc>
|
||||
|
||||
<ul class="stacked-configs list-unstyled">
|
||||
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])):
|
||||
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id'])):
|
||||
<li class="notification-agent" data-id="${notifier['id']}">
|
||||
<span>
|
||||
<span class="toggle-left trigger-tooltip ${'active' if notifier['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Triggers ${'active' if notifier['active'] else 'inactive'}"><i class="fa fa-lg fa-bell"></i></span>
|
||||
|
@@ -4,10 +4,11 @@
|
||||
import sys
|
||||
|
||||
import plexpy
|
||||
from plexpy import common, notifiers
|
||||
from plexpy import common, notifiers, newsletters
|
||||
from plexpy.helpers import anon_url, checked
|
||||
|
||||
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'])
|
||||
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'].lower())
|
||||
available_newsletter_agents = sorted(newsletters.available_newsletter_agents(), key=lambda k: k['label'].lower())
|
||||
%>
|
||||
<%def name="headIncludes()">
|
||||
</%def>
|
||||
@@ -49,8 +50,9 @@
|
||||
<li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li>
|
||||
<li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li>
|
||||
<li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li>
|
||||
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications</a></li>
|
||||
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
|
||||
<li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
|
||||
<li role="presentation"><a href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
|
||||
<li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
|
||||
<li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
|
||||
</ul>
|
||||
@@ -60,9 +62,9 @@
|
||||
<input type="hidden" id="show_advanced_settings" name="show_advanced_settings" value="${config['show_advanced_settings']}" required>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-help_info">
|
||||
% if common.VERSION_NUMBER:
|
||||
% if common.RELEASE:
|
||||
<div class="padded-header">
|
||||
<h3>Version ${common.VERSION_NUMBER} <small><a id="changelog-modal-link" href="#"><i class="fa fa-info-circle"></i> Changelog</a></small></h3>
|
||||
<h3>Version ${common.RELEASE} <small><a id="changelog-modal-link" href="#"><i class="fa fa-info-circle"></i> Changelog</a></small></h3>
|
||||
</div>
|
||||
% endif
|
||||
<div class="padded-header">
|
||||
@@ -113,9 +115,9 @@
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Table and Watch Statistics History
|
||||
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Successive Play History
|
||||
</label>
|
||||
<p class="help-block">Group successive play history by the same user as a single entry in the tables and watch statistics.</p>
|
||||
<p class="help-block">Group successive play history by the same user as a single entry in the watch statistics, tables, and graphs.</p>
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
@@ -267,6 +269,21 @@
|
||||
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-homepage">
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>Activity</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="home_refresh_interval">Activty Refresh Interval</label>
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" data-parsley-type="integer" id="home_refresh_interval" name="home_refresh_interval" value="${config['home_refresh_interval']}" size="5" data-parsley-min="2" data-parsley-trigger="change" data-parsley-errors-container="#home_refresh_interval_error" required>
|
||||
</div>
|
||||
<div id="home_refresh_interval_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
<p class="help-block">Set the interval (in seconds) to refresh the current activity on the homepage. Minimum 2.</p>
|
||||
</div>
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>Sections</h3>
|
||||
</div>
|
||||
@@ -438,6 +455,18 @@
|
||||
</div>
|
||||
<p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="http_base_url">Public Tautulli Domain</label>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control" id="http_base_url" name="http_base_url" value="${config['http_base_url']}" placeholder="http://mydomain.com" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$" data-parsley-errors-container="#http_base_url_error" data-parsley-error-message="Invalid URL">
|
||||
</div>
|
||||
<div id=http_base_url_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Set your public Tautulli domain for self-hosted notification images and newsletters. (e.g. http://mydomain.com)
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="http_root">HTTP Root</label>
|
||||
<div class="row">
|
||||
@@ -554,7 +583,7 @@
|
||||
<label>
|
||||
<input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File
|
||||
</label>
|
||||
<span id="hashPasswordCheck" style="color: #eb8600; padding-left: 10px;"></span>
|
||||
<span id="hashPasswordCheck" class="settings-warning"></span>
|
||||
<p class="help-block">Store a hashed password in the config file.<br />Warning: Your password cannot be recovered if forgotten!</p>
|
||||
</div>
|
||||
<input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]"
|
||||
@@ -572,14 +601,14 @@
|
||||
<label>
|
||||
<input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin
|
||||
</label>
|
||||
<span id="allowPlexCheck" style="color: #eb8600; padding-left: 10px;"></span>
|
||||
<span id="allowPlexCheck" class="settings-warning"></span>
|
||||
<p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli
|
||||
</label>
|
||||
<span id="allowGuestCheck" style="color: #eb8600; padding-left: 10px;"></span>
|
||||
<span id="allowGuestCheck" class="settings-warning"></span>
|
||||
<p class="help-block">Allow shared users to login to Tautulli using their Plex.tv account. Individual user access needs to be enabled from Users > Edit Mode.</p>
|
||||
</div>
|
||||
|
||||
@@ -642,7 +671,7 @@
|
||||
<label for="pms_port">Plex Port</label>
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<input data-parsley-type="integer" class="pms-settings form-control" type="text" id="pms_port" name="pms_port" value="${config['pms_port']}" size="30" data-parsley-trigger="change" data-parsley-errors-container="#pms_port_error" required>
|
||||
<input data-parsley-type="integer" class="form-control pms-settings" type="text" id="pms_port" name="pms_port" value="${config['pms_port']}" size="30" data-parsley-trigger="change" data-parsley-errors-container="#pms_port_error" required>
|
||||
</div>
|
||||
<div id="pms_port_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
@@ -650,31 +679,42 @@
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
|
||||
</label>
|
||||
<p class="help-block">Check this if your Plex Server is not on the same local network as Tautulli.</p>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle" data-id="pms_ssl" value="1" ${checked(config['pms_ssl'])}> Use SSL
|
||||
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" data-id="pms_ssl" value="1" ${checked(config['pms_ssl'])}> Use SSL
|
||||
<input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}">
|
||||
</label>
|
||||
<p class="help-block">If you have secure connections enabled on your Plex Server, communicate with it securely.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pms_url">Plex Server URL</label>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<input type="text" class="form-control" id="pms_url" name="pms_url" value="${config['pms_url']}" size="30" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
The server URL that Tautulli will use to connect to your Plex server. Retrieved automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
|
||||
<input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
|
||||
</label>
|
||||
<span id="cloudManualConnection" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span>
|
||||
<p class="help-block">Use the user defined connection details. Do not retrieve the server connection URL automatically.</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="pms_logs_folder">Plex Web URL</label>
|
||||
<label for="pms_web_url">Plex Web URL</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-9">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL.">
|
||||
<input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button>
|
||||
</span>
|
||||
@@ -750,7 +790,7 @@
|
||||
<input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access
|
||||
</label>
|
||||
<span id="cloudMonitorRemoteAccess" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span>
|
||||
<span id="remoteAccessCheck" style="color: #eb8600; padding-left: 10px;"></span>
|
||||
<span id="remoteAccessCheck" class="settings-warning"></span>
|
||||
<p class="help-block">Enable to have Tautulli check if remote access to the Plex Media Server goes down.</p>
|
||||
</div>
|
||||
|
||||
@@ -896,7 +936,7 @@
|
||||
</div>
|
||||
<!--<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span style="color: #eb8600; padding-left: 10px;">[Not working]</span>
|
||||
<input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span class="settings-warning">[Not working]</span>
|
||||
</label>
|
||||
<p class="help-block">
|
||||
Enable to send another recently added notification when adding a new version of existing media.<br />
|
||||
@@ -905,16 +945,38 @@
|
||||
</div>-->
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>3rd Party APIs</h3>
|
||||
<h3>Newsletters</h3>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="notify_upload_posters" id="notify_upload_posters" value="1" ${config['notify_upload_posters']}> Upload Posters to Imgur for Notifications
|
||||
<input type="checkbox" id="newsletter_self_hosted" name="newsletter_self_hosted" value="1" ${config['newsletter_self_hosted']}> Self-Hosted Newsletters
|
||||
</label>
|
||||
<p class="help-block">Enable to upload Plex posters to Imgur for notifications. Disable if posters are not being used to save bandwidth.</p>
|
||||
<p class="help-block">Enable to host newsletters on your own domain. This will generate a link to an HTML page where you can view the newsletter.</p>
|
||||
</div>
|
||||
<div id="imgur_upload_options">
|
||||
<div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}">
|
||||
<p class="help-block" id="self_host_newsletter_message">Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet.</p>
|
||||
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>3rd Party APIs</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notify_upload_posters">Image Hosting</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
|
||||
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
|
||||
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
|
||||
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public Tautulli domain</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
|
||||
</div>
|
||||
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
|
||||
<div class="form-group">
|
||||
<label for="imgur_client_id">Imgur Client ID</label>
|
||||
<div class="row">
|
||||
@@ -924,9 +986,14 @@
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Enter your Imgur API client ID in order to upload posters.
|
||||
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br />
|
||||
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
|
||||
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
|
||||
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="themoviedb_lookup" id="themoviedb_lookup" value="1" ${config['themoviedb_lookup']}> Lookup TheMovieDB Links
|
||||
@@ -953,6 +1020,9 @@
|
||||
<p class="help-block">
|
||||
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>Notification Agents Guide</a> for instructions on setting up each notification agent.
|
||||
</p>
|
||||
<br />
|
||||
<div id="plexpy-notifiers-table">
|
||||
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading notification agents...</div>
|
||||
@@ -961,13 +1031,33 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agents">
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>Newsletter Agents</h3>
|
||||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
|
||||
</p>
|
||||
<p class="help-block settings-warning" id="newsletter_upload_warning">
|
||||
Note: Either <a data-tab-destination="tabs-notifications" data-target="#notify_upload_posters">Image Hosting</a> on Imgur or <a data-tab-destination="tabs-notifications" data-target="#newsletter_self_hosted">Self-Hosted Newsletters</a> must be enabled.</span>
|
||||
</p>
|
||||
<br/>
|
||||
<div id="plexpy-newsletters-table">
|
||||
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading newsletter agents...</div>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-import_backups">
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>Database Import</h3>
|
||||
</div>
|
||||
|
||||
<p class="help-block">Click a button below to import an exisiting database from another app.</p>
|
||||
<p class="help-block">Click a button below to import an existing database from another app.</p>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexwatch">PlexWatch</button>
|
||||
<button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexivity">Plexivity</button>
|
||||
@@ -1005,6 +1095,17 @@
|
||||
<h3>Directories</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="log_dir">Log Directory</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup_dir">Backup Directory</label>
|
||||
<div class="row">
|
||||
@@ -1030,13 +1131,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="log_dir">Log Directory</label>
|
||||
<label for="newsletter_dir">Newsletter Directory</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
|
||||
</div>
|
||||
<input type="text" class="form-control directory-settings" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,8 +1160,8 @@
|
||||
</div>
|
||||
<p class="form-group">
|
||||
<label>Registered Devices</label>
|
||||
<p class="help-block">Register a new device, or configure an existing device by clicking the settings icon on the right.</p>
|
||||
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-access_control" style="cursor: pointer;">Access Control</a> to use the app.</p>
|
||||
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
|
||||
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p>
|
||||
<div class="row">
|
||||
<div id="plexpy-mobile-devices-table" class="col-md-12">
|
||||
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div>
|
||||
@@ -1214,7 +1312,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<ul class="stacked-configs list-unstyled">
|
||||
% for agent in available_notification_agents:
|
||||
% for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()):
|
||||
<li class="new-notification-agent" data-id="${agent['id']}">
|
||||
<span>${agent['label']}</span>
|
||||
</li>
|
||||
@@ -1230,7 +1328,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add-newsletter-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="add-newsletter-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title">Add a Newsletter Agent</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<ul class="stacked-configs list-unstyled">
|
||||
% for agent in available_newsletter_agents:
|
||||
<li class="new-newsletter-agent" data-id="${agent['id']}">
|
||||
<span>${agent['label']}</span>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Cancel">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notifier-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notifier-config-modal"></div>
|
||||
<div id="newsletter-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-config-modal"></div>
|
||||
<div id="notify-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notify-text-sub-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
@@ -1386,6 +1513,53 @@
|
||||
</div>
|
||||
<div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal">
|
||||
</div>
|
||||
<div id="newsletter-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-text-sub-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">
|
||||
<i class="fa fa-remove"></i>
|
||||
</button>
|
||||
<h4 class="modal-title">Newsletter Parameters</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<p class="help-block">
|
||||
If the value for a selected parameter cannot be provided, it will display as blank.
|
||||
</p>
|
||||
% for category in common.NEWSLETTER_PARAMETERS:
|
||||
<table class="notification-params">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
${category['category']}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for parameter in category['parameters']:
|
||||
<tr>
|
||||
<td><strong>{${parameter['value']}}</strong></td>
|
||||
<td>
|
||||
${parameter['description']}
|
||||
% if parameter.get('example'):
|
||||
<span class="small-muted">(${parameter['example']})</span>
|
||||
% endif
|
||||
% if parameter.get('help_text'):
|
||||
<p class="small-muted">(${parameter['help_text']})</p>
|
||||
% endif
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</tbody>
|
||||
</table>
|
||||
% endfor
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
@@ -1520,6 +1694,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getNewslettersTable() {
|
||||
$.ajax({
|
||||
url: 'get_newsletters_table',
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function(xhr, status) {
|
||||
$("#plexpy-newsletters-table").html(xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadNewsletterConfig(newsletter_id) {
|
||||
$.ajax({
|
||||
url: 'get_newsletter_config_modal',
|
||||
data: { newsletter_id: newsletter_id },
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function (xhr, status) {
|
||||
$("#newsletter-config-modal").html(xhr.responseText).modal('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getMobileDevicesTable() {
|
||||
$.ajax({
|
||||
url: 'get_mobile_devices_table',
|
||||
@@ -1573,7 +1770,7 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
function preSaveChecks(_callback) {
|
||||
if ($("#pms_identifier").val() == "") {
|
||||
if (serverChanged) {
|
||||
verifyServer();
|
||||
}
|
||||
verifyPMSWebURL();
|
||||
@@ -1585,13 +1782,14 @@ $(document).ready(function() {
|
||||
|
||||
// Alert the user that their changes require a restart.
|
||||
function postSaveChecks() {
|
||||
if (serverChanged || authChanged || httpChanged || directoryChanged) {
|
||||
if (authChanged || httpChanged || directoryChanged) {
|
||||
$('#restart-modal').modal('show');
|
||||
}
|
||||
$("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0);
|
||||
getConfigurationTable();
|
||||
getSchedulerTable();
|
||||
getNotifiersTable();
|
||||
getNewslettersTable();
|
||||
getMobileDevicesTable();
|
||||
loadUpdateDistros();
|
||||
settingsChanged = false;
|
||||
@@ -1628,9 +1826,9 @@ $(document).ready(function() {
|
||||
initConfigCheckbox('#enable_https');
|
||||
initConfigCheckbox('#https_create_cert');
|
||||
initConfigCheckbox('#check_github');
|
||||
initConfigCheckbox('#notify_upload_posters');
|
||||
initConfigCheckbox('#monitor_pms_updates');
|
||||
|
||||
initConfigCheckbox('#newsletter_self_hosted');
|
||||
|
||||
$('#menu_link_shutdown').click(function() {
|
||||
$('#confirm-message').text("Are you sure you want to shutdown Tautulli?");
|
||||
$('#confirm-modal').modal();
|
||||
@@ -1675,6 +1873,7 @@ $(document).ready(function() {
|
||||
getConfigurationTable();
|
||||
getSchedulerTable();
|
||||
getNotifiersTable();
|
||||
getNewslettersTable();
|
||||
getMobileDevicesTable();
|
||||
|
||||
$('#changelog-modal-link').on('click', function (e) {
|
||||
@@ -1769,9 +1968,8 @@ $(document).ready(function() {
|
||||
|
||||
$( ".pms-settings" ).change(function() {
|
||||
serverChanged = true;
|
||||
$("#pms_identifier").val("");
|
||||
$("#server_changed").prop('checked', true);
|
||||
verifyServer();
|
||||
$("#pms_verify").hide();
|
||||
});
|
||||
|
||||
$('.checkbox-toggle').click(function () {
|
||||
@@ -1841,7 +2039,11 @@ $(document).ready(function() {
|
||||
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
|
||||
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
|
||||
$('#pms_url_manual').prop('checked', false);
|
||||
$('#pms_url').val('Please verify your server above to retrieve the URL');
|
||||
PMSCloudCheck();
|
||||
},
|
||||
onDropdownOpen: function() {
|
||||
this.clear();
|
||||
}
|
||||
});
|
||||
var select_pms = $select_pms[0].selectize;
|
||||
@@ -1906,6 +2108,7 @@ $(document).ready(function() {
|
||||
var pms_identifier = $("#pms_identifier").val();
|
||||
var pms_ssl = $("#pms_ssl").val();
|
||||
var pms_is_remote = $("#pms_is_remote").val();
|
||||
var pms_url_manual = $("#pms_url_manual").is(':checked') ? 1 : 0;
|
||||
|
||||
if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) {
|
||||
$("#pms_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
|
||||
@@ -1914,9 +2117,11 @@ $(document).ready(function() {
|
||||
data: {
|
||||
hostname: pms_ip,
|
||||
port: pms_port,
|
||||
identifier: pms_identifier,
|
||||
ssl: pms_ssl,
|
||||
remote: pms_is_remote
|
||||
remote: pms_is_remote,
|
||||
manual: pms_url_manual,
|
||||
get_url: true,
|
||||
test_websocket: true
|
||||
},
|
||||
cache: true,
|
||||
async: true,
|
||||
@@ -1925,12 +2130,27 @@ $(document).ready(function() {
|
||||
$("#pms_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
||||
$("#pms_ip_group").addClass("has-error");
|
||||
},
|
||||
success: function (json) {
|
||||
var machine_identifier = json;
|
||||
if (machine_identifier) {
|
||||
$("#pms_identifier").val(machine_identifier);
|
||||
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
|
||||
$("#pms_ip_group").removeClass("has-error");
|
||||
success: function(xhr, status) {
|
||||
var result = xhr;
|
||||
var identifier = result.identifier;
|
||||
var url = result.url;
|
||||
var ws = result.ws;
|
||||
if (identifier) {
|
||||
$("#pms_identifier").val(identifier);
|
||||
|
||||
if (url) {
|
||||
$("#pms_url").val(url);
|
||||
}
|
||||
|
||||
if (ws === false) {
|
||||
$("#pms_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
||||
$("#pms_ip_group").addClass("has-error");
|
||||
showMsg('<i class="fa fa-exclamation-circle"></i> Server found but unable to connect websocket.<br>Check the <a href="logs">logs</a> for errors.', false, true, 5000, true)
|
||||
} else {
|
||||
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
|
||||
$("#pms_ip_group").removeClass("has-error");
|
||||
serverChanged = false;
|
||||
}
|
||||
|
||||
if (_callback) {
|
||||
_callback();
|
||||
@@ -1950,7 +2170,6 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
$('#verify_server_button').on('click', function(){
|
||||
$("#pms_identifier").val("");
|
||||
verifyServer();
|
||||
});
|
||||
|
||||
@@ -2272,6 +2491,32 @@ $(document).ready(function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Add a new newsletter agent
|
||||
$('.new-newsletter-agent').click(function () {
|
||||
$.ajax({
|
||||
url: 'add_newsletter_config',
|
||||
data: { agent_id: $(this).data('id') },
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function (xhr, status) {
|
||||
var result = $.parseJSON(xhr.responseText);
|
||||
var msg = result.message;
|
||||
$('#add-newsletter-modal').modal('hide');
|
||||
if (result.result == 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
|
||||
loadNewsletterConfig(result.newsletter_id);
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
|
||||
}
|
||||
getNewslettersTable();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#http_base_url').change(function () {
|
||||
$(this).val($(this).val().replace(/\/*$/, ''));
|
||||
});
|
||||
|
||||
function apiEnabled() {
|
||||
var api_enabled = $('#api_enabled').prop('checked');
|
||||
$('#app_api_msg').toggle(!(api_enabled));
|
||||
@@ -2281,9 +2526,62 @@ $(document).ready(function() {
|
||||
apiEnabled();
|
||||
});
|
||||
|
||||
function imageUpload() {
|
||||
var upload_val = $('#notify_upload_posters').val();
|
||||
if (upload_val === '1') {
|
||||
$('#imgur_upload_options').slideDown();
|
||||
} else {
|
||||
$('#imgur_upload_options').slideUp();
|
||||
}
|
||||
if (upload_val === '2') {
|
||||
$('#self_host_image_options').slideDown();
|
||||
} else {
|
||||
$('#self_host_image_options').slideUp();
|
||||
}
|
||||
}
|
||||
$('#notify_upload_posters').change(function () {
|
||||
imageUpload();
|
||||
});
|
||||
|
||||
function baseURLSet() {
|
||||
if ($('#http_base_url').val()) {
|
||||
$('.base-url-warning').hide();
|
||||
} else {
|
||||
$('.base-url-warning').show();
|
||||
}
|
||||
}
|
||||
baseURLSet();
|
||||
|
||||
$('#http_base_url').change(function () {
|
||||
baseURLSet();
|
||||
});
|
||||
|
||||
function newsletterUploadEnabled() {
|
||||
if ($('#notify_upload_posters').val() === '1' || $('#newsletter_self_hosted').is(':checked')) {
|
||||
$('#newsletter_upload_warning').hide();
|
||||
} else {
|
||||
$('#newsletter_upload_warning').show();
|
||||
}
|
||||
}
|
||||
newsletterUploadEnabled();
|
||||
|
||||
$('#notify_upload_posters, #newsletter_self_hosted').change(function () {
|
||||
baseURLSet();
|
||||
newsletterUploadEnabled();
|
||||
});
|
||||
|
||||
$('body').on('click', 'a[data-tab-destination]', function () {
|
||||
var tab = $(this).data('tab-destination');
|
||||
$("a[href=#" + tab + "]").click();
|
||||
var scroll_destination = $(this).data('target');
|
||||
if (scroll_destination) {
|
||||
if ($(scroll_destination).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {
|
||||
$('#menu_link_show_advanced_settings').click()
|
||||
}
|
||||
var body_container = $('.body-container')
|
||||
var scroll_pos = scroll_destination ? body_container.scrollTop() + $(scroll_destination).offset().top - 100 : 0;
|
||||
body_container.animate({scrollTop: scroll_pos});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@@ -41,13 +41,16 @@
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
<script>
|
||||
// Remove the update bar
|
||||
$('#updatebar').remove();
|
||||
|
||||
// Use p.countdown as container, pass redirect, duration, and optional message
|
||||
$(".countdown").countdown(reloadPage, ${timer}, "");
|
||||
$('#state-change-modal').modal({
|
||||
keyboard: false
|
||||
})
|
||||
// Make modal visible
|
||||
$('#state-change-modal').modal('show')
|
||||
$('#state-change-modal').modal({
|
||||
backdrop: 'static',
|
||||
keyboard: false
|
||||
}).show();
|
||||
|
||||
// Redirect to home page after countdown.
|
||||
function reloadPage() {
|
||||
|
@@ -58,6 +58,10 @@ DOCUMENTATION :: END
|
||||
<div class="col-sm-12 text-muted stream-info-current">
|
||||
<i class="fa fa-exclamation-circle"></i> Current session. Updated stream details below may be delayed.
|
||||
</div>
|
||||
% elif data['pre_tautulli']:
|
||||
<div class="col-sm-12 text-muted stream-info-current">
|
||||
<i class="fa fa-exclamation-circle"></i> Pre-Tautulli history. Stream details below may be incorrect.
|
||||
</div>
|
||||
% endif
|
||||
<table class="stream-info" style="margin-top: 0;">
|
||||
<thead>
|
||||
@@ -84,8 +88,8 @@ DOCUMENTATION :: END
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_bitrate']} kbps</td>
|
||||
<td>${data['bitrate']} kbps</td>
|
||||
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
|
||||
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
|
||||
</tr>
|
||||
% if data['media_type'] != 'track':
|
||||
<tr>
|
||||
@@ -154,8 +158,8 @@ DOCUMENTATION :: END
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_video_bitrate']} kbps</td>
|
||||
<td>${data['video_bitrate']} kbps</td>
|
||||
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
|
||||
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Width</td>
|
||||
@@ -199,8 +203,8 @@ DOCUMENTATION :: END
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_audio_bitrate']} kbps</td>
|
||||
<td>${data['audio_bitrate']} kbps</td>
|
||||
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
|
||||
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Channels</td>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<style>
|
||||
td {word-wrap: break-word}
|
||||
@@ -20,10 +20,10 @@
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
% if _session['user_group'] == 'admin':
|
||||
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i> Select syncs to delete. Data is deleted upon exiting edit mode.</div>
|
||||
<div class="alert alert-danger alert-edit" role="alert" id="sync-row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i> Select syncs to delete. Data is deleted upon exiting delete mode.</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
|
||||
<i class="fa fa-pencil"></i> Edit mode
|
||||
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
|
||||
<i class="fa fa-trash-o"></i> Delete mode
|
||||
</button> 
|
||||
</div>
|
||||
% endif
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='table-card-back'>
|
||||
<table class="display" id="sync_table" width="100%">
|
||||
<table class="display sync_table" id="sync_table" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" id="delete_row">Delete</th>
|
||||
@@ -100,7 +100,7 @@
|
||||
// Load user ids and names (for the selector)
|
||||
$.ajax({
|
||||
url: 'get_user_names',
|
||||
type: 'get',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function (data) {
|
||||
var select = $('#sync-user');
|
||||
@@ -116,7 +116,8 @@
|
||||
|
||||
function loadSyncTable(selected_user_id) {
|
||||
sync_table_options.ajax = {
|
||||
url: 'get_sync?user_id=' + selected_user_id
|
||||
url: 'get_sync?user_id=' + selected_user_id,
|
||||
type: 'POST'
|
||||
};
|
||||
sync_table = $('#sync_table').DataTable(sync_table_options);
|
||||
var colvis = new $.fn.dataTable.ColVis(sync_table, {
|
||||
@@ -134,12 +135,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}";
|
||||
var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
|
||||
loadSyncTable(selected_user_id);
|
||||
|
||||
% if _session['user_group'] == 'admin':
|
||||
$('#row-edit-mode').on('click', function() {
|
||||
$('#row-edit-mode-alert').fadeIn(200);
|
||||
$('#sync-row-edit-mode').on('click', function() {
|
||||
$('#sync-row-edit-mode-alert').fadeIn(200);
|
||||
|
||||
if ($(this).hasClass('active')) {
|
||||
if (syncs_to_delete.length > 0) {
|
||||
@@ -161,13 +162,13 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
sync_table.draw();
|
||||
sync_table.ajax.reload();
|
||||
});
|
||||
}
|
||||
|
||||
$('.delete-control').each(function () {
|
||||
$(this).addClass('hidden');
|
||||
$('#row-edit-mode-alert').fadeOut(200);
|
||||
$('#sync-row-edit-mode-alert').fadeOut(200);
|
||||
});
|
||||
|
||||
} else {
|
||||
@@ -182,7 +183,7 @@
|
||||
});
|
||||
|
||||
$("#refresh-syncs-list").click(function() {
|
||||
sync_table.draw();
|
||||
sync_table.ajax.reload();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
@@ -32,7 +32,7 @@ DOCUMENTATION :: END
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -213,13 +213,25 @@ DOCUMENTATION :: END
|
||||
</span>
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
% if _session['user_group'] == 'admin':
|
||||
<div class="alert alert-danger alert-edit" role="alert" id="sync-row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i> Select syncs to delete. Data is deleted upon exiting delete mode.</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
|
||||
<i class="fa fa-trash-o"></i> Delete mode
|
||||
</button> 
|
||||
</div>
|
||||
% endif
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-dark refresh-syncs-button" id="refresh-syncs-list"><i class="fa fa-refresh"></i> Refresh synced items</button>
|
||||
</div>
|
||||
<div class="btn-group colvis-button-bar" id="button-bar-sync"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-back">
|
||||
<table class="display" id="sync_table-UID-${data['user_id']}" width="100%">
|
||||
<table class="display sync_table" id="sync_table-UID-${data['user_id']}" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" id="delete_row">Delete</th>
|
||||
<th align="left" id="state">State</th>
|
||||
<th align="left" id="username">Username</th>
|
||||
<th align="left" id="sync_title">Title</th>
|
||||
@@ -252,6 +264,11 @@ DOCUMENTATION :: END
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-dark refresh-ip-address-button" id="refresh-ip-address-list"><i class="fa fa-refresh"></i> Refresh IP addresses</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-back">
|
||||
<table class="display user_ip_table" id="user_ip_table-UID-${data['user_id']}" width="100%">
|
||||
@@ -284,6 +301,9 @@ DOCUMENTATION :: END
|
||||
</span>
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-dark refresh-login-button" id="refresh-login-list"><i class="fa fa-refresh"></i> Refresh logins</button>
|
||||
</div>
|
||||
<div class="btn-group colvis-button-bar" id="button-bar-login"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,6 +318,7 @@ DOCUMENTATION :: END
|
||||
<th align="left" id="host">Host</th>
|
||||
<th align="left" id="os">Operating System</th>
|
||||
<th align="left" id="browser">Browser</th>
|
||||
<th align="left" id="login_success"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
@@ -351,7 +372,7 @@ DOCUMENTATION :: END
|
||||
<h4 class="modal-title" id="myModalLabel">Confirm Delete</h4>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: center;">
|
||||
<p>Are you REALLY sure you want to delete <strong><span id="deleteCount"></span></strong> history item(s)?</p>
|
||||
<p>Are you REALLY sure you want to delete <strong><span id="deleteCount"></span></strong> <span id="deleteType"></span> item(s)?</p>
|
||||
<p>This is permanent and cannot be undone!</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -388,16 +409,11 @@ DOCUMENTATION :: END
|
||||
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
|
||||
});
|
||||
|
||||
$('a[href="#tabs-profile"]').on('shown.bs.tab', function() {
|
||||
var media_type = null;
|
||||
loadHistoryTable(media_type);
|
||||
});
|
||||
|
||||
function loadHistoryTable(media_type) {
|
||||
// Build watch history table
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
@@ -405,7 +421,7 @@ DOCUMENTATION :: END
|
||||
media_type: media_type
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
history_table = $('#history_table-UID-${data["user_id"]}').DataTable(history_table_options);
|
||||
history_table.column(2).visible(false);
|
||||
|
||||
@@ -423,60 +439,98 @@ DOCUMENTATION :: END
|
||||
});
|
||||
}
|
||||
|
||||
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
|
||||
var media_type = null;
|
||||
loadHistoryTable(media_type);
|
||||
});
|
||||
|
||||
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
|
||||
function loadSyncTable() {
|
||||
// Build user sync table
|
||||
sync_table_options.ajax = {
|
||||
url: 'get_sync',
|
||||
data: function(d) {
|
||||
d.user_id = user_id;
|
||||
}
|
||||
}
|
||||
url: 'get_sync?user_id=' + user_id,
|
||||
type: 'POST'
|
||||
};
|
||||
sync_table = $('#sync_table-UID-${data["user_id"]}').DataTable(sync_table_options);
|
||||
sync_table.column(1).visible(false);
|
||||
sync_table.column(2).visible(false);
|
||||
|
||||
var colvis_sync = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' } );
|
||||
var colvis_sync = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0] } );
|
||||
$( colvis_sync.button() ).appendTo('#button-bar-sync');
|
||||
|
||||
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
|
||||
});
|
||||
}
|
||||
|
||||
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
|
||||
function loadIPAddressTable() {
|
||||
// Build user IP table
|
||||
user_ip_table_options.ajax = {
|
||||
url: 'get_user_ips',
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
data: function ( d ) {
|
||||
return {
|
||||
json_data: JSON.stringify( d ),
|
||||
user_id: user_id
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
user_ip_table = $('#user_ip_table-UID-${data["user_id"]}').DataTable(user_ip_table_options);
|
||||
|
||||
clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table);
|
||||
});
|
||||
}
|
||||
|
||||
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
|
||||
function loadLoginTable() {
|
||||
// Build user login table
|
||||
login_log_table_options.ajax = {
|
||||
url: 'get_user_logins',
|
||||
type: 'POST',
|
||||
data: function(d) {
|
||||
d.user_id = user_id;
|
||||
return {
|
||||
json_data: JSON.stringify(d),
|
||||
user_id: user_id
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
login_log_table = $('#login_log_table-UID-${data["user_id"]}').DataTable(login_log_table_options);
|
||||
login_log_table.columns([1, 2]).visible(false);
|
||||
|
||||
var colvis_login = new $.fn.dataTable.ColVis( login_log_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' } );
|
||||
var colvis_login = new $.fn.dataTable.ColVis( login_log_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [7] } );
|
||||
$( colvis_login.button() ).appendTo('#button-bar-login');
|
||||
|
||||
clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table);
|
||||
}
|
||||
|
||||
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
|
||||
if (typeof(history_table) === 'undefined') {
|
||||
var media_type = null;
|
||||
loadHistoryTable(media_type);
|
||||
}
|
||||
});
|
||||
|
||||
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
|
||||
if (typeof(sync_table) === 'undefined') {
|
||||
loadSyncTable(user_id);
|
||||
}
|
||||
});
|
||||
|
||||
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
|
||||
if (typeof(user_ip_table) === 'undefined') {
|
||||
loadIPAddressTable(user_id);
|
||||
}
|
||||
});
|
||||
|
||||
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
|
||||
if (typeof(login_log_table) === 'undefined') {
|
||||
loadLoginTable(user_id);
|
||||
}
|
||||
});
|
||||
|
||||
$("#refresh-history-list").click(function () {
|
||||
history_table.draw();
|
||||
});
|
||||
|
||||
$("#refresh-syncs-list").click(function() {
|
||||
sync_table.ajax.reload();
|
||||
});
|
||||
|
||||
$("#refresh-ip-address-list").click(function () {
|
||||
user_ip_table.draw();
|
||||
});
|
||||
|
||||
$("#refresh-login-list").click(function () {
|
||||
login_log_table.draw();
|
||||
});
|
||||
|
||||
% if _session['user_group'] == 'admin':
|
||||
@@ -502,6 +556,7 @@ DOCUMENTATION :: END
|
||||
if ($(this).hasClass('active')) {
|
||||
if (history_to_delete.length > 0) {
|
||||
$('#deleteCount').text(history_to_delete.length);
|
||||
$('#deleteType').text('history');
|
||||
$('#confirm-modal-delete').modal();
|
||||
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
|
||||
history_to_delete.forEach(function(row, idx) {
|
||||
@@ -520,14 +575,56 @@ DOCUMENTATION :: END
|
||||
});
|
||||
}
|
||||
|
||||
$('.delete-control').each(function () {
|
||||
$('.history_table .delete-control').each(function () {
|
||||
$(this).addClass('hidden');
|
||||
$('#row-edit-mode-alert').fadeOut(200);
|
||||
});
|
||||
|
||||
} else {
|
||||
history_to_delete = [];
|
||||
$('.delete-control').each(function() {
|
||||
$('.history_table .delete-control').each(function() {
|
||||
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
|
||||
$(this).removeClass('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#sync-row-edit-mode').on('click', function() {
|
||||
$('#sync-row-edit-mode-alert').fadeIn(200);
|
||||
|
||||
if ($(this).hasClass('active')) {
|
||||
if (syncs_to_delete.length > 0) {
|
||||
$('#deleteCount').text(syncs_to_delete.length);
|
||||
$('#deleteType').text('sync');
|
||||
$('#confirm-modal-delete').modal();
|
||||
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
|
||||
syncs_to_delete.forEach(function(row, idx) {
|
||||
$.ajax({
|
||||
url: 'delete_sync_rows',
|
||||
type: 'POST',
|
||||
data: {
|
||||
client_id: row.client_id,
|
||||
sync_id: row.sync_id
|
||||
},
|
||||
async: true,
|
||||
success: function (data) {
|
||||
var msg = "Sync deleted";
|
||||
showMsg(msg, false, true, 2000);
|
||||
}
|
||||
});
|
||||
});
|
||||
sync_table.ajax.reload();
|
||||
});
|
||||
}
|
||||
|
||||
$('.sync_table .delete-control').each(function () {
|
||||
$(this).addClass('hidden');
|
||||
$('#sync-row-edit-mode-alert').fadeOut(200);
|
||||
});
|
||||
|
||||
} else {
|
||||
syncs_to_delete = [];
|
||||
$('.sync_table .delete-control').each(function() {
|
||||
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
|
||||
$(this).removeClass('hidden');
|
||||
});
|
||||
@@ -535,10 +632,6 @@ DOCUMENTATION :: END
|
||||
});
|
||||
% endif
|
||||
|
||||
$("#refresh-history-list").click(function () {
|
||||
history_table.draw();
|
||||
});
|
||||
|
||||
function recentlyWatched() {
|
||||
// Populate recently watched
|
||||
$.ajax({
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
||||
<%def name="body()">
|
||||
@@ -94,7 +94,7 @@
|
||||
json_data: JSON.stringify(d)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
users_list_table = $('#users_list_table').DataTable(users_list_table_options);
|
||||
var colvis = new $.fn.dataTable.ColVis(users_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] });
|
||||
|
@@ -14,7 +14,7 @@
|
||||
<meta name="author" content="">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
|
||||
<link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet">
|
||||
<link href="${http_root}css/plexpy.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet">
|
||||
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
|
||||
@@ -51,7 +51,7 @@
|
||||
<form>
|
||||
<div class="wizard-card" data-cardname="card1">
|
||||
<div style="float: right;">
|
||||
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 45px;"></object>
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
|
||||
</div>
|
||||
<h3 style="line-height: 50px;">Welcome!</h3>
|
||||
<br />
|
||||
@@ -94,7 +94,7 @@
|
||||
<label for="pms_ip">Plex IP or Hostname</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<select class="form-control selectize-pms-ip" id="pms_ip" name="pms_ip">
|
||||
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip">
|
||||
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -104,12 +104,12 @@
|
||||
<label for="pms_port">Plex Port</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-3">
|
||||
<input type="text" class="form-control pms_settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
|
||||
<input type="text" class="form-control pms-settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle" data-id="pms_ssl" value="1" ${helpers.checked(config['pms_ssl'])}> Use SSL
|
||||
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" data-id="pms_ssl" value="1" ${helpers.checked(config['pms_ssl'])}> Use SSL
|
||||
<input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}">
|
||||
</label>
|
||||
</div>
|
||||
@@ -117,16 +117,16 @@
|
||||
<div class="col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle" data-id="pms_is_remote" value="1" ${helpers.checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${helpers.checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" class="form-control pms-settings" id="pms_valid" data-validate="validatePMSip" value="">
|
||||
<input type="hidden" id="pms_valid" data-validate="validatePMSip" value="">
|
||||
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
|
||||
<input type="hidden" class="form-control pms-settings" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
|
||||
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
|
||||
<a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a><span style="margin-left: 10px; display: none;" id="pms-verify-status"></span>
|
||||
</div>
|
||||
|
||||
@@ -374,6 +374,9 @@ $(document).ready(function() {
|
||||
$('#pms_is_remote_checkbox').prop('disabled', false);
|
||||
$('#pms_ssl_checkbox').prop('disabled', false);
|
||||
}
|
||||
},
|
||||
onDropdownOpen: function() {
|
||||
this.clear();
|
||||
}
|
||||
});
|
||||
var select_pms = $select_pms[0].selectize;
|
||||
@@ -419,7 +422,8 @@ $(document).ready(function() {
|
||||
port: pms_port,
|
||||
identifier: pms_identifier,
|
||||
ssl: pms_ssl,
|
||||
remote: pms_is_remote },
|
||||
remote: pms_is_remote
|
||||
},
|
||||
cache: true,
|
||||
async: true,
|
||||
timeout: 5000,
|
||||
@@ -427,10 +431,11 @@ $(document).ready(function() {
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
},
|
||||
success: function (json) {
|
||||
var machine_identifier = json;
|
||||
if (machine_identifier) {
|
||||
$("#pms_identifier").val(machine_identifier);
|
||||
success: function(xhr, status) {
|
||||
var result = xhr;
|
||||
var identifier = result.identifier;
|
||||
if (identifier) {
|
||||
$("#pms_identifier").val(identifier);
|
||||
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
pms_verified = true;
|
||||
|
1065
data/interfaces/newsletters/recently_added.html
Normal file
1065
data/interfaces/newsletters/recently_added_master.html
Normal file
@@ -1,54 +1,54 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: plexpy
|
||||
# REQUIRE: plexpy
|
||||
# PROVIDE: tautulli
|
||||
# REQUIRE: tautulli
|
||||
# KEYWORD: shutdown
|
||||
#
|
||||
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
|
||||
# to enable this service:
|
||||
#
|
||||
# plexpy_enable (bool): Set to NO by default.
|
||||
# tautulli_enable (bool): Set to NO by default.
|
||||
# Set it to YES to enable it.
|
||||
# plexpy_user: The user account PlexPy daemon runs as what
|
||||
# you want it to be. It uses 'plexpy' user by
|
||||
# tautulli_user: The user account Tautulli daemon runs as what
|
||||
# you want it to be. It uses 'tautulli' user by
|
||||
# default. Do not sets it as empty or it will run
|
||||
# as root.
|
||||
# plexpy_dir: Directory where PlexPy lives.
|
||||
# Default: /usr/local/plexpy
|
||||
# plexpy_chdir: Change to this directory before running PlexPy.
|
||||
# Default is same as plexpy_dir.
|
||||
# plexpy_pid: The name of the pidfile to create.
|
||||
# Default is plexpy.pid in plexpy_dir.
|
||||
# tautulli_dir: Directory where Tautulli lives.
|
||||
# Default: /usr/local/share/Tautulli
|
||||
# tautulli_chdir: Change to this directory before running Tautulli.
|
||||
# Default is same as tautulli_dir.
|
||||
# tautulli_pid: The name of the pidfile to create.
|
||||
# Default is tautulli.pid in tautulli_dir.
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="plexpy"
|
||||
name="tautulli"
|
||||
rcvar=${name}_enable
|
||||
|
||||
load_rc_config ${name}
|
||||
|
||||
: ${plexpy_enable:="NO"}
|
||||
: ${plexpy_user:="plexpy"}
|
||||
: ${plexpy_dir:="/usr/local/plexpy"}
|
||||
: ${plexpy_chdir:="${plexpy_dir}"}
|
||||
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
|
||||
: ${plexpy_conf:="${plexpy_dir}/config.ini"}
|
||||
: ${tautulli_enable:="NO"}
|
||||
: ${tautulli_user:="tautulli"}
|
||||
: ${tautulli_dir:="/usr/local/share/Tautulli"}
|
||||
: ${tautulli_chdir:="${tautulli_dir}"}
|
||||
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
|
||||
: ${tautulli_conf:="${tautulli_dir}/config.ini"}
|
||||
|
||||
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown PlexPy.
|
||||
if [ -e "${plexpy_conf}" ]; then
|
||||
HOST=`grep -A64 "\[General\]" "${plexpy_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/'`
|
||||
PORT=`grep -A64 "\[General\]" "${plexpy_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/'`
|
||||
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown Tautulli.
|
||||
if [ -e "${tautulli_conf}" ]; then
|
||||
HOST=`grep -A64 "\[General\]" "${tautulli_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/'`
|
||||
PORT=`grep -A64 "\[General\]" "${tautulli_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/'`
|
||||
fi
|
||||
|
||||
status_cmd="${name}_status"
|
||||
stop_cmd="${name}_stop"
|
||||
|
||||
command="${plexpy_dir}/PlexPy.py"
|
||||
command_args="--daemon --quiet --nolaunch --port ${PORT} --pidfile ${plexpy_pid} --config ${plexpy_conf}"
|
||||
command="${tautulli_dir}/Tautulli.py"
|
||||
command_args="--daemon --quiet --nolaunch --port ${PORT} --pidfile ${tautulli_pid} --config ${tautulli_conf}"
|
||||
|
||||
# Check for wget and refuse to start without it.
|
||||
if [ ! -x "${WGET}" ]; then
|
||||
warn "PlexPy not started: You need wget to safely shut down PlexPy."
|
||||
warn "Tautulli not started: You need wget to safely shut down Tautulli."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -58,21 +58,21 @@ if [ `id -u` != "0" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_plexpy_pid() {
|
||||
# Make sure the pid corresponds to the PlexPy process.
|
||||
pid=`cat ${plexpy_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python ${plexpy_dir}/PlexPy.py"
|
||||
verify_tautulli_pid() {
|
||||
# Make sure the pid corresponds to the Tautulli process.
|
||||
pid=`cat ${tautulli_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python ${tautulli_dir}/Tautulli.py"
|
||||
return $?
|
||||
}
|
||||
|
||||
# Try to stop PlexPy cleanly by calling shutdown over http.
|
||||
plexpy_stop() {
|
||||
if [ ! -e "${plexpy_conf}" ]; then
|
||||
echo "PlexPy' settings file does not exist. Try starting PlexPy, as this should create the file."
|
||||
# Try to stop Tautulli cleanly by calling shutdown over http.
|
||||
tautulli_stop() {
|
||||
if [ ! -e "${tautulli_conf}" ]; then
|
||||
echo "Tautulli' settings file does not exist. Try starting Tautulli, as this should create the file."
|
||||
exit 1
|
||||
fi
|
||||
echo "Stopping $name"
|
||||
verify_plexpy_pid
|
||||
verify_tautulli_pid
|
||||
${WGET} -O - -q --user=${SBUSR} --password=${SBPWD} "http://${HOST}:${PORT}/shutdown/" >/dev/null
|
||||
|
||||
if [ -n "${pid}" ]; then
|
||||
@@ -81,8 +81,8 @@ plexpy_stop() {
|
||||
fi
|
||||
}
|
||||
|
||||
plexpy_status() {
|
||||
verify_plexpy_pid && echo "$name is running as ${pid}" || echo "$name is not running"
|
||||
tautulli_status() {
|
||||
verify_tautulli_pid && echo "$name is running as ${pid}" || echo "$name is not running"
|
||||
}
|
||||
|
||||
run_rc_command "$1"
|
||||
|
@@ -1,25 +1,25 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
### BEGIN INIT INFO
|
||||
# Provides: PlexPy
|
||||
# Provides: Tautulli
|
||||
# Required-Start: $all
|
||||
# Required-Stop: $all
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: starts PlexPy
|
||||
# Description: starts PlexPy
|
||||
# Short-Description: starts Tautulli
|
||||
# Description: starts Tautulli
|
||||
### END INIT INFO
|
||||
|
||||
# Source function library.
|
||||
. /etc/init.d/functions
|
||||
|
||||
## Variables
|
||||
prog=plexpy
|
||||
prog=tautulli
|
||||
lockfile=/var/lock/subsys/$prog
|
||||
homedir=/opt/plexpy
|
||||
datadir=/opt/plexpy
|
||||
configfile=/opt/plexpy/config.ini
|
||||
pidfile=/var/run/plexpy.pid
|
||||
homedir=/opt/Tautulli
|
||||
datadir=/opt/Tautulli
|
||||
configfile=/opt/Tautulli/config.ini
|
||||
pidfile=/var/run/tautulli.pid
|
||||
nice=
|
||||
# The following line must point to your Python 2.7 install
|
||||
python27=/usr/src/Python-2.7.11/python
|
||||
@@ -30,7 +30,7 @@ options=" --daemon --config $configfile --pidfile $pidfile --datadir $datadir --
|
||||
start() {
|
||||
# Start daemon.
|
||||
echo -n $"Starting $prog: "
|
||||
daemon --pidfile=$pidfile $nice $python27 $homedir/PlexPy.py $options
|
||||
daemon --pidfile=$pidfile $nice $python27 $homedir/Tautulli.py $options
|
||||
RETVAL=$?
|
||||
echo
|
||||
[ $RETVAL -eq 0 ] && touch $lockfile
|
||||
|
@@ -1,45 +1,45 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: plexpy
|
||||
# REQUIRE: DAEMON plexpy
|
||||
# PROVIDE: tautulli
|
||||
# REQUIRE: DAEMON tautulli
|
||||
# KEYWORD: shutdown
|
||||
#
|
||||
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
|
||||
# to enable this service:
|
||||
#
|
||||
# plexpy_enable (bool): Set to NO by default.
|
||||
# tautulli_enable (bool): Set to NO by default.
|
||||
# Set it to YES to enable it.
|
||||
# plexpy_user: The user account PlexPy daemon runs as what
|
||||
# you want it to be. It uses 'plexpy' user by
|
||||
# tautulli_user: The user account Tautulli daemon runs as what
|
||||
# you want it to be. It uses 'tautulli' user by
|
||||
# default. Do not sets it as empty or it will run
|
||||
# as root.
|
||||
# plexpy_dir: Directory where PlexPy lives.
|
||||
# Default: /usr/local/share/plexpy
|
||||
# plexpy_chdir: Change to this directory before running PlexPy.
|
||||
# Default is same as plexpy_dir.
|
||||
# plexpy_pid: The name of the pidfile to create.
|
||||
# Default is plexpy.pid in plexpy_dir.
|
||||
# tautulli_dir: Directory where Tautulli lives.
|
||||
# Default: /usr/local/share/Tautulli
|
||||
# tautulli_chdir: Change to this directory before running Tautulli.
|
||||
# Default is same as tautulli_dir.
|
||||
# tautulli_pid: The name of the pidfile to create.
|
||||
# Default is tautulli.pid in tautulli_dir.
|
||||
PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="plexpy"
|
||||
name="tautulli"
|
||||
rcvar=${name}_enable
|
||||
|
||||
load_rc_config ${name}
|
||||
|
||||
: ${plexpy_enable:="NO"}
|
||||
: ${plexpy_user:="plexpy"}
|
||||
: ${plexpy_dir:="/usr/local/share/plexpy"}
|
||||
: ${plexpy_chdir:="${plexpy_dir}"}
|
||||
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
|
||||
: ${plexpy_flags:=""}
|
||||
: ${tautulli_enable:="NO"}
|
||||
: ${tautulli_user:="tautulli"}
|
||||
: ${tautulli_dir:="/usr/local/share/Tautulli"}
|
||||
: ${tautulli_chdir:="${tautulli_dir}"}
|
||||
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
|
||||
: ${tautulli_flags:=""}
|
||||
|
||||
status_cmd="${name}_status"
|
||||
stop_cmd="${name}_stop"
|
||||
|
||||
command="${plexpy_dir}/PlexPy.py"
|
||||
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
|
||||
command="${tautulli_dir}/Tautulli.py"
|
||||
command_args="--daemon --pidfile ${tautulli_pid} --quiet --nolaunch ${tautulli_flags}"
|
||||
|
||||
# Ensure user is root when running this script.
|
||||
if [ `id -u` != "0" ]; then
|
||||
@@ -47,21 +47,21 @@ if [ `id -u` != "0" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_plexpy_pid() {
|
||||
# Make sure the pid corresponds to the PlexPy process.
|
||||
if [ -f ${plexpy_pid} ]; then
|
||||
pid=`cat ${plexpy_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python2 ${plexpy_dir}/PlexPy.py"
|
||||
verify_tautulli_pid() {
|
||||
# Make sure the pid corresponds to the Tautulli process.
|
||||
if [ -f ${tautulli_pid} ]; then
|
||||
pid=`cat ${tautulli_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python2 ${tautulli_dir}/Tautulli.py"
|
||||
return $?
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Try to stop PlexPy cleanly by sending SIGTERM
|
||||
plexpy_stop() {
|
||||
# Try to stop Tautulli cleanly by sending SIGTERM
|
||||
tautulli_stop() {
|
||||
echo "Stopping $name"
|
||||
verify_plexpy_pid
|
||||
verify_tautulli_pid
|
||||
if [ -n "${pid}" ]; then
|
||||
kill ${pid}
|
||||
wait_for_pids ${pid}
|
||||
@@ -69,8 +69,8 @@ plexpy_stop() {
|
||||
fi
|
||||
}
|
||||
|
||||
plexpy_status() {
|
||||
verify_plexpy_pid
|
||||
tautulli_status() {
|
||||
verify_tautulli_pid
|
||||
if [ -n "${pid}" ]; then
|
||||
echo "$name is running as ${pid}."
|
||||
else
|
||||
|
@@ -1,45 +1,45 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: plexpy
|
||||
# REQUIRE: DAEMON plexpy
|
||||
# PROVIDE: tautulli
|
||||
# REQUIRE: DAEMON tautulli
|
||||
# KEYWORD: shutdown
|
||||
#
|
||||
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
|
||||
# to enable this service:
|
||||
#
|
||||
# plexpy_enable (bool): Set to NO by default.
|
||||
# tautulli_enable (bool): Set to NO by default.
|
||||
# Set it to YES to enable it.
|
||||
# plexpy_user: The user account PlexPy daemon runs as what
|
||||
# you want it to be. It uses 'plexpy' user by
|
||||
# tautulli_user: The user account Tautulli daemon runs as what
|
||||
# you want it to be. It uses 'tautulli' user by
|
||||
# default. Do not sets it as empty or it will run
|
||||
# as root.
|
||||
# plexpy_dir: Directory where PlexPy lives.
|
||||
# Default: /usr/local/share/plexpy
|
||||
# plexpy_chdir: Change to this directory before running PlexPy.
|
||||
# Default is same as plexpy_dir.
|
||||
# plexpy_pid: The name of the pidfile to create.
|
||||
# Default is plexpy.pid in plexpy_dir.
|
||||
# tautulli_dir: Directory where Tautulli lives.
|
||||
# Default: /usr/local/share/Tautulli
|
||||
# tautulli_chdir: Change to this directory before running Tautulli.
|
||||
# Default is same as tautulli_dir.
|
||||
# tautulli_pid: The name of the pidfile to create.
|
||||
# Default is tautulli.pid in tautulli_dir.
|
||||
PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="plexpy"
|
||||
name="tautulli"
|
||||
rcvar=${name}_enable
|
||||
|
||||
load_rc_config ${name}
|
||||
|
||||
: ${plexpy_enable:="NO"}
|
||||
: ${plexpy_user:="plexpy"}
|
||||
: ${plexpy_dir:="/usr/local/share/plexpy"}
|
||||
: ${plexpy_chdir:="${plexpy_dir}"}
|
||||
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
|
||||
: ${plexpy_flags:=""}
|
||||
: ${tautulli_enable:="NO"}
|
||||
: ${tautulli_user:="tautulli"}
|
||||
: ${tautulli_dir:="/usr/local/share/Tautulli"}
|
||||
: ${tautulli_chdir:="${tautulli_dir}"}
|
||||
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
|
||||
: ${tautulli_flags:=""}
|
||||
|
||||
status_cmd="${name}_status"
|
||||
stop_cmd="${name}_stop"
|
||||
|
||||
command="${plexpy_dir}/PlexPy.py"
|
||||
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
|
||||
command="${tautulli_dir}/Tautulli.py"
|
||||
command_args="--daemon --pidfile ${tautulli_pid} --quiet --nolaunch ${tautulli_flags}"
|
||||
|
||||
# Ensure user is root when running this script.
|
||||
if [ `id -u` != "0" ]; then
|
||||
@@ -47,21 +47,21 @@ if [ `id -u` != "0" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_plexpy_pid() {
|
||||
# Make sure the pid corresponds to the PlexPy process.
|
||||
if [ -f ${plexpy_pid} ]; then
|
||||
pid=`cat ${plexpy_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python2 ${plexpy_dir}/PlexPy.py"
|
||||
verify_tautulli_pid() {
|
||||
# Make sure the pid corresponds to the Tautulli process.
|
||||
if [ -f ${tautulli_pid} ]; then
|
||||
pid=`cat ${tautulli_pid} 2>/dev/null`
|
||||
ps -p ${pid} | grep -q "python2 ${tautulli_dir}/Tautulli.py"
|
||||
return $?
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Try to stop PlexPy cleanly by sending SIGTERM
|
||||
plexpy_stop() {
|
||||
# Try to stop Tautulli cleanly by sending SIGTERM
|
||||
tautulli_stop() {
|
||||
echo "Stopping $name."
|
||||
verify_plexpy_pid
|
||||
verify_tautulli_pid
|
||||
if [ -n "${pid}" ]; then
|
||||
kill ${pid}
|
||||
wait_for_pids ${pid}
|
||||
@@ -69,8 +69,8 @@ plexpy_stop() {
|
||||
fi
|
||||
}
|
||||
|
||||
plexpy_status() {
|
||||
verify_plexpy_pid
|
||||
tautulli_status() {
|
||||
verify_tautulli_pid
|
||||
if [ -n "${pid}" ]; then
|
||||
echo "$name is running as ${pid}."
|
||||
else
|
||||
|
@@ -3,12 +3,12 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>plexpy</string>
|
||||
<string>tautulli</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<!-- Modify these two lines if you need to to reflect your python location and PlexPy install location -->
|
||||
<!-- Modify these two lines if you need to to reflect your python location and Tautulli install location -->
|
||||
<string>/usr/bin/python</string>
|
||||
<string>/Applications/PlexPy/PlexPy.py</string>
|
||||
<string>/Applications/Tautulli/Tautulli.py</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
@@ -2,9 +2,9 @@
|
||||
<!DOCTYPE service_bundle SYSTEM "/usr/share/lib/xml/dtd/service_bundle.dtd.1">
|
||||
<!--
|
||||
Created by Manifold
|
||||
--><service_bundle type="manifest" name="plexpy">
|
||||
--><service_bundle type="manifest" name="tautulli">
|
||||
|
||||
<service name="application/plexpy" type="service" version="1">
|
||||
<service name="application/tautulli" type="service" version="1">
|
||||
|
||||
<create_default_instance enabled="true"/>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
</dependency>
|
||||
|
||||
<method_context>
|
||||
<method_credential user="plexpy" group="nogroup"/>
|
||||
<method_credential user="tautulli" group="nogroup"/>
|
||||
</method_context>
|
||||
|
||||
<exec_method type="method" name="start" exec="python /opt/plexpy/PlexPy.py --daemon --quiet --nolaunch" timeout_seconds="60"/>
|
||||
<exec_method type="method" name="start" exec="python /opt/Tautulli/Tautulli.py --daemon --quiet --nolaunch" timeout_seconds="60"/>
|
||||
|
||||
<exec_method type="method" name="stop" exec=":kill" timeout_seconds="60"/>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<template>
|
||||
<common_name>
|
||||
<loctext xml:lang="C">
|
||||
PlexPy
|
||||
Tautulli
|
||||
</loctext>
|
||||
</common_name>
|
||||
</template>
|
||||
|
@@ -1,11 +1,11 @@
|
||||
# PlexPy - Stats for Plex Media Server usage
|
||||
# Tautulli - Stats for Plex Media Server usage
|
||||
#
|
||||
# Service Unit file for systemd system manager
|
||||
#
|
||||
# INSTALLATION NOTES
|
||||
#
|
||||
# 1. Rename this file as you want, ensuring that it ends in .service
|
||||
# e.g. 'plexpy.service'
|
||||
# e.g. 'tautulli.service'
|
||||
#
|
||||
# 2. Adjust configuration settings as required. More details in the
|
||||
# "CONFIGURATION NOTES" section shown below.
|
||||
@@ -15,39 +15,39 @@
|
||||
#
|
||||
# 4. Enable boot-time autostart with the following commands:
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable plexpy.service
|
||||
# systemctl enable tautulli.service
|
||||
#
|
||||
# 5. Start now with the following command:
|
||||
# systemctl start plexpy.service
|
||||
# systemctl start tautulli.service
|
||||
#
|
||||
# CONFIGURATION NOTES
|
||||
#
|
||||
# - The example settings in this file assume that you will run PlexPy as user: plexpy
|
||||
# - To create this user and give it ownership of the plexpy directory:
|
||||
# sudo adduser --system --no-create-home plexpy
|
||||
# sudo chown plexpy:nogroup -R /opt/plexpy
|
||||
# - The example settings in this file assume that you will run Tautulli as user: tautulli
|
||||
# - To create this user and give it ownership of the tautulli directory:
|
||||
# sudo adduser --system --no-create-home tautulli
|
||||
# sudo chown tautulli:nogroup -R /opt/Tautulli
|
||||
#
|
||||
# - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive)
|
||||
#
|
||||
# - Adjust ExecStart= to point to:
|
||||
# 1. Your PlexPy executable,
|
||||
# 1. Your Tautulli executable,
|
||||
# 2. Your config file (recommended is to put it somewhere in /etc)
|
||||
# 3. Your datadir (recommended is to NOT put it in your PlexPy exec dir)
|
||||
# 3. Your datadir (recommended is to NOT put it in your Tautulli exec dir)
|
||||
#
|
||||
# - Adjust User= and Group= to the user/group you want PlexPy to run as.
|
||||
# - Adjust User= and Group= to the user/group you want Tautulli to run as.
|
||||
#
|
||||
# - WantedBy= specifies which target (i.e. runlevel) to start PlexPy for.
|
||||
# - WantedBy= specifies which target (i.e. runlevel) to start Tautulli for.
|
||||
# multi-user.target equates to runlevel 3 (multi-user text mode)
|
||||
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
|
||||
|
||||
[Unit]
|
||||
Description=PlexPy - Stats for Plex Media Server usage
|
||||
Description=Tautulli - Stats for Plex Media Server usage
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/plexpy/PlexPy.py --quiet --daemon --nolaunch --config /opt/plexpy/config.ini --datadir /opt/plexpy
|
||||
ExecStart=/opt/Tautulli/Tautulli.py --quiet --daemon --nolaunch --config /opt/Tautulli/config.ini --datadir /opt/Tautulli
|
||||
GuessMainPID=no
|
||||
Type=forking
|
||||
User=plexpy
|
||||
User=tautulli
|
||||
Group=nogroup
|
||||
|
||||
[Install]
|
||||
|
@@ -1,71 +1,71 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
## Don't edit this file
|
||||
## Edit user configuation in /etc/default/plexpy to change
|
||||
## Edit user configuation in /etc/default/tautulli to change
|
||||
##
|
||||
## Make sure init script is executable
|
||||
## sudo chmod +x /path/to/init.ubuntu
|
||||
##
|
||||
## Install the init script
|
||||
## sudo ln -s /path/to/init.ubuntu /etc/init.d/plexpy
|
||||
## sudo ln -s /path/to/init.ubuntu /etc/init.d/tautulli
|
||||
##
|
||||
## Create the plexpy daemon user:
|
||||
## sudo adduser --system --no-create-home plexpy
|
||||
## Create the tautulli daemon user:
|
||||
## sudo adduser --system --no-create-home tautulli
|
||||
##
|
||||
## Make sure /opt/plexpy is owned by the plexpy user
|
||||
## sudo chown plexpy:nogroup -R /opt/plexpy
|
||||
## Make sure /opt/Tautulli is owned by the tautulli user
|
||||
## sudo chown tautulli:nogroup -R /opt/Tautulli
|
||||
##
|
||||
## Touch the default file to stop the warning message when starting
|
||||
## sudo touch /etc/default/plexpy
|
||||
## sudo touch /etc/default/tautulli
|
||||
##
|
||||
## To start PlexPy automatically
|
||||
## sudo update-rc.d plexpy defaults
|
||||
## To start Tautulli automatically
|
||||
## sudo update-rc.d tautulli defaults
|
||||
##
|
||||
## To start/stop/restart PlexPy
|
||||
## sudo service plexpy start
|
||||
## sudo service plexpy stop
|
||||
## sudo service plexpy restart
|
||||
## To start/stop/restart Tautulli
|
||||
## sudo service tautulli start
|
||||
## sudo service tautulli stop
|
||||
## sudo service tautulli restart
|
||||
##
|
||||
## HP_USER= #$RUN_AS, username to run plexpy under, the default is plexpy
|
||||
## HP_HOME= #$APP_PATH, the location of PlexPy.py, the default is /opt/plexpy
|
||||
## HP_DATA= #$DATA_DIR, the location of plexpy.db, cache, logs, the default is /opt/plexpy
|
||||
## HP_PIDFILE= #$PID_FILE, the location of plexpy.pid, the default is /var/run/plexpy/plexpy.pid
|
||||
## TAUTULLI_USER= #$RUN_AS, username to run Tautulli under, the default is tautulli
|
||||
## TAUTULLI_HOME= #$APP_PATH, the location of Tautulli.py, the default is /opt/Tautulli
|
||||
## TAUTULLI_DATA= #$DATA_DIR, the location of plexpy.db, cache, logs, the default is /opt/Tautulli
|
||||
## TAUTULLI_PIDFILE= #$PID_FILE, the location of tautulli.pid, the default is /var/run/tautulli/tautulli.pid
|
||||
## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python
|
||||
## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for plexpy, i.e. " --config=/home/plexpy/config.ini"
|
||||
## TAUTULLI_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for Tautulli, i.e. " --config=/home/Tautulli/config.ini"
|
||||
## SSD_OPTS= #$EXTRA_SSD_OPTS, extra start-stop-daemon option like " --group=users"
|
||||
## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
|
||||
## TAUTULLI_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
|
||||
##
|
||||
## EXAMPLE if want to run as different user
|
||||
## add HP_USER=username to /etc/default/plexpy
|
||||
## otherwise default plexpy is used
|
||||
## add TAUTULLI_USER=username to /etc/default/tautulli
|
||||
## otherwise default tautulli is used
|
||||
#
|
||||
### BEGIN INIT INFO
|
||||
# Provides: plexpy
|
||||
# Provides: tautulli
|
||||
# Required-Start: $local_fs $network $remote_fs
|
||||
# Required-Stop: $local_fs $network $remote_fs
|
||||
# Should-Start: $NetworkManager
|
||||
# Should-Stop: $NetworkManager
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: starts instance of PlexPy
|
||||
# Description: starts instance of PlexPy using start-stop-daemon
|
||||
# Short-Description: starts instance of Tautulli
|
||||
# Description: starts instance of Tautulli using start-stop-daemon
|
||||
### END INIT INFO
|
||||
|
||||
# Script name
|
||||
NAME=plexpy
|
||||
NAME=tautulli
|
||||
|
||||
# App name
|
||||
DESC=PlexPy
|
||||
DESC=Tautulli
|
||||
|
||||
SETTINGS_LOADED=FALSE
|
||||
|
||||
. /lib/lsb/init-functions
|
||||
|
||||
# Source PlexPy configuration
|
||||
if [ -f /etc/default/plexpy ]; then
|
||||
SETTINGS=/etc/default/plexpy
|
||||
# Source Tautulli configuration
|
||||
if [ -f /etc/default/tautulli ]; then
|
||||
SETTINGS=/etc/default/tautulli
|
||||
else
|
||||
log_warning_msg "/etc/default/plexpy not found using default settings.";
|
||||
log_warning_msg "/etc/default/tautulli not found using default settings.";
|
||||
fi
|
||||
|
||||
check_retval() {
|
||||
@@ -84,32 +84,32 @@ load_settings() {
|
||||
|
||||
## The defaults
|
||||
# Run as username
|
||||
RUN_AS=${HP_USER-plexpy}
|
||||
RUN_AS=${TAUTULLI_USER-tautulli}
|
||||
|
||||
# Path to app HP_HOME=path_to_app_PlexPy.py
|
||||
APP_PATH=${HP_HOME-/opt/plexpy}
|
||||
# Path to app TAUTULLI_HOME=path_to_app_Tautulli.py
|
||||
APP_PATH=${TAUTULLI_HOME-/opt/Tautulli}
|
||||
|
||||
# Data directory where plexpy.db, cache and logs are stored
|
||||
DATA_DIR=${HP_DATA-/opt/plexpy}
|
||||
DATA_DIR=${TAUTULLI_DATA-/opt/Tautulli}
|
||||
|
||||
# Path to store PID file
|
||||
PID_FILE=${HP_PIDFILE-/var/run/plexpy/plexpy.pid}
|
||||
PID_FILE=${TAUTULLI_PIDFILE-/var/run/tautulli/tautulli.pid}
|
||||
|
||||
# Path to python bin
|
||||
DAEMON=${PYTHON_BIN-/usr/bin/python}
|
||||
|
||||
# Extra daemon option like: HP_OPTS=" --config=/home/plexpy/config.ini"
|
||||
EXTRA_DAEMON_OPTS=${HP_OPTS-}
|
||||
# Extra daemon option like: TAUTULLI_OPTS=" --config=/home/Tautulli/config.ini"
|
||||
EXTRA_DAEMON_OPTS=${TAUTULLI_OPTS-}
|
||||
|
||||
# Extra start-stop-daemon option like START_OPTS=" --group=users"
|
||||
EXTRA_SSD_OPTS=${SSD_OPTS-}
|
||||
|
||||
# Hardcoded port to run on, overrides config.ini settings
|
||||
[ -n "$HP_PORT" ] && {
|
||||
PORT_OPTS=" --port=${HP_PORT} "
|
||||
[ -n "$TAUTULLI_PORT" ] && {
|
||||
PORT_OPTS=" --port=${TAUTULLI_PORT} "
|
||||
}
|
||||
|
||||
DAEMON_OPTS=" PlexPy.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}"
|
||||
DAEMON_OPTS=" Tautulli.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}"
|
||||
|
||||
SETTINGS_LOADED=TRUE
|
||||
fi
|
||||
@@ -162,7 +162,7 @@ handle_updates () {
|
||||
return 0; }
|
||||
}
|
||||
|
||||
start_plexpy () {
|
||||
start_tautulli () {
|
||||
handle_pid
|
||||
handle_datadir
|
||||
handle_updates
|
||||
@@ -175,7 +175,7 @@ start_plexpy () {
|
||||
fi
|
||||
}
|
||||
|
||||
stop_plexpy () {
|
||||
stop_tautulli () {
|
||||
if is_running; then
|
||||
log_daemon_msg "Stopping $DESC"
|
||||
start-stop-daemon -o --stop --pidfile $PID_FILE --retry 15
|
||||
@@ -187,14 +187,14 @@ stop_plexpy () {
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start_plexpy
|
||||
start_tautulli
|
||||
;;
|
||||
stop)
|
||||
stop_plexpy
|
||||
stop_tautulli
|
||||
;;
|
||||
restart|force-reload)
|
||||
stop_plexpy
|
||||
start_plexpy
|
||||
stop_tautulli
|
||||
start_tautulli
|
||||
;;
|
||||
status)
|
||||
status_of_proc -p "$PID_FILE" "$DAEMON" "$DESC"
|
||||
|
@@ -1,18 +1,18 @@
|
||||
# plexpy
|
||||
# tautulli
|
||||
#
|
||||
# This is a session/user job. Install this file into /usr/share/upstart/sessions
|
||||
# if plexpy is installed system wide, and into $XDG_CONFIG_HOME/upstart if
|
||||
# plexpy is installed per user. Change the executable path appropiately.
|
||||
# if Tautulli is installed system wide, and into $XDG_CONFIG_HOME/upstart if
|
||||
# Tautulli is installed per user. Change the executable path appropiately.
|
||||
|
||||
start on desktop-start
|
||||
stop on desktop-end
|
||||
|
||||
env CONFIG=""$XDG_CONFIG_HOME"/plexpy"
|
||||
env DATA=""$XDG_DATA_HOME"/plexpy"
|
||||
env CONFIG=""$XDG_CONFIG_HOME"/Tautulli"
|
||||
env DATA=""$XDG_DATA_HOME"/Tautulli"
|
||||
|
||||
pre-start script
|
||||
[ -d "$CONFIG" ] || mkdir -p "$CONFIG"
|
||||
[ -d "$DATA" ] || mkdir -p "$DATA"
|
||||
end script
|
||||
|
||||
exec PlexPy.py --nolaunch --config "$CONFIG"/config.ini --datadir "$DATA"
|
||||
exec Tautulli.py --nolaunch --config "$CONFIG"/config.ini --datadir "$DATA"
|
||||
|
121
lib/UniversalAnalytics/HTTPLog.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/python
|
||||
###############################################################################
|
||||
# Formatting filter for urllib2's HTTPHandler(debuglevel=1) output
|
||||
# Copyright (c) 2013, Analytics Pros
|
||||
#
|
||||
# This project is free software, distributed under the BSD license.
|
||||
# Analytics Pros offers consulting and integration services if your firm needs
|
||||
# assistance in strategy, implementation, or auditing existing work.
|
||||
###############################################################################
|
||||
|
||||
|
||||
import sys, re, os
|
||||
from cStringIO import StringIO
|
||||
|
||||
|
||||
|
||||
class BufferTranslator(object):
|
||||
""" Provides a buffer-compatible interface for filtering buffer content.
|
||||
"""
|
||||
parsers = []
|
||||
|
||||
def __init__(self, output):
|
||||
self.output = output
|
||||
self.encoding = getattr(output, 'encoding', None)
|
||||
|
||||
def write(self, content):
|
||||
content = self.translate(content)
|
||||
self.output.write(content)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def stripslashes(content):
|
||||
return content.decode('string_escape')
|
||||
|
||||
@staticmethod
|
||||
def addslashes(content):
|
||||
return content.encode('string_escape')
|
||||
|
||||
def translate(self, line):
|
||||
for pattern, method in self.parsers:
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
return method(match)
|
||||
|
||||
return line
|
||||
|
||||
|
||||
|
||||
class LineBufferTranslator(BufferTranslator):
|
||||
""" Line buffer implementation supports translation of line-format input
|
||||
even when input is not already line-buffered. Caches input until newlines
|
||||
occur, and then dispatches translated input to output buffer.
|
||||
"""
|
||||
def __init__(self, *a, **kw):
|
||||
self._linepending = []
|
||||
super(LineBufferTranslator, self).__init__(*a, **kw)
|
||||
|
||||
def write(self, _input):
|
||||
lines = _input.splitlines(True)
|
||||
for i in range(0, len(lines)):
|
||||
last = i
|
||||
if lines[i].endswith('\n'):
|
||||
prefix = len(self._linepending) and ''.join(self._linepending) or ''
|
||||
self.output.write(self.translate(prefix + lines[i]))
|
||||
del self._linepending[0:]
|
||||
last = -1
|
||||
|
||||
if last >= 0:
|
||||
self._linepending.append(lines[ last ])
|
||||
|
||||
|
||||
def __del__(self):
|
||||
if len(self._linepending):
|
||||
self.output.write(self.translate(''.join(self._linepending)))
|
||||
|
||||
|
||||
class HTTPTranslator(LineBufferTranslator):
|
||||
""" Translates output from |urllib2| HTTPHandler(debuglevel = 1) into
|
||||
HTTP-compatible, readible text structures for human analysis.
|
||||
"""
|
||||
|
||||
RE_LINE_PARSER = re.compile(r'^(?:([a-z]+):)\s*(\'?)([^\r\n]*)\2(?:[\r\n]*)$')
|
||||
RE_LINE_BREAK = re.compile(r'(\r?\n|(?:\\r)?\\n)')
|
||||
RE_HTTP_METHOD = re.compile(r'^(POST|GET|HEAD|DELETE|PUT|TRACE|OPTIONS)')
|
||||
RE_PARAMETER_SPACER = re.compile(r'&([a-z0-9]+)=')
|
||||
|
||||
@classmethod
|
||||
def spacer(cls, line):
|
||||
return cls.RE_PARAMETER_SPACER.sub(r' &\1= ', line)
|
||||
|
||||
def translate(self, line):
|
||||
|
||||
parsed = self.RE_LINE_PARSER.match(line)
|
||||
|
||||
if parsed:
|
||||
value = parsed.group(3)
|
||||
stage = parsed.group(1)
|
||||
|
||||
if stage == 'send': # query string is rendered here
|
||||
return '\n# HTTP Request:\n' + self.stripslashes(value)
|
||||
elif stage == 'reply':
|
||||
return '\n\n# HTTP Response:\n' + self.stripslashes(value)
|
||||
elif stage == 'header':
|
||||
return value + '\n'
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def consume(outbuffer = None): # Capture standard output
|
||||
sys.stdout = HTTPTranslator(outbuffer or sys.stdout)
|
||||
return sys.stdout
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
consume(sys.stdout).write(sys.stdin.read())
|
||||
print '\n'
|
||||
|
||||
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4
|
433
lib/UniversalAnalytics/Tracker.py
Normal file
@@ -0,0 +1,433 @@
|
||||
###############################################################################
|
||||
# Universal Analytics for Python
|
||||
# Copyright (c) 2013, Analytics Pros
|
||||
#
|
||||
# This project is free software, distributed under the BSD license.
|
||||
# Analytics Pros offers consulting and integration services if your firm needs
|
||||
# assistance in strategy, implementation, or auditing existing work.
|
||||
###############################################################################
|
||||
|
||||
from urllib2 import urlopen, build_opener, install_opener
|
||||
from urllib2 import Request, HTTPSHandler
|
||||
from urllib2 import URLError, HTTPError
|
||||
from urllib import urlencode
|
||||
|
||||
import random
|
||||
import datetime
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
import socket
|
||||
|
||||
|
||||
def generate_uuid(basedata=None):
|
||||
""" Provides a _random_ UUID with no input, or a UUID4-format MD5 checksum of any input data provided """
|
||||
if basedata is None:
|
||||
return str(uuid.uuid4())
|
||||
elif isinstance(basedata, basestring):
|
||||
checksum = hashlib.md5(basedata).hexdigest()
|
||||
return '%8s-%4s-%4s-%4s-%12s' % (
|
||||
checksum[0:8], checksum[8:12], checksum[12:16], checksum[16:20], checksum[20:32])
|
||||
|
||||
|
||||
class Time(datetime.datetime):
|
||||
""" Wrappers and convenience methods for processing various time representations """
|
||||
|
||||
@classmethod
|
||||
def from_unix(cls, seconds, milliseconds=0):
|
||||
""" Produce a full |datetime.datetime| object from a Unix timestamp """
|
||||
base = list(time.gmtime(seconds))[0:6]
|
||||
base.append(milliseconds * 1000) # microseconds
|
||||
return cls(*base)
|
||||
|
||||
@classmethod
|
||||
def to_unix(cls, timestamp):
|
||||
""" Wrapper over time module to produce Unix epoch time as a float """
|
||||
if not isinstance(timestamp, datetime.datetime):
|
||||
raise TypeError, 'Time.milliseconds expects a datetime object'
|
||||
base = time.mktime(timestamp.timetuple())
|
||||
return base
|
||||
|
||||
@classmethod
|
||||
def milliseconds_offset(cls, timestamp, now=None):
|
||||
""" Offset time (in milliseconds) from a |datetime.datetime| object to now """
|
||||
if isinstance(timestamp, (int, float)):
|
||||
base = timestamp
|
||||
else:
|
||||
base = cls.to_unix(timestamp)
|
||||
base = base + (timestamp.microsecond / 1000000)
|
||||
if now is None:
|
||||
now = time.time()
|
||||
return (now - base) * 1000
|
||||
|
||||
|
||||
class HTTPRequest(object):
|
||||
""" URL Construction and request handling abstraction.
|
||||
This is not intended to be used outside this module.
|
||||
|
||||
Automates mapping of persistent state (i.e. query parameters)
|
||||
onto transcient datasets for each query.
|
||||
"""
|
||||
|
||||
endpoint = 'https://www.google-analytics.com/collect'
|
||||
|
||||
@staticmethod
|
||||
def debug():
|
||||
""" Activate debugging on urllib2 """
|
||||
handler = HTTPSHandler(debuglevel=1)
|
||||
opener = build_opener(handler)
|
||||
install_opener(opener)
|
||||
|
||||
# Store properties for all requests
|
||||
def __init__(self, user_agent=None, *args, **opts):
|
||||
self.user_agent = user_agent or 'Analytics Pros - Universal Analytics (Python)'
|
||||
|
||||
@classmethod
|
||||
def fixUTF8(cls, data): # Ensure proper encoding for UA's servers...
|
||||
""" Convert all strings to UTF-8 """
|
||||
for key in data:
|
||||
if isinstance(data[key], basestring):
|
||||
data[key] = data[key].encode('utf-8')
|
||||
return data
|
||||
|
||||
# Apply stored properties to the given dataset & POST to the configured endpoint
|
||||
def send(self, data):
|
||||
request = Request(
|
||||
self.endpoint + '?' + urlencode(self.fixUTF8(data)),
|
||||
headers={
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
)
|
||||
self.open(request)
|
||||
|
||||
def open(self, request):
|
||||
try:
|
||||
return urlopen(request)
|
||||
except HTTPError as e:
|
||||
return False
|
||||
except URLError as e:
|
||||
self.cache_request(request)
|
||||
return False
|
||||
|
||||
def cache_request(self, request):
|
||||
# TODO: implement a proper caching mechanism here for re-transmitting hits
|
||||
# record = (Time.now(), request.get_full_url(), request.get_data(), request.headers)
|
||||
pass
|
||||
|
||||
|
||||
class HTTPPost(HTTPRequest):
|
||||
|
||||
# Apply stored properties to the given dataset & POST to the configured endpoint
|
||||
def send(self, data):
|
||||
request = Request(
|
||||
self.endpoint,
|
||||
data=urlencode(self.fixUTF8(data)),
|
||||
headers={
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
)
|
||||
self.open(request)
|
||||
|
||||
|
||||
class Tracker(object):
|
||||
""" Primary tracking interface for Universal Analytics """
|
||||
params = None
|
||||
parameter_alias = {}
|
||||
valid_hittypes = ('pageview', 'event', 'social', 'screenview', 'transaction', 'item', 'exception', 'timing')
|
||||
|
||||
@classmethod
|
||||
def alias(cls, typemap, base, *names):
|
||||
""" Declare an alternate (humane) name for a measurement protocol parameter """
|
||||
cls.parameter_alias[base] = (typemap, base)
|
||||
for i in names:
|
||||
cls.parameter_alias[i] = (typemap, base)
|
||||
|
||||
@classmethod
|
||||
def coerceParameter(cls, name, value=None):
|
||||
if isinstance(name, basestring) and name[0] == '&':
|
||||
return name[1:], str(value)
|
||||
elif name in cls.parameter_alias:
|
||||
typecast, param_name = cls.parameter_alias.get(name)
|
||||
return param_name, typecast(value)
|
||||
else:
|
||||
raise KeyError, 'Parameter "{0}" is not recognized'.format(name)
|
||||
|
||||
def payload(self, data):
|
||||
for key, value in data.iteritems():
|
||||
try:
|
||||
yield self.coerceParameter(key, value)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
option_sequence = {
|
||||
'pageview': [(basestring, 'dp')],
|
||||
'event': [(basestring, 'ec'), (basestring, 'ea'), (basestring, 'el'), (int, 'ev')],
|
||||
'social': [(basestring, 'sn'), (basestring, 'sa'), (basestring, 'st')],
|
||||
'timing': [(basestring, 'utc'), (basestring, 'utv'), (basestring, 'utt'), (basestring, 'utl')]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def consume_options(cls, data, hittype, args):
|
||||
""" Interpret sequential arguments related to known hittypes based on declared structures """
|
||||
opt_position = 0
|
||||
data['t'] = hittype # integrate hit type parameter
|
||||
if hittype in cls.option_sequence:
|
||||
for expected_type, optname in cls.option_sequence[hittype]:
|
||||
if opt_position < len(args) and isinstance(args[opt_position], expected_type):
|
||||
data[optname] = args[opt_position]
|
||||
opt_position += 1
|
||||
|
||||
@classmethod
|
||||
def hittime(cls, timestamp=None, age=None, milliseconds=None):
|
||||
""" Returns an integer represeting the milliseconds offset for a given hit (relative to now) """
|
||||
if isinstance(timestamp, (int, float)):
|
||||
return int(Time.milliseconds_offset(Time.from_unix(timestamp, milliseconds=milliseconds)))
|
||||
if isinstance(timestamp, datetime.datetime):
|
||||
return int(Time.milliseconds_offset(timestamp))
|
||||
if isinstance(age, (int, float)):
|
||||
return int(age * 1000) + (milliseconds or 0)
|
||||
|
||||
@property
|
||||
def account(self):
|
||||
return self.params.get('tid', None)
|
||||
|
||||
def __init__(self, account, name=None, client_id=None, hash_client_id=False, user_id=None, user_agent=None,
|
||||
use_post=True):
|
||||
|
||||
if use_post is False:
|
||||
self.http = HTTPRequest(user_agent=user_agent)
|
||||
else:
|
||||
self.http = HTTPPost(user_agent=user_agent)
|
||||
|
||||
self.params = {'v': 1, 'tid': account}
|
||||
|
||||
if client_id is None:
|
||||
client_id = generate_uuid()
|
||||
|
||||
self.params['cid'] = client_id
|
||||
|
||||
self.hash_client_id = hash_client_id
|
||||
|
||||
if user_id is not None:
|
||||
self.params['uid'] = user_id
|
||||
|
||||
def set_timestamp(self, data):
|
||||
""" Interpret time-related options, apply queue-time parameter as needed """
|
||||
if 'hittime' in data: # an absolute timestamp
|
||||
data['qt'] = self.hittime(timestamp=data.pop('hittime', None))
|
||||
if 'hitage' in data: # a relative age (in seconds)
|
||||
data['qt'] = self.hittime(age=data.pop('hitage', None))
|
||||
|
||||
def send(self, hittype, *args, **data):
|
||||
""" Transmit HTTP requests to Google Analytics using the measurement protocol """
|
||||
|
||||
if hittype not in self.valid_hittypes:
|
||||
raise KeyError('Unsupported Universal Analytics Hit Type: {0}'.format(repr(hittype)))
|
||||
|
||||
self.set_timestamp(data)
|
||||
self.consume_options(data, hittype, args)
|
||||
|
||||
for item in args: # process dictionary-object arguments of transcient data
|
||||
if isinstance(item, dict):
|
||||
for key, val in self.payload(item):
|
||||
data[key] = val
|
||||
|
||||
for k, v in self.params.iteritems(): # update only absent parameters
|
||||
if k not in data:
|
||||
data[k] = v
|
||||
|
||||
data = dict(self.payload(data))
|
||||
|
||||
if self.hash_client_id:
|
||||
data['cid'] = generate_uuid(data['cid'])
|
||||
|
||||
# Transmit the hit to Google...
|
||||
self.http.send(data)
|
||||
|
||||
# Setting persistent attibutes of the session/hit/etc (inc. custom dimensions/metrics)
|
||||
def set(self, name, value=None):
|
||||
if isinstance(name, dict):
|
||||
for key, value in name.iteritems():
|
||||
try:
|
||||
param, value = self.coerceParameter(key, value)
|
||||
self.params[param] = value
|
||||
except KeyError:
|
||||
pass
|
||||
elif isinstance(name, basestring):
|
||||
try:
|
||||
param, value = self.coerceParameter(name, value)
|
||||
self.params[param] = value
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __getitem__(self, name):
|
||||
param, value = self.coerceParameter(name, None)
|
||||
return self.params.get(param, None)
|
||||
|
||||
def __setitem__(self, name, value):
|
||||
param, value = self.coerceParameter(name, value)
|
||||
self.params[param] = value
|
||||
|
||||
def __delitem__(self, name):
|
||||
param, value = self.coerceParameter(name, None)
|
||||
if param in self.params:
|
||||
del self.params[param]
|
||||
|
||||
|
||||
def safe_unicode(obj):
|
||||
""" Safe convertion to the Unicode string version of the object """
|
||||
try:
|
||||
return unicode(obj)
|
||||
except UnicodeDecodeError:
|
||||
return obj.decode('utf-8')
|
||||
|
||||
|
||||
# Declaring name mappings for Measurement Protocol parameters
|
||||
MAX_CUSTOM_DEFINITIONS = 200
|
||||
MAX_EC_LISTS = 11 # 1-based index
|
||||
MAX_EC_PRODUCTS = 11 # 1-based index
|
||||
MAX_EC_PROMOTIONS = 11 # 1-based index
|
||||
|
||||
Tracker.alias(int, 'v', 'protocol-version')
|
||||
Tracker.alias(safe_unicode, 'cid', 'client-id', 'clientId', 'clientid')
|
||||
Tracker.alias(safe_unicode, 'tid', 'trackingId', 'account')
|
||||
Tracker.alias(safe_unicode, 'uid', 'user-id', 'userId', 'userid')
|
||||
Tracker.alias(safe_unicode, 'uip', 'user-ip', 'userIp', 'ipaddr')
|
||||
Tracker.alias(safe_unicode, 'ua', 'userAgent', 'userAgentOverride', 'user-agent')
|
||||
Tracker.alias(safe_unicode, 'dp', 'page', 'path')
|
||||
Tracker.alias(safe_unicode, 'dt', 'title', 'pagetitle', 'pageTitle' 'page-title')
|
||||
Tracker.alias(safe_unicode, 'dl', 'location')
|
||||
Tracker.alias(safe_unicode, 'dh', 'hostname')
|
||||
Tracker.alias(safe_unicode, 'sc', 'sessioncontrol', 'session-control', 'sessionControl')
|
||||
Tracker.alias(safe_unicode, 'dr', 'referrer', 'referer')
|
||||
Tracker.alias(int, 'qt', 'queueTime', 'queue-time')
|
||||
Tracker.alias(safe_unicode, 't', 'hitType', 'hittype')
|
||||
Tracker.alias(int, 'aip', 'anonymizeIp', 'anonIp', 'anonymize-ip')
|
||||
Tracker.alias(safe_unicode, 'ds', 'dataSource', 'data-source')
|
||||
|
||||
# Campaign attribution
|
||||
Tracker.alias(safe_unicode, 'cn', 'campaign', 'campaignName', 'campaign-name')
|
||||
Tracker.alias(safe_unicode, 'cs', 'source', 'campaignSource', 'campaign-source')
|
||||
Tracker.alias(safe_unicode, 'cm', 'medium', 'campaignMedium', 'campaign-medium')
|
||||
Tracker.alias(safe_unicode, 'ck', 'keyword', 'campaignKeyword', 'campaign-keyword')
|
||||
Tracker.alias(safe_unicode, 'cc', 'content', 'campaignContent', 'campaign-content')
|
||||
Tracker.alias(safe_unicode, 'ci', 'campaignId', 'campaignID', 'campaign-id')
|
||||
|
||||
# Technical specs
|
||||
Tracker.alias(safe_unicode, 'sr', 'screenResolution', 'screen-resolution', 'resolution')
|
||||
Tracker.alias(safe_unicode, 'vp', 'viewport', 'viewportSize', 'viewport-size')
|
||||
Tracker.alias(safe_unicode, 'de', 'encoding', 'documentEncoding', 'document-encoding')
|
||||
Tracker.alias(int, 'sd', 'colors', 'screenColors', 'screen-colors')
|
||||
Tracker.alias(safe_unicode, 'ul', 'language', 'user-language', 'userLanguage')
|
||||
|
||||
# Mobile app
|
||||
Tracker.alias(safe_unicode, 'an', 'appName', 'app-name', 'app')
|
||||
Tracker.alias(safe_unicode, 'cd', 'contentDescription', 'screenName', 'screen-name', 'content-description')
|
||||
Tracker.alias(safe_unicode, 'av', 'appVersion', 'app-version', 'version')
|
||||
Tracker.alias(safe_unicode, 'aid', 'appID', 'appId', 'application-id', 'app-id', 'applicationId')
|
||||
Tracker.alias(safe_unicode, 'aiid', 'appInstallerId', 'app-installer-id')
|
||||
|
||||
# Ecommerce
|
||||
Tracker.alias(safe_unicode, 'ta', 'affiliation', 'transactionAffiliation', 'transaction-affiliation')
|
||||
Tracker.alias(safe_unicode, 'ti', 'transaction', 'transactionId', 'transaction-id')
|
||||
Tracker.alias(float, 'tr', 'revenue', 'transactionRevenue', 'transaction-revenue')
|
||||
Tracker.alias(float, 'ts', 'shipping', 'transactionShipping', 'transaction-shipping')
|
||||
Tracker.alias(float, 'tt', 'tax', 'transactionTax', 'transaction-tax')
|
||||
Tracker.alias(safe_unicode, 'cu', 'currency', 'transactionCurrency',
|
||||
'transaction-currency') # Currency code, e.g. USD, EUR
|
||||
Tracker.alias(safe_unicode, 'in', 'item-name', 'itemName')
|
||||
Tracker.alias(float, 'ip', 'item-price', 'itemPrice')
|
||||
Tracker.alias(float, 'iq', 'item-quantity', 'itemQuantity')
|
||||
Tracker.alias(safe_unicode, 'ic', 'item-code', 'sku', 'itemCode')
|
||||
Tracker.alias(safe_unicode, 'iv', 'item-variation', 'item-category', 'itemCategory', 'itemVariation')
|
||||
|
||||
# Events
|
||||
Tracker.alias(safe_unicode, 'ec', 'event-category', 'eventCategory', 'category')
|
||||
Tracker.alias(safe_unicode, 'ea', 'event-action', 'eventAction', 'action')
|
||||
Tracker.alias(safe_unicode, 'el', 'event-label', 'eventLabel', 'label')
|
||||
Tracker.alias(int, 'ev', 'event-value', 'eventValue', 'value')
|
||||
Tracker.alias(int, 'ni', 'noninteractive', 'nonInteractive', 'noninteraction', 'nonInteraction')
|
||||
|
||||
# Social
|
||||
Tracker.alias(safe_unicode, 'sa', 'social-action', 'socialAction')
|
||||
Tracker.alias(safe_unicode, 'sn', 'social-network', 'socialNetwork')
|
||||
Tracker.alias(safe_unicode, 'st', 'social-target', 'socialTarget')
|
||||
|
||||
# Exceptions
|
||||
Tracker.alias(safe_unicode, 'exd', 'exception-description', 'exceptionDescription', 'exDescription')
|
||||
Tracker.alias(int, 'exf', 'exception-fatal', 'exceptionFatal', 'exFatal')
|
||||
|
||||
# User Timing
|
||||
Tracker.alias(safe_unicode, 'utc', 'timingCategory', 'timing-category')
|
||||
Tracker.alias(safe_unicode, 'utv', 'timingVariable', 'timing-variable')
|
||||
Tracker.alias(float, 'utt', 'time', 'timingTime', 'timing-time')
|
||||
Tracker.alias(safe_unicode, 'utl', 'timingLabel', 'timing-label')
|
||||
Tracker.alias(float, 'dns', 'timingDNS', 'timing-dns')
|
||||
Tracker.alias(float, 'pdt', 'timingPageLoad', 'timing-page-load')
|
||||
Tracker.alias(float, 'rrt', 'timingRedirect', 'timing-redirect')
|
||||
Tracker.alias(safe_unicode, 'tcp', 'timingTCPConnect', 'timing-tcp-connect')
|
||||
Tracker.alias(safe_unicode, 'srt', 'timingServerResponse', 'timing-server-response')
|
||||
|
||||
# Custom dimensions and metrics
|
||||
for i in range(0, 200):
|
||||
Tracker.alias(safe_unicode, 'cd{0}'.format(i), 'dimension{0}'.format(i))
|
||||
Tracker.alias(int, 'cm{0}'.format(i), 'metric{0}'.format(i))
|
||||
|
||||
# Content groups
|
||||
for i in range(0, 5):
|
||||
Tracker.alias(safe_unicode, 'cg{0}'.format(i), 'contentGroup{0}'.format(i))
|
||||
|
||||
# Enhanced Ecommerce
|
||||
Tracker.alias(str, 'pa') # Product action
|
||||
Tracker.alias(str, 'tcc') # Coupon code
|
||||
Tracker.alias(unicode, 'pal') # Product action list
|
||||
Tracker.alias(int, 'cos') # Checkout step
|
||||
Tracker.alias(str, 'col') # Checkout step option
|
||||
|
||||
Tracker.alias(str, 'promoa') # Promotion action
|
||||
|
||||
for product_index in range(1, MAX_EC_PRODUCTS):
|
||||
Tracker.alias(str, 'pr{0}id'.format(product_index)) # Product SKU
|
||||
Tracker.alias(unicode, 'pr{0}nm'.format(product_index)) # Product name
|
||||
Tracker.alias(unicode, 'pr{0}br'.format(product_index)) # Product brand
|
||||
Tracker.alias(unicode, 'pr{0}ca'.format(product_index)) # Product category
|
||||
Tracker.alias(unicode, 'pr{0}va'.format(product_index)) # Product variant
|
||||
Tracker.alias(str, 'pr{0}pr'.format(product_index)) # Product price
|
||||
Tracker.alias(int, 'pr{0}qt'.format(product_index)) # Product quantity
|
||||
Tracker.alias(str, 'pr{0}cc'.format(product_index)) # Product coupon code
|
||||
Tracker.alias(int, 'pr{0}ps'.format(product_index)) # Product position
|
||||
|
||||
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
|
||||
Tracker.alias(str, 'pr{0}cd{1}'.format(product_index, custom_index)) # Product custom dimension
|
||||
Tracker.alias(int, 'pr{0}cm{1}'.format(product_index, custom_index)) # Product custom metric
|
||||
|
||||
for list_index in range(1, MAX_EC_LISTS):
|
||||
Tracker.alias(str, 'il{0}pi{1}id'.format(list_index, product_index)) # Product impression SKU
|
||||
Tracker.alias(unicode, 'il{0}pi{1}nm'.format(list_index, product_index)) # Product impression name
|
||||
Tracker.alias(unicode, 'il{0}pi{1}br'.format(list_index, product_index)) # Product impression brand
|
||||
Tracker.alias(unicode, 'il{0}pi{1}ca'.format(list_index, product_index)) # Product impression category
|
||||
Tracker.alias(unicode, 'il{0}pi{1}va'.format(list_index, product_index)) # Product impression variant
|
||||
Tracker.alias(int, 'il{0}pi{1}ps'.format(list_index, product_index)) # Product impression position
|
||||
Tracker.alias(int, 'il{0}pi{1}pr'.format(list_index, product_index)) # Product impression price
|
||||
|
||||
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
|
||||
Tracker.alias(str, 'il{0}pi{1}cd{2}'.format(list_index, product_index,
|
||||
custom_index)) # Product impression custom dimension
|
||||
Tracker.alias(int, 'il{0}pi{1}cm{2}'.format(list_index, product_index,
|
||||
custom_index)) # Product impression custom metric
|
||||
|
||||
for list_index in range(1, MAX_EC_LISTS):
|
||||
Tracker.alias(unicode, 'il{0}nm'.format(list_index)) # Product impression list name
|
||||
|
||||
for promotion_index in range(1, MAX_EC_PROMOTIONS):
|
||||
Tracker.alias(str, 'promo{0}id'.format(promotion_index)) # Promotion ID
|
||||
Tracker.alias(unicode, 'promo{0}nm'.format(promotion_index)) # Promotion name
|
||||
Tracker.alias(str, 'promo{0}cr'.format(promotion_index)) # Promotion creative
|
||||
Tracker.alias(str, 'promo{0}ps'.format(promotion_index)) # Promotion position
|
||||
|
||||
|
||||
# Shortcut for creating trackers
|
||||
def create(account, *args, **kwargs):
|
||||
return Tracker(account, *args, **kwargs)
|
||||
|
||||
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4
|
1
lib/UniversalAnalytics/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
import Tracker
|
@@ -1,5 +1,10 @@
|
||||
version_info = (3, 0, 1)
|
||||
version = '3.0.1'
|
||||
release = '3.0.1'
|
||||
from pkg_resources import get_distribution, DistributionNotFound
|
||||
|
||||
__version__ = release # PEP 396
|
||||
try:
|
||||
release = get_distribution('APScheduler').version.split('-')[0]
|
||||
except DistributionNotFound:
|
||||
release = '3.5.0'
|
||||
|
||||
version_info = tuple(int(x) if x.isdigit() else x for x in release.split('.'))
|
||||
version = __version__ = '.'.join(str(x) for x in version_info[:3])
|
||||
del get_distribution, DistributionNotFound
|
||||
|
@@ -1,25 +1,33 @@
|
||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
||||
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_ADDED',
|
||||
'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED',
|
||||
__all__ = ('EVENT_SCHEDULER_STARTED', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_SCHEDULER_PAUSED',
|
||||
'EVENT_SCHEDULER_RESUMED', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
||||
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED',
|
||||
'EVENT_JOB_ADDED', 'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED',
|
||||
'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
|
||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
|
||||
|
||||
|
||||
EVENT_SCHEDULER_START = 1
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2
|
||||
EVENT_EXECUTOR_ADDED = 4
|
||||
EVENT_EXECUTOR_REMOVED = 8
|
||||
EVENT_JOBSTORE_ADDED = 16
|
||||
EVENT_JOBSTORE_REMOVED = 32
|
||||
EVENT_ALL_JOBS_REMOVED = 64
|
||||
EVENT_JOB_ADDED = 128
|
||||
EVENT_JOB_REMOVED = 256
|
||||
EVENT_JOB_MODIFIED = 512
|
||||
EVENT_JOB_EXECUTED = 1024
|
||||
EVENT_JOB_ERROR = 2048
|
||||
EVENT_JOB_MISSED = 4096
|
||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
||||
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
|
||||
EVENT_SCHEDULER_PAUSED = 2 ** 2
|
||||
EVENT_SCHEDULER_RESUMED = 2 ** 3
|
||||
EVENT_EXECUTOR_ADDED = 2 ** 4
|
||||
EVENT_EXECUTOR_REMOVED = 2 ** 5
|
||||
EVENT_JOBSTORE_ADDED = 2 ** 6
|
||||
EVENT_JOBSTORE_REMOVED = 2 ** 7
|
||||
EVENT_ALL_JOBS_REMOVED = 2 ** 8
|
||||
EVENT_JOB_ADDED = 2 ** 9
|
||||
EVENT_JOB_REMOVED = 2 ** 10
|
||||
EVENT_JOB_MODIFIED = 2 ** 11
|
||||
EVENT_JOB_EXECUTED = 2 ** 12
|
||||
EVENT_JOB_ERROR = 2 ** 13
|
||||
EVENT_JOB_MISSED = 2 ** 14
|
||||
EVENT_JOB_SUBMITTED = 2 ** 15
|
||||
EVENT_JOB_MAX_INSTANCES = 2 ** 16
|
||||
EVENT_ALL = (EVENT_SCHEDULER_STARTED | EVENT_SCHEDULER_SHUTDOWN | EVENT_SCHEDULER_PAUSED |
|
||||
EVENT_SCHEDULER_RESUMED | EVENT_EXECUTOR_ADDED | EVENT_EXECUTOR_REMOVED |
|
||||
EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED | EVENT_ALL_JOBS_REMOVED |
|
||||
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
||||
EVENT_JOB_ERROR | EVENT_JOB_MISSED)
|
||||
EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES)
|
||||
|
||||
|
||||
class SchedulerEvent(object):
|
||||
@@ -55,9 +63,21 @@ class JobEvent(SchedulerEvent):
|
||||
self.jobstore = jobstore
|
||||
|
||||
|
||||
class JobSubmissionEvent(JobEvent):
|
||||
"""
|
||||
An event that concerns the submission of a job to its executor.
|
||||
|
||||
:ivar scheduled_run_times: a list of datetimes when the job was intended to run
|
||||
"""
|
||||
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_times):
|
||||
super(JobSubmissionEvent, self).__init__(code, job_id, jobstore)
|
||||
self.scheduled_run_times = scheduled_run_times
|
||||
|
||||
|
||||
class JobExecutionEvent(JobEvent):
|
||||
"""
|
||||
An event that concerns the execution of individual jobs.
|
||||
An event that concerns the running of a job within its executor.
|
||||
|
||||
:ivar scheduled_run_time: the time when the job was scheduled to be run
|
||||
:ivar retval: the return value of the successfully executed job
|
||||
@@ -65,7 +85,8 @@ class JobExecutionEvent(JobEvent):
|
||||
:ivar traceback: a formatted traceback for the exception
|
||||
"""
|
||||
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None):
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None,
|
||||
traceback=None):
|
||||
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
||||
self.scheduled_run_time = scheduled_run_time
|
||||
self.retval = retval
|
||||
|
@@ -1,28 +1,60 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
try:
|
||||
from asyncio import iscoroutinefunction
|
||||
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||
except ImportError:
|
||||
from trollius import iscoroutinefunction
|
||||
run_coroutine_job = None
|
||||
|
||||
|
||||
class AsyncIOExecutor(BaseExecutor):
|
||||
"""
|
||||
Runs jobs in the default executor of the event loop.
|
||||
|
||||
If the job function is a native coroutine function, it is scheduled to be run directly in the
|
||||
event loop as soon as possible. All other functions are run in the event loop's default
|
||||
executor which is usually a thread pool.
|
||||
|
||||
Plugin alias: ``asyncio``
|
||||
"""
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(AsyncIOExecutor, self).start(scheduler, alias)
|
||||
self._eventloop = scheduler._eventloop
|
||||
self._pending_futures = set()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
# There is no way to honor wait=True without converting this method into a coroutine method
|
||||
for f in self._pending_futures:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
|
||||
self._pending_futures.clear()
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(f):
|
||||
self._pending_futures.discard(f)
|
||||
try:
|
||||
events = f.result()
|
||||
except:
|
||||
except BaseException:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
if iscoroutinefunction(job.func):
|
||||
if run_coroutine_job is not None:
|
||||
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f = self._eventloop.create_task(coro)
|
||||
else:
|
||||
raise Exception('Executing coroutine based jobs is not supported with Trollius')
|
||||
else:
|
||||
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
|
||||
f.add_done_callback(callback)
|
||||
self._pending_futures.add(f)
|
||||
|
@@ -8,13 +8,15 @@ import sys
|
||||
from pytz import utc
|
||||
import six
|
||||
|
||||
from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
from apscheduler.events import (
|
||||
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
|
||||
|
||||
|
||||
class MaxInstancesReachedError(Exception):
|
||||
def __init__(self, job):
|
||||
super(MaxInstancesReachedError, self).__init__(
|
||||
'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances))
|
||||
'Job "%s" has already reached its maximum number of instances (%d)' %
|
||||
(job.id, job.max_instances))
|
||||
|
||||
|
||||
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
@@ -30,13 +32,14 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
"""
|
||||
Called by the scheduler when the scheduler is being started or when the executor is being added to an already
|
||||
running scheduler.
|
||||
Called by the scheduler when the scheduler is being started or when the executor is being
|
||||
added to an already running scheduler.
|
||||
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this executor
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||
this executor
|
||||
:param str|unicode alias: alias of this executor as it was assigned to the scheduler
|
||||
"""
|
||||
|
||||
"""
|
||||
self._scheduler = scheduler
|
||||
self._lock = scheduler._create_lock()
|
||||
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
||||
@@ -45,7 +48,8 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
"""
|
||||
Shuts down this executor.
|
||||
|
||||
:param bool wait: ``True`` to wait until all submitted jobs have been executed
|
||||
:param bool wait: ``True`` to wait until all submitted jobs
|
||||
have been executed
|
||||
"""
|
||||
|
||||
def submit_job(self, job, run_times):
|
||||
@@ -53,10 +57,12 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
Submits job for execution.
|
||||
|
||||
:param Job job: job to execute
|
||||
:param list[datetime] run_times: list of datetimes specifying when the job should have been run
|
||||
:raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached
|
||||
"""
|
||||
:param list[datetime] run_times: list of datetimes specifying
|
||||
when the job should have been run
|
||||
:raises MaxInstancesReachedError: if the maximum number of
|
||||
allowed instances for this job has been reached
|
||||
|
||||
"""
|
||||
assert self._lock is not None, 'This executor has not been started yet'
|
||||
with self._lock:
|
||||
if self._instances[job.id] >= job.max_instances:
|
||||
@@ -70,50 +76,71 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
"""Performs the actual task of scheduling `run_job` to be called."""
|
||||
|
||||
def _run_job_success(self, job_id, events):
|
||||
"""Called by the executor with the list of generated events when `run_job` has been successfully called."""
|
||||
"""
|
||||
Called by the executor with the list of generated events when :func:`run_job` has been
|
||||
successfully called.
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
self._instances[job_id] -= 1
|
||||
if self._instances[job_id] == 0:
|
||||
del self._instances[job_id]
|
||||
|
||||
for event in events:
|
||||
self._scheduler._dispatch_event(event)
|
||||
|
||||
def _run_job_error(self, job_id, exc, traceback=None):
|
||||
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
||||
|
||||
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
||||
with self._lock:
|
||||
self._instances[job_id] -= 1
|
||||
if self._instances[job_id] == 0:
|
||||
del self._instances[job_id]
|
||||
|
||||
exc_info = (exc.__class__, exc, traceback)
|
||||
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
||||
|
||||
|
||||
def run_job(job, jobstore_alias, run_times, logger_name):
|
||||
"""Called by executors to run the job. Returns a list of scheduler events to be dispatched by the scheduler."""
|
||||
"""
|
||||
Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
|
||||
scheduler.
|
||||
|
||||
"""
|
||||
events = []
|
||||
logger = logging.getLogger(logger_name)
|
||||
for run_time in run_times:
|
||||
# See if the job missed its run time window, and handle possible misfires accordingly
|
||||
# See if the job missed its run time window, and handle
|
||||
# possible misfires accordingly
|
||||
if job.misfire_grace_time is not None:
|
||||
difference = datetime.now(utc) - run_time
|
||||
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||
if difference > grace_time:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, run_time))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
|
||||
run_time))
|
||||
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
||||
continue
|
||||
|
||||
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
||||
try:
|
||||
retval = job.func(*job.args, **job.kwargs)
|
||||
except:
|
||||
except BaseException:
|
||||
exc, tb = sys.exc_info()[1:]
|
||||
formatted_tb = ''.join(format_tb(tb))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, exception=exc,
|
||||
traceback=formatted_tb))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
|
||||
exception=exc, traceback=formatted_tb))
|
||||
logger.exception('Job "%s" raised an exception', job)
|
||||
|
||||
# This is to prevent cyclic references that would lead to memory leaks
|
||||
if six.PY2:
|
||||
sys.exc_clear()
|
||||
del tb
|
||||
else:
|
||||
import traceback
|
||||
traceback.clear_frames(tb)
|
||||
del tb
|
||||
else:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||
retval=retval))
|
||||
logger.info('Job "%s" executed successfully', job)
|
||||
|
||||
return events
|
||||
|
41
lib/apscheduler/executors/base_py3.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from traceback import format_tb
|
||||
|
||||
from pytz import utc
|
||||
|
||||
from apscheduler.events import (
|
||||
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
|
||||
|
||||
|
||||
async def run_coroutine_job(job, jobstore_alias, run_times, logger_name):
|
||||
"""Coroutine version of run_job()."""
|
||||
events = []
|
||||
logger = logging.getLogger(logger_name)
|
||||
for run_time in run_times:
|
||||
# See if the job missed its run time window, and handle possible misfires accordingly
|
||||
if job.misfire_grace_time is not None:
|
||||
difference = datetime.now(utc) - run_time
|
||||
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||
if difference > grace_time:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
|
||||
run_time))
|
||||
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
||||
continue
|
||||
|
||||
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
||||
try:
|
||||
retval = await job.func(*job.args, **job.kwargs)
|
||||
except BaseException:
|
||||
exc, tb = sys.exc_info()[1:]
|
||||
formatted_tb = ''.join(format_tb(tb))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
|
||||
exception=exc, traceback=formatted_tb))
|
||||
logger.exception('Job "%s" raised an exception', job)
|
||||
else:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||
retval=retval))
|
||||
logger.info('Job "%s" executed successfully', job)
|
||||
|
||||
return events
|
@@ -5,7 +5,8 @@ from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
class DebugExecutor(BaseExecutor):
|
||||
"""
|
||||
A special executor that executes the target callable directly instead of deferring it to a thread or process.
|
||||
A special executor that executes the target callable directly instead of deferring it to a
|
||||
thread or process.
|
||||
|
||||
Plugin alias: ``debug``
|
||||
"""
|
||||
@@ -13,7 +14,7 @@ class DebugExecutor(BaseExecutor):
|
||||
def _do_submit_job(self, job, run_times):
|
||||
try:
|
||||
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
except:
|
||||
except BaseException:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
@@ -21,9 +21,10 @@ class GeventExecutor(BaseExecutor):
|
||||
def callback(greenlet):
|
||||
try:
|
||||
events = greenlet.get()
|
||||
except:
|
||||
except BaseException:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
|
||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\
|
||||
link(callback)
|
||||
|
54
lib/apscheduler/executors/tornado.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from tornado.gen import convert_yielded
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
try:
|
||||
from inspect import iscoroutinefunction
|
||||
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||
except ImportError:
|
||||
def iscoroutinefunction(func):
|
||||
return False
|
||||
|
||||
|
||||
class TornadoExecutor(BaseExecutor):
|
||||
"""
|
||||
Runs jobs either in a thread pool or directly on the I/O loop.
|
||||
|
||||
If the job function is a native coroutine function, it is scheduled to be run directly in the
|
||||
I/O loop as soon as possible. All other functions are run in a thread pool.
|
||||
|
||||
Plugin alias: ``tornado``
|
||||
|
||||
:param int max_workers: maximum number of worker threads in the thread pool
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers=10):
|
||||
super(TornadoExecutor, self).__init__()
|
||||
self.executor = ThreadPoolExecutor(max_workers)
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(TornadoExecutor, self).start(scheduler, alias)
|
||||
self._ioloop = scheduler._ioloop
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(f):
|
||||
try:
|
||||
events = f.result()
|
||||
except BaseException:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
if iscoroutinefunction(job.func):
|
||||
f = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
else:
|
||||
f = self.executor.submit(run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
|
||||
f = convert_yielded(f)
|
||||
f.add_done_callback(callback)
|
@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
|
||||
else:
|
||||
self._run_job_error(job.id, result.value, result.tb)
|
||||
|
||||
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
self._reactor.getThreadPool().callInThreadWithCallback(
|
||||
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
|
@@ -4,8 +4,9 @@ from uuid import uuid4
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \
|
||||
convert_to_datetime
|
||||
from apscheduler.util import (
|
||||
ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args,
|
||||
convert_to_datetime)
|
||||
|
||||
|
||||
class Job(object):
|
||||
@@ -21,13 +22,20 @@ class Job(object):
|
||||
:var bool coalesce: whether to only run the job once when several run times are due
|
||||
:var trigger: the trigger object that controls the schedule of this job
|
||||
:var str executor: the name of the executor that will run this job
|
||||
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
|
||||
:var int max_instances: the maximum number of concurrently executing instances allowed for this job
|
||||
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
|
||||
be late
|
||||
:var int max_instances: the maximum number of concurrently executing instances allowed for this
|
||||
job
|
||||
:var datetime.datetime next_run_time: the next scheduled run time of this job
|
||||
|
||||
.. note::
|
||||
The ``misfire_grace_time`` has some non-obvious effects on job execution. See the
|
||||
:ref:`missed-job-executions` section in the documentation for an in-depth explanation.
|
||||
"""
|
||||
|
||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
|
||||
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
|
||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref',
|
||||
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
|
||||
'next_run_time')
|
||||
|
||||
def __init__(self, scheduler, id=None, **kwargs):
|
||||
super(Job, self).__init__()
|
||||
@@ -38,53 +46,69 @@ class Job(object):
|
||||
def modify(self, **changes):
|
||||
"""
|
||||
Makes the given changes to this job and saves it in the associated job store.
|
||||
|
||||
Accepted keyword arguments are the same as the variables on this class.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
||||
"""
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
||||
return self
|
||||
|
||||
def reschedule(self, trigger, **trigger_args):
|
||||
"""
|
||||
Shortcut for switching the trigger on this job.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
||||
"""
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
||||
return self
|
||||
|
||||
def pause(self):
|
||||
"""
|
||||
Temporarily suspend the execution of this job.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
||||
"""
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
||||
return self
|
||||
|
||||
def resume(self):
|
||||
"""
|
||||
Resume the schedule of this job if previously paused.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
||||
"""
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
||||
return self
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Unschedules this job and removes it from its associated job store.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
||||
"""
|
||||
|
||||
"""
|
||||
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
||||
|
||||
@property
|
||||
def pending(self):
|
||||
"""Returns ``True`` if the referenced job is still waiting to be added to its designated job store."""
|
||||
"""
|
||||
Returns ``True`` if the referenced job is still waiting to be added to its designated job
|
||||
store.
|
||||
|
||||
"""
|
||||
return self._jobstore_alias is None
|
||||
|
||||
#
|
||||
@@ -97,8 +121,8 @@ class Job(object):
|
||||
|
||||
:type now: datetime.datetime
|
||||
:rtype: list[datetime.datetime]
|
||||
"""
|
||||
|
||||
"""
|
||||
run_times = []
|
||||
next_run_time = self.next_run_time
|
||||
while next_run_time and next_run_time <= now:
|
||||
@@ -108,8 +132,11 @@ class Job(object):
|
||||
return run_times
|
||||
|
||||
def _modify(self, **changes):
|
||||
"""Validates the changes to the Job and makes the modifications if and only if all of them validate."""
|
||||
"""
|
||||
Validates the changes to the Job and makes the modifications if and only if all of them
|
||||
validate.
|
||||
|
||||
"""
|
||||
approved = {}
|
||||
|
||||
if 'id' in changes:
|
||||
@@ -125,7 +152,7 @@ class Job(object):
|
||||
args = changes.pop('args') if 'args' in changes else self.args
|
||||
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
||||
|
||||
if isinstance(func, str):
|
||||
if isinstance(func, six.string_types):
|
||||
func_ref = func
|
||||
func = ref_to_obj(func)
|
||||
elif callable(func):
|
||||
@@ -177,7 +204,8 @@ class Job(object):
|
||||
if 'trigger' in changes:
|
||||
trigger = changes.pop('trigger')
|
||||
if not isinstance(trigger, BaseTrigger):
|
||||
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
|
||||
raise TypeError('Expected a trigger instance, got %s instead' %
|
||||
trigger.__class__.__name__)
|
||||
|
||||
approved['trigger'] = trigger
|
||||
|
||||
@@ -189,10 +217,12 @@ class Job(object):
|
||||
|
||||
if 'next_run_time' in changes:
|
||||
value = changes.pop('next_run_time')
|
||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
|
||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone,
|
||||
'next_run_time')
|
||||
|
||||
if changes:
|
||||
raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes))
|
||||
raise AttributeError('The following are not modifiable attributes of Job: %s' %
|
||||
', '.join(changes))
|
||||
|
||||
for key, value in six.iteritems(approved):
|
||||
setattr(self, key, value)
|
||||
@@ -200,9 +230,10 @@ class Job(object):
|
||||
def __getstate__(self):
|
||||
# Don't allow this Job to be serialized if the function reference could not be determined
|
||||
if not self.func_ref:
|
||||
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
|
||||
'determined. Consider giving a textual reference (module:function name) instead.' %
|
||||
(self.func,))
|
||||
raise ValueError(
|
||||
'This Job cannot be serialized since the reference to its callable (%r) could not '
|
||||
'be determined. Consider giving a textual reference (module:function name) '
|
||||
'instead.' % (self.func,))
|
||||
|
||||
return {
|
||||
'version': 1,
|
||||
@@ -221,7 +252,8 @@ class Job(object):
|
||||
|
||||
def __setstate__(self, state):
|
||||
if state.get('version', 1) > 1:
|
||||
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
|
||||
raise ValueError('Job has version %s, but only version 1 can be handled' %
|
||||
state['version'])
|
||||
|
||||
self.id = state['id']
|
||||
self.func_ref = state['func']
|
||||
@@ -245,8 +277,13 @@ class Job(object):
|
||||
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
||||
|
||||
def __str__(self):
|
||||
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
|
||||
datetime_repr(self.next_run_time))
|
||||
return repr_escape(self.__unicode__())
|
||||
|
||||
def __unicode__(self):
|
||||
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
|
||||
if hasattr(self, 'next_run_time'):
|
||||
status = ('next run at: ' + datetime_repr(self.next_run_time) if
|
||||
self.next_run_time else 'paused')
|
||||
else:
|
||||
status = 'pending'
|
||||
|
||||
return u'%s (trigger: %s, %s)' % (self.name, self.trigger, status)
|
||||
|
@@ -8,23 +8,27 @@ class JobLookupError(KeyError):
|
||||
"""Raised when the job store cannot find a job for update or removal."""
|
||||
|
||||
def __init__(self, job_id):
|
||||
super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id)
|
||||
super(JobLookupError, self).__init__(u'No job by the id of %s was found' % job_id)
|
||||
|
||||
|
||||
class ConflictingIdError(KeyError):
|
||||
"""Raised when the uniqueness of job IDs is being violated."""
|
||||
|
||||
def __init__(self, job_id):
|
||||
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
|
||||
super(ConflictingIdError, self).__init__(
|
||||
u'Job identifier (%s) conflicts with an existing job' % job_id)
|
||||
|
||||
|
||||
class TransientJobError(ValueError):
|
||||
"""Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected."""
|
||||
"""
|
||||
Raised when an attempt to add transient (with no func_ref) job to a persistent job store is
|
||||
detected.
|
||||
"""
|
||||
|
||||
def __init__(self, job_id):
|
||||
super(TransientJobError, self).__init__(
|
||||
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
|
||||
'determined.') % job_id)
|
||||
u'Job (%s) cannot be added to this job store because a reference to the callable '
|
||||
u'could not be determined.' % job_id)
|
||||
|
||||
|
||||
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@@ -36,10 +40,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
"""
|
||||
Called by the scheduler when the scheduler is being started or when the job store is being added to an already
|
||||
running scheduler.
|
||||
Called by the scheduler when the scheduler is being started or when the job store is being
|
||||
added to an already running scheduler.
|
||||
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this job store
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||
this job store
|
||||
:param str|unicode alias: alias of this job store as it was assigned to the scheduler
|
||||
"""
|
||||
|
||||
@@ -50,13 +55,22 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
def shutdown(self):
|
||||
"""Frees any resources still bound to this job store."""
|
||||
|
||||
def _fix_paused_jobs_sorting(self, jobs):
|
||||
for i, job in enumerate(jobs):
|
||||
if job.next_run_time is not None:
|
||||
if i > 0:
|
||||
paused_jobs = jobs[:i]
|
||||
del jobs[:i]
|
||||
jobs.extend(paused_jobs)
|
||||
break
|
||||
|
||||
@abstractmethod
|
||||
def lookup_job(self, job_id):
|
||||
"""
|
||||
Returns a specific job, or ``None`` if it isn't found..
|
||||
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to
|
||||
point to the scheduler and itself, respectively.
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||
the returned job to point to the scheduler and itself, respectively.
|
||||
|
||||
:param str|unicode job_id: identifier of the job
|
||||
:rtype: Job
|
||||
@@ -75,7 +89,8 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@abstractmethod
|
||||
def get_next_run_time(self):
|
||||
"""
|
||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if there are no active jobs.
|
||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if
|
||||
there are no active jobs.
|
||||
|
||||
:rtype: datetime.datetime
|
||||
"""
|
||||
@@ -83,11 +98,12 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@abstractmethod
|
||||
def get_all_jobs(self):
|
||||
"""
|
||||
Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending).
|
||||
Paused jobs (next_run_time is None) should be sorted last.
|
||||
Returns a list of all jobs in this job store.
|
||||
The returned jobs should be sorted by next run time (ascending).
|
||||
Paused jobs (next_run_time == None) should be sorted last.
|
||||
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to
|
||||
point to the scheduler and itself, respectively.
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||
the returned jobs to point to the scheduler and itself, respectively.
|
||||
|
||||
:rtype: list[Job]
|
||||
"""
|
||||
|
@@ -13,7 +13,8 @@ class MemoryJobStore(BaseJobStore):
|
||||
|
||||
def __init__(self):
|
||||
super(MemoryJobStore, self).__init__()
|
||||
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||
# list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||
self._jobs = []
|
||||
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
@@ -80,13 +81,13 @@ class MemoryJobStore(BaseJobStore):
|
||||
|
||||
def _get_job_index(self, timestamp, job_id):
|
||||
"""
|
||||
Returns the index of the given job, or if it's not found, the index where the job should be inserted based on
|
||||
the given timestamp.
|
||||
Returns the index of the given job, or if it's not found, the index where the job should be
|
||||
inserted based on the given timestamp.
|
||||
|
||||
:type timestamp: int
|
||||
:type job_id: str
|
||||
"""
|
||||
|
||||
"""
|
||||
lo, hi = 0, len(self._jobs)
|
||||
timestamp = float('inf') if timestamp is None else timestamp
|
||||
while lo < hi:
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
import warnings
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||
@@ -19,16 +20,18 @@ except ImportError: # pragma: nocover
|
||||
|
||||
class MongoDBJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to pymongo's `MongoClient
|
||||
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to
|
||||
pymongo's `MongoClient
|
||||
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
|
||||
|
||||
Plugin alias: ``mongodb``
|
||||
|
||||
:param str database: database to store jobs in
|
||||
:param str collection: collection to store jobs in
|
||||
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of providing connection
|
||||
arguments
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
||||
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of
|
||||
providing connection arguments
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
"""
|
||||
|
||||
def __init__(self, database='apscheduler', collection='jobs', client=None,
|
||||
@@ -42,14 +45,23 @@ class MongoDBJobStore(BaseJobStore):
|
||||
raise ValueError('The "collection" parameter must not be empty')
|
||||
|
||||
if client:
|
||||
self.connection = maybe_ref(client)
|
||||
self.client = maybe_ref(client)
|
||||
else:
|
||||
connect_args.setdefault('w', 1)
|
||||
self.connection = MongoClient(**connect_args)
|
||||
self.client = MongoClient(**connect_args)
|
||||
|
||||
self.collection = self.connection[database][collection]
|
||||
self.collection = self.client[database][collection]
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(MongoDBJobStore, self).start(scheduler, alias)
|
||||
self.collection.ensure_index('next_run_time', sparse=True)
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
warnings.warn('The "connection" member is deprecated -- use "client" instead',
|
||||
DeprecationWarning)
|
||||
return self.client
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
document = self.collection.find_one(job_id, ['job_state'])
|
||||
return self._reconstitute_job(document['job_state']) if document else None
|
||||
@@ -59,12 +71,15 @@ class MongoDBJobStore(BaseJobStore):
|
||||
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
||||
|
||||
def get_next_run_time(self):
|
||||
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
|
||||
document = self.collection.find_one({'next_run_time': {'$ne': None}},
|
||||
projection=['next_run_time'],
|
||||
sort=[('next_run_time', ASCENDING)])
|
||||
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
return self._get_jobs({})
|
||||
jobs = self._get_jobs({})
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
|
||||
def add_job(self, job):
|
||||
try:
|
||||
@@ -83,7 +98,7 @@ class MongoDBJobStore(BaseJobStore):
|
||||
}
|
||||
result = self.collection.update({'_id': job.id}, {'$set': changes})
|
||||
if result and result['n'] == 0:
|
||||
raise JobLookupError(id)
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
result = self.collection.remove(job_id)
|
||||
@@ -94,7 +109,7 @@ class MongoDBJobStore(BaseJobStore):
|
||||
self.collection.remove()
|
||||
|
||||
def shutdown(self):
|
||||
self.connection.disconnect()
|
||||
self.client.close()
|
||||
|
||||
def _reconstitute_job(self, job_state):
|
||||
job_state = pickle.loads(job_state)
|
||||
@@ -107,11 +122,13 @@ class MongoDBJobStore(BaseJobStore):
|
||||
def _get_jobs(self, conditions):
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
|
||||
for document in self.collection.find(conditions, ['_id', 'job_state'],
|
||||
sort=[('next_run_time', ASCENDING)]):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(document['job_state']))
|
||||
except:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', document['_id'])
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it',
|
||||
document['_id'])
|
||||
failed_job_ids.append(document['_id'])
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
@@ -121,4 +138,4 @@ class MongoDBJobStore(BaseJobStore):
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
||||
|
@@ -1,5 +1,7 @@
|
||||
from __future__ import absolute_import
|
||||
from datetime import datetime
|
||||
|
||||
from pytz import utc
|
||||
import six
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
@@ -19,14 +21,16 @@ except ImportError: # pragma: nocover
|
||||
|
||||
class RedisJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
|
||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's
|
||||
:class:`~redis.StrictRedis`.
|
||||
|
||||
Plugin alias: ``redis``
|
||||
|
||||
:param int db: the database number to store jobs in
|
||||
:param str jobs_key: key to store jobs in
|
||||
:param str run_times_key: key to store the jobs' run times in
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
"""
|
||||
|
||||
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
|
||||
@@ -65,7 +69,8 @@ class RedisJobStore(BaseJobStore):
|
||||
def get_all_jobs(self):
|
||||
job_states = self.redis.hgetall(self.jobs_key)
|
||||
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
||||
return sorted(jobs, key=lambda job: job.next_run_time)
|
||||
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
|
||||
return sorted(jobs, key=lambda job: job.next_run_time or paused_sort_key)
|
||||
|
||||
def add_job(self, job):
|
||||
if self.redis.hexists(self.jobs_key, job.id):
|
||||
@@ -73,8 +78,10 @@ class RedisJobStore(BaseJobStore):
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.multi()
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||
self.pickle_protocol))
|
||||
if job.next_run_time:
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
pipe.execute()
|
||||
|
||||
def update_job(self, job):
|
||||
@@ -82,7 +89,8 @@ class RedisJobStore(BaseJobStore):
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||
self.pickle_protocol))
|
||||
if job.next_run_time:
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
else:
|
||||
@@ -121,7 +129,7 @@ class RedisJobStore(BaseJobStore):
|
||||
for job_id, job_state in job_states:
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(job_state))
|
||||
except:
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
||||
failed_job_ids.append(job_id)
|
||||
|
||||
|
153
lib/apscheduler/jobstores/rethinkdb.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||
from apscheduler.job import Job
|
||||
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
import rethinkdb as r
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('RethinkDBJobStore requires rethinkdb installed')
|
||||
|
||||
|
||||
class RethinkDBJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a RethinkDB database. Any leftover keyword arguments are directly passed to
|
||||
rethinkdb's `RethinkdbClient <http://www.rethinkdb.com/api/#connect>`_.
|
||||
|
||||
Plugin alias: ``rethinkdb``
|
||||
|
||||
:param str database: database to store jobs in
|
||||
:param str collection: collection to store jobs in
|
||||
:param client: a :class:`rethinkdb.net.Connection` instance to use instead of providing
|
||||
connection arguments
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
"""
|
||||
|
||||
def __init__(self, database='apscheduler', table='jobs', client=None,
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||
super(RethinkDBJobStore, self).__init__()
|
||||
|
||||
if not database:
|
||||
raise ValueError('The "database" parameter must not be empty')
|
||||
if not table:
|
||||
raise ValueError('The "table" parameter must not be empty')
|
||||
|
||||
self.database = database
|
||||
self.table = table
|
||||
self.client = client
|
||||
self.pickle_protocol = pickle_protocol
|
||||
self.connect_args = connect_args
|
||||
self.conn = None
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(RethinkDBJobStore, self).start(scheduler, alias)
|
||||
|
||||
if self.client:
|
||||
self.conn = maybe_ref(self.client)
|
||||
else:
|
||||
self.conn = r.connect(db=self.database, **self.connect_args)
|
||||
|
||||
if self.database not in r.db_list().run(self.conn):
|
||||
r.db_create(self.database).run(self.conn)
|
||||
|
||||
if self.table not in r.table_list().run(self.conn):
|
||||
r.table_create(self.table).run(self.conn)
|
||||
|
||||
if 'next_run_time' not in r.table(self.table).index_list().run(self.conn):
|
||||
r.table(self.table).index_create('next_run_time').run(self.conn)
|
||||
|
||||
self.table = r.db(self.database).table(self.table)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
results = list(self.table.get_all(job_id).pluck('job_state').run(self.conn))
|
||||
return self._reconstitute_job(results[0]['job_state']) if results else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
return self._get_jobs(r.row['next_run_time'] <= datetime_to_utc_timestamp(now))
|
||||
|
||||
def get_next_run_time(self):
|
||||
results = list(
|
||||
self.table
|
||||
.filter(r.row['next_run_time'] != None) # flake8: noqa
|
||||
.order_by(r.asc('next_run_time'))
|
||||
.map(lambda x: x['next_run_time'])
|
||||
.limit(1)
|
||||
.run(self.conn)
|
||||
)
|
||||
return utc_timestamp_to_datetime(results[0]) if results else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
jobs = self._get_jobs()
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
|
||||
def add_job(self, job):
|
||||
job_dict = {
|
||||
'id': job.id,
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
}
|
||||
results = self.table.insert(job_dict).run(self.conn)
|
||||
if results['errors'] > 0:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
changes = {
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
}
|
||||
results = self.table.get_all(job.id).update(changes).run(self.conn)
|
||||
skipped = False in map(lambda x: results[x] == 0, results.keys())
|
||||
if results['skipped'] > 0 or results['errors'] > 0 or not skipped:
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
results = self.table.get_all(job_id).delete().run(self.conn)
|
||||
if results['deleted'] + results['skipped'] != 1:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
self.table.delete().run(self.conn)
|
||||
|
||||
def shutdown(self):
|
||||
self.conn.close()
|
||||
|
||||
def _reconstitute_job(self, job_state):
|
||||
job_state = pickle.loads(job_state)
|
||||
job = Job.__new__(Job)
|
||||
job.__setstate__(job_state)
|
||||
job._scheduler = self._scheduler
|
||||
job._jobstore_alias = self._alias
|
||||
return job
|
||||
|
||||
def _get_jobs(self, predicate=None):
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
query = (self.table.filter(r.row['next_run_time'] != None).filter(predicate) if
|
||||
predicate else self.table)
|
||||
query = query.order_by('next_run_time', 'id').pluck('id', 'job_state')
|
||||
|
||||
for document in query.run(self.conn):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(document['job_state']))
|
||||
except:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', document['id'])
|
||||
failed_job_ids.append(document['id'])
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
r.expr(failed_job_ids).for_each(
|
||||
lambda job_id: self.table.get_all(job_id).delete()).run(self.conn)
|
||||
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
connection = self.conn
|
||||
return '<%s (connection=%s)>' % (self.__class__.__name__, connection)
|
@@ -10,29 +10,38 @@ except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
|
||||
from sqlalchemy import (
|
||||
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.sql.expression import null
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||
|
||||
|
||||
class SQLAlchemyJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database.
|
||||
Stores jobs in a database table using SQLAlchemy.
|
||||
The table will be created if it doesn't exist in the database.
|
||||
|
||||
Plugin alias: ``sqlalchemy``
|
||||
|
||||
:param str url: connection string (see `SQLAlchemy documentation
|
||||
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
|
||||
on this)
|
||||
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
|
||||
:param str url: connection string (see
|
||||
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
|
||||
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
|
||||
new one based on ``url``
|
||||
:param str tablename: name of the table to store jobs in
|
||||
:param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
||||
:param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
|
||||
new one
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
:param str tableschema: name of the (existing) schema in the target database where the table
|
||||
should be
|
||||
:param dict engine_options: keyword arguments to :func:`~sqlalchemy.create_engine`
|
||||
(ignored if ``engine`` is given)
|
||||
"""
|
||||
|
||||
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None):
|
||||
super(SQLAlchemyJobStore, self).__init__()
|
||||
self.pickle_protocol = pickle_protocol
|
||||
metadata = maybe_ref(metadata) or MetaData()
|
||||
@@ -40,18 +49,22 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
if engine:
|
||||
self.engine = maybe_ref(engine)
|
||||
elif url:
|
||||
self.engine = create_engine(url)
|
||||
self.engine = create_engine(url, **(engine_options or {}))
|
||||
else:
|
||||
raise ValueError('Need either "engine" or "url" defined')
|
||||
|
||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 25 = precision that translates to an 8-byte float
|
||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables,
|
||||
# 25 = precision that translates to an 8-byte float
|
||||
self.jobs_t = Table(
|
||||
tablename, metadata,
|
||||
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
|
||||
Column('next_run_time', Float(25), index=True),
|
||||
Column('job_state', LargeBinary, nullable=False)
|
||||
Column('job_state', LargeBinary, nullable=False),
|
||||
schema=tableschema
|
||||
)
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(SQLAlchemyJobStore, self).start(scheduler, alias)
|
||||
self.jobs_t.create(self.engine, True)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
@@ -64,13 +77,16 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||
|
||||
def get_next_run_time(self):
|
||||
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
|
||||
selectable = select([self.jobs_t.c.next_run_time]).\
|
||||
where(self.jobs_t.c.next_run_time != null()).\
|
||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||
next_run_time = self.engine.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
|
||||
def get_all_jobs(self):
|
||||
return self._get_jobs()
|
||||
jobs = self._get_jobs()
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
|
||||
def add_job(self, job):
|
||||
insert = self.jobs_t.insert().values(**{
|
||||
@@ -116,13 +132,14 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
|
||||
def _get_jobs(self, *conditions):
|
||||
jobs = []
|
||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).\
|
||||
order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = selectable.where(*conditions) if conditions else selectable
|
||||
failed_job_ids = set()
|
||||
for row in self.engine.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except:
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||
failed_job_ids.add(row.id)
|
||||
|
||||
|
179
lib/apscheduler/jobstores/zookeeper.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from pytz import utc
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||
from apscheduler.job import Job
|
||||
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from kazoo.client import KazooClient
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('ZooKeeperJobStore requires Kazoo installed')
|
||||
|
||||
|
||||
class ZooKeeperJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a ZooKeeper tree. Any leftover keyword arguments are directly passed to
|
||||
kazoo's `KazooClient
|
||||
<http://kazoo.readthedocs.io/en/latest/api/client.html>`_.
|
||||
|
||||
Plugin alias: ``zookeeper``
|
||||
|
||||
:param str path: path to store jobs in
|
||||
:param client: a :class:`~kazoo.client.KazooClient` instance to use instead of
|
||||
providing connection arguments
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
"""
|
||||
|
||||
def __init__(self, path='/apscheduler', client=None, close_connection_on_exit=False,
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||
super(ZooKeeperJobStore, self).__init__()
|
||||
self.pickle_protocol = pickle_protocol
|
||||
self.close_connection_on_exit = close_connection_on_exit
|
||||
|
||||
if not path:
|
||||
raise ValueError('The "path" parameter must not be empty')
|
||||
|
||||
self.path = path
|
||||
|
||||
if client:
|
||||
self.client = maybe_ref(client)
|
||||
else:
|
||||
self.client = KazooClient(**connect_args)
|
||||
self._ensured_path = False
|
||||
|
||||
def _ensure_paths(self):
|
||||
if not self._ensured_path:
|
||||
self.client.ensure_path(self.path)
|
||||
self._ensured_path = True
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(ZooKeeperJobStore, self).start(scheduler, alias)
|
||||
if not self.client.connected:
|
||||
self.client.start()
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
self._ensure_paths()
|
||||
node_path = os.path.join(self.path, job_id)
|
||||
try:
|
||||
content, _ = self.client.get(node_path)
|
||||
doc = pickle.loads(content)
|
||||
job = self._reconstitute_job(doc['job_state'])
|
||||
return job
|
||||
except BaseException:
|
||||
return None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
jobs = [job_def['job'] for job_def in self._get_jobs()
|
||||
if job_def['next_run_time'] is not None and job_def['next_run_time'] <= timestamp]
|
||||
return jobs
|
||||
|
||||
def get_next_run_time(self):
|
||||
next_runs = [job_def['next_run_time'] for job_def in self._get_jobs()
|
||||
if job_def['next_run_time'] is not None]
|
||||
return utc_timestamp_to_datetime(min(next_runs)) if len(next_runs) > 0 else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
jobs = [job_def['job'] for job_def in self._get_jobs()]
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
|
||||
def add_job(self, job):
|
||||
self._ensure_paths()
|
||||
node_path = os.path.join(self.path, str(job.id))
|
||||
value = {
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': job.__getstate__()
|
||||
}
|
||||
data = pickle.dumps(value, self.pickle_protocol)
|
||||
try:
|
||||
self.client.create(node_path, value=data)
|
||||
except NodeExistsError:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
self._ensure_paths()
|
||||
node_path = os.path.join(self.path, str(job.id))
|
||||
changes = {
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': job.__getstate__()
|
||||
}
|
||||
data = pickle.dumps(changes, self.pickle_protocol)
|
||||
try:
|
||||
self.client.set(node_path, value=data)
|
||||
except NoNodeError:
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
self._ensure_paths()
|
||||
node_path = os.path.join(self.path, str(job_id))
|
||||
try:
|
||||
self.client.delete(node_path)
|
||||
except NoNodeError:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
try:
|
||||
self.client.delete(self.path, recursive=True)
|
||||
except NoNodeError:
|
||||
pass
|
||||
self._ensured_path = False
|
||||
|
||||
def shutdown(self):
|
||||
if self.close_connection_on_exit:
|
||||
self.client.stop()
|
||||
self.client.close()
|
||||
|
||||
def _reconstitute_job(self, job_state):
|
||||
job_state = job_state
|
||||
job = Job.__new__(Job)
|
||||
job.__setstate__(job_state)
|
||||
job._scheduler = self._scheduler
|
||||
job._jobstore_alias = self._alias
|
||||
return job
|
||||
|
||||
def _get_jobs(self):
|
||||
self._ensure_paths()
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
all_ids = self.client.get_children(self.path)
|
||||
for node_name in all_ids:
|
||||
try:
|
||||
node_path = os.path.join(self.path, node_name)
|
||||
content, _ = self.client.get(node_path)
|
||||
doc = pickle.loads(content)
|
||||
job_def = {
|
||||
'job_id': node_name,
|
||||
'next_run_time': doc['next_run_time'] if doc['next_run_time'] else None,
|
||||
'job_state': doc['job_state'],
|
||||
'job': self._reconstitute_job(doc['job_state']),
|
||||
'creation_time': _.ctime
|
||||
}
|
||||
jobs.append(job_def)
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it' % node_name)
|
||||
failed_job_ids.append(node_name)
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
for failed_id in failed_job_ids:
|
||||
self.remove_job(failed_id)
|
||||
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
|
||||
return sorted(jobs, key=lambda job_def: (job_def['job'].next_run_time or paused_sort_key,
|
||||
job_def['creation_time']))
|
||||
|
||||
def __repr__(self):
|
||||
self._logger.exception('<%s (client=%s)>' % (self.__class__.__name__, self.client))
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
@@ -1,5 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
from functools import wraps
|
||||
from functools import wraps, partial
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.util import maybe_ref
|
||||
@@ -10,13 +10,15 @@ except ImportError: # pragma: nocover
|
||||
try:
|
||||
import trollius as asyncio
|
||||
except ImportError:
|
||||
raise ImportError('AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
|
||||
raise ImportError(
|
||||
'AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
|
||||
|
||||
|
||||
def run_in_event_loop(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
|
||||
wrapped = partial(func, self, *args, **kwargs)
|
||||
self._eventloop.call_soon_threadsafe(wrapped)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -24,6 +26,8 @@ class AsyncIOScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
||||
|
||||
The default executor can run jobs based on native coroutines (``async def``).
|
||||
|
||||
Extra options:
|
||||
|
||||
============== =============================================================
|
||||
@@ -34,10 +38,6 @@ class AsyncIOScheduler(BaseScheduler):
|
||||
_eventloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self):
|
||||
super(AsyncIOScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_event_loop
|
||||
def shutdown(self, wait=True):
|
||||
super(AsyncIOScheduler, self).shutdown(wait)
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from threading import Thread, Event
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
@@ -13,11 +14,12 @@ class BackgroundScheduler(BlockingScheduler):
|
||||
|
||||
Extra options:
|
||||
|
||||
========== ============================================================================================
|
||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
|
||||
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||
========== =============================================================================
|
||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
|
||||
`the documentation
|
||||
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||
for further details)
|
||||
========== ============================================================================================
|
||||
========== =============================================================================
|
||||
"""
|
||||
|
||||
_thread = None
|
||||
@@ -26,14 +28,14 @@ class BackgroundScheduler(BlockingScheduler):
|
||||
self._daemon = asbool(config.pop('daemon', True))
|
||||
super(BackgroundScheduler, self)._configure(config)
|
||||
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
def start(self, *args, **kwargs):
|
||||
self._event = Event()
|
||||
BaseScheduler.start(self, *args, **kwargs)
|
||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||
self._thread.daemon = self._daemon
|
||||
self._thread.start()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(BackgroundScheduler, self).shutdown(wait)
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(BackgroundScheduler, self).shutdown(*args, **kwargs)
|
||||
self._thread.join()
|
||||
del self._thread
|
||||
|
@@ -1,21 +1,21 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from threading import Event
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED
|
||||
from apscheduler.util import TIMEOUT_MAX
|
||||
|
||||
|
||||
class BlockingScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs in the foreground (:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
||||
A scheduler that runs in the foreground
|
||||
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
||||
"""
|
||||
|
||||
MAX_WAIT_TIME = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||
|
||||
_event = None
|
||||
|
||||
def start(self):
|
||||
super(BlockingScheduler, self).start()
|
||||
def start(self, *args, **kwargs):
|
||||
self._event = Event()
|
||||
super(BlockingScheduler, self).start(*args, **kwargs)
|
||||
self._main_loop()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
@@ -23,10 +23,11 @@ class BlockingScheduler(BaseScheduler):
|
||||
self._event.set()
|
||||
|
||||
def _main_loop(self):
|
||||
while self.running:
|
||||
wait_seconds = self._process_jobs()
|
||||
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
|
||||
wait_seconds = TIMEOUT_MAX
|
||||
while self.state != STATE_STOPPED:
|
||||
self._event.wait(wait_seconds)
|
||||
self._event.clear()
|
||||
wait_seconds = self._process_jobs()
|
||||
|
||||
def wakeup(self):
|
||||
self._event.set()
|
||||
|
@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
|
||||
|
||||
_greenlet = None
|
||||
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
def start(self, *args, **kwargs):
|
||||
self._event = Event()
|
||||
BaseScheduler.start(self, *args, **kwargs)
|
||||
self._greenlet = gevent.spawn(self._main_loop)
|
||||
return self._greenlet
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(GeventScheduler, self).shutdown(wait)
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(GeventScheduler, self).shutdown(*args, **kwargs)
|
||||
self._greenlet.join()
|
||||
del self._greenlet
|
||||
|
||||
|
@@ -4,7 +4,7 @@ from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
try:
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
except ImportError: # pragma: nocover
|
||||
except (ImportError, RuntimeError): # pragma: nocover
|
||||
try:
|
||||
from PyQt4.QtCore import QObject, QTimer
|
||||
except ImportError:
|
||||
@@ -19,12 +19,8 @@ class QtScheduler(BaseScheduler):
|
||||
|
||||
_timer = None
|
||||
|
||||
def start(self):
|
||||
super(QtScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(QtScheduler, self).shutdown(wait)
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(QtScheduler, self).shutdown(*args, **kwargs)
|
||||
self._stop_timer()
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
@@ -22,6 +23,8 @@ class TornadoScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs on a Tornado IOLoop.
|
||||
|
||||
The default executor can run jobs based on native coroutines (``async def``).
|
||||
|
||||
=========== ===============================================================
|
||||
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
||||
=========== ===============================================================
|
||||
@@ -30,10 +33,6 @@ class TornadoScheduler(BaseScheduler):
|
||||
_ioloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self):
|
||||
super(TornadoScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_ioloop
|
||||
def shutdown(self, wait=True):
|
||||
super(TornadoScheduler, self).shutdown(wait)
|
||||
@@ -53,6 +52,10 @@ class TornadoScheduler(BaseScheduler):
|
||||
self._ioloop.remove_timeout(self._timeout)
|
||||
del self._timeout
|
||||
|
||||
def _create_default_executor(self):
|
||||
from apscheduler.executors.tornado import TornadoExecutor
|
||||
return TornadoExecutor()
|
||||
|
||||
@run_in_ioloop
|
||||
def wakeup(self):
|
||||
self._stop_timer()
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
@@ -35,10 +36,6 @@ class TwistedScheduler(BaseScheduler):
|
||||
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
||||
super(TwistedScheduler, self)._configure(config)
|
||||
|
||||
def start(self):
|
||||
super(TwistedScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_reactor
|
||||
def shutdown(self, wait=True):
|
||||
super(TwistedScheduler, self).shutdown(wait)
|
||||
|
@@ -1,4 +1,6 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from datetime import timedelta
|
||||
import random
|
||||
|
||||
import six
|
||||
|
||||
@@ -6,11 +8,41 @@ import six
|
||||
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
||||
"""Abstract base class that defines the interface that every trigger must implement."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
@abstractmethod
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
"""
|
||||
Returns the next datetime to fire on, If no such datetime can be calculated, returns ``None``.
|
||||
Returns the next datetime to fire on, If no such datetime can be calculated, returns
|
||||
``None``.
|
||||
|
||||
:param datetime.datetime previous_fire_time: the previous time the trigger was fired
|
||||
:param datetime.datetime now: current datetime
|
||||
"""
|
||||
|
||||
def _apply_jitter(self, next_fire_time, jitter, now):
|
||||
"""
|
||||
Randomize ``next_fire_time`` by adding or subtracting a random value (the jitter). If the
|
||||
resulting datetime is in the past, returns the initial ``next_fire_time`` without jitter.
|
||||
|
||||
``next_fire_time - jitter <= result <= next_fire_time + jitter``
|
||||
|
||||
:param datetime.datetime|None next_fire_time: next fire time without jitter applied. If
|
||||
``None``, returns ``None``.
|
||||
:param int|None jitter: maximum number of seconds to add or subtract to
|
||||
``next_fire_time``. If ``None`` or ``0``, returns ``next_fire_time``
|
||||
:param datetime.datetime now: current datetime
|
||||
:return datetime.datetime|None: next fire time with a jitter.
|
||||
"""
|
||||
if next_fire_time is None or not jitter:
|
||||
return next_fire_time
|
||||
|
||||
next_fire_time_with_jitter = next_fire_time + timedelta(
|
||||
seconds=random.uniform(-jitter, jitter))
|
||||
|
||||
if next_fire_time_with_jitter < now:
|
||||
# Next fire time with jitter is in the past.
|
||||
# Ignore jitter to avoid false misfire.
|
||||
return next_fire_time
|
||||
|
||||
return next_fire_time_with_jitter
|
||||
|
95
lib/apscheduler/triggers/combining.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import obj_to_ref, ref_to_obj
|
||||
|
||||
|
||||
class BaseCombiningTrigger(BaseTrigger):
|
||||
__slots__ = ('triggers', 'jitter')
|
||||
|
||||
def __init__(self, triggers, jitter=None):
|
||||
self.triggers = triggers
|
||||
self.jitter = jitter
|
||||
|
||||
def __getstate__(self):
|
||||
return {
|
||||
'version': 1,
|
||||
'triggers': [(obj_to_ref(trigger.__class__), trigger.__getstate__())
|
||||
for trigger in self.triggers],
|
||||
'jitter': self.jitter
|
||||
}
|
||||
|
||||
def __setstate__(self, state):
|
||||
if state.get('version', 1) > 1:
|
||||
raise ValueError(
|
||||
'Got serialized data for version %s of %s, but only versions up to 1 can be '
|
||||
'handled' % (state['version'], self.__class__.__name__))
|
||||
|
||||
self.jitter = state['jitter']
|
||||
self.triggers = []
|
||||
for clsref, state in state['triggers']:
|
||||
cls = ref_to_obj(clsref)
|
||||
trigger = cls.__new__(cls)
|
||||
trigger.__setstate__(state)
|
||||
self.triggers.append(trigger)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{}({}{})>'.format(self.__class__.__name__, self.triggers,
|
||||
', jitter={}'.format(self.jitter) if self.jitter else '')
|
||||
|
||||
|
||||
class AndTrigger(BaseCombiningTrigger):
|
||||
"""
|
||||
Always returns the earliest next fire time that all the given triggers can agree on.
|
||||
The trigger is considered to be finished when any of the given triggers has finished its
|
||||
schedule.
|
||||
|
||||
Trigger alias: ``and``
|
||||
|
||||
:param list triggers: triggers to combine
|
||||
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
while True:
|
||||
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
|
||||
for trigger in self.triggers]
|
||||
if None in fire_times:
|
||||
return None
|
||||
elif min(fire_times) == max(fire_times):
|
||||
return self._apply_jitter(fire_times[0], self.jitter, now)
|
||||
else:
|
||||
now = max(fire_times)
|
||||
|
||||
def __str__(self):
|
||||
return 'and[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
|
||||
|
||||
|
||||
class OrTrigger(BaseCombiningTrigger):
|
||||
"""
|
||||
Always returns the earliest next fire time produced by any of the given triggers.
|
||||
The trigger is considered finished when all the given triggers have finished their schedules.
|
||||
|
||||
Trigger alias: ``or``
|
||||
|
||||
:param list triggers: triggers to combine
|
||||
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
|
||||
|
||||
.. note:: Triggers that depends on the previous fire time, such as the interval trigger, may
|
||||
seem to behave strangely since they are always passed the previous fire time produced by
|
||||
any of the given triggers.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
|
||||
for trigger in self.triggers]
|
||||
fire_times = [fire_time for fire_time in fire_times if fire_time is not None]
|
||||
if fire_times:
|
||||
return self._apply_jitter(min(fire_times), self.jitter, now)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
return 'or[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
|
@@ -4,13 +4,15 @@ from tzlocal import get_localzone
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
|
||||
from apscheduler.triggers.cron.fields import (
|
||||
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
|
||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
||||
|
||||
|
||||
class CronTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works.
|
||||
Triggers when current time matches all specified time constraints,
|
||||
similarly to how the UNIX cron scheduler works.
|
||||
|
||||
:param int|str year: 4-digit year
|
||||
:param int|str month: month (1-12)
|
||||
@@ -22,8 +24,9 @@ class CronTrigger(BaseTrigger):
|
||||
:param int|str second: second (0-59)
|
||||
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
|
||||
:param datetime|str end_date: latest possible date/time to trigger on (inclusive)
|
||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||
(defaults to scheduler timezone)
|
||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
|
||||
to scheduler timezone)
|
||||
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
|
||||
|
||||
.. note:: The first weekday is always **monday**.
|
||||
"""
|
||||
@@ -31,7 +34,7 @@ class CronTrigger(BaseTrigger):
|
||||
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
||||
FIELDS_MAP = {
|
||||
'year': BaseField,
|
||||
'month': BaseField,
|
||||
'month': MonthField,
|
||||
'week': WeekField,
|
||||
'day': DayOfMonthField,
|
||||
'day_of_week': DayOfWeekField,
|
||||
@@ -40,15 +43,16 @@ class CronTrigger(BaseTrigger):
|
||||
'second': BaseField
|
||||
}
|
||||
|
||||
__slots__ = 'timezone', 'start_date', 'end_date', 'fields'
|
||||
__slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter'
|
||||
|
||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
|
||||
second=None, start_date=None, end_date=None, timezone=None):
|
||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None,
|
||||
minute=None, second=None, start_date=None, end_date=None, timezone=None,
|
||||
jitter=None):
|
||||
if timezone:
|
||||
self.timezone = astimezone(timezone)
|
||||
elif start_date and start_date.tzinfo:
|
||||
elif isinstance(start_date, datetime) and start_date.tzinfo:
|
||||
self.timezone = start_date.tzinfo
|
||||
elif end_date and end_date.tzinfo:
|
||||
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||
self.timezone = end_date.tzinfo
|
||||
else:
|
||||
self.timezone = get_localzone()
|
||||
@@ -56,6 +60,8 @@ class CronTrigger(BaseTrigger):
|
||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||
|
||||
self.jitter = jitter
|
||||
|
||||
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
||||
if key in self.FIELD_NAMES and value is not None)
|
||||
self.fields = []
|
||||
@@ -76,13 +82,35 @@ class CronTrigger(BaseTrigger):
|
||||
field = field_class(field_name, exprs, is_default)
|
||||
self.fields.append(field)
|
||||
|
||||
@classmethod
|
||||
def from_crontab(cls, expr, timezone=None):
|
||||
"""
|
||||
Create a :class:`~CronTrigger` from a standard crontab expression.
|
||||
|
||||
See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here.
|
||||
|
||||
:param expr: minute, hour, day of month, month, day of week
|
||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (
|
||||
defaults to scheduler timezone)
|
||||
:return: a :class:`~CronTrigger` instance
|
||||
|
||||
"""
|
||||
values = expr.split()
|
||||
if len(values) != 5:
|
||||
raise ValueError('Wrong number of fields; got {}, expected 5'.format(len(values)))
|
||||
|
||||
return cls(minute=values[0], hour=values[1], day=values[2], month=values[3],
|
||||
day_of_week=values[4], timezone=timezone)
|
||||
|
||||
def _increment_field_value(self, dateval, fieldnum):
|
||||
"""
|
||||
Increments the designated field and resets all less significant fields to their minimum values.
|
||||
Increments the designated field and resets all less significant fields to their minimum
|
||||
values.
|
||||
|
||||
:type dateval: datetime
|
||||
:type fieldnum: int
|
||||
:return: a tuple containing the new date, and the number of the field that was actually incremented
|
||||
:return: a tuple containing the new date, and the number of the field that was actually
|
||||
incremented
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
@@ -128,12 +156,13 @@ class CronTrigger(BaseTrigger):
|
||||
else:
|
||||
values[field.name] = new_value
|
||||
|
||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||
return self.timezone.normalize(dateval + difference)
|
||||
return self.timezone.localize(datetime(**values))
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
if previous_fire_time:
|
||||
start_date = max(now, previous_fire_time + timedelta(microseconds=1))
|
||||
start_date = min(now, previous_fire_time + timedelta(microseconds=1))
|
||||
if start_date == previous_fire_time:
|
||||
start_date += timedelta(microseconds=1)
|
||||
else:
|
||||
start_date = max(now, self.start_date) if self.start_date else now
|
||||
|
||||
@@ -163,8 +192,36 @@ class CronTrigger(BaseTrigger):
|
||||
return None
|
||||
|
||||
if fieldnum >= 0:
|
||||
if self.jitter is not None:
|
||||
next_date = self._apply_jitter(next_date, self.jitter, now)
|
||||
return next_date
|
||||
|
||||
def __getstate__(self):
|
||||
return {
|
||||
'version': 2,
|
||||
'timezone': self.timezone,
|
||||
'start_date': self.start_date,
|
||||
'end_date': self.end_date,
|
||||
'fields': self.fields,
|
||||
'jitter': self.jitter,
|
||||
}
|
||||
|
||||
def __setstate__(self, state):
|
||||
# This is for compatibility with APScheduler 3.0.x
|
||||
if isinstance(state, tuple):
|
||||
state = state[1]
|
||||
|
||||
if state.get('version', 1) > 2:
|
||||
raise ValueError(
|
||||
'Got serialized data for version %s of %s, but only versions up to 2 can be '
|
||||
'handled' % (state['version'], self.__class__.__name__))
|
||||
|
||||
self.timezone = state['timezone']
|
||||
self.start_date = state['start_date']
|
||||
self.end_date = state['end_date']
|
||||
self.fields = state['fields']
|
||||
self.jitter = state.get('jitter')
|
||||
|
||||
def __str__(self):
|
||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||
return 'cron[%s]' % (', '.join(options))
|
||||
@@ -172,5 +229,11 @@ class CronTrigger(BaseTrigger):
|
||||
def __repr__(self):
|
||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||
if self.start_date:
|
||||
options.append("start_date='%s'" % datetime_repr(self.start_date))
|
||||
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
|
||||
options.append("start_date=%r" % datetime_repr(self.start_date))
|
||||
if self.end_date:
|
||||
options.append("end_date=%r" % datetime_repr(self.end_date))
|
||||
if self.jitter:
|
||||
options.append('jitter=%s' % self.jitter)
|
||||
|
||||
return "<%s (%s, timezone='%s')>" % (
|
||||
self.__class__.__name__, ', '.join(options), self.timezone)
|
||||
|
@@ -1,17 +1,16 @@
|
||||
"""
|
||||
This module contains the expressions applicable for CronTrigger's fields.
|
||||
"""
|
||||
"""This module contains the expressions applicable for CronTrigger's fields."""
|
||||
|
||||
from calendar import monthrange
|
||||
import re
|
||||
|
||||
from apscheduler.util import asint
|
||||
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
|
||||
'LastDayOfMonthExpression')
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
|
||||
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
|
||||
|
||||
|
||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||
|
||||
|
||||
class AllExpression(object):
|
||||
@@ -22,6 +21,14 @@ class AllExpression(object):
|
||||
if self.step == 0:
|
||||
raise ValueError('Increment must be higher than 0')
|
||||
|
||||
def validate_range(self, field_name):
|
||||
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||
|
||||
value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name]
|
||||
if self.step and self.step > value_range:
|
||||
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||
'expression ({})'.format(self.step, value_range))
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
start = field.get_value(date)
|
||||
minval = field.get_min(date)
|
||||
@@ -37,6 +44,9 @@ class AllExpression(object):
|
||||
if next <= maxval:
|
||||
return next
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, self.__class__) and self.step == other.step
|
||||
|
||||
def __str__(self):
|
||||
if self.step:
|
||||
return '*/%d' % self.step
|
||||
@@ -51,7 +61,7 @@ class RangeExpression(AllExpression):
|
||||
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
||||
|
||||
def __init__(self, first, last=None, step=None):
|
||||
AllExpression.__init__(self, step)
|
||||
super(RangeExpression, self).__init__(step)
|
||||
first = asint(first)
|
||||
last = asint(last)
|
||||
if last is None and step is None:
|
||||
@@ -61,25 +71,41 @@ class RangeExpression(AllExpression):
|
||||
self.first = first
|
||||
self.last = last
|
||||
|
||||
def validate_range(self, field_name):
|
||||
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||
|
||||
super(RangeExpression, self).validate_range(field_name)
|
||||
if self.first < MIN_VALUES[field_name]:
|
||||
raise ValueError('the first value ({}) is lower than the minimum value ({})'
|
||||
.format(self.first, MIN_VALUES[field_name]))
|
||||
if self.last is not None and self.last > MAX_VALUES[field_name]:
|
||||
raise ValueError('the last value ({}) is higher than the maximum value ({})'
|
||||
.format(self.last, MAX_VALUES[field_name]))
|
||||
value_range = (self.last or MAX_VALUES[field_name]) - self.first
|
||||
if self.step and self.step > value_range:
|
||||
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||
'expression ({})'.format(self.step, value_range))
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
start = field.get_value(date)
|
||||
startval = field.get_value(date)
|
||||
minval = field.get_min(date)
|
||||
maxval = field.get_max(date)
|
||||
|
||||
# Apply range limits
|
||||
minval = max(minval, self.first)
|
||||
if self.last is not None:
|
||||
maxval = min(maxval, self.last)
|
||||
start = max(start, minval)
|
||||
maxval = min(maxval, self.last) if self.last is not None else maxval
|
||||
nextval = max(minval, startval)
|
||||
|
||||
if not self.step:
|
||||
next = start
|
||||
else:
|
||||
distance_to_next = (self.step - (start - minval)) % self.step
|
||||
next = start + distance_to_next
|
||||
# Apply the step if defined
|
||||
if self.step:
|
||||
distance_to_next = (self.step - (nextval - minval)) % self.step
|
||||
nextval += distance_to_next
|
||||
|
||||
if next <= maxval:
|
||||
return next
|
||||
return nextval if nextval <= maxval else None
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, self.__class__) and self.first == other.first and
|
||||
self.last == other.last)
|
||||
|
||||
def __str__(self):
|
||||
if self.last != self.first and self.last is not None:
|
||||
@@ -100,6 +126,37 @@ class RangeExpression(AllExpression):
|
||||
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||
|
||||
|
||||
class MonthRangeExpression(RangeExpression):
|
||||
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||
|
||||
def __init__(self, first, last=None):
|
||||
try:
|
||||
first_num = MONTHS.index(first.lower()) + 1
|
||||
except ValueError:
|
||||
raise ValueError('Invalid month name "%s"' % first)
|
||||
|
||||
if last:
|
||||
try:
|
||||
last_num = MONTHS.index(last.lower()) + 1
|
||||
except ValueError:
|
||||
raise ValueError('Invalid month name "%s"' % last)
|
||||
else:
|
||||
last_num = None
|
||||
|
||||
super(MonthRangeExpression, self).__init__(first_num, last_num)
|
||||
|
||||
def __str__(self):
|
||||
if self.last != self.first and self.last is not None:
|
||||
return '%s-%s' % (MONTHS[self.first - 1], MONTHS[self.last - 1])
|
||||
return MONTHS[self.first - 1]
|
||||
|
||||
def __repr__(self):
|
||||
args = ["'%s'" % MONTHS[self.first]]
|
||||
if self.last != self.first and self.last is not None:
|
||||
args.append("'%s'" % MONTHS[self.last - 1])
|
||||
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||
|
||||
|
||||
class WeekdayRangeExpression(RangeExpression):
|
||||
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||
|
||||
@@ -117,7 +174,7 @@ class WeekdayRangeExpression(RangeExpression):
|
||||
else:
|
||||
last_num = None
|
||||
|
||||
RangeExpression.__init__(self, first_num, last_num)
|
||||
super(WeekdayRangeExpression, self).__init__(first_num, last_num)
|
||||
|
||||
def __str__(self):
|
||||
if self.last != self.first and self.last is not None:
|
||||
@@ -133,9 +190,11 @@ class WeekdayRangeExpression(RangeExpression):
|
||||
|
||||
class WeekdayPositionExpression(AllExpression):
|
||||
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.join(options), re.IGNORECASE)
|
||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' %
|
||||
'|'.join(options), re.IGNORECASE)
|
||||
|
||||
def __init__(self, option_name, weekday_name):
|
||||
super(WeekdayPositionExpression, self).__init__(None)
|
||||
try:
|
||||
self.option_num = self.options.index(option_name.lower())
|
||||
except ValueError:
|
||||
@@ -147,8 +206,7 @@ class WeekdayPositionExpression(AllExpression):
|
||||
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
# Figure out the weekday of the month's first day and the number
|
||||
# of days in that month
|
||||
# Figure out the weekday of the month's first day and the number of days in that month
|
||||
first_day_wday, last_day = monthrange(date.year, date.month)
|
||||
|
||||
# Calculate which day of the month is the first of the target weekdays
|
||||
@@ -160,23 +218,28 @@ class WeekdayPositionExpression(AllExpression):
|
||||
if self.option_num < 5:
|
||||
target_day = first_hit_day + self.option_num * 7
|
||||
else:
|
||||
target_day = first_hit_day + ((last_day - first_hit_day) / 7) * 7
|
||||
target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7
|
||||
|
||||
if target_day <= last_day and target_day >= date.day:
|
||||
return target_day
|
||||
|
||||
def __eq__(self, other):
|
||||
return (super(WeekdayPositionExpression, self).__eq__(other) and
|
||||
self.option_num == other.option_num and self.weekday == other.weekday)
|
||||
|
||||
def __str__(self):
|
||||
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||
|
||||
def __repr__(self):
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num],
|
||||
WEEKDAYS[self.weekday])
|
||||
|
||||
|
||||
class LastDayOfMonthExpression(AllExpression):
|
||||
value_re = re.compile(r'last', re.IGNORECASE)
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
super(LastDayOfMonthExpression, self).__init__(None)
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
return monthrange(date.year, date.month)[1]
|
||||
|