Compare commits

...

246 Commits

Author SHA1 Message Date
JonnyWong16
541d2904d3 Merge branch 'dev' 2016-04-18 21:29:13 -07:00
JonnyWong16
2080bbcbca v1.3.15 2016-04-18 21:28:45 -07:00
JonnyWong16
f4c38008a1 Fix logger typo 2016-04-16 22:46:47 -07:00
JonnyWong16
2d38b15f1b Merge pull request #672 from alotufo/dev
Optimized iOS, Android and IE10 images
2016-04-16 09:36:20 -07:00
Al Lotufo
13257b9f86 Optimize iOS images
Ran images through ImageOptim to reduce file size
2016-04-16 12:23:09 -04:00
Al Lotufo
22f106b357 Optimize IE10 images
Ran images through ImageOptim to reduce file size
2016-04-16 12:21:17 -04:00
Al Lotufo
352b5aadba Optimize Android images
Ran images through ImageOptim to reduce file size
2016-04-16 12:20:42 -04:00
JonnyWong16
f86c9ea947 Fix 127.0.0.1 showing as external IP address on tables 2016-04-13 18:03:05 -07:00
JonnyWong16
2f5f0ba1e1 Fix getting pms_url when multiple connections published
* Fixes bug grabbing the wrong pms_url when multiple local/remote
connections are published to plex.tv
* This tries to find the connection with the matching address first,
otherwise grabs the first valid local/remote connection (prior
behaviour)
2016-04-12 22:52:53 -07:00
JonnyWong16
6a72923182 Fix Slack notifications with an icon URL not sending 2016-04-12 08:55:08 -07:00
JonnyWong16
8ca6255ff3 Merge pull request #658 from xtjoeytx/patch-1
Update welcome.html
2016-04-08 16:13:33 -07:00
Joseph
d3b3afd593 Update welcome.html
fixed spelling error
2016-04-08 01:07:41 -04:00
JonnyWong16
57cb5ff6cf Fix fallback pms_url if server URL not found 2016-04-06 19:23:59 -07:00
JonnyWong16
39f3da6cde Fix regression file sizes not shown in media info table footer 2016-04-06 19:21:52 -07:00
JonnyWong16
5a236c9357 Fix wording of notification delay help text 2016-04-06 19:19:13 -07:00
JonnyWong16
4b0eab57a8 Fix regression PMS down notifications failing 2016-04-06 19:17:56 -07:00
JonnyWong16
74a232630a Add {transcode_key} and {username} notification options 2016-04-02 14:11:18 -07:00
JonnyWong16
71d023ab77 Fix logger typo in notification handler 2016-04-02 12:13:54 -07:00
JonnyWong16
786a374233 Merge branch 'dev' 2016-03-29 08:16:25 -07:00
JonnyWong16
41899872cd v1.3.14 2016-03-29 08:15:35 -07:00
JonnyWong16
076659db52 Fix regression missing notify_action for script notifications 2016-03-28 22:21:23 -07:00
JonnyWong16
8f665622d6 Fix typo for home stats cards in settings 2016-03-28 18:12:32 -07:00
JonnyWong16
5cc6e0b172 Merge branch 'dev' 2016-03-27 17:25:53 -07:00
JonnyWong16
bff22900cb v1.3.13 2016-03-27 17:25:16 -07:00
JonnyWong16
5e79c9fd62 Only filter logger if string is longer than 5 characters 2016-03-27 17:22:30 -07:00
JonnyWong16
92f55c254c Merge branch 'dev' 2016-03-27 16:51:30 -07:00
JonnyWong16
39034e38f6 v1.3.12 2016-03-27 16:43:06 -07:00
JonnyWong16
3c7b9558fe Access log file from the Help & Info page 2016-03-27 16:41:57 -07:00
JonnyWong16
c8f7f40b46 ISO date format for logs 2016-03-27 16:10:02 -07:00
JonnyWong16
2a764cf190 Add "First" and "Last" page buttons to datatables 2016-03-27 15:32:03 -07:00
JonnyWong16
ba6ef4d629 Add toggle for log blacklist and mask public IP addresses 2016-03-27 15:18:41 -07:00
JonnyWong16
67d3505733 Merge pull request #627 from JonnyWong16/dev-env
Enable PlexPy dev environment
2016-03-27 10:30:42 -07:00
JonnyWong16
252145cf58 Enable PlexPy dev environment using --dev flag 2016-03-27 10:25:46 -07:00
JonnyWong16
dbc62542ef Merge pull request #634 from evilmarty/enhancement/ifttt
Allow formatting of IFTTT event key with action name
2016-03-26 18:08:45 -07:00
Marty Zalega
005829ab72 Allow formatting of ifttt event key with action name 2016-03-27 10:46:09 +10:00
JonnyWong16
448c8b0e8a Merge pull request #632 from alshain/patch-2
Fix unicode logging with 1252 encoded locale on Windows
2016-03-26 12:09:27 -07:00
Chris
ff0e724ee5 Fix unicode logging with 1252 encoded locale on Windows
Replaces the abbreviation for months in the log output with numerals. Works around logging exceptions on Windows during March ("Mär") with Swiss-German locale on Windows, which is encoded with Windows-1252.
2016-03-26 19:59:12 +01:00
JonnyWong16
568e4a5ee8 Fix blacklist logging again ed8c7c1 2016-03-26 10:00:58 -07:00
JonnyWong16
fbf4a524c1 Catch URLError when uploading to Imgur 2016-03-25 15:39:31 -07:00
JonnyWong16
29db2e958f Hide Plex notification agent 2016-03-25 13:24:24 -07:00
JonnyWong16
cc7bcbf9d5 Change log directory to log file in "Help & Info" 2016-03-25 13:20:50 -07:00
JonnyWong16
98d4484e6c Update CONTRIBUTING.md 2016-03-25 13:20:25 -07:00
JonnyWong16
0b126278f9 Fix blacklisting of blank strings from ed8c7c1 2016-03-25 13:00:04 -07:00
JonnyWong16
cc919415bb Merge pull request #612 from codedecay/dev
Add CherryPy Environment Option
2016-03-25 12:59:28 -07:00
JonnyWong16
a3f398390c Clean up Arnie quotes 2016-03-25 09:16:24 -07:00
JonnyWong16
5ae89368f1 Merge pull request #625 from Chrisophogus/patch-1
Extra Arnie
2016-03-25 09:14:25 -07:00
JonnyWong16
c8f1cb0a0a Start PlexPy for different environment variables 2016-03-25 09:12:40 -07:00
Chrisophogus
f783b08b78 Additional changes 2016-03-23 20:52:50 +00:00
Chrisophogus
85e0c6d3cd Extra Arnie
Added some additional Arnie quotes.
2016-03-23 20:14:48 +00:00
JonnyWong16
2259a96058 Use default poster for Facebook if unable to upload poster 2016-03-21 18:53:56 -07:00
JonnyWong16
afed5841e7 Remove old notify_log table upgrades 2016-03-20 17:17:50 -07:00
JonnyWong16
52361cd505 Catch error if unable to retrieve poster for notification 2016-03-20 17:06:58 -07:00
JonnyWong16
b743cca7bc Add summary to Facebook posts 2016-03-19 23:27:26 -07:00
JonnyWong16
1d01d0bff1 Add FeatHub feature requests and guidelines modal popup 2016-03-19 22:24:09 -07:00
JonnyWong16
c45a488962 Remove experimental from Facebook and Scripts 2016-03-19 20:45:49 -07:00
JonnyWong16
6731c44541 Merge pull request #614 from Vilsol/dev
Update Arnold Quotes
2016-03-19 16:20:31 -07:00
JonnyWong16
b04ed83963 Make sure build_notify_text returns two values 2016-03-16 19:13:29 -07:00
Vilsol
c35b79e642 Update Arnold Quotes 2016-03-16 16:33:56 +00:00
JonnyWong16
ed8c7c1052 Filter out tokens/keys/passwords from logger 2016-03-15 23:49:35 -07:00
JonnyWong16
498a074222 Add user GitHub API Token to settings 2016-03-15 23:49:27 -07:00
JonnyWong16
3fe6db4d42 Fix "Check GitHub for updates" not rescheduling when toggling setting 2016-03-15 20:42:45 -07:00
Eric Solari
6e5cd82dfb Add CherryPy Environment Option 2016-03-15 22:24:08 -05:00
JonnyWong16
cbf3488de9 Merge branch 'dev' 2016-03-15 19:15:46 -07:00
JonnyWong16
c72314fb71 v1.3.11 2016-03-15 19:14:56 -07:00
JonnyWong16
c4af6feb92 Fix typo preventing history logging for websockets 2016-03-15 19:13:13 -07:00
JonnyWong16
08537c1d69 Merge branch 'dev' 2016-03-12 15:04:40 -08:00
JonnyWong16
425da82f5f v1.3.10 2016-03-12 15:04:16 -08:00
JonnyWong16
2cfbf7c39a Rename "watched" to "played" on user/library pages 2016-03-12 14:35:29 -08:00
JonnyWong16
d4eed9f8fd Merge pull request #594 from chiviak/160223_freenas_dev
Few improvements to the FreeNAS/FreeBSD init scripts
2016-03-09 22:13:24 -08:00
JonnyWong16
fe10170826 Fix checked settings to int when saving config 2016-03-09 22:11:23 -08:00
JonnyWong16
8dc3b0b250 Do not retrieve user/library details if importing plexWatch database 2016-03-09 22:10:33 -08:00
JonnyWong16
75da1220af Fix expanding media info tables from 464d2a5 2016-03-08 18:23:58 -08:00
JonnyWong16
37a2c3c631 Fix typo in notification settings 2016-03-08 18:21:01 -08:00
JonnyWong16
5c5722714d Add ability to clear the temporary sessions table from database 2016-03-07 19:40:18 -08:00
JonnyWong16
2ba529f9e3 Fix missing time import 2016-03-06 17:06:05 -08:00
JonnyWong16
fd760ff015 Fix missing notifiers import 2016-03-06 17:05:52 -08:00
Scott Serrano
daab1d917b Update FreeBSD script to match the latest FreeNAS changes 2016-03-05 21:14:53 -08:00
Scott Serrano
4c3a63a7e1 Allow for additional plexpy arguments like port 2016-03-05 21:14:48 -08:00
Scott Serrano
7cc58b84da Run plexpy directly in daemon mode instead of using the daemon program 2016-03-05 21:14:42 -08:00
Scott Serrano
2bac4ac1a7 Make the freenas init script executable 2016-03-05 21:14:35 -08:00
JonnyWong16
c5b2b86786 Enable keep_history for default user/library
* Log sessions to "Local" if retrieving user/library data fails
2016-03-05 16:21:59 -08:00
JonnyWong16
5652a2b6c2 Revert set_session_state from d73e379 2016-03-05 16:05:32 -08:00
JonnyWong16
bd19f543a2 Merge pull request #586 from JonnyWong16/websockets-watched-notify-fix
Check if notification agents enabled before sending notifications
2016-03-05 13:22:11 -08:00
JonnyWong16
cc1e888227 Check if notification agents enabled before sending notifications 2016-03-05 13:20:28 -08:00
JonnyWong16
d73e379dcf Do not remove session from db until it is successfully written
* For activity pinger only
2016-03-05 13:07:26 -08:00
JonnyWong16
0569abd00d Add customizable backup, cache, and log directory 2016-03-04 23:41:18 -08:00
JonnyWong16
7f5d9bec87 Add button to clear notification logs 2016-03-04 23:13:19 -08:00
JonnyWong16
b39e7bbb6d Do not strip newlines from notification text
* Behaviour is more predictable this way
2016-03-04 22:50:21 -08:00
JonnyWong16
70270a8e3b Hide days selection from Play Totals graph 2016-03-04 22:27:52 -08:00
JonnyWong16
efdc050a28 Filter history modal on graphs based on clicked series 2016-03-04 22:21:34 -08:00
JonnyWong16
e8a65df7f0 Add transcode_decision to media_info table 2016-03-04 22:20:15 -08:00
JonnyWong16
59628a72fb Fix typo in PlexWatch importer 2016-03-04 22:19:16 -08:00
JonnyWong16
a4d6c6c0d8 Fix datatables modal popups from 464d2a5 2016-03-04 22:18:34 -08:00
JonnyWong16
bea82c6640 Fix IPv6 address 2016-03-03 14:26:39 -08:00
drzoidberg33
1ba3bdfbda Don't check for PMS updates every 10 seconds. 2016-03-02 14:00:30 +02:00
JonnyWong16
98b4000bc0 Add ability to reset Imgur posters from info page 2016-03-01 23:29:49 -08:00
JonnyWong16
14f6824931 Use Parsley to verify pms logs folder is not a shortcut 2016-03-01 21:49:12 -08:00
JonnyWong16
795d7d0a93 Add ability to get notified of PMS updates 2016-03-01 21:04:57 -08:00
JonnyWong16
673fa2b556 Fix auto-refresh of log tabs 2016-03-01 20:31:45 -08:00
JonnyWong16
0e2504fc78 Document remaining time format options 2016-03-01 20:31:21 -08:00
JonnyWong16
2afca9f2b4 Alert if PMS logs folder is a shortcut 2016-02-28 15:39:13 -08:00
JonnyWong16
464d2a541d Give tables unique ids to save state indivdually 2016-02-27 13:39:21 -08:00
JonnyWong16
fa8c5e0982 Only use user_id in current activity link to user page 2016-02-27 01:18:40 -08:00
JonnyWong16
5e15884d8f Fix scrollers when items don't fill up the row 2016-02-27 01:00:12 -08:00
JonnyWong16
b5e9ff3b4e Add scrolling recently watched and added to user and library pages 2016-02-27 00:00:11 -08:00
JonnyWong16
fed7d4cc34 Add scrolling recently added to homepage 2016-02-26 23:53:03 -08:00
JonnyWong16
d7ab066ff8 Revert datatables save state to true 2016-02-26 21:57:22 -08:00
JonnyWong16
4100917016 Add ability to disable Facebook poster link to Plex Web 2016-02-26 21:39:41 -08:00
JonnyWong16
5d2c1ffb88 Fix bug in checking for PMS version in settings 2016-02-26 19:17:42 -08:00
JonnyWong16
ddb0f198a9 Add tooltip to current activity progress bars 2016-02-25 21:56:23 -08:00
JonnyWong16
13438e3e25 Anonymize more URLs 2016-02-25 09:57:05 -08:00
JonnyWong16
05a410b327 Catch blank view_offset or duration in history table query 2016-02-24 21:40:29 -08:00
JonnyWong16
23fa64d289 Change colour of grouped recently added note on checkbox toggle 2016-02-24 21:40:19 -08:00
JonnyWong16
1920c9b7e3 Reconnect websocket on server change 2016-02-23 19:05:20 -08:00
JonnyWong16
eedd0d9c07 Use subprocess.Popen on windows to restart PlexPy
* See python bug: https://bugs.python.org/issue19066
2016-02-23 18:29:33 -08:00
JonnyWong16
9ef389d335 Actually allow HTML tags for Pushover 2016-02-22 21:20:05 -08:00
JonnyWong16
6542997520 Merge branch 'dev' 2016-02-21 23:07:33 -08:00
JonnyWong16
a58b2e2038 v1.3.9 2016-02-21 23:06:22 -08:00
JonnyWong16
6860e348dc Fix typo in setting recently added notification state 2016-02-21 23:01:18 -08:00
JonnyWong16
5e094e7597 Merge pull request #477 from elseym/pushover-html-support
Pushover HTML Support
2016-02-21 21:35:07 -08:00
JonnyWong16
2f2cb8386b Change wording for enable posters in notification help text 2016-02-21 20:41:18 -08:00
JonnyWong16
965fd170bd Merge branch 'dev' 2016-02-21 20:32:14 -08:00
JonnyWong16
2610d29b60 v1.3.8 2016-02-21 20:31:51 -08:00
JonnyWong16
064131c842 Uncheck monitor remote access if remote access diabled on server 2016-02-21 17:20:18 -08:00
JonnyWong16
00b6bf8394 Return default ip_address/poster_url if database query fails 2016-02-21 17:11:49 -08:00
JonnyWong16
2a885d709d Allow disabling poster upload to Imgur
* Disabled by default
2016-02-21 17:06:05 -08:00
JonnyWong16
5bed46c0aa Encode poster title to UTF-8 for Imgur upload 2016-02-21 17:05:10 -08:00
JonnyWong16
8b27c7e01a Remove poster url from notification logs table 2016-02-21 16:42:29 -08:00
JonnyWong16
177902a286 Remove media tags from script_args for server notifications 2016-02-21 16:42:08 -08:00
JonnyWong16
48b0f7dc27 Fix NoneType error in set_notify_state 2016-02-21 16:33:42 -08:00
JonnyWong16
d5f4a1a48a Make readme consistent with settings page 2016-02-21 16:18:26 -08:00
JonnyWong16
de9f60aa7f Add notification log table 2016-02-21 15:45:28 -08:00
JonnyWong16
c93b65b299 Rework notify_log table to save each notification separately 2016-02-21 15:44:21 -08:00
JonnyWong16
3c6a6cdc5b Fix wording on settings page 2016-02-21 14:56:19 -08:00
JonnyWong16
b669f3d715 Fix regression unable to clear the http password 2016-02-21 09:58:27 -08:00
JonnyWong16
f663fac220 Save Imgur URL to database 2016-02-21 09:34:51 -08:00
JonnyWong16
bc42e79bb5 Catch HTTP errors for Imgur upload 2016-02-21 09:33:31 -08:00
JonnyWong16
ca29333cd0 Log if opening secure websocket 2016-02-20 20:50:20 -08:00
JonnyWong16
f9f478e100 Update CONTRIBUTING.md with info about issue reporting and feature requests 2016-02-20 20:47:33 -08:00
JonnyWong16
97c414d1ad Merge branch 'dev' 2016-02-20 19:54:37 -08:00
JonnyWong16
7afbd98d17 v1.3.7 2016-02-20 19:53:21 -08:00
JonnyWong16
1f5c60588e Change Facebook help text 2016-02-20 08:45:00 -08:00
JonnyWong16
284ab45a17 Upload Plex posters to Imgur for notifications 2016-02-19 23:25:33 -08:00
JonnyWong16
eab6365af9 Disable IP logging checkbox depending on server version 2016-02-19 21:02:48 -08:00
JonnyWong16
de86516a0a Disable monitor remote access checkbox if remote access is disabled
* And anonymize URLs
2016-02-18 22:48:02 -08:00
JonnyWong16
3e50e11933 Simplify log_type 2016-02-18 22:26:13 -08:00
JonnyWong16
e2ac8be451 Cleanup save settings 2016-02-18 22:24:19 -08:00
JonnyWong16
0e53252a27 Move get poster to notification handler 2016-02-18 21:09:07 -08:00
JonnyWong16
b1ecff3d10 Add TV posters to Facebook notifications 2016-02-18 18:52:07 -08:00
JonnyWong16
0fee4fee2a Fix typo from e38e98d9e7 2016-02-18 12:03:28 -08:00
drzoidberg33
66282d817c Merge pull request #551 from drzoidberg33/scanner-log-view
Add Plex Media Scanner log files to Log viewer.
2016-02-18 18:08:11 +02:00
Tim Van
932c93e573 Ensure we default to the server log. 2016-02-18 18:06:36 +02:00
Tim Van
71d30af582 Add Plex Media Scanner log files to Log viewer. 2016-02-18 18:01:42 +02:00
JonnyWong16
1c8428c3ea Add backup back to api 2016-02-18 06:53:07 -08:00
JonnyWong16
e38e98d9e7 Some code cleanup for libraries and users 2016-02-17 22:10:00 -08:00
JonnyWong16
85b3f081bf Add scheduled database backups 2016-02-17 18:41:55 -08:00
JonnyWong16
3926d97fc6 Open settings links in new tabs 2016-02-17 18:41:16 -08:00
JonnyWong16
13ac8f2ea4 Revert homepage watch statistic back to "last watched" 2016-02-17 18:33:01 -08:00
JonnyWong16
d94f991ab5 Add icon to scheduler status 2016-02-17 18:32:17 -08:00
JonnyWong16
d476d2e96a Merge pull request #541 from JonnyWong16/ssl-certificates
Create self-signed HTTPS certificates
2016-02-15 18:38:15 -08:00
JonnyWong16
635bf364ac Hide HTTPS Domains and IPs if not creating self-signed certificate 2016-02-15 18:36:01 -08:00
JonnyWong16
e1c7a37f62 Only create self-signed certificate if enabled 2016-02-15 18:36:01 -08:00
JonnyWong16
9d780701f5 Create self-signed HTTPS certificates 2016-02-15 18:36:01 -08:00
JonnyWong16
0bd40405b5 Test poster images for Facebook notifications 2016-02-14 22:36:19 -08:00
JonnyWong16
25c2f95e48 Separate out scheduler table to allow reloading 2016-02-14 21:02:14 -08:00
JonnyWong16
5d738e58eb Schedule PlexPy database backup task 2016-02-14 18:25:58 -08:00
JonnyWong16
70325f9247 Bold "bell icon" on notification agents page 2016-02-14 18:25:36 -08:00
JonnyWong16
38c9c5a6ea Add configuration and scheduler info to settings page 2016-02-14 17:51:14 -08:00
JonnyWong16
c90dd147bb Rename config_id to agent_id 2016-02-14 11:39:03 -08:00
JonnyWong16
322f106e75 Log JS errors from the WebUI 2016-02-14 11:35:14 -08:00
JonnyWong16
91a5529438 Some APIv2 cleanup 2016-02-14 11:03:32 -08:00
JonnyWong16
8f7dd2df6a Merge pull request #377 from Hellowlol/api2
Api2
2016-02-13 09:30:29 -08:00
Hellowlol
2fcd55eb60 API2 2016-02-10 22:09:41 +01:00
JonnyWong16
9359567a8a Add optional subject line to notification agents 2016-02-09 23:00:10 -08:00
JonnyWong16
42bfacfb19 Add IMDB, TVDB, TMDb, last.fm, and trakt to notification options 2016-02-09 22:20:17 -08:00
JonnyWong16
71131c699e Add total duration to libraries and users tables 2016-02-09 17:58:56 -08:00
JonnyWong16
6ebfc516a6 Add ETA to current activity 2016-02-09 17:08:59 -08:00
JonnyWong16
5c952b1d86 Fix regression where {stream_duration} not reported 2016-02-09 17:08:44 -08:00
JonnyWong16
1d9a4e0b99 Add view_offset to history grouping logic 2016-02-08 17:34:24 -08:00
JonnyWong16
ebae628d8d Fix typo in notification exclusion tag usage modal 2016-02-07 23:23:36 -08:00
JonnyWong16
9865460fe5 Move PMS_SSL to correct section in config file 2016-02-07 16:12:37 -08:00
drzoidberg33
39884b71fe Merge pull request #529 from drzoidberg33/machine-id-fix
Fix bad SSL connections.
2016-02-08 02:08:51 +02:00
Tim Van
82b7128c04 Allow secure websocket connections. 2016-02-08 01:29:57 +02:00
Tim Van
16756ddb8c Don't chose a custom URL when picking a hostname for local SSL configs. 2016-02-08 00:21:40 +02:00
JonnyWong16
877002961f Use custom library icons in library statistics 2016-02-07 12:42:24 -08:00
JonnyWong16
7e9e68ecd8 Fix video media flags for tracks 2016-02-07 12:42:07 -08:00
Tim Van
6419190272 Revert silly naming bug. 2016-02-07 22:34:38 +02:00
Tim Van
ac42563c5e Refresh PMS URL when changing is_remote option in settings. 2016-02-07 22:28:04 +02:00
Tim Van
98c1063e07 Allows us to retrieve the serverId again if we have secure connections required. 2016-02-07 22:21:46 +02:00
Tim Van
a4dfc57cbe Fix some issues with possible mismatching serverIDs causing bad ssl connections. 2016-02-07 15:00:06 +02:00
JonnyWong16
db543b8912 Add {machine_id} to notification options 2016-02-04 08:34:15 -08:00
JonnyWong16
49fb4540a2 Merge branch 'dev' 2016-02-03 20:55:10 -08:00
JonnyWong16
e2120393a2 v1.3.6 2016-02-03 20:54:28 -08:00
JonnyWong16
0b301fff3f Fix regression where duration not reported as min 2016-02-03 09:32:32 -08:00
JonnyWong16
eeb351e991 Update readme 2016-02-02 22:34:46 -08:00
JonnyWong16
1095e29b4d Fix FreeBSD and FreeNAS init scripts daemonizing 2016-02-02 21:23:48 -08:00
JonnyWong16
be058eaff7 Merge branch 'dev' 2016-02-02 21:13:34 -08:00
JonnyWong16
f409dda2ef v1.3.5 2016-02-02 21:12:53 -08:00
JonnyWong16
f409cdda8f Merge pull request #502 from JonnyWong16/startup-tasks-after-daemonizing
Run startup tasks after daemonizing
2016-02-02 21:03:52 -08:00
JonnyWong16
9cd6396c35 Add method to delete duplicate libraries 2016-02-02 20:54:34 -08:00
JonnyWong16
ee754ea533 Remove trailing slash from Facebook redirect URI 2016-02-02 20:38:16 -08:00
JonnyWong16
36de20dd75 Fix getting new pms_identifier for server only 2016-02-02 20:33:47 -08:00
JonnyWong16
a957e8eb4f Clean up time formats for server notifications 2016-02-02 20:33:08 -08:00
JonnyWong16
14a90d84ec Add {stream_time}, {remaining_time}, and {progress_time} to notification options 2016-01-31 16:15:06 -08:00
JonnyWong16
fae9bc618a Initialize PlexPy after daemonizing 2016-01-31 15:13:35 -08:00
JonnyWong16
3248e6500e Clean up build_notify_text
* session is now a dict, so no need for "default values"
2016-01-31 13:34:51 -08:00
JonnyWong16
c17bf79d79 Fix server verification for unpublished servers 2016-01-31 11:32:44 -08:00
JonnyWong16
1ff1270bfa Clean up powershell for scripts 2016-01-30 16:18:45 -08:00
JonnyWong16
b1a2cf33d8 Merge pull request #498 from Hellowlol/ps1
add support for powershell
2016-01-30 15:54:34 -08:00
Hellowlol
b2292e98c1 add support for powershell 2016-01-31 00:18:58 +01:00
JonnyWong16
4d156a8911 Allow expanding of media info table when missing added at date 2016-01-30 00:48:51 -08:00
JonnyWong16
7193b6518b Fix removing unique constraints from database 2016-01-30 00:40:06 -08:00
JonnyWong16
cff6b44109 Merge branch 'dev' 2016-01-29 21:32:37 -08:00
JonnyWong16
fb7ad9438e v1.3.4 2016-01-29 21:31:25 -08:00
JonnyWong16
afc265a188 Fix schedulers not starting with library update 2016-01-29 21:26:27 -08:00
JonnyWong16
01fe7bf612 Reorganize notification options 2016-01-29 19:06:08 -08:00
JonnyWong16
1cb75bd053 Remove unnecessary quoting of script arguments 2016-01-29 18:47:12 -08:00
JonnyWong16
0eaea4d011 Fix empty libraries not added 2016-01-29 18:38:19 -08:00
JonnyWong16
67377a2561 Fix server verification in settings 2016-01-27 23:32:21 -08:00
JonnyWong16
a8aae9f1f5 Fix libraries and users refresh 2016-01-27 23:32:01 -08:00
JonnyWong16
a9ce92decb Change Telegram wording 2016-01-27 21:30:35 -08:00
JonnyWong16
c19162295a Update Facebook instructions 2016-01-27 21:20:04 -08:00
JonnyWong16
58796c45ed Remove built in Twitter consumer key and secret 2016-01-27 21:19:18 -08:00
JonnyWong16
d94b348780 Fix buffer notifications even when disabled with websockets 2016-01-27 19:52:30 -08:00
JonnyWong16
95f92bd292 Add unique identifiers to notification options 2016-01-27 19:51:58 -08:00
JonnyWong16
bc52ac3559 Remove media type toggles from recently added notifications 2016-01-27 19:51:36 -08:00
JonnyWong16
8bbc6a6611 Fix libraries without section_id in database 2016-01-27 19:51:10 -08:00
JonnyWong16
8902b93a26 Merge branch 'dev' 2016-01-26 00:14:38 -08:00
JonnyWong16
ae36af807d v1.3.3 2016-01-26 00:13:58 -08:00
JonnyWong16
fd256625c6 Fix Plays by Month graph not loading 2016-01-25 18:43:51 -08:00
JonnyWong16
bee543a25a Disable datatables caching 2016-01-25 18:30:30 -08:00
JonnyWong16
55eb79cb52 Even faster library updating 2016-01-25 12:01:59 -08:00
JonnyWong16
35965a8320 Merge branch 'dev' 2016-01-24 22:52:47 -08:00
JonnyWong16
8a902ae3e6 v1.3.2 2016-01-24 22:51:36 -08:00
JonnyWong16
52bed5bf98 Attempt at improved library updating 2016-01-24 22:19:48 -08:00
JonnyWong16
9e83f6d779 Another fix for 'datestamp' and 'timestamp' 2016-01-24 09:52:20 -08:00
elseym
0873beaed2 enable pushover html support by default, introduce option to deactivate 2016-01-24 18:06:36 +01:00
JonnyWong16
0ba5012464 Merge branch 'dev' 2016-01-23 22:59:46 -08:00
JonnyWong16
73ff28465d v1.3.1 2016-01-23 22:58:44 -08:00
JonnyWong16
7484d65dbb Fix datestamp and timestamp notification options 2016-01-23 22:53:13 -08:00
JonnyWong16
4a120e7a54 Fix unable to startup if library refresh fails 2016-01-23 21:06:41 -08:00
JonnyWong16
8d63d85821 Fix star rating overlapping text 2016-01-23 19:14:54 -08:00
JonnyWong16
5cec84a802 More descriptive libraries updating message 2016-01-23 19:13:16 -08:00
JonnyWong16
48da41690d Fix empty brackets on tables 2016-01-23 19:12:13 -08:00
JonnyWong16
1c82241f30 Fix notifier config ajax calls for reverse proxies 2016-01-23 16:23:20 -08:00
JonnyWong16
b1ea3bcd4e Rename last watched to last played 2016-01-23 14:06:25 -08:00
119 changed files with 15771 additions and 2188 deletions

5
.gitignore vendored
View File

@@ -23,6 +23,9 @@ cache/*
*.csr
*.pem
# Mergetool
*.orgin
# OS generated files #
######################
.DS_Store?
@@ -32,7 +35,7 @@ Icon?
Thumbs.db
#Ignore files generated by PyCharm
.idea/*
*.idea/*
#Ignore files generated by vi
*.swp

View File

@@ -1,22 +1,205 @@
# Changelog
## v1.3.15 (2016-04-18)
* Fix: Slack notifications failing when using and icon URL.
* Fix: 127.0.0.1 showing as an external IP address on the history tables.
* Fix: Regression file sizes not shown in the media info table footer.
* Fix: Retrieving proper PMS URL when multiple connections are published to plex.tv.
* Fix: Some typos in the logger.
* Fix: Some other typos in the WebUI. (Thanks @xtjoeytx)
* Change: Optimized mobile web app icons and spash screens. (Thanks @alotufo)
## v1.3.14 (2016-03-29)
* Fix: Regression for missing notify_action for script notifications.
* Fix: Typo for home stats cards in the settings.
## v1.3.13 (2016-03-27)
* Fix: Only mask strings longer than 5 characters in logs.
## v1.3.12 (2016-03-27)
* Fix: "Check GitHub for updates" not rescheduling when toggling setting.
* Fix: Bug where notifications would fail if metadata is not found.
* Fix: Bug where notifications would fail if unable to upload poster to Imgur.
* Fix: PlexPy will now start properly for different Python environment variables.
* New: Feature requests moved to FeatHub.
* New: Ability to specify a GitHub API token for updates (optional).
* New: Mask out sensitive information from the logs.
* New: New and updated Arnold quotes. (Thanks @Vilsol & @Chrisophogus)
* New: "First" and "Last" page buttons to datatables.
* New: Access log file from the "Help & Info" page.
* New: CherryPy environment options (for development). (Thanks @codedecay)
* New: PlexPy development environment (for development only).
* Change: Facebook posts with a posters now include a summary.
* Change: Facebook posts now use a default poster if the poster is not found or unable to upload to Imgur.
* Change: IFTTT events can be fromatted with the {action} name.
* Change: Logs now use ISO date format to avoid locale encoding errors. (Thanks @alshain)
* Remove: Non-functioning Plex notification agent.
## v1.3.11 (2016-03-15)
* Fix: Typo preventing history logging for websockets.
## v1.3.10 (2016-03-12)
* Fix: Actually allow HTML tags for Pushover.
* Fix: PlexPy not restarting on Windows if there is a space in the folder path.
* Fix: Reconnect websocket when changing PMS SSL setting.
* Fix: Datatables not loading when view_offset or duration is blank.
* Fix: Bug when checking the PMS version in the settings.
* Fix: Auto-refreshing of log tables.
* Fix: Logging of IPv6 addresses. (PMS version >0.9.14 only.)
* Fix: Hide days selection from the Play Totals graph page.
* Fix: PlexPy overwriting user's own SSL certificate/key.
* Fix: Multiple watched notifications when using websocket.
* Fix: Some missing python library imports.
* Fix: Some typos in settings and PlexWatch importer.
* New: Ability to get notified of PMS updates.
* New: Ability to disable the link to Plex Web with Facebook notifications and use IMDB, TVDB, TMDb, or Last.fm instead.
* New: Ability to reset Imgur poster url from the info page if the poster is changed.
* New: Tooltips on the current activity progress bars.
* New: Side scrolling of Recently Added/Recently Played items.
* New: Document all date/time format options.
* New: Button to clear notification logs.
* New: Customizable backup, cache, and log directories.
* Change: Retry writing sessions to history if it fails, so sessions don't get lost. (Activity pinger only, not availble for websocket.)
* Change: Save any unknown sessions to the "Local" user.
* Change: History table modal is filtered depending on which graph series is clicked.
* Change: Revert back to saving the state of datatables (search, sorting, entries per page, etc.).
* Change: Newlines are not longer stripped from notification text which allows for finer control of how notifications look.
* Change: Updated FreeNAS/FreeBSD init scripts. (Must have updated jails.) (Thanks @chiviak)
## v1.3.9 (2016-02-21)
* Fix: Recently added notification not sent to all notification agents.
* New: Pushover HTML support. (Thanks @elseym)
## v1.3.8 (2016-02-21)
* Fix: Regression unable to clear HTTP password.
* Fix: Remove media tags from script arguments for server notifications.
* Fix: Encode poster titles to UTF-8 for Imgur upload.
* Fix: Allow notifications to send without poster if Imgur upload fails.
* New: Notification Logs table in the Logs tab.
* New: Toggle in settings to enable posters in notifications. (Disabled by default.)
* Change: Save Imgur poster URL to database so upload is not needed every time.
* Change: Notify log in database to log each event as a separate entry.
* Change: Monitor remote access is unchecked if remote access is disabled on server.
## v1.3.7 (2016-02-20)
* Fix: Verifying server with SSL enabled.
* Fix: Regression where {stream_duration} reported as 0.
* Fix: Video metadata flags showing up for track info.
* Fix: Custom library icons not applied to Library Statistics.
* Fix: Typos in the Web UI.
* New: ETA to Current Activity overlay.
* New: Total duration to Libraries and Users tables.
* New: {machine_id} to notification options.
* New: IMDB, TVDB, TMDb, Last.fm, and Trackt IDs/URLs to notification options.
* New: {poster_url} to notification options using Imgur.
* New: Poster and link for Facebook notifications.
* New: Log javascript errors from the Web UI.
* New: Configuration and Scheduler info to the settings page.
* New: Schedule background task to backup the PlexPy database.
* New: URL anonymizer for external links.
* New: Plex Media Scanner log file to Log viewer.
* New: API v2 (sill very experimental). (Thanks @Hellowlol)
* Change: Allow secure websocket connections.
* Change: History grouping now accounts for the view offset.
* Change: Subject line can be toggled off for Facebook, Slack, Telegram, and Twitter.
* Change: Create self-signed SSL certificates when enabling HTTPS.
* Change: Revert homepage "Last Played" to "Last Watched".
* Change: Disable monitor remote access checkbox if remote access is not enabled on the PMS.
* Change: Disable IP logging checkbox if PMS version is 0.9.14 or greater.
## v1.3.6 (2016-02-03)
* Fix: Regression where {duration} not reported in minutes.
* Fix: Proper daemonizing in FreeBSD and FreeNAS init scripts.
* Change: Update readme documentation.
## v1.3.5 (2016-02-02)
* Fix: Removing unique constraints from database.
* Fix: Unable to expand media info table when missing "Added At" date.
* Fix: Server verification for unpublished servers.
* Fix: Updating PMS identifier for server change.
* New: {stream_time}, {remaining_time}, and {progress_time} to notification options.
* New: Powershell script support. (Thanks @Hellowlol)
* New: Method to delete duplicate libraries.
* Change: Daemonize before running start up tasks.
## v1.3.4 (2016-01-29)
* Fix: Activity checker not starting with library update (history not logging).
* Fix: Libraries duplicated in database.
* Fix: Buffer notifications even when disabled when using websockets.
* Fix: Libraries and Users lists not refreshing.
* Fix: Server verification in settings.
* Fix: Empty libraries not added to database.
* New: Unique identifiers to notification options.
* Remove: Requirement of media type toggles for recently added notifications.
* Remove: Built in Twitter key and secret.
* Change: Unnecessary quoting of script arguments.
* Change: Facebook notification instructions.
## v1.3.3 (2016-01-26)
* Fix: Plays by Month graph not loading.
* Change: Disable caching for datatables.
* Change: Improved updating library data in the database again.
## v1.3.2 (2016-01-24)
* Fix: 'datestamp' and 'timestamp' for server notifications.
* Change: New method for updating library data in database.
## v1.3.1 (2016-01-23)
* Fix: Notifiers authorization popups for reverse proxies.
* Fix: Empty brackets in titles on tables.
* Fix: Star rating overlapping text.
* Fix: Unable to startup when library refresh fails.
* Fix: Unable to parse 'datestamp' and 'timestamp' format.
* Change: Rename "Last Watched" to "Last Played".
* Change: More descriptive libraries updating message.
## v1.3.0 (2016-01-23)
* Add: Brand new Libraries section.
* Add: Lots of new library statistics.
* Add: Media info table for libraries.
* Add: Web app for Android and iOS. (Thanks @zobe123)
* Add: Slack notification agent. (Thanks @richipargo)
* Add: Facebook notification agent.
* Add: Custom script notification agent. (Thanks @Hellowlol)
* Add: Custom "From Name" to email notification agent.
* Add: Ability to test notifications / send custom one-off notifications.
* Add: 'datestamp' and 'timestamp' notification options.
* Add: More concurrent stream statistics.
* Add: Media info flags on the info pages.
* Add: Ability to fix broken metadata if the item has been moved in Plex.
* Add: Ability to rearrange the homepage statistics cards.
* Add: CentOS startup script (Thanks @PHoSawyer)
* New: Brand new Libraries section.
* New: Lots of new library statistics.
* New: Media info table for libraries.
* New: Web app for Android and iOS. (Thanks @zobe123)
* New: Slack notification agent. (Thanks @richipargo)
* New: Facebook notification agent.
* New: Custom script notification agent. (Thanks @Hellowlol)
* New: Custom "From Name" to email notification agent.
* New: Ability to test notifications / send custom one-off notifications.
* New: 'datestamp' and 'timestamp' notification options.
* New: More concurrent stream statistics.
* New: Media info flags on the info pages.
* New: Ability to fix broken metadata if the item has been moved in Plex.
* New: Ability to rearrange the homepage statistics cards.
* New: CentOS startup script (Thanks @PHoSawyer)
* Fix: Server name blank after first run wizard.
* Fix: Incorrect duration for grouped home stats.
* Fix: Allow SSL when verifying server in settings.

View File

@@ -1,12 +1,45 @@
# Contributing to PlexPy
## Issues
In case you read this because you are posting an issue, please take a minute and conside the things below. The issue tracker is not a support forum. It is primarily intended to submit bugs, improvements or feature requests. However, we are glad to help you, and make sure the problem is not caused by PlexPy, but don't expect step-by-step answers.
In case you read this because you are posting an issue, please take a minute and conside the things below. The issue tracker is not a support forum. It is primarily intended to submit bugs. However, we are glad to help you, and make sure the problem is not caused by PlexPy, but don't expect step-by-step answers.
* Use the search function. Chances are that your problem is already discussed. Do not append to (closed) issues if your problem does not fit the discussion.
* Visit the [Troubleshooting](../../wiki/TroubleShooting) wiki first.
* Use [proper formatting](https://help.github.com/articles/github-flavored-markdown/). Paste your logs in code blocks.
* Close your issue if you resolved it.
##### Many issues can simply be solved by:
- Making sure you update to the latest version.
- Turning your device off and on again.
- Analyzing your logs, you just might find the solution yourself!
- Using the **search** function to see if this issue has already been reported/solved.
- Checking the [Wiki](https://github.com/drzoidberg33/plexpy/wiki) for
[ [Installation] ](https://github.com/drzoidberg33/plexpy/wiki/Installation) and
[ [FAQs] ](https://github.com/drzoidberg33/plexpy/wiki/Frequently-Asked-Questions-(FAQ)).
- For basic questions try asking on [Gitter](https://gitter.im/drzoidberg33/plexpy) or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue.
##### If nothing has worked:
1. Open a new issue on the GitHub [issue tracker](http://github.com/drzoidberg33/plexpy/issues).
2. Provide a clear title to easily help identify your problem.
3. Use proper [markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your post (i.e. code/log in code blocks).
4. Make sure you provide the following information:
- [ ] Version
- [ ] Branch
- [ ] Commit hash
- [ ] Operating system
- [ ] Python version
- [ ] What you did?
- [ ] What happened?
- [ ] What you expected?
- [ ] How can we reproduce your issue?
- [ ] What are your (relevant) settings?
- [ ] Include a link to your **FULL** (not just a few lines!) log file that has the error. Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
5. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
## Feature Requests
Feature requests are handled on [FeatHub](http://feathub.com/drzoidberg33/plexpy).
1. Search the existing requests to see if your suggestion has already been submitted.
2. If a similar request exists, give it a thumbs up (+1), or add additional comments to the request.
3. If no similar requests exist, you can create a new one. Make sure to provide a clear title to easily identify the feature request.
## Pull Requests
If you think you can contribute code to the PlexPy repository, do not hesitate to submit a pull request.

View File

@@ -1,4 +1,9 @@
#!/usr/bin/env python
#!/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 PlexPy.
@@ -76,11 +81,14 @@ def main():
'-d', '--daemon', action='store_true', help='Run as a daemon')
parser.add_argument(
'-p', '--port', type=int, help='Force PlexPy to run on a specified port')
parser.add_argument(
'--dev', action='store_true', help='Start PlexPy 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(
'--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)')
@@ -95,6 +103,10 @@ def main():
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
verbose=plexpy.VERBOSE)
if args.dev:
plexpy.DEV = True
logger.debug(u"PlexPy is running in the dev environment.")
if args.daemon:
if sys.platform == 'win32':
sys.stderr.write(
@@ -153,11 +165,24 @@ def main():
# Put the database in the DATA_DIR
plexpy.DB_FILE = os.path.join(plexpy.DATA_DIR, 'plexpy.db')
if plexpy.DAEMON:
plexpy.daemonize()
# Read config and start logging
plexpy.initialize(config_file)
if plexpy.DAEMON:
plexpy.daemonize()
# Start the background threads
plexpy.start()
# Open connection for websocket
if plexpy.CONFIG.MONITORING_USE_WEBSOCKET:
try:
web_socket.start_thread()
except:
logger.warn(u"Websocket :: Unable to open connection.")
# Fallback to polling
plexpy.POLLING_FAILOVER = True
plexpy.initialize_scheduler()
# Force the http port if neccessary
if args.port:
@@ -181,6 +206,7 @@ def main():
'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,
@@ -190,21 +216,8 @@ def main():
}
webstart.initialize(web_config)
# Start the background threads
plexpy.start()
# Open connection for websocket
if plexpy.CONFIG.MONITORING_USE_WEBSOCKET:
try:
web_socket.start_thread()
except:
logger.warn(u"Websocket :: Unable to open connection.")
# Fallback to polling
plexpy.POLLING_FAILOVER = True
plexpy.initialize_scheduler()
# Open webbrowser
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch:
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)

164
README.md
View File

@@ -4,118 +4,76 @@
A python based web application for monitoring, analytics and notifications for Plex Media Server (www.plex.tv).
This project is based on code from Headphones (https://github.com/rembo10/headphones) and PlexWatchWeb (https://github.com/ecleese/plexWatchWeb).
This project is based on code from [Headphones](https://github.com/rembo10/headphones) and [PlexWatchWeb](https://github.com/ecleese/plexWatchWeb).
* PlexPy forum thread: https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program
* PlexPy [forum thread](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program)
## Features
###Support
-----------
* PlexPy Wiki: https://github.com/drzoidberg33/plexpy/wiki
* Responsive web design viewable on desktop, tablet and mobile web browsers.
* Themed to complement Plex/Web.
* Easy configuration setup (no separate web server required).
* Monitor current Plex Media Server activity.
* Fully customizable notifications for stream activity and recently added media.
* Top statistics on home page with configurable duration and measurement metric.
* Global watching history with search/filtering & dynamic column sorting.
* Full user list with general information and comparison stats.
* Individual user information including devices IP addresses.
* Complete library statistics and media file information.
* Rich analytics presented using Highcharts graphing.
* Beautiful content information pages.
* Full sync list data on all users syncing items from your library.
* And many more!!
## Installation and Support
###Features
-----------
* Responsive web design viewable on desktop, tablet and mobile web browsers
* Themed to complement Plex/Web
* Easy configuration setup via html form
* Current Plex Media Server viewing activity including:
* number of current users
* title
* progress
* platform
* user
* state (playing, paused, buffering, etc)
* stream type (direct, transcoded)
* video type & resolution
* audio type & channel count.
* Top statistics on home page with configurable duration and measurement metric:
* Most watched TV
* Most popular TV
* Most watched Movie
* Most popular Movie
* Most active user
* Most active platform
* Recently added media and how long ago it was added
* Global watching history with search/filtering & dynamic column sorting
* date
* user
* platform
* ip address
* title
* stream information details
* start time
* paused duration length
* stop time
* duration length
* watched progress
* show/hide columns
* delete mode - allows deletion of specific history items
* Full user list with general information and comparison stats
* Individual user information
* username and gravatar (if available)
* daily, weekly, monthly, all time stats for play count and duration length
* individual platform stats for each user
* public ip address history with last seen date and geo tag location
* recently watched content
* watching history
* synced items
* assign users custom friendly names within PlexPy
* assign users custom avatar URL within PlexPy
* disable history logging per user
* disable notifications per user
* option to purge all history per user.
* Rich analytics presented using Highcharts graphing
* user-selectable time periods of 30, 90 or 365 days
* daily watch count and duration
* totals by day of week and hours of the day
* totals by top 10 platform
* totals by top 10 users
* detailed breakdown by transcode decision
* source and stream resolutions
* transcode decision counts by user and platform
* total monthly counts
* Content information pages
* movies (includes watching history)
* tv shows (includes watching history)
* tv seasons
* tv episodes (includes watching history)
* Full sync list data on all users syncing items from your library
## Installation and Notes
* [Installation page](../../wiki/Installation) shows you how to install PlexPy.
* [Usage guide](../../wiki/Usage-guide) introduces you to PlexPy.
* [Troubleshooting page](../../wiki/TroubleShooting) in the wiki can help you with common problems.
**Issues** can be reported on the GitHub issue tracker considering these rules:
1. Analyze your log, you just might find the solution yourself!
2. You read the wiki and searched existing issues, but this is not solving your problem.
3. Post the issue with a clear title, description and the HP log and use [proper markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your text (code/log in code blocks).
4. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
**Feature requests** can be reported on the GitHub issue tracker too:
1. Search for similar existing 'issues', feature requests can be recognized by the label 'Request'.
2. If a similar Request exists, post a comment (+1, or add a new idea to the existing request), otherwise you can create a new one.
If you **comply with these rules** you can [post your request/issue](http://github.com/drzoidberg33/plexpy/issues).
* [Installation Guides](https://github.com/drzoidberg33/plexpy/wiki/Installation) shows you how to install PlexPy.
* [FAQs](https://github.com/drzoidberg33/plexpy/wiki/Frequently-Asked-Questions-(FAQ)) in the wiki can help you with common problems.
**Support** the project by implementing new features, solving support tickets and provide bug fixes.
## Issues
##### Many issues can simply be solved by:
- Making sure you update to the latest version.
- Turning your device off and on again.
- Analyzing your logs, you just might find the solution yourself!
- Using the **search** function to see if this issue has already been reported/solved.
- Checking the [Wiki](https://github.com/drzoidberg33/plexpy/wiki) for
[ [Installation] ](https://github.com/drzoidberg33/plexpy/wiki/Installation) and
[ [FAQs] ](https://github.com/drzoidberg33/plexpy/wiki/Frequently-Asked-Questions-(FAQ)).
- For basic questions try asking on [Gitter](https://gitter.im/drzoidberg33/plexpy) or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue.
##### If nothing has worked:
1. Open a new issue on the GitHub [issue tracker](http://github.com/drzoidberg33/plexpy/issues).
2. Provide a clear title to easily help identify your problem.
3. Use proper [markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your post (i.e. code/log in code blocks).
4. Make sure you provide the following information:
- [ ] Version
- [ ] Branch
- [ ] Commit hash
- [ ] Operating system
- [ ] Python version
- [ ] What you did?
- [ ] What happened?
- [ ] What you expected?
- [ ] How can we reproduce your issue?
- [ ] What are your (relevant) settings?
- [ ] Include a link to your **FULL** (not just a few lines!) log file that has the error. Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
5. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
## Feature Requests
Feature requests are handled on [FeatHub](http://feathub.com/drzoidberg33/plexpy).
1. Search the existing requests to see if your suggestion has already been submitted.
2. If a similar request exists, give it a thumbs up (+1), or add additional comments to the request.
3. If no similar requests exist, you can create a new one. Make sure to provide a clear title to easily identify the feature request.
## License
This is free software under the GPL v3 open source license. Feel free to do with it what you wish, but any modification must be open sourced. A copy of the license is included.
This software includes Highsoft software libraries which you may freely distribute for non-commercial use. Commerical users must licence this software, for more information visit https://shop.highsoft.com/faq/non-commercial#non-commercial-redistribution.

View File

@@ -1,6 +1,7 @@
<%
import plexpy
from plexpy import version
from plexpy.helpers import anon_url
%>
<!doctype html>
@@ -136,15 +137,15 @@ from plexpy import version
<div id="ajaxMsg" class="ajaxMsg"></div>
% if plexpy.CONFIG.CHECK_GITHUB and not plexpy.CURRENT_VERSION:
<div id="updatebar" style="display: none;">
You're running an unknown version of PlexPy. <a href="update">Update</a> or
<a href="#" id="updateDismiss">Close</a>
You're running an unknown version of PlexPy.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Close</a>
</div>
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.COMMITS_BEHIND > 0 and plexpy.INSTALL_TYPE != 'win':
<div id="updatebar" style="display: none;">
A <a
href="https://github.com/${plexpy.CONFIG.GIT_USER}/plexpy/compare/${plexpy.CURRENT_VERSION}...${plexpy.LATEST_VERSION}" target="_blank">
newer version</a> is available. You're ${plexpy.COMMITS_BEHIND} commits behind. <a href="update">Update</a> or
<a href="#" id="updateDismiss">Close</a>
A <a href="${anon_url('https://github.com/%s/plexpy/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
newer version</a> is available.<br />
You're ${plexpy.COMMITS_BEHIND} commits behind.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Close</a>
</div>
% endif
<nav class="navbar navbar-fixed-top">

View File

@@ -501,7 +501,8 @@ textarea.form-control:focus {
.libraries-poster-face {
overflow: hidden;
float: left;
background-size: contain;
background-size: cover;
background-position: center;
height: 40px;
width: 40px;
/*-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
@@ -885,6 +886,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
text-align: center;
position: relative;
z-index: 0;
overflow: hidden;
}
.dashboard-recent-media {
width: 100%;
@@ -1326,8 +1328,7 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span {
white-space: nowrap;
margin-top: 3px;
height: 21px;
position: absolute;
right: 0;
float: right;
}
.star-rating .star-icon {
width: auto;
@@ -1718,7 +1719,8 @@ a:hover .item-children-poster {
float: left;
margin-top: 15px;
margin-right: 15px;
background-size: contain;
background-size: cover;
background-position: center;
height: 80px;
width: 80px;
/*-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
@@ -2179,6 +2181,10 @@ a .home-platforms-instance-list-oval:hover,
.refresh-libraries-button {
float: right;
}
.refresh-users-button,
.refresh-libraries-button {
margin-right: 5px;
}
.nav-settings,
.nav-settings ul {
margin: 0px 0px 20px 0px;
@@ -2402,6 +2408,9 @@ a .home-platforms-instance-list-oval:hover,
padding: 0;
border: 0;
}
.history-thumbnail-popover.popover.left {
margin-left: -15px;
}
.history-thumbnail-popover.popover.right {
margin-left: 15px;
}
@@ -2650,6 +2659,7 @@ table[id^='media_info_child'] table[id^='media_info_child'] thead th {
margin: 5px 0 0 0.5em;
}
.notification-params {
width: 100%;
margin-top: 10px;
background-color: #282828;
}
@@ -2664,6 +2674,14 @@ table[id^='media_info_child'] table[id^='media_info_child'] thead th {
padding-left: 10px;
width: 200px;
}
.notification-params.time-options td:first-child {
padding-left: 10px;
width: 125px;
}
.notification-params.time-options td:nth-child(2) {
padding-left: 10px;
width: 275px;
}
.notification-params td:not(:first-child) {
padding-right: 10px;
}
@@ -2714,3 +2732,61 @@ table[id^='media_info_child'] table[id^='media_info_child'] thead th {
.selectize-input input[type='text'] {
height: 20px;
}
.small-muted {
font-size: small;
color: #777;
}
.config-info-table,
.config-scheduler-table {
width: 100%
}
.config-info-table td,
.config-info-table th,
.config-scheduler-table td,
.config-scheduler-table th {
padding-bottom: 5px;
}
.config-info-table td:first-child {
width: 150px;
}
.config-scheduler-table td:first-child {
width: 225px;
}
.config-scheduler-table th {
color: #fff;
}
a.no-highlight {
color: #777;
}
a.no-highlight:hover {
color: #fff;
}
.top-line {
border-top: 1px dotted #777;
padding-top: 5px;
}
.help-bold {
font-weight: bold;
color: #fff;
}
.save-button {
margin-top: 15px;
}
.nav-dashboard > li {
float: left;
}
.btn-gray.disabled,
.btn-gray.disabled:focus,
.btn-gray.disabled:hover,
.btn-gray.disabled:active {
color: #323232;
cursor: default;
}
.nav-header > li > a:focus,
.nav-header > li > a:hover {
background-color: transparent;
}
#recently-added-row-scroller,
#recently-watched-row-scroller {
position: relative;
}

View File

@@ -198,6 +198,13 @@ DOCUMENTATION :: END
% else:
<span>IP: N/A</span>
% endif
<br />
ETA:
<span id="stream-eta-${a['session_key']}">
<script>
$("#stream-eta-${a['session_key']}").html(moment().add(parseInt(${a['duration']}) - parseInt(${a['view_offset']}), 'milliseconds').format(time_format));
</script>
</span>
</div>
<div class="dashboard-activity-poster-info-time">
<span class="progress_time">${a['view_offset']}</span>/<span class="progress_time">${a['duration']}</span>
@@ -210,8 +217,8 @@ DOCUMENTATION :: END
% endif
<div class="dashboard-activity-progress">
<div class="dashboard-activity-progress-bar">
<div class="bufferbar" style="width: ${a['transcode_progress']}%">${a['transcode_progress']}%</div>
<div class="bar" style="width: ${a['progress_percent']}%">${a['progress_percent']}%</div>
<div class="bufferbar" style="width: ${a['transcode_progress']}%" data-toggle="tooltip" title="Transcoder Progress">${a['transcode_progress']}%</div>
<div class="bar" style="width: ${a['progress_percent']}%" data-toggle="tooltip" title="Stream Progress">${a['progress_percent']}%</div>
</div>
</div>
<div class="dashboard-activity-metadata-wrapper">
@@ -254,11 +261,7 @@ DOCUMENTATION :: END
% endif
</div>
<div class="dashboard-activity-metadata-user">
% if a['user_id']:
<a href="user?user_id=${a['user_id']}" title="${a['friendly_name']}">${a['friendly_name']}</a>
% else:
<a href="user?user=${a['user']}" title="${a['friendly_name']}">${a['friendly_name']}</a>
% endif
</div>
</div>
</div>
@@ -282,11 +285,13 @@ DOCUMENTATION :: END
});
// Add hover class to dashboard-instance
$('.dashboard-activity-poster').hover(function() {
$('.dashboard-activity-poster, .dashboard-activity-progress-bar').hover(function() {
$(this).closest('.dashboard-instance').addClass('hover');
}, function() {
$(this).closest('.dashboard-instance').removeClass('hover');
});
$('.bar, .bufferbar').tooltip({container: 'body', placement: 'right', delay: 50});
</script>
% else:
<div class="text-muted">Nothing is currently being watched.</div><br>

View File

@@ -249,7 +249,7 @@
<script>
// Modal popup dialog
function selectHandler(selectedDate) {
function selectHandler(selectedDate, selectedSeries) {
try
{
@@ -259,10 +259,25 @@
var y = dateValue.getFullYear();
var dateString = '' + y + '-' + (m<=9 ? '0' + m : m) + '-' + (d <= 9 ? '0' + d : d);
var media_type = 'all';
var transcode_decision = null;
switch(selectedSeries) {
case "TV": media_type = 'episode'; break;
case "Movies": media_type = 'movie'; break;
case "Music": media_type = 'track'; break;
case "Direct Play": transcode_decision = 'direct play'; break;
case "Direct Stream": transcode_decision = 'copy'; break;
case "Transcode": transcode_decision = 'transcode'; break;
}
$.ajax({
"url": "history_table_modal",
url: "history_table_modal",
type: 'post',
data: { 'start_date': dateString },
data: {
start_date: dateString,
media_type: media_type,
transcode_decision: transcode_decision
},
complete: function(xhr, status) {
$('#history-modal').modal('show');
$("#history-modal").html(xhr.responseText);
@@ -271,7 +286,7 @@
}
catch(err)
{
console.log("Failed to retrieve data");
console.log("Failed to retrieve history modal data.");
}
}
</script>
@@ -299,6 +314,8 @@
var music_visible = (${config['music_logging_enable']} == 1 ? true : false);
function loadGraphsTab1(time_range, yaxis) {
$('#days-selection').show();
setGraphFormat(yaxis);
$.ajax({
@@ -382,6 +399,8 @@
}
function loadGraphsTab2(time_range, yaxis) {
$('#days-selection').show();
setGraphFormat(yaxis);
$.ajax({
@@ -460,6 +479,8 @@
}
function loadGraphsTab3(yaxis) {
$('#days-selection').hide();
setGraphFormat(yaxis);
$.ajax({

View File

@@ -22,7 +22,7 @@
</div>
</div>
<div class='table-card-back'>
<table class="display" id="history_table" width="100%">
<table class="display history_table" id="history_table" width="100%">
<thead>
<tr>
<th align="left" id="delete_row">Delete</th>

View File

@@ -5,12 +5,12 @@
<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">
<i class="fa fa-history"></i> History for <span id="date-header">${data}</span>
<i class="fa fa-history"></i> History for <span id="date-header">${data['start_date']}</span>
</span></strong>
</h4>
</div>
<div class="modal-body" id="modal-text">
<table class="display" id="history_table" width="100%">
<table class="display history_table" id="history_table_modal" width="100%">
<thead>
<tr>
<th align="left" id="started">Started</th>
@@ -32,7 +32,7 @@
<script src="interfaces/default/js/tables/history_table_modal.js"></script>
<script>
$(document).ready(function() {
$('#date-header').html(moment('${data}','YYYY-MM-DD').format('ddd MMM Do YYYY'));
$('#date-header').html(moment('${data["start_date"]}','YYYY-MM-DD').format('ddd MMM Do YYYY'));
history_table_modal_options.ajax = {
url: 'get_history',
type: 'post',
@@ -40,14 +40,16 @@
return {
json_data: JSON.stringify(d),
grouping: false,
start_date: '${data}'
start_date: "${data['start_date']}",
media_type: "${data.get('media_type')}",
transcode_decision: "${data.get('transcode_decision')}"
};
}
}
history_table = $('#history_table').DataTable(history_table_modal_options);
history_table = $('#history_table_modal').DataTable(history_table_modal_options);
clearSearchButton('history_table', history_table);
clearSearchButton('history_table_modal', history_table);
// Move #info-modal to parent container
if (!($('#history-modal').next().is('#info-modal'))) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -23,7 +23,7 @@
<h3>Watch Statistics <small>Last ${config['home_stats_length']} days</small></h3>
</div>
<div id="home-stats" class="home-platforms">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading stats...</div>
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Loading stats...</div>
<br>
</div>
</div>
@@ -36,19 +36,27 @@
<h3>Library Statistics <small>${config['pms_name']}</small></h3>
</div>
<div id="library-stats" class="library-platforms">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading stats...</div>
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Loading stats...</div>
<br>
</div>
</div>
</div>
% endif
<div class='row'>
<div class="row">
<div class="col-md-12">
<div class="padded-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<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>
<h3>Recently Added</h3>
</div>
<div id='recentlyAdded'>
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Looking for new items...</div>
<div id="recentlyAdded" style="margin-right: -15px;">
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Looking for new items...</div>
<br>
</div>
</div>
@@ -59,7 +67,6 @@
<%def name="javascriptIncludes()">
<script src="interfaces/default/js/moment-with-locale.js"></script>
<script>
function currentActivityHeader() {
$.ajax({
url: 'get_current_activity_header',
@@ -71,7 +78,6 @@
});
}
currentActivityHeader();
setInterval(currentActivityHeader, 15000);
function currentActivity() {
$.ajax({
@@ -84,7 +90,12 @@
});
}
currentActivity();
setInterval(currentActivity, 15000);
setInterval(function () {
$('.bar, .bufferbar').tooltip('destroy');
currentActivityHeader();
currentActivity();
}, 15000);
function getHomeStats(days) {
$.ajax({
@@ -97,6 +108,7 @@
}
});
}
getHomeStats();
function getLibraryStats() {
$.ajax({
@@ -109,33 +121,21 @@
}
});
}
getLibraryStats();
function recentlyAdded() {
var widthVal = $('body').find(".container-fluid").width();
var tmp = (widthVal-20) / 182;
if (tmp > 0) {
containerSize = parseInt(tmp);
} else {
containerSize = 1;
}
$.ajax({
url: 'get_recently_added',
type: "GET",
async: true,
data: { count : containerSize },
data: { count : 50 },
complete: function(xhr, status) {
$("#recentlyAdded").html(xhr.responseText);
highlightAddedScrollerButton();
}
});
}
$(document).ready(function () {
recentlyAdded();
$(window).resize(function() {
recentlyAdded();
});
});
var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a';
@@ -148,10 +148,44 @@
}
});
getHomeStats();
getLibraryStats();
function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("body").find(".container-fluid").width()) {
$("#recently-added-page-right").removeClass("disabled");
} else {
$("#recently-added-page-right").addClass("disabled");
}
}
$(window).resize(function () {
highlightAddedScrollerButton();
});
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-added-row-scroller");
var containerWidth = $("body").find(".container-fluid").width();
var scrollAmount = $(this).data("id") * parseInt((containerWidth - 15) / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
$("#recently-added-page-left").addClass("disabled").blur();
} else {
$("#recently-added-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
$("#recently-added-page-right").addClass("disabled").blur();
} else {
$("#recently-added-page-right").removeClass("disabled");
}
});
</script>
</%def>

View File

@@ -171,10 +171,10 @@ DOCUMENTATION :: END
% endif
% if data['media_type'] == 'movie' or data['media_type'] == 'episode' or data['media_type'] == 'track':
<div class="summary-content-media-info-wrapper">
% if data['video_codec']:
% if data['media_type'] != 'track' and data['video_codec']:
<img class="summary-content-media-flag" title="${data['video_codec']}" src="interfaces/default/images/media_flags/video_codec/${data['video_codec'] | vf}.png" />
% endif
% if data['video_resolution']:
% if data['media_type'] != 'track' and data['video_resolution']:
<img class="summary-content-media-flag" title="${data['video_resolution']}" src="interfaces/default/images/media_flags/video_resolution/${data['video_resolution']}.png" />
% endif
% if data['audio_codec']:
@@ -344,25 +344,41 @@ DOCUMENTATION :: END
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>
% if source == 'history':
<a href="update_metadata?rating_key=${data['rating_key']}&update=True" class="btn btn-danger btn-edit" id="fix-metadata">
<i class="fa fa-wrench"></i> Fix Metadata
</a>
% endif
% if data.get('poster_url'):
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
% else:
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
% endif
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="delete-imgur-poster">
<i class="fa fa-picture-o"></i> Reset Imgur Poster
</button>
</span>
% endif
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect rows to delete. Data is deleted upon exiting delete mode.</div>
</div>
</div>
<div class="table-card-back">
<table class="display" id="history_table" width="100%">
<table class="display history_table" id="history_table-RK-${data['rating_key']}" width="100%">
<thead>
<tr>
<th align='left' id="delete">Delete</th>
<th align='left' id="time">Time</th>
<th align='left' id="friendly_name">User</th>
<th align='left' id="ip_address">IP Address</th>
<th align='left' id="platform">Platform</th>
<th align='left' id="player">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>
<th align="left" id="delete">Delete</th>
<th align="left" id="time">Time</th>
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="player">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></tbody>
@@ -458,11 +474,11 @@ DOCUMENTATION :: END
<script>
$(document).ready(function () {
get_history();
history_table = $('#history_table').DataTable(history_table_options);
history_table = $('#history_table-RK-${data["rating_key"]}').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] });
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('history_table', history_table);
clearSearchButton('history_table-RK-${data["rating_key"]}', history_table);
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
@@ -519,10 +535,33 @@ DOCUMENTATION :: END
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
</script>
% if source == 'history':
% if data.get('poster_url'):
<script>
$('#row-edit-mode').after('<a href="update_metadata?rating_key=${data['rating_key']}&update=True" class="btn btn-danger btn-edit" id="fix-metadata"> \
<i class="fa fa-wrench"></i> Fix Metadata</a>');
$('.imgur-poster-tooltip').popover({
html: true,
container: 'body',
trigger: 'hover',
placement: 'left',
template: '<div class="popover history-thumbnail-popover" role="tooltip"><div class="arrow" style="top: 50%;"></div><div class="popover-content"></div></div>',
content: function () {
return '<div class="history-thumbnail" style="background-image: url(' + $(this).data('img') + '); height: ' + $(this).data('height') + 'px; width: ' + $(this).data('width') + 'px;" />';
}
});
$('#delete-imgur-poster').on('click', function() {
$.ajax({
url: 'delete_poster_url',
type: 'POST',
async: true,
data: { poster_url : "${data['poster_url']}" },
success: function (data) {
var msg = '<i class="fa fa-check"></i>&nbsp; Imgur poster reset';
showMsg(msg, false, true, 2000);
$('.imgur-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('span').remove();
}
});
});
</script>
% endif
% endif

View File

@@ -32,7 +32,8 @@
</div>
</div>
<div class="modal-footer">
<span class="text-muted">Telize service written by <a href="https://github.com/fcambus/telize" target="_blank">Frederic Cambus</a>.</span>
<% from plexpy.helpers import anon_url %>
<span class="text-muted">Telize service written by <a href="${anon_url('https://github.com/fcambus/telize')}" target="_blank">Frederic Cambus</a>.</span>
</div>
</div>
</div>

View File

@@ -29,7 +29,7 @@ var hc_plays_by_day_options = {
point: {
events: {
click: function () {
selectHandler(this.category);
selectHandler(this.category, this.series.name);
}
}
}
@@ -60,7 +60,8 @@ var hc_plays_by_day_options = {
}
},
tooltip: {
shared: true
shared: true,
crosshairs: true
},
series: [{}]
};

View File

@@ -29,7 +29,7 @@ var hc_plays_by_stream_type_options = {
point: {
events: {
click: function() {
selectHandler(this.category);
selectHandler(this.category, this.series.name);
}
}
}
@@ -60,7 +60,8 @@ var hc_plays_by_stream_type_options = {
}
},
tooltip: {
shared: true
shared: true,
crosshairs: true
},
series: [{}]
};

View File

@@ -39,7 +39,6 @@ function showMsg(msg,loader,timeout,ms,error) {
}
if (error) {
feedback.css("background-color", "rgba(255,0,0,0.5)");
console.log('is error');
}
$(feedback).html(message);
feedback.fadeIn();
@@ -49,12 +48,13 @@ function showMsg(msg,loader,timeout,ms,error) {
message.fadeOut(function(){
$(this).remove();
feedback.fadeOut();
feedback.css("background-color", "");
});
},ms);
}
}
function doAjaxCall(url,elem,reload,form) {
function doAjaxCall(url, elem, reload, form, callback) {
// Set Message
feedback = $("#ajaxMsg");
update = $("#updatebar");
@@ -157,6 +157,9 @@ function doAjaxCall(url,elem,reload,form) {
complete: function(jqXHR, textStatus) {
// Remove loaders and stuff, ajax request is complete!
loader.remove();
if (typeof callback === "function") {
callback();
}
}
});
}
@@ -239,7 +242,8 @@ function isPrivateIP(ip_address) {
// get IPv4 mapped address (xxx.xxx.xxx.xxx) from IPv6 addresss (::ffff:xxx.xxx.xxx.xxx)
var parts = ip_address.split(":");
var parts = parts[parts.length - 1].split('.');
if (parts[0] === '10' ||
if ((parts[0] === '127' && parts[1] === '0' && parts[2] === '0' && parts[3] === '1') ||
(parts[0] === '10') ||
(parts[0] === '172' && (parseInt(parts[1], 10) >= 16 && parseInt(parts[1], 10) <= 31)) ||
(parts[0] === '192' && parts[1] === '168')) {
return true;
@@ -252,13 +256,13 @@ function isPrivateIP(ip_address) {
function humanTime(seconds) {
if (seconds >= 86400) {
text = '<h3>' + Math.floor(moment.duration(seconds, 'seconds').asDays()) +
'</h3><p> days </p><h3>' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) +
'</h3><p> hrs</p><h3>' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '</h3><p> mins</p>';
text = '<h3>' + Math.floor(moment.duration(seconds, 'seconds').asDays()) + '</h3><p> days</p>' +
'<h3>' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + '</h3><p> hrs</p>' +
'<h3>' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '</h3><p> mins</p>';
return text;
} else if (seconds >= 3600) {
text = '<h3>' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) +
'</h3><p>hrs</p><h3>' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '</h3><p> mins</p>';
text = '<h3>' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + '</h3><p> hrs</p>' +
'<h3>' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '</h3><p> mins</p>';
return text;
} else if (seconds >= 60) {
text = '<h3>' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '</h3><p> mins</p>';
@@ -269,6 +273,25 @@ function humanTime(seconds) {
}
}
function humanTimeClean(seconds) {
if (seconds >= 86400) {
text = Math.floor(moment.duration(seconds, 'seconds').asDays()) + ' days ' +
Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' +
Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else if (seconds >= 3600) {
text = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' +
Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else if (seconds >= 60) {
text = Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else {
text = '0';
return text;
}
}
String.prototype.toProperCase = function () {
return this.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
};
@@ -372,3 +395,16 @@ function clearSearchButton(tableName, table) {
table.search('').draw();
});
}
// Taken from https://github.com/Hellowlol/HTPC-Manager
window.onerror = function (message, file, line) {
var e = {
'page': window.location.href,
'message': message,
'file': file,
'line': line
};
$.post("log_js_errors", e, function (data) {
});
};

View File

@@ -21,7 +21,7 @@ history_table_options = {
"infoFiltered":"<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table"
},
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"stateSave": true,
"processing": false,
"serverSide": true,
@@ -107,7 +107,7 @@ history_table_options = {
}
},
"width": "10%",
"className": "no-wrap hidden-md hidden-sm hidden-xs modal-control"
"className": "no-wrap hidden-md hidden-sm hidden-xs"
},
{
"targets": [5],
@@ -115,11 +115,11 @@ history_table_options = {
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var transcode_dec = '';
if (rowData['video_decision'] === 'transcode' || rowData['audio_decision'] === 'transcode') {
if (rowData['transcode_decision'] === 'transcode') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Transcode"><i class="fa fa-server fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'copy' || rowData['audio_decision'] === 'copy') {
} else if (rowData['transcode_decision'] === 'copy') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Stream"><i class="fa fa-video-camera fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'direct play' || rowData['audio_decision'] === 'direct play') {
} else if (rowData['transcode_decision'] === 'direct play') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Play"><i class="fa fa-play-circle fa-fw"></i></span>';
}
$(td).html('<div><a href="#" data-target="#info-modal" data-toggle="modal"><div style="float: left;">' + transcode_dec + '&nbsp;' + cellData + '</div></a></div>');
@@ -133,20 +133,23 @@ history_table_options = {
"data":"full_title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = '';
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">' + cellData + ' (' + rowData['year'] + ')</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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
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=poster" data-height="120" data-width="80">' + cellData + ' \
(S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')</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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
if (rowData['parent_title']) { parent_info = ' (' + rowData['parent_title'] + ')'; }
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=poster" data-height="80" data-width="80">' + cellData + ' (' + rowData['parent_title'] + ')</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=poster" data-height="80" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
@@ -304,7 +307,7 @@ history_table_options = {
}
// Parent table platform modal
$('#history_table').on('click', '> tbody > tr > td.modal-control', function () {
$('.history_table').on('click', '> tbody > tr > td.modal-control', function () {
var tr = $(this).closest('tr');
var row = history_table.row( tr );
var rowData = row.data();
@@ -324,7 +327,7 @@ $('#history_table').on('click', '> tbody > tr > td.modal-control', function () {
});
// Parent table ip address modal
$('#history_table').on('click', '> tbody > tr > td.modal-control-ip', function () {
$('.history_table').on('click', '> tbody > tr > td.modal-control-ip', function () {
var tr = $(this).closest('tr');
var row = history_table.row( tr );
var rowData = row.data();
@@ -347,7 +350,7 @@ $('#history_table').on('click', '> tbody > tr > td.modal-control-ip', function (
});
// Parent table delete mode
$('#history_table').on('click', '> tbody > tr > td.delete-control > button', function () {
$('.history_table').on('click', '> tbody > tr > td.delete-control > button', function () {
var tr = $(this).closest('tr');
var row = history_table.row( tr );
var rowData = row.data();
@@ -396,7 +399,7 @@ $('#history_table').on('click', '> tbody > tr > td.delete-control > button', fun
});
// Parent table expand detailed history
$('#history_table').on('click', '> tbody > tr > td.expand-history a', function () {
$('.history_table').on('click', '> tbody > tr > td.expand-history a', function () {
var tr = $(this).closest('tr');
var row = history_table.row(tr);
var rowData = row.data();

View File

@@ -19,7 +19,7 @@ history_table_modal_options = {
"infoFiltered":"",
"emptyTable": "No data in table",
},
"pagingType": "bootstrap",
"pagingType": "simple_numbers",
"stateSave": false,
"processing": false,
"serverSide": true,
@@ -79,11 +79,11 @@ history_table_modal_options = {
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var transcode_dec = '';
if (rowData['video_decision'] === 'transcode' || rowData['audio_decision'] === 'transcode') {
if (rowData['transcode_decision'] === 'transcode') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Transcode"><i class="fa fa-server fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'copy' || rowData['audio_decision'] === 'copy') {
} else if (rowData['transcode_decision'] === 'copy') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Stream"><i class="fa fa-video-camera fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'direct play' || rowData['audio_decision'] === 'direct play') {
} else if (rowData['transcode_decision'] === 'direct play') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Play"><i class="fa fa-play-circle fa-fw"></i></span>';
}
$(td).html('<div><a href="#" data-target="#info-modal" data-toggle="modal"><div style="float: left;">' + transcode_dec + '&nbsp' + cellData + '</div></a></div>');
@@ -97,21 +97,24 @@ history_table_modal_options = {
"data":"full_title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = '';
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">' + cellData + ' (' + rowData['year'] + ')</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
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=poster" data-height="120" data-width="80">' + cellData + ' \
(S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
if (rowData['parent_title']) { parent_info = ' (' + rowData['parent_title'] + ')'; }
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=poster" data-height="80" data-width="80">' + cellData + ' (' + rowData['parent_title'] + ')</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=poster" data-height="80" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
}
@@ -145,7 +148,7 @@ history_table_modal_options = {
}
}
$('#history_table').on('click', 'td.modal-control', function () {
$('.history_table').on('click', 'td.modal-control', function () {
var tr = $(this).parents('tr');
var row = history_table.row(tr);
var rowData = row.data();

View File

@@ -17,7 +17,7 @@ libraries_list_table_options = {
"order": [ 2, 'asc'],
"autoWidth": true,
"stateSave": true,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"columnDefs": [
{
"targets": [0],
@@ -133,23 +133,27 @@ libraries_list_table_options = {
},
{
"targets": [8],
"data":"last_watched",
"data":"last_played",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = ''
var thumb_popover = '';
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
if (rowData['parent_title']) { parent_info = ' (' + rowData['parent_title'] + ')'; }
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=80&height=80&fallback=poster" data-height="80" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=poster" data-height="80" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type']) {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
}
@@ -157,12 +161,28 @@ libraries_list_table_options = {
$(td).html('n/a');
}
},
"width": "25%",
"width": "18%",
"className": "hidden-sm hidden-xs"
},
{
"targets": [9],
"data": "plays",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(cellData);
}
},
"searchable": false,
"width": "7%"
},
{
"targets": [10],
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData));
}
},
"searchable": false,
"width": "10%"
}

View File

@@ -2,7 +2,7 @@ var log_table_options = {
"destroy": true,
"serverSide": true,
"processing": false,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,

View File

@@ -22,8 +22,8 @@ media_info_table_options = {
"infoFiltered":"<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table"
},
"pagingType": "bootstrap",
"stateSave": false,
"pagingType": "full_numbers",
"stateSave": true,
"processing": false,
"serverSide": true,
"pageLength": 25,
@@ -34,9 +34,12 @@ media_info_table_options = {
"targets": [0],
"data": "added_at",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
if (rowData) {
var expand_details = '';
var date = moment(cellData, "X").format(date_format);
var date = '';
if (cellData !== null && cellData !== '') {
date = moment(cellData, "X").format(date_format);
}
if (rowData['media_type'] === 'show') {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Seasons"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
@@ -66,11 +69,13 @@ media_info_table_options = {
"data": "title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = '';
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">' + cellData + ' (' + rowData['year'] + ')</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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + 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>';
@@ -199,7 +204,7 @@ media_info_table_options = {
},
{
"targets": [10],
"data": "last_watched",
"data": "last_played",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
date = moment(cellData, "X").format(date_format);
@@ -271,7 +276,7 @@ media_info_table_options = {
get_file_sizes = false;
}
$("#media_info_table_info").append('<span class="hidden-md hidden-sm hidden-xs"> with a total file size of ' +
$("#media_info_table-SID-" + section_id + "_info").append('<span class="hidden-md hidden-sm hidden-xs"> with a total file size of ' +
Math.round(settings.json.filtered_file_size / Math.pow(1024, 3)).toString() + ' GiB' +
' (filtered from ' + Math.round(settings.json.total_file_size / Math.pow(1024, 3)).toString() + ' GiB)</span>');
},
@@ -289,7 +294,7 @@ media_info_table_options = {
}
// Parent table expand detailed media info
$('#media_info_table').on('click', '> tbody > tr > td.expand-media-info a', function () {
$('.media_info_table').on('click', '> tbody > tr > td.expand-media-info a', function () {
var tr = $(this).closest('tr');
var row = media_info_table.row(tr);
var rowData = row.data();
@@ -427,7 +432,7 @@ function childTableFormatMedia(rowData) {
'<th align="left" id="audio_codec">Audio Codec</th>' +
'<th align="left" id="audio_channels">Audio Channels</th>' +
'<th align="left" id="file_size">File Size</th>' +
'<th align="left" id="last_watched">Last Watched</th>' +
'<th align="left" id="last_played">Last Played</th>' +
'<th align="left" id="total_plays">Total Plays</th>' +
'</tr>' +
'</thead>' +

View File

@@ -0,0 +1,89 @@
notification_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)"},
"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 hidden-xs"
},
{
"targets": [1],
"data": "agent_name",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "7%",
"className": "no-wrap hidden-sm hidden-xs"
},
{
"targets": [2],
"data": "notify_action",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%"
},
{
"targets": [3],
"data": "subject_text",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "20%"
},
{
"targets": [4],
"data": "body_text",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "38%"
},
{
"targets": [5],
"data": "script_args",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "20%"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0)
}
}

View File

@@ -2,7 +2,7 @@ var plex_log_table_options = {
"destroy": true,
"processing": false,
"serverSide": false,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,

View File

@@ -1,7 +1,7 @@
sync_table_options = {
"processing": false,
"serverSide": false,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"order": [ [ 0, 'desc'], [ 1, 'asc'], [2, 'asc'] ],
"pageLength": 25,
"stateSave": true,

View File

@@ -9,7 +9,7 @@ user_ip_table_options = {
"emptyTable": "No data in table",
},
"stateSave": true,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"processing": false,
"serverSide": true,
"pageLength": 10,
@@ -56,7 +56,7 @@ user_ip_table_options = {
}
},
"width": "15%",
"className": "no-wrap hidden-md hidden-sm hidden-xs modal-control"
"className": "no-wrap hidden-md hidden-sm hidden-xs"
},
{
"targets": [3],
@@ -64,12 +64,12 @@ user_ip_table_options = {
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData) {
var transcode_dec = '';
if (rowData['video_decision'] === 'transcode') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Transcode"><i class="fa fa-server fa-fw"></i></span>&nbsp';
} else if (rowData['video_decision'] === 'copy') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Stream"><i class="fa fa-video-camera fa-fw"></i></span>&nbsp';
} else if (rowData['video_decision'] === 'direct play' || rowData['video_decision'] === '') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Play"><i class="fa fa-play-circle fa-fw"></i></span>&nbsp';
if (rowData['transcode_decision'] === 'transcode') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Transcode"><i class="fa fa-server fa-fw"></i></span>';
} else if (rowData['transcode_decision'] === 'copy') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Stream"><i class="fa fa-video-camera fa-fw"></i></span>';
} else if (rowData['transcode_decision'] === 'direct play') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Play"><i class="fa fa-play-circle fa-fw"></i></span>';
}
$(td).html('<div><a href="#" data-target="#info-modal" data-toggle="modal"><div style="float: left;">' + transcode_dec + '&nbsp' + cellData + '</div></a></div>');
} else {
@@ -81,23 +81,27 @@ user_ip_table_options = {
},
{
"targets": [4],
"data":"last_watched",
"data":"last_played",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = ''
var thumb_popover = '';
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
if (rowData['parent_title']) { parent_info = ' (' + rowData['parent_title'] + ')'; }
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=80&height=80&fallback=poster" data-height="80" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=poster" data-height="80" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type']) {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
} else {
@@ -142,11 +146,11 @@ user_ip_table_options = {
}
}
$('#user_ip_table').on('mouseenter', 'td.modal-control span', function () {
$('.user_ip_table').on('mouseenter', 'td.modal-control span', function () {
$(this).tooltip();
});
$('#user_ip_table').on('click', 'td.modal-control', function () {
$('.user_ip_table').on('click', 'td.modal-control', function () {
var tr = $(this).parents('tr');
var row = user_ip_table.row(tr);
var rowData = row.data();
@@ -165,7 +169,7 @@ $('#user_ip_table').on('click', 'td.modal-control', function () {
showStreamDetails();
});
$('#user_ip_table').on('click', 'td.modal-control-ip', function () {
$('.user_ip_table').on('click', 'td.modal-control-ip', function () {
var tr = $(this).parents('tr');
var row = user_ip_table.row( tr );
var rowData = row.data();

View File

@@ -17,7 +17,7 @@ users_list_table_options = {
"order": [ 2, 'asc'],
"autoWidth": true,
"stateSave": true,
"pagingType": "bootstrap",
"pagingType": "full_numbers",
"columnDefs": [
{
"targets": [0],
@@ -120,11 +120,11 @@ users_list_table_options = {
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
var transcode_dec = '';
if (rowData['video_decision'] === 'transcode' || rowData['audio_decision'] === 'transcode') {
if (rowData['transcode_decision'] === 'transcode') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Transcode"><i class="fa fa-server fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'copy' || rowData['audio_decision'] === 'copy') {
} else if (rowData['transcode_decision'] === 'copy') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Stream"><i class="fa fa-video-camera fa-fw"></i></span>';
} else if (rowData['video_decision'] === 'direct play' || rowData['audio_decision'] === 'direct play') {
} else if (rowData['transcode_decision'] === 'direct play') {
transcode_dec = '<span class="transcode-tooltip" data-toggle="tooltip" title="Direct Play"><i class="fa fa-play-circle fa-fw"></i></span>';
}
$(td).html('<div><a href="#" data-target="#info-modal" data-toggle="modal"><div style="float: left;">' + transcode_dec + '&nbsp;' + cellData + '</div></a></div>');
@@ -137,23 +137,27 @@ users_list_table_options = {
},
{
"targets": [7],
"data":"last_watched",
"data":"last_played",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
var parent_info = '';
var media_type = '';
var thumb_popover = ''
var thumb_popover = '';
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
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=80&height=120&fallback=poster" data-height="120" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
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">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
if (rowData['parent_title']) { parent_info = ' (' + rowData['parent_title'] + ')'; }
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=80&height=80&fallback=poster" data-height="80" data-width="80">' + cellData + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp' + thumb_popover + '</div></a></div>');
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=poster" data-height="80" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type']) {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
}
@@ -161,12 +165,28 @@ users_list_table_options = {
$(td).html('n/a');
}
},
"width": "30%",
"width": "23%",
"className": "hidden-sm hidden-xs"
},
{
"targets": [8],
"data": "plays",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(cellData);
}
},
"searchable": false,
"width": "7%"
},
{
"targets": [9],
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData));
}
},
"searchable": false,
"width": "10%"
}

View File

@@ -2,6 +2,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="interfaces/default/css/dataTables.bootstrap.css">
<link rel="stylesheet" href="interfaces/default/css/dataTables.colVis.css">
<link rel="stylesheet" href="interfaces/default/css/plexpy-dataTables.css">
</%def>
@@ -13,7 +14,7 @@
</div>
% elif config['update_section_ids'] == -1:
<div id="update_section_ids_message" style="text-align: center; margin-top: 20px;">
<i class="fa fa-refresh fa-spin"></i> PlexPy is updating library IDs in the database. This could take a few minutes depending on the size of your database.
<i class="fa fa-refresh fa-spin"></i> PlexPy is updating library IDs in the database. This could take a few minutes to hours depending on the size of your database.
<br />
You may leave this page and come back later.
</div>
@@ -23,6 +24,7 @@
<span><i class="fa fa-book"></i> All Libraries</span>
</div>
<div class="button-bar">
<div class="colvis-button-bar hidden-xs"></div>
% if config['update_section_ids'] == -1:
<button class="btn btn-dark refresh-libraries-button" id="refresh-libraries-list" disabled><i class="fa fa-refresh"></i> Refresh libraries</button>
% else:
@@ -46,8 +48,9 @@
<th align="left" id="parent_count">Total Seasons / Albums</th>
<th align="left" id="child_count">Total Episodes / Tracks</th>
<th align="left" id="last_accessed">Last Accessed</th>
<th align="left" id="last_watched">Last Watched</th>
<th align="left" id="last_played">Last Played</th>
<th align="left" id="total_plays">Total Plays</th>
<th align="left" id="total_duration">Total Duration</th>
</tr>
</thead>
<tbody>
@@ -79,6 +82,7 @@
<%def name="javascriptIncludes()">
<script src="interfaces/default/js/jquery.dataTables.min.js"></script>
<script src="interfaces/default/js/dataTables.colVis.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.min.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.pagination.js"></script>
<script src="interfaces/default/js/moment-with-locale.js"></script>
@@ -96,6 +100,8 @@
}
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] });
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('libraries_list_table', libraries_list_table);
@@ -177,7 +183,7 @@
$("#refresh-libraries-list").click(function () {
if ("${config['update_section_ids']}" == "1") {
$('#update_section_ids_message').html(
'<i class="fa fa-refresh fa-spin"></i> PlexPy is updating library IDs in the database. This could take a few minutes depending on the size of your database.' +
'<i class="fa fa-refresh fa-spin"></i> PlexPy is updating library IDs in the database. This could take a few minutes to hours depending on the size of your database.' +
'<br />' +
'You may leave this page and come back later.');
$(this).prop('disabled', true);

View File

@@ -37,7 +37,9 @@ DOCUMENTATION :: END
% if data:
<div class="container-fluid">
<div class="row">
% if data['library_art']:
<div class="art-face" style="background-image:url(pms_image_proxy?img=${data['library_art']}&width=1920&height=1080)"></div>
% endif
<div class="summary-container">
<div class="summary-navbar">
<div class="col-md-12">
@@ -52,7 +54,7 @@ DOCUMENTATION :: END
<div class="col-md-12">
<div class="table-card-back">
<div class="user-info-wrapper">
% if data['library_thumb'][:4] == 'http':
% if data['library_thumb'][:4] == 'http' or data['library_thumb'][:10] == 'interfaces':
<div class="library-info-poster-face" style="background-image: url(${data['library_thumb']});"></div>
% else:
<div class="library-info-poster-face" style="background-image: url(pms_image_proxy?img=${data['library_thumb']}&width=80&height=80&fallback=cover);"></div>
@@ -116,8 +118,16 @@ DOCUMENTATION :: END
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
<span><i class="fa fa-history"></i> Recently Watched</span>
<span><i class="fa fa-history"></i> Recently Played</span>
</div>
</div>
<div class="table-card-back">
@@ -133,6 +143,14 @@ DOCUMENTATION :: END
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
<span><i class="fa fa-history"></i> Recently Added</span>
</div>
@@ -168,7 +186,7 @@ DOCUMENTATION :: END
</div>
</div>
<div class="table-card-back">
<table class="display" id="history_table" width="100%">
<table class="display history_table" id="history_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="delete">Delete</th>
@@ -227,7 +245,7 @@ DOCUMENTATION :: END
</div>
</div>
<div class="table-card-back">
<table class="display" id="media_info_table" width="100%">
<table class="display media_info_table" id="media_info_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="added_at">Added At</th>
@@ -240,7 +258,7 @@ DOCUMENTATION :: END
<th align="left" id="audio_codec">Audio Codec</th>
<th align="left" id="audio_channels">Audio Channels</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="last_watched">Last Watched</th>
<th align="left" id="last_played">Last Played</th>
<th align="left" id="total_plays">Total Plays</th>
</tr>
</thead>
@@ -362,12 +380,12 @@ DOCUMENTATION :: END
};
}
}
history_table = $('#history_table').DataTable(history_table_options);
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] });
$(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table', history_table);
clearSearchButton('history_table-SID-${data["section_id"]}', history_table);
}
function loadMediaInfoTable() {
@@ -383,12 +401,12 @@ DOCUMENTATION :: END
};
}
}
media_info_table = $('#media_info_table').DataTable(media_info_table_options);
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' });
$(colvis.button()).appendTo('#button-bar-media-info');
clearSearchButton('media_info_table', media_info_table);
clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table);
}
$( "#history-tab-btn" ).one( "click", function() {
@@ -458,61 +476,116 @@ DOCUMENTATION :: END
});
function recentlyWatched() {
var widthVal = $("#library-recently-watched").width();
var tmp = (widthVal-25) / 175;
if (tmp > 0) {
var containerSize = parseInt(tmp);
} else {
var containerSize = 1;
}
// Populate recently watched
$.ajax({
url: 'get_library_recently_watched',
async: true,
data: {
section_id: section_id,
limit: containerSize
limit: 50
},
complete: function(xhr, status) {
$("#library-recently-watched").html(xhr.responseText);
highlightWatchedScrollerButton();
}
});
}
function recentlyAdded() {
var widthVal = $("#library-recently-added").width();
var tmp = (widthVal-25) / 175;
if (tmp > 0) {
var containerSize = parseInt(tmp);
} else {
var containerSize = 1;
}
// Populate recently added
$.ajax({
url: 'get_library_recently_added',
async: true,
data: {
section_id: section_id,
limit: containerSize
limit: 50
},
complete: function(xhr, status) {
$("#library-recently-added").html(xhr.responseText);
highlightAddedScrollerButton();
}
});
}
recentlyWatched();
recentlyAdded();
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#library-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#library-recently-added").width()) {
$("#recently-added-page-right").removeClass("disabled");
} else {
$("#recently-added-page-right").addClass("disabled");
}
}
$(window).resize(function() {
recentlyWatched();
recentlyAdded();
highlightWatchedScrollerButton();
highlightAddedScrollerButton();
});
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
var leftTotalWatched = 0;
$(".paginate-watched").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#library-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotalWatched = Math.max(Math.min(leftTotalWatched + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotalWatched }, 250);
if (leftTotalWatched == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotalWatched == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
var leftTotalAdded = 0;
$(".paginate-added").click(function (e) {
e.preventDefault();
var scroller = $("#recently-added-row-scroller");
var containerWidth = $("#library-recently-added").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotalAdded = Math.max(Math.min(leftTotalAdded + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotalAdded }, 250);
if (leftTotalAdded == 0) {
$("#recently-added-page-left").addClass("disabled").blur();
} else {
$("#recently-added-page-left").removeClass("disabled");
}
if (leftTotalAdded == leftMax) {
$("#recently-added-page-right").addClass("disabled").blur();
} else {
$("#recently-added-page-right").removeClass("disabled");
}
});
});
</script>
% endif

View File

@@ -32,6 +32,7 @@ DOCUMENTATION :: END
% if data:
<div class="dashboard-recent-media-row">
<div id="recently-added-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<li>
@@ -90,7 +91,8 @@ DOCUMENTATION :: END
% endfor
</ul>
</div>
</div>
% else:
<div class="text-muted">Unable to retrieve data from database.
<div class="text-muted">No stats to show.
</div><br>
% endif

View File

@@ -75,13 +75,13 @@ DOCUMENTATION :: END
</div>
% endif
</div>
% if library['thumb']:
% if library['thumb'].startswith("http"):
<div class="home-platforms-instance-poster">
<div class="home-platforms-library-thumb" style="background-image: url(pms_image_proxy?img=${library['thumb']}&width=300&height=300&fallback=poster);"></div>
<div class="home-platforms-library-thumb" style="background-image: url(${library['thumb']});"></div>
</div>
% else:
<div class="home-platforms-instance-poster">
<div class="home-platforms-library-thumb" style="background-image: url(interfaces/default/images/poster.png);"></div>
<div class="home-platforms-library-thumb" style="background-image: url(pms_image_proxy?img=${library['thumb']}&width=300&height=300&fallback=cover);"></div>
</div>
% endif
</li>

View File

@@ -22,6 +22,7 @@ from plexpy import helpers
</div>
<div class="button-bar">
<button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear log</button>
<button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear log</button>
</div>
</div>
<div class='table-card-back'>
@@ -29,6 +30,8 @@ from plexpy import helpers
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="active"><a id="plexpy-logs-btn" href="#tabs-1" aria-controls="tabs-1" role="tab" data-toggle="tab">PlexPy Logs</a></li>
<li role="presentation"><a id="plex-logs-btn" href="#tabs-2" aria-controls="tabs-2" role="tab" data-toggle="tab">Plex Media Server Logs</a></li>
<li role="presentation"><a id="plex-scanner-logs-btn" href="#tabs-3" aria-controls="tabs-3" role="tab" data-toggle="tab">Plex Media Scanner Logs</a></li>
<li role="presentation"><a id="notification-logs-btn" href="#tabs-4" aria-controls="tabs-4" role="tab" data-toggle="tab">Notification Logs</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-1">
@@ -57,6 +60,33 @@ from plexpy import helpers
</tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-3">
<table class="display" id="plex_scanner_log_table" width="100%">
<thead>
<tr>
<th align='left' id="plex_scanner_timestamp">Timestamp</th>
<th align='left' id="plex_scanner_level">Level</th>
<th align='left' id="plex_scanner_message">Message</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-4">
<table class="display" id="notification_log_table" width="100%">
<thead>
<tr>
<th align='left' id="notification_timestamp">Timestamp</th>
<th align='left' id="notification_agent_name">Agent</th>
<th align='left' id="notification_action">Action</th>
<th align='left' id="notification_poster_url">Subject Text</th>
<th align='left' id="notification_poster_url">Body Text</th>
<th align='left' id="notification_poster_url">Script Args</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -80,8 +110,10 @@ from plexpy import helpers
<script src="interfaces/default/js/jquery.dataTables.min.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.min.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.pagination.js"></script>
<script src="interfaces/default/js/moment-with-locale.js"></script>
<script src="interfaces/default/js/tables/logs.js"></script>
<script src="interfaces/default/js/tables/plex_logs.js"></script>
<script src="interfaces/default/js/tables/notification_logs.js"></script>
<script>
$(document).ready(function() {
@@ -91,30 +123,66 @@ from plexpy import helpers
function LoadPlexPyLogs() {
log_table_options.ajax = {
"url": "getLog"
url: "getLog"
}
log_table = $('#log_table').DataTable(log_table_options);
}
function LoadPlexLogs() {
plex_log_table_options.ajax = {
"url": "get_plex_log"
url: "get_plex_log?log_type=server"
}
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"
}
plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options);
}
function LoadNotificationLogs() {
notification_log_table_options.ajax = {
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);
}
$("#plexpy-logs-btn").click(function () {
$("#clear-logs").show();
$("#clear-notify-logs").hide();
LoadPlexPyLogs();
clearSearchButton('log_table', log_table);
});
$("#plex-logs-btn").click(function () {
$("#clear-logs").hide();
$("#clear-notify-logs").hide();
LoadPlexLogs();
clearSearchButton('plex_log_table', plex_log_table);
});
$("#plex-scanner-logs-btn").click(function () {
$("#clear-logs").hide();
$("#clear-notify-logs").hide();
LoadPlexScannerLogs();
clearSearchButton('plex_scanner_log_table', plex_scanner_log_table);
});
$("#notification-logs-btn").click(function () {
$("#clear-logs").hide();
$("#clear-notify-logs").show();
LoadNotificationLogs();
clearSearchButton('notification_log_table', notification_log_table);
});
$("#clear-logs").click(function () {
var r = confirm("Are you sure you want to clear the PlexPy log?");
if (r == true) {
@@ -122,6 +190,19 @@ from plexpy import helpers
}
});
$("#clear-notify-logs").click(function () {
var r = confirm("Are you sure you want to clear the PlexPy notification log?");
if (r == true) {
$.ajax({
url: 'clearNotifyLogs',
type: 'POST',
success: function (data) {
notification_log_table.draw();
}
});
}
});
var timer;
function setRefresh()
{
@@ -137,8 +218,12 @@ from plexpy import helpers
timer = setInterval(function() {
if ($("#tabs-1").hasClass("active")) {
log_table.ajax.reload();
} else {
} else if ($("#tabs-2").hasClass("active")) {
plex_log_table.ajax.reload();
} else if ($("#tabs-3").hasClass("active")) {
plex_scanner_log_table.ajax.reload();
} else if ($("#tabs-4").hasClass("active")) {
notification_log_table.ajax.reload();
}
}, 1000*refreshrate.value);
}

View File

@@ -132,7 +132,7 @@ from plexpy import helpers
function reloadModal() {
$.ajax({
url: 'get_notification_agent_config',
data: { config_id: '${agent["id"]}' },
data: { agent_id: '${agent["id"]}' },
cache: false,
async: true,
complete: function (xhr, status) {
@@ -143,13 +143,12 @@ from plexpy import helpers
$('#osxnotifyregister').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); });
$.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
})
$('#save-notification-item').click(function () {
doAjaxCall('set_notification_config', $(this), 'tabs', true);
// Reload modal to update certain fields
reloadModal();
doAjaxCall('set_notification_config', $(this), 'tabs', true, reloadModal);
return false;
});
@@ -163,12 +162,12 @@ from plexpy import helpers
});
$('#twitterStep1').click(function () {
$.get('/twitterStep1', function (data) {window.open(data); })
$.get('twitterStep1', function (data) {window.open(data); })
.done(function () { showMsg('<i class="fa fa-check"></i> Confirm Authorization. Check pop-up blocker if no response.', false, true, 3000); });
});
$('#twitterStep2').click(function () {
var twitter_key = $('#twitter_key').val();
$.get('/twitterStep2', { 'key': twitter_key }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
$.get('twitterStep2', { 'key': twitter_key }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
});
function disableFacebookRequest() {
@@ -181,8 +180,12 @@ from plexpy import helpers
});
$('#facebookStep1').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));
}
doAjaxCall('set_notification_config', $(this), 'tabs', true);
$.get('/facebookStep1', function (data) { window.open(data); })
$.get('facebookStep1', function (data) { window.open(data); })
.done(function () { showMsg('<i class="fa fa-check"></i> Confirm Authorization. Check pop-up blocker if no response.', false, true, 3000); });
});
@@ -191,7 +194,7 @@ from plexpy import helpers
$.ajax({
url: 'test_notifier',
data: {
config_id: '${agent["id"]}',
agent_id: '${agent["id"]}',
subject: $('#test_subject').val(),
body: $('#test_body').val(),
script: $('#test_script').val(),
@@ -207,8 +210,8 @@ from plexpy import helpers
});
$('#pushbullet_apikey, #pushover_apitoken, #scripts_folder').on('change', function () {
doAjaxCall('set_notification_config', $(this), 'tabs', true);
reloadModal();
// Reload modal to update certain fields
doAjaxCall('set_notification_config', $(this), 'tabs', true, reloadModal);
return false;
});

View File

@@ -64,13 +64,6 @@ from plexpy import helpers
</label>
<p class="help-block">Trigger notification when a media item is added to the Plex Media Server.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_extdown" ${helpers.checked(data['on_extdown'])} class="toggle-switches">
Notify on Plex remote access down
</label>
<p class="help-block">Trigger notification when the Plex Media Server cannot be reached externally.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_intdown" ${helpers.checked(data['on_intdown'])} class="toggle-switches">
@@ -78,6 +71,20 @@ from plexpy import helpers
</label>
<p class="help-block">Trigger notification when the Plex Media Server cannot be reached internally.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_intup" ${helpers.checked(data['on_intup'])} class="toggle-switches">
Notify on Plex server back up
</label>
<p class="help-block">Trigger notification when the Plex Media Server can be reached internally after being down.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_extdown" ${helpers.checked(data['on_extdown'])} class="toggle-switches">
Notify on Plex remote access down
</label>
<p class="help-block">Trigger notification when the Plex Media Server cannot be reached externally.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_extup" ${helpers.checked(data['on_extup'])} class="toggle-switches">
@@ -87,10 +94,10 @@ from plexpy import helpers
</div>
<div class="checkbox">
<label>
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_intup" ${helpers.checked(data['on_intup'])} class="toggle-switches">
Notify on Plex server back up
<input type="checkbox" data-size="small" data-id="${data['id']}" data-config-name="${data['config_prefix']}_on_pmsupdate" ${helpers.checked(data['on_pmsupdate'])} class="toggle-switches">
Notify on Plex update available
</label>
<p class="help-block">Trigger notification when the Plex Media Server can be reached internally after being down.</p>
<p class="help-block">Trigger notification when an update for the Plex Media Server is available.</p>
</div>
</div>
</div>

View File

@@ -32,6 +32,7 @@ DOCUMENTATION :: END
% if data != None:
<div class="dashboard-recent-media-row">
<div id="recently-added-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<div class="dashboard-recent-media-instance">
@@ -83,6 +84,7 @@ DOCUMENTATION :: END
% endfor
</ul>
</div>
</div>
% else:
<div class="text-muted">There was an error communicating with your Plex Server. Please check your <a href="settings">settings</a>.
</div><br>

View File

@@ -0,0 +1,64 @@
<%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: scheduler_table.html
Version: 0.1
DOCUMENTATION :: END
</%doc>
<%!
import arrow
import plexpy
from plexpy import common
scheduled_jobs = [j.id for j in plexpy.SCHED.get_jobs()]
%>
<table class="config-scheduler-table small-muted">
<thead>
<tr>
<th>Scheduled Task</th>
<th>State</th>
<th>Interval</th>
<th>Next Run In</th>
<th>Next Run Time</th>
</tr>
</thead>
<tbody>
% for job in common.SCHEDULER_LIST:
% if job in scheduled_jobs:
<%
sched_job = plexpy.SCHED.get_job(job)
run_interval = arrow.get(str(sched_job.trigger.interval), ['H:mm:ss', 'HH:mm:ss'])
next_run_interval = arrow.get(sched_job.next_run_time).timestamp - arrow.now().timestamp
%>
<tr>
<td>${sched_job.id}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Active</td>
<td>${arrow.get(run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(next_run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(sched_job.next_run_time).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr>
% elif job == 'Check for active sessions' and plexpy.CONFIG.MONITORING_USE_WEBSOCKET and not plexpy.POLLING_FAILOVER:
<tr>
<td>${job}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Using Websocket</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
</tr>
% else:
<tr>
<td>${job}</td>
<td><i class="fa fa-sm fa-fw fa-times"></i> Inactive</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
</tr>
% endif
% endfor
</tbody>
</table>

File diff suppressed because it is too large Load Diff

View File

@@ -112,8 +112,16 @@ from plexpy import helpers
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-watched-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-watched-page-right" class="paginate btn-gray" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
<span><i class="fa fa-history"></i> Recently Watched</span>
<span><i class="fa fa-history"></i> Recently Played</span>
</div>
</div>
<div class="table-card-back">
@@ -140,14 +148,14 @@ from plexpy import helpers
</div>
</div>
<div class="table-card-back">
<table id="user_ip_table" class="display" width="100%">
<table class="display user_ip_table" id="user_ip_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left">Last Seen</th>
<th align="left">IP Address</th>
<th align="left">Last Platform</th>
<th align="left">Last Player</th>
<th align="left">Last Watched</th>
<th align="left">Last Played</th>
<th align="left">Play Count</th>
</tr>
</thead>
@@ -178,7 +186,7 @@ from plexpy import helpers
</div>
</div>
<div class="table-card-back">
<table class="display" id="history_table" width="100%">
<table class="display history_table" id="history_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="delete">Delete</th>
@@ -218,7 +226,7 @@ from plexpy import helpers
</div>
</div>
<div class="table-card-back">
<table class="display" id="sync_table" width="100%">
<table class="display" id="sync_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="state">State</th>
@@ -348,13 +356,13 @@ from plexpy import helpers
};
}
}
history_table = $('#history_table').DataTable(history_table_options);
history_table = $('#history_table-UID-${data["user_id"]}').DataTable(history_table_options);
history_table.column(2).visible(false);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
$(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table', history_table);
clearSearchButton('history_table-UID-${data["user_id"]}', history_table);
$('#history_table_filter').prepend('<div class="btn-group" data-toggle="buttons" id="media_type-selection" style="padding-right: 15px;"> \
<label class="btn btn-dark active"> \
@@ -397,9 +405,9 @@ from plexpy import helpers
};
}
}
user_ip_table = $('#user_ip_table').DataTable(user_ip_table_options);
user_ip_table = $('#user_ip_table-UID-${data["user_id"]}').DataTable(user_ip_table_options);
clearSearchButton('user_ip_table', user_ip_table);
clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table);
});
$( "#sync-tab-btn" ).one( "click", function() {
@@ -410,13 +418,13 @@ from plexpy import helpers
d.user_id = user_id;
}
}
sync_table = $('#sync_table').DataTable(sync_table_options);
sync_table = $('#sync_table-UID-${data["user_id"]}').DataTable(sync_table_options);
sync_table.column(1).visible(false);
var colvis_sync = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' } );
$( colvis_sync.button() ).appendTo('#button-bar-sync');
clearSearchButton('sync_table', sync_table);
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
});
// Load edit user modal
@@ -471,32 +479,60 @@ from plexpy import helpers
});
function recentlyWatched() {
var widthVal = $("#user-recently-watched").width();
var tmp = (widthVal-25) / 175;
if (tmp > 0) {
var containerSize = parseInt(tmp);
} else {
var containerSize = 1;
}
// Populate recently watched
$.ajax({
url: 'get_user_recently_watched',
async: true,
data: {
user_id: user_id,
limit: containerSize
limit: 50
},
complete: function(xhr, status) {
$("#user-recently-watched").html(xhr.responseText);
highlightWatchedScrollerButton();
}
});
}
recentlyWatched();
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#user-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
$(window).resize(function() {
recentlyWatched();
highlightWatchedScrollerButton();
});
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#user-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
});
</script>

View File

@@ -28,6 +28,7 @@ DOCUMENTATION :: END
% if data:
<div class="dashboard-recent-media-row">
<div id="recently-watched-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<li>
@@ -79,6 +80,7 @@ DOCUMENTATION :: END
% endfor
</ul>
</div>
</div>
% else:
<div class="text-muted">No stats to show.</div><br>
% endif

View File

@@ -2,6 +2,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="interfaces/default/css/dataTables.bootstrap.css">
<link rel="stylesheet" href="interfaces/default/css/dataTables.colVis.css">
<link rel="stylesheet" href="interfaces/default/css/plexpy-dataTables.css">
</%def>
@@ -12,6 +13,7 @@
<span><i class="fa fa-group"></i> All Users</span>
</div>
<div class="button-bar">
<div class="colvis-button-bar hidden-xs"></div>
<button class="btn btn-dark refresh-users-button" id="refresh-users-list"><i class="fa fa-refresh"></i> Refresh users</button>
<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
@@ -30,8 +32,9 @@
<th align="left" id="last_known_ip">Last Known IP</th>
<th align="left" id="last_platform">Last Platform</th>
<th align="left" id="last_player">Last Player</th>
<th align="left" id="last_watched">Last Watched</th>
<th align="left" id="last_played">Last Played</th>
<th align="left" id="total_plays">Total Plays</th>
<th align="left" id="total_duration">Total Duration</th>
</tr>
</thead>
<tbody>
@@ -67,6 +70,7 @@
<%def name="javascriptIncludes()">
<script src="interfaces/default/js/jquery.dataTables.min.js"></script>
<script src="interfaces/default/js/dataTables.colVis.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.min.js"></script>
<script src="interfaces/default/js/dataTables.bootstrap.pagination.js"></script>
<script src="interfaces/default/js/moment-with-locale.js"></script>
@@ -84,6 +88,8 @@
}
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] });
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('users_list_table', users_list_table);

View File

@@ -83,7 +83,7 @@ from plexpy import common
<div class="col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" id="pms_ssl" name="pms_ssl" value="1"> Force SSL
<input type="checkbox" id="pms_ssl" name="pms_ssl" value="1"> Use SSL
</label>
</div>
</div>
@@ -127,7 +127,7 @@ from plexpy import common
</div>
<div class="wizard-card" data-cardname="card5" data-validate="validateNotifications">
<h3>Notifications</h3>
<p class="help-block">PlexPy supports a wide variety of notification options. To set up a notification agent conifgure this in <strong>Settings -> Notification Agents</strong>
<p class="help-block">PlexPy supports a wide variety of notification options. To set up a notification agent configure this in <strong>Settings -> Notification Agents</strong>
after you have completed this setup wizard.</p><br/>
<div class="wizard-input-section">
<input type="checkbox" name="movie_notify_enable" id="movie_notify_enable" value="1" ${config['movie_notify_enable']}> Enable notifications on Movie playback
@@ -169,6 +169,7 @@ from plexpy import common
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}>
<input type="checkbox" name="refresh_users_on_startup" id="refresh_users_on_startup" value="1" ${config['refresh_users_on_startup']}>
<input type="checkbox" name="refresh_libraries_on_startup" id="refresh_libraries_on_startup" value="1" ${config['refresh_libraries_on_startup']}>
<input type="checkbox" name="server_changed" id="server_changed" value="1" checked>
<input type="checkbox" name="first_run_complete" id="first_run_complete" value="1" checked>
<input type="checkbox" name="check_github" id="check_github" value="1" checked>
<input type="text" name="home_stats_cards" id="home_stats_cards" value="first_run_wizard">
@@ -243,7 +244,7 @@ from plexpy import common
},
render: {
option: function (item, escape) {
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '">' + item.value + '</div>';
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '" data-label="' + item.label + '">' + item.value + ' (' + item.label + ')</div>';
},
item: function (item, escape) {
// first item is rendered before initialization bug?
@@ -253,7 +254,7 @@ from plexpy import common
.filter('[value="' + item.value + '"]').data());
}
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '">' + item.value + '</div>';
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '" data-label="' + item.label + '">' + item.value + ' (' + item.label + ')</div>';
}
},
onChange: function (item) {
@@ -377,8 +378,8 @@ from plexpy import common
var pms_ip = $("#pms_ip").val().trim();
var pms_port = $("#pms_port").val().trim();
var pms_identifier = $("#pms_identifier").val();
var pms_ssl = $("#pms_ssl").val();
var pms_is_remote = $("#pms_is_remote").val();
var pms_ssl = $("#pms_ssl").is(':checked') ? 1 : 0;
var pms_is_remote = $("#pms_is_remote").is(':checked') ? 1 : 0;
if ((pms_ip !== '') || (pms_port !== '')) {
$("#pms-verify-status").html('<i class="fa fa-refresh fa-spin"></i> Validating server...');
$('#pms-verify-status').fadeIn('fast');
@@ -392,9 +393,10 @@ from plexpy import common
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
$('#pms-verify-status').fadeIn('fast');
},
success: function (xml) {
if ($(xml).find('MediaContainer').attr('machineIdentifier')) {
$("#pms_identifier").val($(xml).find('MediaContainer').attr('machineIdentifier'));
success: function (json) {
var machine_identifier = json;
if (machine_identifier) {
$("#pms_identifier").val(machine_identifier);
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!');
$('#pms-verify-status').fadeIn('fast');
pms_verified = true;

View File

@@ -33,12 +33,13 @@ load_rc_config ${name}
: ${plexpy_dir:="/usr/local/plexpy"}
: ${plexpy_chdir:="${plexpy_dir}"}
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
: ${plexpy_flags:=""}
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="/usr/sbin/daemon"
command_args="-f -p ${plexpy_pid} python ${plexpy_dir}/PlexPy.py ${plexpy_flags} --quiet --nolaunch"
command="${plexpy_dir}/PlexPy.py"
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
# Ensure user is root when running this script.
if [ `id -u` != "0" ]; then

5
init-scripts/init.freenas Normal file → Executable file
View File

@@ -33,12 +33,13 @@ load_rc_config ${name}
: ${plexpy_dir:="/usr/local/share/plexpy"}
: ${plexpy_chdir:="${plexpy_dir}"}
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
: ${plexpy_flags:=""}
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="/usr/sbin/daemon"
command_args="-f -p ${plexpy_pid} python2 ${plexpy_dir}/PlexPy.py ${plexpy_flags} --quiet --nolaunch"
command="${plexpy_dir}/PlexPy.py"
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
# Ensure user is root when running this script.
if [ `id -u` != "0" ]; then

1652
lib/IPy.py Normal file

File diff suppressed because it is too large Load Diff

8
lib/arrow/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from .arrow import Arrow
from .factory import ArrowFactory
from .api import get, now, utcnow
__version__ = '0.7.0'
VERSION = __version__

55
lib/arrow/api.py Normal file
View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
'''
Provides the default implementation of :class:`ArrowFactory <arrow.factory.ArrowFactory>`
methods for use as a module API.
'''
from __future__ import absolute_import
from arrow.factory import ArrowFactory
# internal default factory.
_factory = ArrowFactory()
def get(*args, **kwargs):
''' Implements the default :class:`ArrowFactory <arrow.factory.ArrowFactory>`
``get`` method.
'''
return _factory.get(*args, **kwargs)
def utcnow():
''' Implements the default :class:`ArrowFactory <arrow.factory.ArrowFactory>`
``utcnow`` method.
'''
return _factory.utcnow()
def now(tz=None):
''' Implements the default :class:`ArrowFactory <arrow.factory.ArrowFactory>`
``now`` method.
'''
return _factory.now(tz)
def factory(type):
''' Returns an :class:`.ArrowFactory` for the specified :class:`Arrow <arrow.arrow.Arrow>`
or derived type.
:param type: the type, :class:`Arrow <arrow.arrow.Arrow>` or derived.
'''
return ArrowFactory(type)
__all__ = ['get', 'utcnow', 'now', 'factory', 'iso']

896
lib/arrow/arrow.py Normal file
View File

@@ -0,0 +1,896 @@
# -*- coding: utf-8 -*-
'''
Provides the :class:`Arrow <arrow.arrow.Arrow>` class, an enhanced ``datetime``
replacement.
'''
from __future__ import absolute_import
from datetime import datetime, timedelta, tzinfo
from dateutil import tz as dateutil_tz
from dateutil.relativedelta import relativedelta
import calendar
import sys
from arrow import util, locales, parser, formatter
class Arrow(object):
'''An :class:`Arrow <arrow.arrow.Arrow>` object.
Implements the ``datetime`` interface, behaving as an aware ``datetime`` while implementing
additional functionality.
:param year: the calendar year.
:param month: the calendar month.
:param day: the calendar day.
:param hour: (optional) the hour. Defaults to 0.
:param minute: (optional) the minute, Defaults to 0.
:param second: (optional) the second, Defaults to 0.
:param microsecond: (optional) the microsecond. Defaults 0.
:param tzinfo: (optional) the ``tzinfo`` object. Defaults to ``None``.
If tzinfo is None, it is assumed to be UTC on creation.
Usage::
>>> import arrow
>>> arrow.Arrow(2013, 5, 5, 12, 30, 45)
<Arrow [2013-05-05T12:30:45+00:00]>
'''
resolution = datetime.resolution
_ATTRS = ['year', 'month', 'day', 'hour', 'minute', 'second', 'microsecond']
_ATTRS_PLURAL = ['{0}s'.format(a) for a in _ATTRS]
def __init__(self, year, month, day, hour=0, minute=0, second=0, microsecond=0,
tzinfo=None):
if util.isstr(tzinfo):
tzinfo = parser.TzinfoParser.parse(tzinfo)
tzinfo = tzinfo or dateutil_tz.tzutc()
self._datetime = datetime(year, month, day, hour, minute, second,
microsecond, tzinfo)
# factories: single object, both original and from datetime.
@classmethod
def now(cls, tzinfo=None):
'''Constructs an :class:`Arrow <arrow.arrow.Arrow>` object, representing "now".
:param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time.
'''
utc = datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc())
dt = utc.astimezone(dateutil_tz.tzlocal() if tzinfo is None else tzinfo)
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, dt.tzinfo)
@classmethod
def utcnow(cls):
''' Constructs an :class:`Arrow <arrow.arrow.Arrow>` object, representing "now" in UTC
time.
'''
dt = datetime.utcnow()
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, dateutil_tz.tzutc())
@classmethod
def fromtimestamp(cls, timestamp, tzinfo=None):
''' Constructs an :class:`Arrow <arrow.arrow.Arrow>` object from a timestamp.
:param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either.
:param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time.
'''
tzinfo = tzinfo or dateutil_tz.tzlocal()
timestamp = cls._get_timestamp_from_input(timestamp)
dt = datetime.fromtimestamp(timestamp, tzinfo)
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, tzinfo)
@classmethod
def utcfromtimestamp(cls, timestamp):
'''Constructs an :class:`Arrow <arrow.arrow.Arrow>` object from a timestamp, in UTC time.
:param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either.
'''
timestamp = cls._get_timestamp_from_input(timestamp)
dt = datetime.utcfromtimestamp(timestamp)
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, dateutil_tz.tzutc())
@classmethod
def fromdatetime(cls, dt, tzinfo=None):
''' Constructs an :class:`Arrow <arrow.arrow.Arrow>` object from a ``datetime`` and optional
``tzinfo`` object.
:param dt: the ``datetime``
:param tzinfo: (optional) a ``tzinfo`` object. Defaults to UTC.
'''
tzinfo = tzinfo or dt.tzinfo or dateutil_tz.tzutc()
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, tzinfo)
@classmethod
def fromdate(cls, date, tzinfo=None):
''' Constructs an :class:`Arrow <arrow.arrow.Arrow>` object from a ``date`` and optional
``tzinfo`` object. Time values are set to 0.
:param date: the ``date``
:param tzinfo: (optional) a ``tzinfo`` object. Defaults to UTC.
'''
tzinfo = tzinfo or dateutil_tz.tzutc()
return cls(date.year, date.month, date.day, tzinfo=tzinfo)
@classmethod
def strptime(cls, date_str, fmt, tzinfo=None):
''' Constructs an :class:`Arrow <arrow.arrow.Arrow>` object from a date string and format,
in the style of ``datetime.strptime``.
:param date_str: the date string.
:param fmt: the format string.
:param tzinfo: (optional) an optional ``tzinfo``
'''
dt = datetime.strptime(date_str, fmt)
tzinfo = tzinfo or dt.tzinfo
return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, tzinfo)
# factories: ranges and spans
@classmethod
def range(cls, frame, start, end=None, tz=None, limit=None):
''' Returns an array of :class:`Arrow <arrow.arrow.Arrow>` objects, representing
an iteration of time between two inputs.
:param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...).
:param start: A datetime expression, the start of the range.
:param end: (optional) A datetime expression, the end of the range.
:param tz: (optional) A timezone expression. Defaults to UTC.
:param limit: (optional) A maximum number of tuples to return.
**NOTE**: the **end** or **limit** must be provided. Call with **end** alone to
return the entire range, with **limit** alone to return a maximum # of results from the
start, and with both to cap a range at a maximum # of results.
Supported frame values: year, quarter, month, week, day, hour, minute, second
Recognized datetime expressions:
- An :class:`Arrow <arrow.arrow.Arrow>` object.
- A ``datetime`` object.
Recognized timezone expressions:
- A ``tzinfo`` object.
- A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
- A ``str`` in ISO-8601 style, as in '+07:00'.
- A ``str``, one of the following: 'local', 'utc', 'UTC'.
Usage:
>>> start = datetime(2013, 5, 5, 12, 30)
>>> end = datetime(2013, 5, 5, 17, 15)
>>> for r in arrow.Arrow.range('hour', start, end):
... print repr(r)
...
<Arrow [2013-05-05T12:30:00+00:00]>
<Arrow [2013-05-05T13:30:00+00:00]>
<Arrow [2013-05-05T14:30:00+00:00]>
<Arrow [2013-05-05T15:30:00+00:00]>
<Arrow [2013-05-05T16:30:00+00:00]>
'''
_, frame_relative, relative_steps = cls._get_frames(frame)
tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz)
start = cls._get_datetime(start).replace(tzinfo=tzinfo)
end, limit = cls._get_iteration_params(end, limit)
end = cls._get_datetime(end).replace(tzinfo=tzinfo)
current = cls.fromdatetime(start)
results = []
while current <= end and len(results) < limit:
results.append(current)
values = [getattr(current, f) for f in cls._ATTRS]
current = cls(*values, tzinfo=tzinfo) + relativedelta(**{frame_relative: relative_steps})
return results
@classmethod
def span_range(cls, frame, start, end, tz=None, limit=None):
''' Returns an array of tuples, each :class:`Arrow <arrow.arrow.Arrow>` objects,
representing a series of timespans between two inputs.
:param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...).
:param start: A datetime expression, the start of the range.
:param end: (optional) A datetime expression, the end of the range.
:param tz: (optional) A timezone expression. Defaults to UTC.
:param limit: (optional) A maximum number of tuples to return.
**NOTE**: the **end** or **limit** must be provided. Call with **end** alone to
return the entire range, with **limit** alone to return a maximum # of results from the
start, and with both to cap a range at a maximum # of results.
Supported frame values: year, quarter, month, week, day, hour, minute, second
Recognized datetime expressions:
- An :class:`Arrow <arrow.arrow.Arrow>` object.
- A ``datetime`` object.
Recognized timezone expressions:
- A ``tzinfo`` object.
- A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
- A ``str`` in ISO-8601 style, as in '+07:00'.
- A ``str``, one of the following: 'local', 'utc', 'UTC'.
Usage:
>>> start = datetime(2013, 5, 5, 12, 30)
>>> end = datetime(2013, 5, 5, 17, 15)
>>> for r in arrow.Arrow.span_range('hour', start, end):
... print r
...
(<Arrow [2013-05-05T12:00:00+00:00]>, <Arrow [2013-05-05T12:59:59.999999+00:00]>)
(<Arrow [2013-05-05T13:00:00+00:00]>, <Arrow [2013-05-05T13:59:59.999999+00:00]>)
(<Arrow [2013-05-05T14:00:00+00:00]>, <Arrow [2013-05-05T14:59:59.999999+00:00]>)
(<Arrow [2013-05-05T15:00:00+00:00]>, <Arrow [2013-05-05T15:59:59.999999+00:00]>)
(<Arrow [2013-05-05T16:00:00+00:00]>, <Arrow [2013-05-05T16:59:59.999999+00:00]>)
'''
tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz)
start = cls.fromdatetime(start, tzinfo).span(frame)[0]
_range = cls.range(frame, start, end, tz, limit)
return [r.span(frame) for r in _range]
# representations
def __repr__(self):
dt = self._datetime
attrs = ', '.join([str(i) for i in [dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second, dt.microsecond]])
return '<{0} [{1}]>'.format(self.__class__.__name__, self.__str__())
def __str__(self):
return self._datetime.isoformat()
def __format__(self, formatstr):
if len(formatstr) > 0:
return self.format(formatstr)
return str(self)
def __hash__(self):
return self._datetime.__hash__()
# attributes & properties
def __getattr__(self, name):
if name == 'week':
return self.isocalendar()[1]
if not name.startswith('_'):
value = getattr(self._datetime, name, None)
if value is not None:
return value
return object.__getattribute__(self, name)
@property
def tzinfo(self):
''' Gets the ``tzinfo`` of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
return self._datetime.tzinfo
@tzinfo.setter
def tzinfo(self, tzinfo):
''' Sets the ``tzinfo`` of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
self._datetime = self._datetime.replace(tzinfo=tzinfo)
@property
def datetime(self):
''' Returns a datetime representation of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
return self._datetime
@property
def naive(self):
''' Returns a naive datetime representation of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
return self._datetime.replace(tzinfo=None)
@property
def timestamp(self):
''' Returns a timestamp representation of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
return calendar.timegm(self._datetime.utctimetuple())
@property
def float_timestamp(self):
''' Returns a floating-point representation of the :class:`Arrow <arrow.arrow.Arrow>` object. '''
return self.timestamp + float(self.microsecond) / 1000000
# mutation and duplication.
def clone(self):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, cloned from the current one.
Usage:
>>> arw = arrow.utcnow()
>>> cloned = arw.clone()
'''
return self.fromdatetime(self._datetime)
def replace(self, **kwargs):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object with attributes updated
according to inputs.
Use single property names to set their value absolutely:
>>> import arrow
>>> arw = arrow.utcnow()
>>> arw
<Arrow [2013-05-11T22:27:34.787885+00:00]>
>>> arw.replace(year=2014, month=6)
<Arrow [2014-06-11T22:27:34.787885+00:00]>
Use plural property names to shift their current value relatively:
>>> arw.replace(years=1, months=-1)
<Arrow [2014-04-11T22:27:34.787885+00:00]>
You can also provide a timezone expression can also be replaced:
>>> arw.replace(tzinfo=tz.tzlocal())
<Arrow [2013-05-11T22:27:34.787885-07:00]>
Recognized timezone expressions:
- A ``tzinfo`` object.
- A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
- A ``str`` in ISO-8601 style, as in '+07:00'.
- A ``str``, one of the following: 'local', 'utc', 'UTC'.
'''
absolute_kwargs = {}
relative_kwargs = {}
for key, value in kwargs.items():
if key in self._ATTRS:
absolute_kwargs[key] = value
elif key in self._ATTRS_PLURAL or key == 'weeks':
relative_kwargs[key] = value
elif key == 'week':
raise AttributeError('setting absolute week is not supported')
elif key !='tzinfo':
raise AttributeError()
current = self._datetime.replace(**absolute_kwargs)
current += relativedelta(**relative_kwargs)
tzinfo = kwargs.get('tzinfo')
if tzinfo is not None:
tzinfo = self._get_tzinfo(tzinfo)
current = current.replace(tzinfo=tzinfo)
return self.fromdatetime(current)
def to(self, tz):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, converted to the target
timezone.
:param tz: an expression representing a timezone.
Recognized timezone expressions:
- A ``tzinfo`` object.
- A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
- A ``str`` in ISO-8601 style, as in '+07:00'.
- A ``str``, one of the following: 'local', 'utc', 'UTC'.
Usage::
>>> utc = arrow.utcnow()
>>> utc
<Arrow [2013-05-09T03:49:12.311072+00:00]>
>>> utc.to('US/Pacific')
<Arrow [2013-05-08T20:49:12.311072-07:00]>
>>> utc.to(tz.tzlocal())
<Arrow [2013-05-08T20:49:12.311072-07:00]>
>>> utc.to('-07:00')
<Arrow [2013-05-08T20:49:12.311072-07:00]>
>>> utc.to('local')
<Arrow [2013-05-08T20:49:12.311072-07:00]>
>>> utc.to('local').to('utc')
<Arrow [2013-05-09T03:49:12.311072+00:00]>
'''
if not isinstance(tz, tzinfo):
tz = parser.TzinfoParser.parse(tz)
dt = self._datetime.astimezone(tz)
return self.__class__(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
dt.microsecond, dt.tzinfo)
def span(self, frame, count=1):
''' Returns two new :class:`Arrow <arrow.arrow.Arrow>` objects, representing the timespan
of the :class:`Arrow <arrow.arrow.Arrow>` object in a given timeframe.
:param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...).
:param count: (optional) the number of frames to span.
Supported frame values: year, quarter, month, week, day, hour, minute, second
Usage::
>>> arrow.utcnow()
<Arrow [2013-05-09T03:32:36.186203+00:00]>
>>> arrow.utcnow().span('hour')
(<Arrow [2013-05-09T03:00:00+00:00]>, <Arrow [2013-05-09T03:59:59.999999+00:00]>)
>>> arrow.utcnow().span('day')
(<Arrow [2013-05-09T00:00:00+00:00]>, <Arrow [2013-05-09T23:59:59.999999+00:00]>)
>>> arrow.utcnow().span('day', count=2)
(<Arrow [2013-05-09T00:00:00+00:00]>, <Arrow [2013-05-10T23:59:59.999999+00:00]>)
'''
frame_absolute, frame_relative, relative_steps = self._get_frames(frame)
if frame_absolute == 'week':
attr = 'day'
elif frame_absolute == 'quarter':
attr = 'month'
else:
attr = frame_absolute
index = self._ATTRS.index(attr)
frames = self._ATTRS[:index + 1]
values = [getattr(self, f) for f in frames]
for i in range(3 - len(values)):
values.append(1)
floor = self.__class__(*values, tzinfo=self.tzinfo)
if frame_absolute == 'week':
floor = floor + relativedelta(days=-(self.isoweekday() - 1))
elif frame_absolute == 'quarter':
floor = floor + relativedelta(months=-((self.month - 1) % 3))
ceil = floor + relativedelta(
**{frame_relative: count * relative_steps}) + relativedelta(microseconds=-1)
return floor, ceil
def floor(self, frame):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, representing the "floor"
of the timespan of the :class:`Arrow <arrow.arrow.Arrow>` object in a given timeframe.
Equivalent to the first element in the 2-tuple returned by
:func:`span <arrow.arrow.Arrow.span>`.
:param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...).
Usage::
>>> arrow.utcnow().floor('hour')
<Arrow [2013-05-09T03:00:00+00:00]>
'''
return self.span(frame)[0]
def ceil(self, frame):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, representing the "ceiling"
of the timespan of the :class:`Arrow <arrow.arrow.Arrow>` object in a given timeframe.
Equivalent to the second element in the 2-tuple returned by
:func:`span <arrow.arrow.Arrow.span>`.
:param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...).
Usage::
>>> arrow.utcnow().ceil('hour')
<Arrow [2013-05-09T03:59:59.999999+00:00]>
'''
return self.span(frame)[1]
# string output and formatting.
def format(self, fmt='YYYY-MM-DD HH:mm:ssZZ', locale='en_us'):
''' Returns a string representation of the :class:`Arrow <arrow.arrow.Arrow>` object,
formatted according to a format string.
:param fmt: the format string.
Usage::
>>> arrow.utcnow().format('YYYY-MM-DD HH:mm:ss ZZ')
'2013-05-09 03:56:47 -00:00'
>>> arrow.utcnow().format('X')
'1368071882'
>>> arrow.utcnow().format('MMMM DD, YYYY')
'May 09, 2013'
>>> arrow.utcnow().format()
'2013-05-09 03:56:47 -00:00'
'''
return formatter.DateTimeFormatter(locale).format(self._datetime, fmt)
def humanize(self, other=None, locale='en_us', only_distance=False):
''' Returns a localized, humanized representation of a relative difference in time.
:param other: (optional) an :class:`Arrow <arrow.arrow.Arrow>` or ``datetime`` object.
Defaults to now in the current :class:`Arrow <arrow.arrow.Arrow>` object's timezone.
:param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'.
:param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part.
Usage::
>>> earlier = arrow.utcnow().replace(hours=-2)
>>> earlier.humanize()
'2 hours ago'
>>> later = later = earlier.replace(hours=4)
>>> later.humanize(earlier)
'in 4 hours'
'''
locale = locales.get_locale(locale)
if other is None:
utc = datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc())
dt = utc.astimezone(self._datetime.tzinfo)
elif isinstance(other, Arrow):
dt = other._datetime
elif isinstance(other, datetime):
if other.tzinfo is None:
dt = other.replace(tzinfo=self._datetime.tzinfo)
else:
dt = other.astimezone(self._datetime.tzinfo)
else:
raise TypeError()
delta = int(util.total_seconds(self._datetime - dt))
sign = -1 if delta < 0 else 1
diff = abs(delta)
delta = diff
if diff < 10:
return locale.describe('now', only_distance=only_distance)
if diff < 45:
return locale.describe('seconds', sign, only_distance=only_distance)
elif diff < 90:
return locale.describe('minute', sign, only_distance=only_distance)
elif diff < 2700:
minutes = sign * int(max(delta / 60, 2))
return locale.describe('minutes', minutes, only_distance=only_distance)
elif diff < 5400:
return locale.describe('hour', sign, only_distance=only_distance)
elif diff < 79200:
hours = sign * int(max(delta / 3600, 2))
return locale.describe('hours', hours, only_distance=only_distance)
elif diff < 129600:
return locale.describe('day', sign, only_distance=only_distance)
elif diff < 2160000:
days = sign * int(max(delta / 86400, 2))
return locale.describe('days', days, only_distance=only_distance)
elif diff < 3888000:
return locale.describe('month', sign, only_distance=only_distance)
elif diff < 29808000:
self_months = self._datetime.year * 12 + self._datetime.month
other_months = dt.year * 12 + dt.month
months = sign * abs(other_months - self_months)
return locale.describe('months', months, only_distance=only_distance)
elif diff < 47260800:
return locale.describe('year', sign, only_distance=only_distance)
else:
years = sign * int(max(delta / 31536000, 2))
return locale.describe('years', years, only_distance=only_distance)
# math
def __add__(self, other):
if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime + other, self._datetime.tzinfo)
raise TypeError()
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, other):
if isinstance(other, timedelta):
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
elif isinstance(other, datetime):
return self._datetime - other
elif isinstance(other, Arrow):
return self._datetime - other._datetime
raise TypeError()
def __rsub__(self, other):
return self.__sub__(other)
# comparisons
def _cmperror(self, other):
raise TypeError('can\'t compare \'{0}\' to \'{1}\''.format(
type(self), type(other)))
def __eq__(self, other):
if not isinstance(other, (Arrow, datetime)):
return False
other = self._get_datetime(other)
return self._datetime == self._get_datetime(other)
def __ne__(self, other):
return not self.__eq__(other)
def __gt__(self, other):
if not isinstance(other, (Arrow, datetime)):
self._cmperror(other)
return self._datetime > self._get_datetime(other)
def __ge__(self, other):
if not isinstance(other, (Arrow, datetime)):
self._cmperror(other)
return self._datetime >= self._get_datetime(other)
def __lt__(self, other):
if not isinstance(other, (Arrow, datetime)):
self._cmperror(other)
return self._datetime < self._get_datetime(other)
def __le__(self, other):
if not isinstance(other, (Arrow, datetime)):
self._cmperror(other)
return self._datetime <= self._get_datetime(other)
# datetime methods
def date(self):
''' Returns a ``date`` object with the same year, month and day. '''
return self._datetime.date()
def time(self):
''' Returns a ``time`` object with the same hour, minute, second, microsecond. '''
return self._datetime.time()
def timetz(self):
''' Returns a ``time`` object with the same hour, minute, second, microsecond and tzinfo. '''
return self._datetime.timetz()
def astimezone(self, tz):
''' Returns a ``datetime`` object, adjusted to the specified tzinfo.
:param tz: a ``tzinfo`` object.
'''
return self._datetime.astimezone(tz)
def utcoffset(self):
''' Returns a ``timedelta`` object representing the whole number of minutes difference from UTC time. '''
return self._datetime.utcoffset()
def dst(self):
''' Returns the daylight savings time adjustment. '''
return self._datetime.dst()
def timetuple(self):
''' Returns a ``time.struct_time``, in the current timezone. '''
return self._datetime.timetuple()
def utctimetuple(self):
''' Returns a ``time.struct_time``, in UTC time. '''
return self._datetime.utctimetuple()
def toordinal(self):
''' Returns the proleptic Gregorian ordinal of the date. '''
return self._datetime.toordinal()
def weekday(self):
''' Returns the day of the week as an integer (0-6). '''
return self._datetime.weekday()
def isoweekday(self):
''' Returns the ISO day of the week as an integer (1-7). '''
return self._datetime.isoweekday()
def isocalendar(self):
''' Returns a 3-tuple, (ISO year, ISO week number, ISO weekday). '''
return self._datetime.isocalendar()
def isoformat(self, sep='T'):
'''Returns an ISO 8601 formatted representation of the date and time. '''
return self._datetime.isoformat(sep)
def ctime(self):
''' Returns a ctime formatted representation of the date and time. '''
return self._datetime.ctime()
def strftime(self, format):
''' Formats in the style of ``datetime.strptime``.
:param format: the format string.
'''
return self._datetime.strftime(format)
def for_json(self):
'''Serializes for the ``for_json`` protocol of simplejson.'''
return self.isoformat()
# internal tools.
@staticmethod
def _get_tzinfo(tz_expr):
if tz_expr is None:
return dateutil_tz.tzutc()
if isinstance(tz_expr, tzinfo):
return tz_expr
else:
try:
return parser.TzinfoParser.parse(tz_expr)
except parser.ParserError:
raise ValueError('\'{0}\' not recognized as a timezone'.format(
tz_expr))
@classmethod
def _get_datetime(cls, expr):
if isinstance(expr, Arrow):
return expr.datetime
if isinstance(expr, datetime):
return expr
try:
expr = float(expr)
return cls.utcfromtimestamp(expr).datetime
except:
raise ValueError(
'\'{0}\' not recognized as a timestamp or datetime'.format(expr))
@classmethod
def _get_frames(cls, name):
if name in cls._ATTRS:
return name, '{0}s'.format(name), 1
elif name in ['week', 'weeks']:
return 'week', 'weeks', 1
elif name in ['quarter', 'quarters']:
return 'quarter', 'months', 3
raise AttributeError()
@classmethod
def _get_iteration_params(cls, end, limit):
if end is None:
if limit is None:
raise Exception('one of \'end\' or \'limit\' is required')
return cls.max, limit
else:
return end, sys.maxsize
@staticmethod
def _get_timestamp_from_input(timestamp):
try:
return float(timestamp)
except:
raise ValueError('cannot parse \'{0}\' as a timestamp'.format(timestamp))
Arrow.min = Arrow.fromdatetime(datetime.min)
Arrow.max = Arrow.fromdatetime(datetime.max)

254
lib/arrow/factory.py Normal file
View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
Implements the :class:`ArrowFactory <arrow.factory.ArrowFactory>` class,
providing factory methods for common :class:`Arrow <arrow.arrow.Arrow>`
construction scenarios.
"""
from __future__ import absolute_import
from arrow.arrow import Arrow
from arrow import parser
from arrow.util import is_timestamp, isstr
from datetime import datetime, tzinfo, date
from dateutil import tz as dateutil_tz
from time import struct_time
import calendar
class ArrowFactory(object):
''' A factory for generating :class:`Arrow <arrow.arrow.Arrow>` objects.
:param type: (optional) the :class:`Arrow <arrow.arrow.Arrow>`-based class to construct from.
Defaults to :class:`Arrow <arrow.arrow.Arrow>`.
'''
def __init__(self, type=Arrow):
self.type = type
def get(self, *args, **kwargs):
''' Returns an :class:`Arrow <arrow.arrow.Arrow>` object based on flexible inputs.
Usage::
>>> import arrow
**No inputs** to get current UTC time::
>>> arrow.get()
<Arrow [2013-05-08T05:51:43.316458+00:00]>
**None** to also get current UTC time::
>>> arrow.get(None)
<Arrow [2013-05-08T05:51:43.316458+00:00]>
**One** :class:`Arrow <arrow.arrow.Arrow>` object, to get a copy.
>>> arw = arrow.utcnow()
>>> arrow.get(arw)
<Arrow [2013-10-23T15:21:54.354846+00:00]>
**One** ``str``, ``float``, or ``int``, convertible to a floating-point timestamp, to get that timestamp in UTC::
>>> arrow.get(1367992474.293378)
<Arrow [2013-05-08T05:54:34.293378+00:00]>
>>> arrow.get(1367992474)
<Arrow [2013-05-08T05:54:34+00:00]>
>>> arrow.get('1367992474.293378')
<Arrow [2013-05-08T05:54:34.293378+00:00]>
>>> arrow.get('1367992474')
<Arrow [2013-05-08T05:54:34+0struct_time0:00]>
**One** ISO-8601-formatted ``str``, to parse it::
>>> arrow.get('2013-09-29T01:26:43.830580')
<Arrow [2013-09-29T01:26:43.830580+00:00]>
**One** ``tzinfo``, to get the current time in that timezone::
>>> arrow.get(tz.tzlocal())
<Arrow [2013-05-07T22:57:28.484717-07:00]>
**One** naive ``datetime``, to get that datetime in UTC::
>>> arrow.get(datetime(2013, 5, 5))
<Arrow [2013-05-05T00:00:00+00:00]>
**One** aware ``datetime``, to get that datetime::
>>> arrow.get(datetime(2013, 5, 5, tzinfo=tz.tzlocal()))
<Arrow [2013-05-05T00:00:00-07:00]>
**One** naive ``date``, to get that date in UTC::
>>> arrow.get(date(2013, 5, 5))
<Arrow [2013-05-05T00:00:00+00:00]>
**Two** arguments, a naive or aware ``datetime``, and a timezone expression (as above)::
>>> arrow.get(datetime(2013, 5, 5), 'US/Pacific')
<Arrow [2013-05-05T00:00:00-07:00]>
**Two** arguments, a naive ``date``, and a timezone expression (as above)::
>>> arrow.get(date(2013, 5, 5), 'US/Pacific')
<Arrow [2013-05-05T00:00:00-07:00]>
**Two** arguments, both ``str``, to parse the first according to the format of the second::
>>> arrow.get('2013-05-05 12:30:45', 'YYYY-MM-DD HH:mm:ss')
<Arrow [2013-05-05T12:30:45+00:00]>
**Two** arguments, first a ``str`` to parse and second a ``list`` of formats to try::
>>> arrow.get('2013-05-05 12:30:45', ['MM/DD/YYYY', 'YYYY-MM-DD HH:mm:ss'])
<Arrow [2013-05-05T12:30:45+00:00]>
**Three or more** arguments, as for the constructor of a ``datetime``::
>>> arrow.get(2013, 5, 5, 12, 30, 45)
<Arrow [2013-05-05T12:30:45+00:00]>
**One** time.struct time::
>>> arrow.get(gmtime(0))
<Arrow [1970-01-01T00:00:00+00:00]>
'''
arg_count = len(args)
locale = kwargs.get('locale', 'en_us')
tz = kwargs.get('tzinfo', None)
# () -> now, @ utc.
if arg_count == 0:
if isinstance(tz, tzinfo):
return self.type.now(tz)
return self.type.utcnow()
if arg_count == 1:
arg = args[0]
# (None) -> now, @ utc.
if arg is None:
return self.type.utcnow()
# try (int, float, str(int), str(float)) -> utc, from timestamp.
if is_timestamp(arg):
return self.type.utcfromtimestamp(arg)
# (Arrow) -> from the object's datetime.
if isinstance(arg, Arrow):
return self.type.fromdatetime(arg.datetime)
# (datetime) -> from datetime.
if isinstance(arg, datetime):
return self.type.fromdatetime(arg)
# (date) -> from date.
if isinstance(arg, date):
return self.type.fromdate(arg)
# (tzinfo) -> now, @ tzinfo.
elif isinstance(arg, tzinfo):
return self.type.now(arg)
# (str) -> now, @ tzinfo.
elif isstr(arg):
dt = parser.DateTimeParser(locale).parse_iso(arg)
return self.type.fromdatetime(dt)
# (struct_time) -> from struct_time
elif isinstance(arg, struct_time):
return self.type.utcfromtimestamp(calendar.timegm(arg))
else:
raise TypeError('Can\'t parse single argument type of \'{0}\''.format(type(arg)))
elif arg_count == 2:
arg_1, arg_2 = args[0], args[1]
if isinstance(arg_1, datetime):
# (datetime, tzinfo) -> fromdatetime @ tzinfo/string.
if isinstance(arg_2, tzinfo) or isstr(arg_2):
return self.type.fromdatetime(arg_1, arg_2)
else:
raise TypeError('Can\'t parse two arguments of types \'datetime\', \'{0}\''.format(
type(arg_2)))
# (date, tzinfo/str) -> fromdate @ tzinfo/string.
elif isinstance(arg_1, date):
if isinstance(arg_2, tzinfo) or isstr(arg_2):
return self.type.fromdate(arg_1, tzinfo=arg_2)
else:
raise TypeError('Can\'t parse two arguments of types \'date\', \'{0}\''.format(
type(arg_2)))
# (str, format) -> parse.
elif isstr(arg_1) and (isstr(arg_2) or isinstance(arg_2, list)):
dt = parser.DateTimeParser(locale).parse(args[0], args[1])
return self.type.fromdatetime(dt, tzinfo=tz)
else:
raise TypeError('Can\'t parse two arguments of types \'{0}\', \'{1}\''.format(
type(arg_1), type(arg_2)))
# 3+ args -> datetime-like via constructor.
else:
return self.type(*args, **kwargs)
def utcnow(self):
'''Returns an :class:`Arrow <arrow.arrow.Arrow>` object, representing "now" in UTC time.
Usage::
>>> import arrow
>>> arrow.utcnow()
<Arrow [2013-05-08T05:19:07.018993+00:00]>
'''
return self.type.utcnow()
def now(self, tz=None):
'''Returns an :class:`Arrow <arrow.arrow.Arrow>` object, representing "now".
:param tz: (optional) An expression representing a timezone. Defaults to local time.
Recognized timezone expressions:
- A ``tzinfo`` object.
- A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
- A ``str`` in ISO-8601 style, as in '+07:00'.
- A ``str``, one of the following: 'local', 'utc', 'UTC'.
Usage::
>>> import arrow
>>> arrow.now()
<Arrow [2013-05-07T22:19:11.363410-07:00]>
>>> arrow.now('US/Pacific')
<Arrow [2013-05-07T22:19:15.251821-07:00]>
>>> arrow.now('+02:00')
<Arrow [2013-05-08T07:19:25.618646+02:00]>
>>> arrow.now('local')
<Arrow [2013-05-07T22:19:39.130059-07:00]>
'''
if tz is None:
tz = dateutil_tz.tzlocal()
elif not isinstance(tz, tzinfo):
tz = parser.TzinfoParser.parse(tz)
return self.type.now(tz)

105
lib/arrow/formatter.py Normal file
View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import calendar
import re
from dateutil import tz as dateutil_tz
from arrow import util, locales
class DateTimeFormatter(object):
_FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?|a|A|X)')
def __init__(self, locale='en_us'):
self.locale = locales.get_locale(locale)
def format(cls, dt, fmt):
return cls._FORMAT_RE.sub(lambda m: cls._format_token(dt, m.group(0)), fmt)
def _format_token(self, dt, token):
if token == 'YYYY':
return self.locale.year_full(dt.year)
if token == 'YY':
return self.locale.year_abbreviation(dt.year)
if token == 'MMMM':
return self.locale.month_name(dt.month)
if token == 'MMM':
return self.locale.month_abbreviation(dt.month)
if token == 'MM':
return '{0:02d}'.format(dt.month)
if token == 'M':
return str(dt.month)
if token == 'DDDD':
return '{0:03d}'.format(dt.timetuple().tm_yday)
if token == 'DDD':
return str(dt.timetuple().tm_yday)
if token == 'DD':
return '{0:02d}'.format(dt.day)
if token == 'D':
return str(dt.day)
if token == 'Do':
return self.locale.ordinal_number(dt.day)
if token == 'dddd':
return self.locale.day_name(dt.isoweekday())
if token == 'ddd':
return self.locale.day_abbreviation(dt.isoweekday())
if token == 'd':
return str(dt.isoweekday())
if token == 'HH':
return '{0:02d}'.format(dt.hour)
if token == 'H':
return str(dt.hour)
if token == 'hh':
return '{0:02d}'.format(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12))
if token == 'h':
return str(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12))
if token == 'mm':
return '{0:02d}'.format(dt.minute)
if token == 'm':
return str(dt.minute)
if token == 'ss':
return '{0:02d}'.format(dt.second)
if token == 's':
return str(dt.second)
if token == 'SSSSSS':
return str('{0:06d}'.format(int(dt.microsecond)))
if token == 'SSSSS':
return str('{0:05d}'.format(int(dt.microsecond / 10)))
if token == 'SSSS':
return str('{0:04d}'.format(int(dt.microsecond / 100)))
if token == 'SSS':
return str('{0:03d}'.format(int(dt.microsecond / 1000)))
if token == 'SS':
return str('{0:02d}'.format(int(dt.microsecond / 10000)))
if token == 'S':
return str(int(dt.microsecond / 100000))
if token == 'X':
return str(calendar.timegm(dt.utctimetuple()))
if token in ['ZZ', 'Z']:
separator = ':' if token == 'ZZ' else ''
tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo
total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60)
sign = '+' if total_minutes > 0 else '-'
total_minutes = abs(total_minutes)
hour, minute = divmod(total_minutes, 60)
return '{0}{1:02d}{2}{3:02d}'.format(sign, hour, separator, minute)
if token in ('a', 'A'):
return self.locale.meridian(dt.hour, token)

1703
lib/arrow/locales.py Normal file

File diff suppressed because it is too large Load Diff

308
lib/arrow/parser.py Normal file
View File

@@ -0,0 +1,308 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals
from datetime import datetime
from dateutil import tz
import re
from arrow import locales
class ParserError(RuntimeError):
pass
class DateTimeParser(object):
_FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X)')
_ONE_THROUGH_SIX_DIGIT_RE = re.compile('\d{1,6}')
_ONE_THROUGH_FIVE_DIGIT_RE = re.compile('\d{1,5}')
_ONE_THROUGH_FOUR_DIGIT_RE = re.compile('\d{1,4}')
_ONE_TWO_OR_THREE_DIGIT_RE = re.compile('\d{1,3}')
_ONE_OR_TWO_DIGIT_RE = re.compile('\d{1,2}')
_FOUR_DIGIT_RE = re.compile('\d{4}')
_TWO_DIGIT_RE = re.compile('\d{2}')
_TZ_RE = re.compile('[+\-]?\d{2}:?\d{2}')
_TZ_NAME_RE = re.compile('\w[\w+\-/]+')
_BASE_INPUT_RE_MAP = {
'YYYY': _FOUR_DIGIT_RE,
'YY': _TWO_DIGIT_RE,
'MM': _TWO_DIGIT_RE,
'M': _ONE_OR_TWO_DIGIT_RE,
'DD': _TWO_DIGIT_RE,
'D': _ONE_OR_TWO_DIGIT_RE,
'HH': _TWO_DIGIT_RE,
'H': _ONE_OR_TWO_DIGIT_RE,
'hh': _TWO_DIGIT_RE,
'h': _ONE_OR_TWO_DIGIT_RE,
'mm': _TWO_DIGIT_RE,
'm': _ONE_OR_TWO_DIGIT_RE,
'ss': _TWO_DIGIT_RE,
's': _ONE_OR_TWO_DIGIT_RE,
'X': re.compile('\d+'),
'ZZZ': _TZ_NAME_RE,
'ZZ': _TZ_RE,
'Z': _TZ_RE,
'SSSSSS': _ONE_THROUGH_SIX_DIGIT_RE,
'SSSSS': _ONE_THROUGH_FIVE_DIGIT_RE,
'SSSS': _ONE_THROUGH_FOUR_DIGIT_RE,
'SSS': _ONE_TWO_OR_THREE_DIGIT_RE,
'SS': _ONE_OR_TWO_DIGIT_RE,
'S': re.compile('\d'),
}
MARKERS = ['YYYY', 'MM', 'DD']
SEPARATORS = ['-', '/', '.']
def __init__(self, locale='en_us'):
self.locale = locales.get_locale(locale)
self._input_re_map = self._BASE_INPUT_RE_MAP.copy()
self._input_re_map.update({
'MMMM': self._choice_re(self.locale.month_names[1:], re.IGNORECASE),
'MMM': self._choice_re(self.locale.month_abbreviations[1:],
re.IGNORECASE),
'Do': re.compile(self.locale.ordinal_day_re),
'a': self._choice_re(
(self.locale.meridians['am'], self.locale.meridians['pm'])
),
# note: 'A' token accepts both 'am/pm' and 'AM/PM' formats to
# ensure backwards compatibility of this token
'A': self._choice_re(self.locale.meridians.values())
})
def parse_iso(self, string):
has_time = 'T' in string or ' ' in string.strip()
space_divider = ' ' in string.strip()
if has_time:
if space_divider:
date_string, time_string = string.split(' ', 1)
else:
date_string, time_string = string.split('T', 1)
time_parts = re.split('[+-]', time_string, 1)
has_tz = len(time_parts) > 1
has_seconds = time_parts[0].count(':') > 1
has_subseconds = '.' in time_parts[0]
if has_subseconds:
subseconds_token = 'S' * min(len(re.split('\D+', time_parts[0].split('.')[1], 1)[0]), 6)
formats = ['YYYY-MM-DDTHH:mm:ss.%s' % subseconds_token]
elif has_seconds:
formats = ['YYYY-MM-DDTHH:mm:ss']
else:
formats = ['YYYY-MM-DDTHH:mm']
else:
has_tz = False
# generate required formats: YYYY-MM-DD, YYYY-MM-DD, YYYY
# using various separators: -, /, .
l = len(self.MARKERS)
formats = [separator.join(self.MARKERS[:l-i])
for i in range(l)
for separator in self.SEPARATORS]
if has_time and has_tz:
formats = [f + 'Z' for f in formats]
if space_divider:
formats = [item.replace('T', ' ', 1) for item in formats]
return self._parse_multiformat(string, formats)
def parse(self, string, fmt):
if isinstance(fmt, list):
return self._parse_multiformat(string, fmt)
# fmt is a string of tokens like 'YYYY-MM-DD'
# we construct a new string by replacing each
# token by its pattern:
# 'YYYY-MM-DD' -> '(?P<YYYY>\d{4})-(?P<MM>\d{2})-(?P<DD>\d{2})'
fmt_pattern = fmt
tokens = []
offset = 0
for m in self._FORMAT_RE.finditer(fmt):
token = m.group(0)
try:
input_re = self._input_re_map[token]
except KeyError:
raise ParserError('Unrecognized token \'{0}\''.format(token))
input_pattern = '(?P<{0}>{1})'.format(token, input_re.pattern)
tokens.append(token)
# a pattern doesn't have the same length as the token
# it replaces! We keep the difference in the offset variable.
# This works because the string is scanned left-to-right and matches
# are returned in the order found by finditer.
fmt_pattern = fmt_pattern[:m.start() + offset] + input_pattern + fmt_pattern[m.end() + offset:]
offset += len(input_pattern) - (m.end() - m.start())
match = re.search(fmt_pattern, string, flags=re.IGNORECASE)
if match is None:
raise ParserError('Failed to match \'{0}\' when parsing \'{1}\''.format(fmt_pattern, string))
parts = {}
for token in tokens:
if token == 'Do':
value = match.group('value')
else:
value = match.group(token)
self._parse_token(token, value, parts)
return self._build_datetime(parts)
def _parse_token(self, token, value, parts):
if token == 'YYYY':
parts['year'] = int(value)
elif token == 'YY':
value = int(value)
parts['year'] = 1900 + value if value > 68 else 2000 + value
elif token in ['MMMM', 'MMM']:
parts['month'] = self.locale.month_number(value.lower())
elif token in ['MM', 'M']:
parts['month'] = int(value)
elif token in ['DD', 'D']:
parts['day'] = int(value)
elif token in ['Do']:
parts['day'] = int(value)
elif token.upper() in ['HH', 'H']:
parts['hour'] = int(value)
elif token in ['mm', 'm']:
parts['minute'] = int(value)
elif token in ['ss', 's']:
parts['second'] = int(value)
elif token == 'SSSSSS':
parts['microsecond'] = int(value)
elif token == 'SSSSS':
parts['microsecond'] = int(value) * 10
elif token == 'SSSS':
parts['microsecond'] = int(value) * 100
elif token == 'SSS':
parts['microsecond'] = int(value) * 1000
elif token == 'SS':
parts['microsecond'] = int(value) * 10000
elif token == 'S':
parts['microsecond'] = int(value) * 100000
elif token == 'X':
parts['timestamp'] = int(value)
elif token in ['ZZZ', 'ZZ', 'Z']:
parts['tzinfo'] = TzinfoParser.parse(value)
elif token in ['a', 'A']:
if value in (
self.locale.meridians['am'],
self.locale.meridians['AM']
):
parts['am_pm'] = 'am'
elif value in (
self.locale.meridians['pm'],
self.locale.meridians['PM']
):
parts['am_pm'] = 'pm'
@staticmethod
def _build_datetime(parts):
timestamp = parts.get('timestamp')
if timestamp:
tz_utc = tz.tzutc()
return datetime.fromtimestamp(timestamp, tz=tz_utc)
am_pm = parts.get('am_pm')
hour = parts.get('hour', 0)
if am_pm == 'pm' and hour < 12:
hour += 12
elif am_pm == 'am' and hour == 12:
hour = 0
return datetime(year=parts.get('year', 1), month=parts.get('month', 1),
day=parts.get('day', 1), hour=hour, minute=parts.get('minute', 0),
second=parts.get('second', 0), microsecond=parts.get('microsecond', 0),
tzinfo=parts.get('tzinfo'))
def _parse_multiformat(self, string, formats):
_datetime = None
for fmt in formats:
try:
_datetime = self.parse(string, fmt)
break
except:
pass
if _datetime is None:
raise ParserError('Could not match input to any of {0} on \'{1}\''.format(formats, string))
return _datetime
@staticmethod
def _map_lookup(input_map, key):
try:
return input_map[key]
except KeyError:
raise ParserError('Could not match "{0}" to {1}'.format(key, input_map))
@staticmethod
def _try_timestamp(string):
try:
return float(string)
except:
return None
@staticmethod
def _choice_re(choices, flags=0):
return re.compile('({0})'.format('|'.join(choices)), flags=flags)
class TzinfoParser(object):
_TZINFO_RE = re.compile('([+\-])?(\d\d):?(\d\d)')
@classmethod
def parse(cls, string):
tzinfo = None
if string == 'local':
tzinfo = tz.tzlocal()
elif string in ['utc', 'UTC']:
tzinfo = tz.tzutc()
else:
iso_match = cls._TZINFO_RE.match(string)
if iso_match:
sign, hours, minutes = iso_match.groups()
seconds = int(hours) * 3600 + int(minutes) * 60
if sign == '-':
seconds *= -1
tzinfo = tz.tzoffset(None, seconds)
else:
tzinfo = tz.gettz(string)
if tzinfo is None:
raise ParserError('Could not parse timezone expression "{0}"', string)
return tzinfo

45
lib/arrow/util.py Normal file
View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import sys
# python 2.6 / 2.7 definitions for total_seconds function.
def _total_seconds_27(td): # pragma: no cover
return td.total_seconds()
def _total_seconds_26(td):
return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 1e6) / 1e6
# get version info and assign correct total_seconds function.
version = '{0}.{1}.{2}'.format(*sys.version_info[:3])
if version < '2.7': # pragma: no cover
total_seconds = _total_seconds_26
else: # pragma: no cover
total_seconds = _total_seconds_27
def is_timestamp(value):
try:
float(value)
return True
except:
return False
# python 2.7 / 3.0+ definitions for isstr function.
try: # pragma: no cover
basestring
def isstr(s):
return isinstance(s, basestring)
except NameError: #pragma: no cover
def isstr(s):
return isinstance(s, str)
__all__ = ['total_seconds', 'is_timestamp', 'isstr']

View File

@@ -1,26 +1,21 @@
# -*- coding: latin-1 -*-
#
# Copyright (C) Martin Sj<53>gren and AB Strakt 2001, All rights reserved
# Copyright (C) Jean-Paul Calderone 2008, All rights reserved
# This file is licenced under the GNU LESSER GENERAL PUBLIC LICENSE Version 2.1 or later (aka LGPL v2.1)
# Please see LGPL2.1.txt for more information
# Copyright (C) AB Strakt
# Copyright (C) Jean-Paul Calderone
# See LICENSE for details.
"""
Certificate generation module.
"""
from OpenSSL import crypto
import time
TYPE_RSA = crypto.TYPE_RSA
TYPE_DSA = crypto.TYPE_DSA
serial = int(time.time())
def createKeyPair(type, bits):
"""
Create a public/private key pair.
Arguments: type - Key type, must be one of TYPE_RSA and TYPE_DSA
bits - Number of bits to use in the key
Returns: The public/private key pair in a PKey object
@@ -29,12 +24,11 @@ def createKeyPair(type, bits):
pkey.generate_key(type, bits)
return pkey
def createCertRequest(pkey, digest="md5", **name):
def createCertRequest(pkey, digest="sha256", **name):
"""
Create a certificate request.
Arguments: pkey - The key to associate with the request
digest - Digestion method to use for signing, default is md5
digest - Digestion method to use for signing, default is sha256
**name - The name of the subject of the request, possible
arguments are:
C - Country name
@@ -49,18 +43,17 @@ def createCertRequest(pkey, digest="md5", **name):
req = crypto.X509Req()
subj = req.get_subject()
for (key,value) in name.items():
for key, value in name.items():
setattr(subj, key, value)
req.set_pubkey(pkey)
req.sign(pkey, digest)
return req
def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter), digest="md5"):
def createCertificate(req, issuerCertKey, serial, validityPeriod, digest="sha256"):
"""
Generate a certificate given a certificate request.
Arguments: req - Certificate reqeust to use
Arguments: req - Certificate request to use
issuerCert - The certificate of the issuer
issuerKey - The private key of the issuer
serial - Serial number for the certificate
@@ -68,9 +61,11 @@ def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter
starts being valid
notAfter - Timestamp (relative to now) when the certificate
stops being valid
digest - Digest method to use for signing, default is md5
digest - Digest method to use for signing, default is sha256
Returns: The signed certificate in an X509 object
"""
issuerCert, issuerKey = issuerCertKey
notBefore, notAfter = validityPeriod
cert = crypto.X509()
cert.set_serial_number(serial)
cert.gmtime_adj_notBefore(notBefore)
@@ -80,3 +75,32 @@ def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter
cert.set_pubkey(req.get_pubkey())
cert.sign(issuerKey, digest)
return cert
def createSelfSignedCertificate((issuerName, issuerKey), serial, (notBefore, notAfter), altNames, digest="sha256"):
"""
Generate a certificate given a certificate request.
Arguments: issuerName - The name of the issuer
issuerKey - The private key of the issuer
serial - Serial number for the certificate
notBefore - Timestamp (relative to now) when the certificate
starts being valid
notAfter - Timestamp (relative to now) when the certificate
stops being valid
altNames - The alternative names
digest - Digest method to use for signing, default is sha256
Returns: The signed certificate in an X509 object
"""
cert = crypto.X509()
cert.set_version(2)
cert.set_serial_number(serial)
cert.get_subject().CN = issuerName
cert.gmtime_adj_notBefore(notBefore)
cert.gmtime_adj_notAfter(notAfter)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(issuerKey)
if altNames:
cert.add_extensions([crypto.X509Extension("subjectAltName", False, altNames)])
cert.sign(issuerKey, digest)
return cert

2
lib/dateutil/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
__version__ = "2.4.2"

89
lib/dateutil/easter.py Normal file
View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
This module offers a generic easter computing method for any given year, using
Western, Orthodox or Julian algorithms.
"""
import datetime
__all__ = ["easter", "EASTER_JULIAN", "EASTER_ORTHODOX", "EASTER_WESTERN"]
EASTER_JULIAN = 1
EASTER_ORTHODOX = 2
EASTER_WESTERN = 3
def easter(year, method=EASTER_WESTERN):
"""
This method was ported from the work done by GM Arts,
on top of the algorithm by Claus Tondering, which was
based in part on the algorithm of Ouding (1940), as
quoted in "Explanatory Supplement to the Astronomical
Almanac", P. Kenneth Seidelmann, editor.
This algorithm implements three different easter
calculation methods:
1 - Original calculation in Julian calendar, valid in
dates after 326 AD
2 - Original method, with date converted to Gregorian
calendar, valid in years 1583 to 4099
3 - Revised method, in Gregorian calendar, valid in
years 1583 to 4099 as well
These methods are represented by the constants:
EASTER_JULIAN = 1
EASTER_ORTHODOX = 2
EASTER_WESTERN = 3
The default method is method 3.
More about the algorithm may be found at:
http://users.chariot.net.au/~gmarts/eastalg.htm
and
http://www.tondering.dk/claus/calendar.html
"""
if not (1 <= method <= 3):
raise ValueError("invalid method")
# g - Golden year - 1
# c - Century
# h - (23 - Epact) mod 30
# i - Number of days from March 21 to Paschal Full Moon
# j - Weekday for PFM (0=Sunday, etc)
# p - Number of days from March 21 to Sunday on or before PFM
# (-6 to 28 methods 1 & 3, to 56 for method 2)
# e - Extra days to add for method 2 (converting Julian
# date to Gregorian date)
y = year
g = y % 19
e = 0
if method < 3:
# Old method
i = (19*g + 15) % 30
j = (y + y//4 + i) % 7
if method == 2:
# Extra dates to convert Julian to Gregorian date
e = 10
if y > 1600:
e = e + y//100 - 16 - (y//100 - 16)//4
else:
# New method
c = y//100
h = (c - c//4 - (8*c + 13)//25 + 19*g + 15) % 30
i = h - (h//28)*(1 - (h//28)*(29//(h + 1))*((21 - g)//11))
j = (y + y//4 + i + 2 - c + c//4) % 7
# p can be from -6 to 56 corresponding to dates 22 March to 23 May
# (later dates apply to method 2, although 23 May never actually occurs)
p = i - j + e
d = 1 + (p + 27 + (p + 6)//40) % 31
m = 3 + (p + 26)//30
return datetime.date(int(y), int(m), int(d))

1205
lib/dateutil/parser.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,450 @@
# -*- coding: utf-8 -*-
import datetime
import calendar
from six import integer_types
__all__ = ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"]
class weekday(object):
__slots__ = ["weekday", "n"]
def __init__(self, weekday, n=None):
self.weekday = weekday
self.n = n
def __call__(self, n):
if n == self.n:
return self
else:
return self.__class__(self.weekday, n)
def __eq__(self, other):
try:
if self.weekday != other.weekday or self.n != other.n:
return False
except AttributeError:
return False
return True
def __repr__(self):
s = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[self.weekday]
if not self.n:
return s
else:
return "%s(%+d)" % (s, self.n)
MO, TU, WE, TH, FR, SA, SU = weekdays = tuple([weekday(x) for x in range(7)])
class relativedelta(object):
"""
The relativedelta type is based on the specification of the excellent
work done by M.-A. Lemburg in his
`mx.DateTime <http://www.egenix.com/files/python/mxDateTime.html>`_ extension.
However, notice that this type does *NOT* implement the same algorithm as
his work. Do *NOT* expect it to behave like mx.DateTime's counterpart.
There are two different ways to build a relativedelta instance. The
first one is passing it two date/datetime classes::
relativedelta(datetime1, datetime2)
The second one is passing it any number of the following keyword arguments::
relativedelta(arg1=x,arg2=y,arg3=z...)
year, month, day, hour, minute, second, microsecond:
Absolute information (argument is singular); adding or subtracting a
relativedelta with absolute information does not perform an aritmetic
operation, but rather REPLACES the corresponding value in the
original datetime with the value(s) in relativedelta.
years, months, weeks, days, hours, minutes, seconds, microseconds:
Relative information, may be negative (argument is plural); adding
or subtracting a relativedelta with relative information performs
the corresponding aritmetic operation on the original datetime value
with the information in the relativedelta.
weekday:
One of the weekday instances (MO, TU, etc). These instances may
receive a parameter N, specifying the Nth weekday, which could
be positive or negative (like MO(+1) or MO(-2). Not specifying
it is the same as specifying +1. You can also use an integer,
where 0=MO.
leapdays:
Will add given days to the date found, if year is a leap
year, and the date found is post 28 of february.
yearday, nlyearday:
Set the yearday or the non-leap year day (jump leap days).
These are converted to day/month/leapdays information.
Here is the behavior of operations with relativedelta:
1. Calculate the absolute year, using the 'year' argument, or the
original datetime year, if the argument is not present.
2. Add the relative 'years' argument to the absolute year.
3. Do steps 1 and 2 for month/months.
4. Calculate the absolute day, using the 'day' argument, or the
original datetime day, if the argument is not present. Then,
subtract from the day until it fits in the year and month
found after their operations.
5. Add the relative 'days' argument to the absolute day. Notice
that the 'weeks' argument is multiplied by 7 and added to
'days'.
6. Do steps 1 and 2 for hour/hours, minute/minutes, second/seconds,
microsecond/microseconds.
7. If the 'weekday' argument is present, calculate the weekday,
with the given (wday, nth) tuple. wday is the index of the
weekday (0-6, 0=Mon), and nth is the number of weeks to add
forward or backward, depending on its signal. Notice that if
the calculated date is already Monday, for example, using
(0, 1) or (0, -1) won't change the day.
"""
def __init__(self, dt1=None, dt2=None,
years=0, months=0, days=0, leapdays=0, weeks=0,
hours=0, minutes=0, seconds=0, microseconds=0,
year=None, month=None, day=None, weekday=None,
yearday=None, nlyearday=None,
hour=None, minute=None, second=None, microsecond=None):
if dt1 and dt2:
# datetime is a subclass of date. So both must be date
if not (isinstance(dt1, datetime.date) and
isinstance(dt2, datetime.date)):
raise TypeError("relativedelta only diffs datetime/date")
# We allow two dates, or two datetimes, so we coerce them to be
# of the same type
if (isinstance(dt1, datetime.datetime) !=
isinstance(dt2, datetime.datetime)):
if not isinstance(dt1, datetime.datetime):
dt1 = datetime.datetime.fromordinal(dt1.toordinal())
elif not isinstance(dt2, datetime.datetime):
dt2 = datetime.datetime.fromordinal(dt2.toordinal())
self.years = 0
self.months = 0
self.days = 0
self.leapdays = 0
self.hours = 0
self.minutes = 0
self.seconds = 0
self.microseconds = 0
self.year = None
self.month = None
self.day = None
self.weekday = None
self.hour = None
self.minute = None
self.second = None
self.microsecond = None
self._has_time = 0
months = (dt1.year*12+dt1.month)-(dt2.year*12+dt2.month)
self._set_months(months)
dtm = self.__radd__(dt2)
if dt1 < dt2:
while dt1 > dtm:
months += 1
self._set_months(months)
dtm = self.__radd__(dt2)
else:
while dt1 < dtm:
months -= 1
self._set_months(months)
dtm = self.__radd__(dt2)
delta = dt1 - dtm
self.seconds = delta.seconds+delta.days*86400
self.microseconds = delta.microseconds
else:
self.years = years
self.months = months
self.days = days+weeks*7
self.leapdays = leapdays
self.hours = hours
self.minutes = minutes
self.seconds = seconds
self.microseconds = microseconds
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.microsecond = microsecond
if isinstance(weekday, integer_types):
self.weekday = weekdays[weekday]
else:
self.weekday = weekday
yday = 0
if nlyearday:
yday = nlyearday
elif yearday:
yday = yearday
if yearday > 59:
self.leapdays = -1
if yday:
ydayidx = [31, 59, 90, 120, 151, 181, 212,
243, 273, 304, 334, 366]
for idx, ydays in enumerate(ydayidx):
if yday <= ydays:
self.month = idx+1
if idx == 0:
self.day = yday
else:
self.day = yday-ydayidx[idx-1]
break
else:
raise ValueError("invalid year day (%d)" % yday)
self._fix()
def _fix(self):
if abs(self.microseconds) > 999999:
s = self.microseconds//abs(self.microseconds)
div, mod = divmod(self.microseconds*s, 1000000)
self.microseconds = mod*s
self.seconds += div*s
if abs(self.seconds) > 59:
s = self.seconds//abs(self.seconds)
div, mod = divmod(self.seconds*s, 60)
self.seconds = mod*s
self.minutes += div*s
if abs(self.minutes) > 59:
s = self.minutes//abs(self.minutes)
div, mod = divmod(self.minutes*s, 60)
self.minutes = mod*s
self.hours += div*s
if abs(self.hours) > 23:
s = self.hours//abs(self.hours)
div, mod = divmod(self.hours*s, 24)
self.hours = mod*s
self.days += div*s
if abs(self.months) > 11:
s = self.months//abs(self.months)
div, mod = divmod(self.months*s, 12)
self.months = mod*s
self.years += div*s
if (self.hours or self.minutes or self.seconds or self.microseconds
or self.hour is not None or self.minute is not None or
self.second is not None or self.microsecond is not None):
self._has_time = 1
else:
self._has_time = 0
def _set_months(self, months):
self.months = months
if abs(self.months) > 11:
s = self.months//abs(self.months)
div, mod = divmod(self.months*s, 12)
self.months = mod*s
self.years = div*s
else:
self.years = 0
def __add__(self, other):
if isinstance(other, relativedelta):
return relativedelta(years=other.years+self.years,
months=other.months+self.months,
days=other.days+self.days,
hours=other.hours+self.hours,
minutes=other.minutes+self.minutes,
seconds=other.seconds+self.seconds,
microseconds=(other.microseconds +
self.microseconds),
leapdays=other.leapdays or self.leapdays,
year=other.year or self.year,
month=other.month or self.month,
day=other.day or self.day,
weekday=other.weekday or self.weekday,
hour=other.hour or self.hour,
minute=other.minute or self.minute,
second=other.second or self.second,
microsecond=(other.microsecond or
self.microsecond))
if not isinstance(other, datetime.date):
raise TypeError("unsupported type for add operation")
elif self._has_time and not isinstance(other, datetime.datetime):
other = datetime.datetime.fromordinal(other.toordinal())
year = (self.year or other.year)+self.years
month = self.month or other.month
if self.months:
assert 1 <= abs(self.months) <= 12
month += self.months
if month > 12:
year += 1
month -= 12
elif month < 1:
year -= 1
month += 12
day = min(calendar.monthrange(year, month)[1],
self.day or other.day)
repl = {"year": year, "month": month, "day": day}
for attr in ["hour", "minute", "second", "microsecond"]:
value = getattr(self, attr)
if value is not None:
repl[attr] = value
days = self.days
if self.leapdays and month > 2 and calendar.isleap(year):
days += self.leapdays
ret = (other.replace(**repl)
+ datetime.timedelta(days=days,
hours=self.hours,
minutes=self.minutes,
seconds=self.seconds,
microseconds=self.microseconds))
if self.weekday:
weekday, nth = self.weekday.weekday, self.weekday.n or 1
jumpdays = (abs(nth)-1)*7
if nth > 0:
jumpdays += (7-ret.weekday()+weekday) % 7
else:
jumpdays += (ret.weekday()-weekday) % 7
jumpdays *= -1
ret += datetime.timedelta(days=jumpdays)
return ret
def __radd__(self, other):
return self.__add__(other)
def __rsub__(self, other):
return self.__neg__().__radd__(other)
def __sub__(self, other):
if not isinstance(other, relativedelta):
raise TypeError("unsupported type for sub operation")
return relativedelta(years=self.years-other.years,
months=self.months-other.months,
days=self.days-other.days,
hours=self.hours-other.hours,
minutes=self.minutes-other.minutes,
seconds=self.seconds-other.seconds,
microseconds=self.microseconds-other.microseconds,
leapdays=self.leapdays or other.leapdays,
year=self.year or other.year,
month=self.month or other.month,
day=self.day or other.day,
weekday=self.weekday or other.weekday,
hour=self.hour or other.hour,
minute=self.minute or other.minute,
second=self.second or other.second,
microsecond=self.microsecond or other.microsecond)
def __neg__(self):
return relativedelta(years=-self.years,
months=-self.months,
days=-self.days,
hours=-self.hours,
minutes=-self.minutes,
seconds=-self.seconds,
microseconds=-self.microseconds,
leapdays=self.leapdays,
year=self.year,
month=self.month,
day=self.day,
weekday=self.weekday,
hour=self.hour,
minute=self.minute,
second=self.second,
microsecond=self.microsecond)
def __bool__(self):
return not (not self.years and
not self.months and
not self.days and
not self.hours and
not self.minutes and
not self.seconds and
not self.microseconds and
not self.leapdays and
self.year is None and
self.month is None and
self.day is None and
self.weekday is None and
self.hour is None and
self.minute is None and
self.second is None and
self.microsecond is None)
# Compatibility with Python 2.x
__nonzero__ = __bool__
def __mul__(self, other):
f = float(other)
return relativedelta(years=int(self.years*f),
months=int(self.months*f),
days=int(self.days*f),
hours=int(self.hours*f),
minutes=int(self.minutes*f),
seconds=int(self.seconds*f),
microseconds=int(self.microseconds*f),
leapdays=self.leapdays,
year=self.year,
month=self.month,
day=self.day,
weekday=self.weekday,
hour=self.hour,
minute=self.minute,
second=self.second,
microsecond=self.microsecond)
__rmul__ = __mul__
def __eq__(self, other):
if not isinstance(other, relativedelta):
return False
if self.weekday or other.weekday:
if not self.weekday or not other.weekday:
return False
if self.weekday.weekday != other.weekday.weekday:
return False
n1, n2 = self.weekday.n, other.weekday.n
if n1 != n2 and not ((not n1 or n1 == 1) and (not n2 or n2 == 1)):
return False
return (self.years == other.years and
self.months == other.months and
self.days == other.days and
self.hours == other.hours and
self.minutes == other.minutes and
self.seconds == other.seconds and
self.leapdays == other.leapdays and
self.year == other.year and
self.month == other.month and
self.day == other.day and
self.hour == other.hour and
self.minute == other.minute and
self.second == other.second and
self.microsecond == other.microsecond)
def __ne__(self, other):
return not self.__eq__(other)
def __div__(self, other):
return self.__mul__(1/float(other))
__truediv__ = __div__
def __repr__(self):
l = []
for attr in ["years", "months", "days", "leapdays",
"hours", "minutes", "seconds", "microseconds"]:
value = getattr(self, attr)
if value:
l.append("%s=%+d" % (attr, value))
for attr in ["year", "month", "day", "weekday",
"hour", "minute", "second", "microsecond"]:
value = getattr(self, attr)
if value is not None:
l.append("%s=%s" % (attr, repr(value)))
return "%s(%s)" % (self.__class__.__name__, ", ".join(l))
# vim:ts=4:sw=4:et

1375
lib/dateutil/rrule.py Normal file

File diff suppressed because it is too large Load Diff

986
lib/dateutil/tz.py Normal file
View File

@@ -0,0 +1,986 @@
# -*- coding: utf-8 -*-
"""
This module offers timezone implementations subclassing the abstract
:py:`datetime.tzinfo` type. There are classes to handle tzfile format files
(usually are in :file:`/etc/localtime`, :file:`/usr/share/zoneinfo`, etc), TZ
environment string (in all known formats), given ranges (with help from
relative deltas), local machine timezone, fixed offset timezone, and UTC
timezone.
"""
import datetime
import struct
import time
import sys
import os
from six import string_types, PY3
try:
from dateutil.tzwin import tzwin, tzwinlocal
except ImportError:
tzwin = tzwinlocal = None
relativedelta = None
parser = None
rrule = None
__all__ = ["tzutc", "tzoffset", "tzlocal", "tzfile", "tzrange",
"tzstr", "tzical", "tzwin", "tzwinlocal", "gettz"]
def tzname_in_python2(myfunc):
"""Change unicode output into bytestrings in Python 2
tzname() API changed in Python 3. It used to return bytes, but was changed
to unicode strings
"""
def inner_func(*args, **kwargs):
if PY3:
return myfunc(*args, **kwargs)
else:
return myfunc(*args, **kwargs).encode()
return inner_func
ZERO = datetime.timedelta(0)
EPOCHORDINAL = datetime.datetime.utcfromtimestamp(0).toordinal()
class tzutc(datetime.tzinfo):
def utcoffset(self, dt):
return ZERO
def dst(self, dt):
return ZERO
@tzname_in_python2
def tzname(self, dt):
return "UTC"
def __eq__(self, other):
return (isinstance(other, tzutc) or
(isinstance(other, tzoffset) and other._offset == ZERO))
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return "%s()" % self.__class__.__name__
__reduce__ = object.__reduce__
class tzoffset(datetime.tzinfo):
def __init__(self, name, offset):
self._name = name
self._offset = datetime.timedelta(seconds=offset)
def utcoffset(self, dt):
return self._offset
def dst(self, dt):
return ZERO
@tzname_in_python2
def tzname(self, dt):
return self._name
def __eq__(self, other):
return (isinstance(other, tzoffset) and
self._offset == other._offset)
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return "%s(%s, %s)" % (self.__class__.__name__,
repr(self._name),
self._offset.days*86400+self._offset.seconds)
__reduce__ = object.__reduce__
class tzlocal(datetime.tzinfo):
_std_offset = datetime.timedelta(seconds=-time.timezone)
if time.daylight:
_dst_offset = datetime.timedelta(seconds=-time.altzone)
else:
_dst_offset = _std_offset
def utcoffset(self, dt):
if self._isdst(dt):
return self._dst_offset
else:
return self._std_offset
def dst(self, dt):
if self._isdst(dt):
return self._dst_offset-self._std_offset
else:
return ZERO
@tzname_in_python2
def tzname(self, dt):
return time.tzname[self._isdst(dt)]
def _isdst(self, dt):
# We can't use mktime here. It is unstable when deciding if
# the hour near to a change is DST or not.
#
# timestamp = time.mktime((dt.year, dt.month, dt.day, dt.hour,
# dt.minute, dt.second, dt.weekday(), 0, -1))
# return time.localtime(timestamp).tm_isdst
#
# The code above yields the following result:
#
# >>> import tz, datetime
# >>> t = tz.tzlocal()
# >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname()
# 'BRDT'
# >>> datetime.datetime(2003,2,16,0,tzinfo=t).tzname()
# 'BRST'
# >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname()
# 'BRST'
# >>> datetime.datetime(2003,2,15,22,tzinfo=t).tzname()
# 'BRDT'
# >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname()
# 'BRDT'
#
# Here is a more stable implementation:
#
timestamp = ((dt.toordinal() - EPOCHORDINAL) * 86400
+ dt.hour * 3600
+ dt.minute * 60
+ dt.second)
return time.localtime(timestamp+time.timezone).tm_isdst
def __eq__(self, other):
if not isinstance(other, tzlocal):
return False
return (self._std_offset == other._std_offset and
self._dst_offset == other._dst_offset)
return True
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return "%s()" % self.__class__.__name__
__reduce__ = object.__reduce__
class _ttinfo(object):
__slots__ = ["offset", "delta", "isdst", "abbr", "isstd", "isgmt"]
def __init__(self):
for attr in self.__slots__:
setattr(self, attr, None)
def __repr__(self):
l = []
for attr in self.__slots__:
value = getattr(self, attr)
if value is not None:
l.append("%s=%s" % (attr, repr(value)))
return "%s(%s)" % (self.__class__.__name__, ", ".join(l))
def __eq__(self, other):
if not isinstance(other, _ttinfo):
return False
return (self.offset == other.offset and
self.delta == other.delta and
self.isdst == other.isdst and
self.abbr == other.abbr and
self.isstd == other.isstd and
self.isgmt == other.isgmt)
def __ne__(self, other):
return not self.__eq__(other)
def __getstate__(self):
state = {}
for name in self.__slots__:
state[name] = getattr(self, name, None)
return state
def __setstate__(self, state):
for name in self.__slots__:
if name in state:
setattr(self, name, state[name])
class tzfile(datetime.tzinfo):
# http://www.twinsun.com/tz/tz-link.htm
# ftp://ftp.iana.org/tz/tz*.tar.gz
def __init__(self, fileobj, filename=None):
file_opened_here = False
if isinstance(fileobj, string_types):
self._filename = fileobj
fileobj = open(fileobj, 'rb')
file_opened_here = True
elif filename is not None:
self._filename = filename
elif hasattr(fileobj, "name"):
self._filename = fileobj.name
else:
self._filename = repr(fileobj)
# From tzfile(5):
#
# The time zone information files used by tzset(3)
# begin with the magic characters "TZif" to identify
# them as time zone information files, followed by
# sixteen bytes reserved for future use, followed by
# six four-byte values of type long, written in a
# ``standard'' byte order (the high-order byte
# of the value is written first).
try:
if fileobj.read(4).decode() != "TZif":
raise ValueError("magic not found")
fileobj.read(16)
(
# The number of UTC/local indicators stored in the file.
ttisgmtcnt,
# The number of standard/wall indicators stored in the file.
ttisstdcnt,
# The number of leap seconds for which data is
# stored in the file.
leapcnt,
# The number of "transition times" for which data
# is stored in the file.
timecnt,
# The number of "local time types" for which data
# is stored in the file (must not be zero).
typecnt,
# The number of characters of "time zone
# abbreviation strings" stored in the file.
charcnt,
) = struct.unpack(">6l", fileobj.read(24))
# The above header is followed by tzh_timecnt four-byte
# values of type long, sorted in ascending order.
# These values are written in ``standard'' byte order.
# Each is used as a transition time (as returned by
# time(2)) at which the rules for computing local time
# change.
if timecnt:
self._trans_list = struct.unpack(">%dl" % timecnt,
fileobj.read(timecnt*4))
else:
self._trans_list = []
# Next come tzh_timecnt one-byte values of type unsigned
# char; each one tells which of the different types of
# ``local time'' types described in the file is associated
# with the same-indexed transition time. These values
# serve as indices into an array of ttinfo structures that
# appears next in the file.
if timecnt:
self._trans_idx = struct.unpack(">%dB" % timecnt,
fileobj.read(timecnt))
else:
self._trans_idx = []
# Each ttinfo structure is written as a four-byte value
# for tt_gmtoff of type long, in a standard byte
# order, followed by a one-byte value for tt_isdst
# and a one-byte value for tt_abbrind. In each
# structure, tt_gmtoff gives the number of
# seconds to be added to UTC, tt_isdst tells whether
# tm_isdst should be set by localtime(3), and
# tt_abbrind serves as an index into the array of
# time zone abbreviation characters that follow the
# ttinfo structure(s) in the file.
ttinfo = []
for i in range(typecnt):
ttinfo.append(struct.unpack(">lbb", fileobj.read(6)))
abbr = fileobj.read(charcnt).decode()
# Then there are tzh_leapcnt pairs of four-byte
# values, written in standard byte order; the
# first value of each pair gives the time (as
# returned by time(2)) at which a leap second
# occurs; the second gives the total number of
# leap seconds to be applied after the given time.
# The pairs of values are sorted in ascending order
# by time.
# Not used, for now
# if leapcnt:
# leap = struct.unpack(">%dl" % (leapcnt*2),
# fileobj.read(leapcnt*8))
# Then there are tzh_ttisstdcnt standard/wall
# indicators, each stored as a one-byte value;
# they tell whether the transition times associated
# with local time types were specified as standard
# time or wall clock time, and are used when
# a time zone file is used in handling POSIX-style
# time zone environment variables.
if ttisstdcnt:
isstd = struct.unpack(">%db" % ttisstdcnt,
fileobj.read(ttisstdcnt))
# Finally, there are tzh_ttisgmtcnt UTC/local
# indicators, each stored as a one-byte value;
# they tell whether the transition times associated
# with local time types were specified as UTC or
# local time, and are used when a time zone file
# is used in handling POSIX-style time zone envi-
# ronment variables.
if ttisgmtcnt:
isgmt = struct.unpack(">%db" % ttisgmtcnt,
fileobj.read(ttisgmtcnt))
# ** Everything has been read **
finally:
if file_opened_here:
fileobj.close()
# Build ttinfo list
self._ttinfo_list = []
for i in range(typecnt):
gmtoff, isdst, abbrind = ttinfo[i]
# Round to full-minutes if that's not the case. Python's
# datetime doesn't accept sub-minute timezones. Check
# http://python.org/sf/1447945 for some information.
gmtoff = (gmtoff+30)//60*60
tti = _ttinfo()
tti.offset = gmtoff
tti.delta = datetime.timedelta(seconds=gmtoff)
tti.isdst = isdst
tti.abbr = abbr[abbrind:abbr.find('\x00', abbrind)]
tti.isstd = (ttisstdcnt > i and isstd[i] != 0)
tti.isgmt = (ttisgmtcnt > i and isgmt[i] != 0)
self._ttinfo_list.append(tti)
# Replace ttinfo indexes for ttinfo objects.
trans_idx = []
for idx in self._trans_idx:
trans_idx.append(self._ttinfo_list[idx])
self._trans_idx = tuple(trans_idx)
# Set standard, dst, and before ttinfos. before will be
# used when a given time is before any transitions,
# and will be set to the first non-dst ttinfo, or to
# the first dst, if all of them are dst.
self._ttinfo_std = None
self._ttinfo_dst = None
self._ttinfo_before = None
if self._ttinfo_list:
if not self._trans_list:
self._ttinfo_std = self._ttinfo_first = self._ttinfo_list[0]
else:
for i in range(timecnt-1, -1, -1):
tti = self._trans_idx[i]
if not self._ttinfo_std and not tti.isdst:
self._ttinfo_std = tti
elif not self._ttinfo_dst and tti.isdst:
self._ttinfo_dst = tti
if self._ttinfo_std and self._ttinfo_dst:
break
else:
if self._ttinfo_dst and not self._ttinfo_std:
self._ttinfo_std = self._ttinfo_dst
for tti in self._ttinfo_list:
if not tti.isdst:
self._ttinfo_before = tti
break
else:
self._ttinfo_before = self._ttinfo_list[0]
# Now fix transition times to become relative to wall time.
#
# I'm not sure about this. In my tests, the tz source file
# is setup to wall time, and in the binary file isstd and
# isgmt are off, so it should be in wall time. OTOH, it's
# always in gmt time. Let me know if you have comments
# about this.
laststdoffset = 0
self._trans_list = list(self._trans_list)
for i in range(len(self._trans_list)):
tti = self._trans_idx[i]
if not tti.isdst:
# This is std time.
self._trans_list[i] += tti.offset
laststdoffset = tti.offset
else:
# This is dst time. Convert to std.
self._trans_list[i] += laststdoffset
self._trans_list = tuple(self._trans_list)
def _find_ttinfo(self, dt, laststd=0):
timestamp = ((dt.toordinal() - EPOCHORDINAL) * 86400
+ dt.hour * 3600
+ dt.minute * 60
+ dt.second)
idx = 0
for trans in self._trans_list:
if timestamp < trans:
break
idx += 1
else:
return self._ttinfo_std
if idx == 0:
return self._ttinfo_before
if laststd:
while idx > 0:
tti = self._trans_idx[idx-1]
if not tti.isdst:
return tti
idx -= 1
else:
return self._ttinfo_std
else:
return self._trans_idx[idx-1]
def utcoffset(self, dt):
if not self._ttinfo_std:
return ZERO
return self._find_ttinfo(dt).delta
def dst(self, dt):
if not self._ttinfo_dst:
return ZERO
tti = self._find_ttinfo(dt)
if not tti.isdst:
return ZERO
# The documentation says that utcoffset()-dst() must
# be constant for every dt.
return tti.delta-self._find_ttinfo(dt, laststd=1).delta
# An alternative for that would be:
#
# return self._ttinfo_dst.offset-self._ttinfo_std.offset
#
# However, this class stores historical changes in the
# dst offset, so I belive that this wouldn't be the right
# way to implement this.
@tzname_in_python2
def tzname(self, dt):
if not self._ttinfo_std:
return None
return self._find_ttinfo(dt).abbr
def __eq__(self, other):
if not isinstance(other, tzfile):
return False
return (self._trans_list == other._trans_list and
self._trans_idx == other._trans_idx and
self._ttinfo_list == other._ttinfo_list)
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, repr(self._filename))
def __reduce__(self):
if not os.path.isfile(self._filename):
raise ValueError("Unpickable %s class" % self.__class__.__name__)
return (self.__class__, (self._filename,))
class tzrange(datetime.tzinfo):
def __init__(self, stdabbr, stdoffset=None,
dstabbr=None, dstoffset=None,
start=None, end=None):
global relativedelta
if not relativedelta:
from dateutil import relativedelta
self._std_abbr = stdabbr
self._dst_abbr = dstabbr
if stdoffset is not None:
self._std_offset = datetime.timedelta(seconds=stdoffset)
else:
self._std_offset = ZERO
if dstoffset is not None:
self._dst_offset = datetime.timedelta(seconds=dstoffset)
elif dstabbr and stdoffset is not None:
self._dst_offset = self._std_offset+datetime.timedelta(hours=+1)
else:
self._dst_offset = ZERO
if dstabbr and start is None:
self._start_delta = relativedelta.relativedelta(
hours=+2, month=4, day=1, weekday=relativedelta.SU(+1))
else:
self._start_delta = start
if dstabbr and end is None:
self._end_delta = relativedelta.relativedelta(
hours=+1, month=10, day=31, weekday=relativedelta.SU(-1))
else:
self._end_delta = end
def utcoffset(self, dt):
if self._isdst(dt):
return self._dst_offset
else:
return self._std_offset
def dst(self, dt):
if self._isdst(dt):
return self._dst_offset-self._std_offset
else:
return ZERO
@tzname_in_python2
def tzname(self, dt):
if self._isdst(dt):
return self._dst_abbr
else:
return self._std_abbr
def _isdst(self, dt):
if not self._start_delta:
return False
year = datetime.datetime(dt.year, 1, 1)
start = year+self._start_delta
end = year+self._end_delta
dt = dt.replace(tzinfo=None)
if start < end:
return dt >= start and dt < end
else:
return dt >= start or dt < end
def __eq__(self, other):
if not isinstance(other, tzrange):
return False
return (self._std_abbr == other._std_abbr and
self._dst_abbr == other._dst_abbr and
self._std_offset == other._std_offset and
self._dst_offset == other._dst_offset and
self._start_delta == other._start_delta and
self._end_delta == other._end_delta)
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return "%s(...)" % self.__class__.__name__
__reduce__ = object.__reduce__
class tzstr(tzrange):
def __init__(self, s):
global parser
if not parser:
from dateutil import parser
self._s = s
res = parser._parsetz(s)
if res is None:
raise ValueError("unknown string format")
# Here we break the compatibility with the TZ variable handling.
# GMT-3 actually *means* the timezone -3.
if res.stdabbr in ("GMT", "UTC"):
res.stdoffset *= -1
# We must initialize it first, since _delta() needs
# _std_offset and _dst_offset set. Use False in start/end
# to avoid building it two times.
tzrange.__init__(self, res.stdabbr, res.stdoffset,
res.dstabbr, res.dstoffset,
start=False, end=False)
if not res.dstabbr:
self._start_delta = None
self._end_delta = None
else:
self._start_delta = self._delta(res.start)
if self._start_delta:
self._end_delta = self._delta(res.end, isend=1)
def _delta(self, x, isend=0):
kwargs = {}
if x.month is not None:
kwargs["month"] = x.month
if x.weekday is not None:
kwargs["weekday"] = relativedelta.weekday(x.weekday, x.week)
if x.week > 0:
kwargs["day"] = 1
else:
kwargs["day"] = 31
elif x.day:
kwargs["day"] = x.day
elif x.yday is not None:
kwargs["yearday"] = x.yday
elif x.jyday is not None:
kwargs["nlyearday"] = x.jyday
if not kwargs:
# Default is to start on first sunday of april, and end
# on last sunday of october.
if not isend:
kwargs["month"] = 4
kwargs["day"] = 1
kwargs["weekday"] = relativedelta.SU(+1)
else:
kwargs["month"] = 10
kwargs["day"] = 31
kwargs["weekday"] = relativedelta.SU(-1)
if x.time is not None:
kwargs["seconds"] = x.time
else:
# Default is 2AM.
kwargs["seconds"] = 7200
if isend:
# Convert to standard time, to follow the documented way
# of working with the extra hour. See the documentation
# of the tzinfo class.
delta = self._dst_offset-self._std_offset
kwargs["seconds"] -= delta.seconds+delta.days*86400
return relativedelta.relativedelta(**kwargs)
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, repr(self._s))
class _tzicalvtzcomp(object):
def __init__(self, tzoffsetfrom, tzoffsetto, isdst,
tzname=None, rrule=None):
self.tzoffsetfrom = datetime.timedelta(seconds=tzoffsetfrom)
self.tzoffsetto = datetime.timedelta(seconds=tzoffsetto)
self.tzoffsetdiff = self.tzoffsetto-self.tzoffsetfrom
self.isdst = isdst
self.tzname = tzname
self.rrule = rrule
class _tzicalvtz(datetime.tzinfo):
def __init__(self, tzid, comps=[]):
self._tzid = tzid
self._comps = comps
self._cachedate = []
self._cachecomp = []
def _find_comp(self, dt):
if len(self._comps) == 1:
return self._comps[0]
dt = dt.replace(tzinfo=None)
try:
return self._cachecomp[self._cachedate.index(dt)]
except ValueError:
pass
lastcomp = None
lastcompdt = None
for comp in self._comps:
if not comp.isdst:
# Handle the extra hour in DST -> STD
compdt = comp.rrule.before(dt-comp.tzoffsetdiff, inc=True)
else:
compdt = comp.rrule.before(dt, inc=True)
if compdt and (not lastcompdt or lastcompdt < compdt):
lastcompdt = compdt
lastcomp = comp
if not lastcomp:
# RFC says nothing about what to do when a given
# time is before the first onset date. We'll look for the
# first standard component, or the first component, if
# none is found.
for comp in self._comps:
if not comp.isdst:
lastcomp = comp
break
else:
lastcomp = comp[0]
self._cachedate.insert(0, dt)
self._cachecomp.insert(0, lastcomp)
if len(self._cachedate) > 10:
self._cachedate.pop()
self._cachecomp.pop()
return lastcomp
def utcoffset(self, dt):
return self._find_comp(dt).tzoffsetto
def dst(self, dt):
comp = self._find_comp(dt)
if comp.isdst:
return comp.tzoffsetdiff
else:
return ZERO
@tzname_in_python2
def tzname(self, dt):
return self._find_comp(dt).tzname
def __repr__(self):
return "<tzicalvtz %s>" % repr(self._tzid)
__reduce__ = object.__reduce__
class tzical(object):
def __init__(self, fileobj):
global rrule
if not rrule:
from dateutil import rrule
if isinstance(fileobj, string_types):
self._s = fileobj
# ical should be encoded in UTF-8 with CRLF
fileobj = open(fileobj, 'r')
elif hasattr(fileobj, "name"):
self._s = fileobj.name
else:
self._s = repr(fileobj)
self._vtz = {}
self._parse_rfc(fileobj.read())
def keys(self):
return list(self._vtz.keys())
def get(self, tzid=None):
if tzid is None:
keys = list(self._vtz.keys())
if len(keys) == 0:
raise ValueError("no timezones defined")
elif len(keys) > 1:
raise ValueError("more than one timezone available")
tzid = keys[0]
return self._vtz.get(tzid)
def _parse_offset(self, s):
s = s.strip()
if not s:
raise ValueError("empty offset")
if s[0] in ('+', '-'):
signal = (-1, +1)[s[0] == '+']
s = s[1:]
else:
signal = +1
if len(s) == 4:
return (int(s[:2])*3600+int(s[2:])*60)*signal
elif len(s) == 6:
return (int(s[:2])*3600+int(s[2:4])*60+int(s[4:]))*signal
else:
raise ValueError("invalid offset: "+s)
def _parse_rfc(self, s):
lines = s.splitlines()
if not lines:
raise ValueError("empty string")
# Unfold
i = 0
while i < len(lines):
line = lines[i].rstrip()
if not line:
del lines[i]
elif i > 0 and line[0] == " ":
lines[i-1] += line[1:]
del lines[i]
else:
i += 1
tzid = None
comps = []
invtz = False
comptype = None
for line in lines:
if not line:
continue
name, value = line.split(':', 1)
parms = name.split(';')
if not parms:
raise ValueError("empty property name")
name = parms[0].upper()
parms = parms[1:]
if invtz:
if name == "BEGIN":
if value in ("STANDARD", "DAYLIGHT"):
# Process component
pass
else:
raise ValueError("unknown component: "+value)
comptype = value
founddtstart = False
tzoffsetfrom = None
tzoffsetto = None
rrulelines = []
tzname = None
elif name == "END":
if value == "VTIMEZONE":
if comptype:
raise ValueError("component not closed: "+comptype)
if not tzid:
raise ValueError("mandatory TZID not found")
if not comps:
raise ValueError(
"at least one component is needed")
# Process vtimezone
self._vtz[tzid] = _tzicalvtz(tzid, comps)
invtz = False
elif value == comptype:
if not founddtstart:
raise ValueError("mandatory DTSTART not found")
if tzoffsetfrom is None:
raise ValueError(
"mandatory TZOFFSETFROM not found")
if tzoffsetto is None:
raise ValueError(
"mandatory TZOFFSETFROM not found")
# Process component
rr = None
if rrulelines:
rr = rrule.rrulestr("\n".join(rrulelines),
compatible=True,
ignoretz=True,
cache=True)
comp = _tzicalvtzcomp(tzoffsetfrom, tzoffsetto,
(comptype == "DAYLIGHT"),
tzname, rr)
comps.append(comp)
comptype = None
else:
raise ValueError("invalid component end: "+value)
elif comptype:
if name == "DTSTART":
rrulelines.append(line)
founddtstart = True
elif name in ("RRULE", "RDATE", "EXRULE", "EXDATE"):
rrulelines.append(line)
elif name == "TZOFFSETFROM":
if parms:
raise ValueError(
"unsupported %s parm: %s " % (name, parms[0]))
tzoffsetfrom = self._parse_offset(value)
elif name == "TZOFFSETTO":
if parms:
raise ValueError(
"unsupported TZOFFSETTO parm: "+parms[0])
tzoffsetto = self._parse_offset(value)
elif name == "TZNAME":
if parms:
raise ValueError(
"unsupported TZNAME parm: "+parms[0])
tzname = value
elif name == "COMMENT":
pass
else:
raise ValueError("unsupported property: "+name)
else:
if name == "TZID":
if parms:
raise ValueError(
"unsupported TZID parm: "+parms[0])
tzid = value
elif name in ("TZURL", "LAST-MODIFIED", "COMMENT"):
pass
else:
raise ValueError("unsupported property: "+name)
elif name == "BEGIN" and value == "VTIMEZONE":
tzid = None
comps = []
invtz = True
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, repr(self._s))
if sys.platform != "win32":
TZFILES = ["/etc/localtime", "localtime"]
TZPATHS = ["/usr/share/zoneinfo", "/usr/lib/zoneinfo", "/etc/zoneinfo"]
else:
TZFILES = []
TZPATHS = []
def gettz(name=None):
tz = None
if not name:
try:
name = os.environ["TZ"]
except KeyError:
pass
if name is None or name == ":":
for filepath in TZFILES:
if not os.path.isabs(filepath):
filename = filepath
for path in TZPATHS:
filepath = os.path.join(path, filename)
if os.path.isfile(filepath):
break
else:
continue
if os.path.isfile(filepath):
try:
tz = tzfile(filepath)
break
except (IOError, OSError, ValueError):
pass
else:
tz = tzlocal()
else:
if name.startswith(":"):
name = name[:-1]
if os.path.isabs(name):
if os.path.isfile(name):
tz = tzfile(name)
else:
tz = None
else:
for path in TZPATHS:
filepath = os.path.join(path, name)
if not os.path.isfile(filepath):
filepath = filepath.replace(' ', '_')
if not os.path.isfile(filepath):
continue
try:
tz = tzfile(filepath)
break
except (IOError, OSError, ValueError):
pass
else:
tz = None
if tzwin is not None:
try:
tz = tzwin(name)
except WindowsError:
tz = None
if not tz:
from dateutil.zoneinfo import gettz
tz = gettz(name)
if not tz:
for c in name:
# name must have at least one offset to be a tzstr
if c in "0123456789":
try:
tz = tzstr(name)
except ValueError:
pass
break
else:
if name in ("GMT", "UTC"):
tz = tzutc()
elif name in time.tzname:
tz = tzlocal()
return tz
# vim:ts=4:sw=4:et

184
lib/dateutil/tzwin.py Normal file
View File

@@ -0,0 +1,184 @@
# This code was originally contributed by Jeffrey Harris.
import datetime
import struct
from six.moves import winreg
__all__ = ["tzwin", "tzwinlocal"]
ONEWEEK = datetime.timedelta(7)
TZKEYNAMENT = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones"
TZKEYNAME9X = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Time Zones"
TZLOCALKEYNAME = r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation"
def _settzkeyname():
handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
try:
winreg.OpenKey(handle, TZKEYNAMENT).Close()
TZKEYNAME = TZKEYNAMENT
except WindowsError:
TZKEYNAME = TZKEYNAME9X
handle.Close()
return TZKEYNAME
TZKEYNAME = _settzkeyname()
class tzwinbase(datetime.tzinfo):
"""tzinfo class based on win32's timezones available in the registry."""
def utcoffset(self, dt):
if self._isdst(dt):
return datetime.timedelta(minutes=self._dstoffset)
else:
return datetime.timedelta(minutes=self._stdoffset)
def dst(self, dt):
if self._isdst(dt):
minutes = self._dstoffset - self._stdoffset
return datetime.timedelta(minutes=minutes)
else:
return datetime.timedelta(0)
def tzname(self, dt):
if self._isdst(dt):
return self._dstname
else:
return self._stdname
def list():
"""Return a list of all time zones known to the system."""
handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
tzkey = winreg.OpenKey(handle, TZKEYNAME)
result = [winreg.EnumKey(tzkey, i)
for i in range(winreg.QueryInfoKey(tzkey)[0])]
tzkey.Close()
handle.Close()
return result
list = staticmethod(list)
def display(self):
return self._display
def _isdst(self, dt):
if not self._dstmonth:
# dstmonth == 0 signals the zone has no daylight saving time
return False
dston = picknthweekday(dt.year, self._dstmonth, self._dstdayofweek,
self._dsthour, self._dstminute,
self._dstweeknumber)
dstoff = picknthweekday(dt.year, self._stdmonth, self._stddayofweek,
self._stdhour, self._stdminute,
self._stdweeknumber)
if dston < dstoff:
return dston <= dt.replace(tzinfo=None) < dstoff
else:
return not dstoff <= dt.replace(tzinfo=None) < dston
class tzwin(tzwinbase):
def __init__(self, name):
self._name = name
# multiple contexts only possible in 2.7 and 3.1, we still support 2.6
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle:
with winreg.OpenKey(handle,
"%s\%s" % (TZKEYNAME, name)) as tzkey:
keydict = valuestodict(tzkey)
self._stdname = keydict["Std"].encode("iso-8859-1")
self._dstname = keydict["Dlt"].encode("iso-8859-1")
self._display = keydict["Display"]
# See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm
tup = struct.unpack("=3l16h", keydict["TZI"])
self._stdoffset = -tup[0]-tup[1] # Bias + StandardBias * -1
self._dstoffset = self._stdoffset-tup[2] # + DaylightBias * -1
# for the meaning see the win32 TIME_ZONE_INFORMATION structure docs
# http://msdn.microsoft.com/en-us/library/windows/desktop/ms725481(v=vs.85).aspx
(self._stdmonth,
self._stddayofweek, # Sunday = 0
self._stdweeknumber, # Last = 5
self._stdhour,
self._stdminute) = tup[4:9]
(self._dstmonth,
self._dstdayofweek, # Sunday = 0
self._dstweeknumber, # Last = 5
self._dsthour,
self._dstminute) = tup[12:17]
def __repr__(self):
return "tzwin(%s)" % repr(self._name)
def __reduce__(self):
return (self.__class__, (self._name,))
class tzwinlocal(tzwinbase):
def __init__(self):
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle:
with winreg.OpenKey(handle, TZLOCALKEYNAME) as tzlocalkey:
keydict = valuestodict(tzlocalkey)
self._stdname = keydict["StandardName"].encode("iso-8859-1")
self._dstname = keydict["DaylightName"].encode("iso-8859-1")
try:
with winreg.OpenKey(
handle, "%s\%s" % (TZKEYNAME, self._stdname)) as tzkey:
_keydict = valuestodict(tzkey)
self._display = _keydict["Display"]
except OSError:
self._display = None
self._stdoffset = -keydict["Bias"]-keydict["StandardBias"]
self._dstoffset = self._stdoffset-keydict["DaylightBias"]
# See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm
tup = struct.unpack("=8h", keydict["StandardStart"])
(self._stdmonth,
self._stddayofweek, # Sunday = 0
self._stdweeknumber, # Last = 5
self._stdhour,
self._stdminute) = tup[1:6]
tup = struct.unpack("=8h", keydict["DaylightStart"])
(self._dstmonth,
self._dstdayofweek, # Sunday = 0
self._dstweeknumber, # Last = 5
self._dsthour,
self._dstminute) = tup[1:6]
def __reduce__(self):
return (self.__class__, ())
def picknthweekday(year, month, dayofweek, hour, minute, whichweek):
"""dayofweek == 0 means Sunday, whichweek 5 means last instance"""
first = datetime.datetime(year, month, 1, hour, minute)
weekdayone = first.replace(day=((dayofweek-first.isoweekday()) % 7+1))
for n in range(whichweek):
dt = weekdayone+(whichweek-n)*ONEWEEK
if dt.month == month:
return dt
def valuestodict(key):
"""Convert a registry key's values to a dictionary."""
dict = {}
size = winreg.QueryInfoKey(key)[1]
for i in range(size):
data = winreg.EnumValue(key, i)
dict[data[0]] = data[1]
return dict

732
lib/profilehooks.py Normal file
View File

@@ -0,0 +1,732 @@
"""
Profiling hooks
This module contains a couple of decorators (`profile` and `coverage`) that
can be used to wrap functions and/or methods to produce profiles and line
coverage reports. There's a third convenient decorator (`timecall`) that
measures the duration of function execution without the extra profiling
overhead.
Usage example (Python 2.4 or newer)::
from profilehooks import profile, coverage
@profile # or @coverage
def fn(n):
if n < 2: return 1
else: return n * fn(n-1)
print fn(42)
Usage example (Python 2.3 or older)::
from profilehooks import profile, coverage
def fn(n):
if n < 2: return 1
else: return n * fn(n-1)
# Now wrap that function in a decorator
fn = profile(fn) # or coverage(fn)
print fn(42)
Reports for all thusly decorated functions will be printed to sys.stdout
on program termination. You can alternatively request for immediate
reports for each call by passing immediate=True to the profile decorator.
There's also a @timecall decorator for printing the time to sys.stderr
every time a function is called, when you just want to get a rough measure
instead of a detailed (but costly) profile.
Caveats
A thread on python-dev convinced me that hotshot produces bogus numbers.
See http://mail.python.org/pipermail/python-dev/2005-November/058264.html
I don't know what will happen if a decorated function will try to call
another decorated function. All decorators probably need to explicitly
support nested profiling (currently TraceFuncCoverage is the only one
that supports this, while HotShotFuncProfile has support for recursive
functions.)
Profiling with hotshot creates temporary files (*.prof for profiling,
*.cprof for coverage) in the current directory. These files are not
cleaned up. Exception: when you specify a filename to the profile
decorator (to store the pstats.Stats object for later inspection),
the temporary file will be the filename you specified with '.raw'
appended at the end.
Coverage analysis with hotshot seems to miss some executions resulting
in lower line counts and some lines errorneously marked as never
executed. For this reason coverage analysis now uses trace.py which is
slower, but more accurate.
Copyright (c) 2004--2008 Marius Gedminas <marius@pov.lt>
Copyright (c) 2007 Hanno Schlichting
Copyright (c) 2008 Florian Schulze
Released under the MIT licence since December 2006:
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
(Previously it was distributed under the GNU General Public Licence.)
"""
# $Id: profilehooks.py 29 2010-08-13 16:29:20Z mg $
__author__ = "Marius Gedminas (marius@gedmin.as)"
__copyright__ = "Copyright 2004-2009 Marius Gedminas"
__license__ = "MIT"
__version__ = "1.4"
__date__ = "2009-03-31"
import atexit
import inspect
import sys
import re
# For profiling
from profile import Profile
import pstats
# For hotshot profiling (inaccurate!)
try:
import hotshot
import hotshot.stats
except ImportError:
hotshot = None
# For trace.py coverage
import trace
# For hotshot coverage (inaccurate!; uses undocumented APIs; might break)
if hotshot is not None:
import _hotshot
import hotshot.log
# For cProfile profiling (best)
try:
import cProfile
except ImportError:
cProfile = None
# For timecall
import time
# registry of available profilers
AVAILABLE_PROFILERS = {}
def profile(fn=None, skip=0, filename=None, immediate=False, dirs=False,
sort=None, entries=40,
profiler=('cProfile', 'profile', 'hotshot')):
"""Mark `fn` for profiling.
If `skip` is > 0, first `skip` calls to `fn` will not be profiled.
If `immediate` is False, profiling results will be printed to
sys.stdout on program termination. Otherwise results will be printed
after each call.
If `dirs` is False only the name of the file will be printed.
Otherwise the full path is used.
`sort` can be a list of sort keys (defaulting to ['cumulative',
'time', 'calls']). The following ones are recognized::
'calls' -- call count
'cumulative' -- cumulative time
'file' -- file name
'line' -- line number
'module' -- file name
'name' -- function name
'nfl' -- name/file/line
'pcalls' -- call count
'stdname' -- standard name
'time' -- internal time
`entries` limits the output to the first N entries.
`profiler` can be used to select the preferred profiler, or specify a
sequence of them, in order of preference. The default is ('cProfile'.
'profile', 'hotshot').
If `filename` is specified, the profile stats will be stored in the
named file. You can load them pstats.Stats(filename).
Usage::
def fn(...):
...
fn = profile(fn, skip=1)
If you are using Python 2.4, you should be able to use the decorator
syntax::
@profile(skip=3)
def fn(...):
...
or just ::
@profile
def fn(...):
...
"""
if fn is None: # @profile() syntax -- we are a decorator maker
def decorator(fn):
return profile(fn, skip=skip, filename=filename,
immediate=immediate, dirs=dirs,
sort=sort, entries=entries,
profiler=profiler)
return decorator
# @profile syntax -- we are a decorator.
if isinstance(profiler, str):
profiler = [profiler]
for p in profiler:
if p in AVAILABLE_PROFILERS:
profiler_class = AVAILABLE_PROFILERS[p]
break
else:
raise ValueError('only these profilers are available: %s'
% ', '.join(AVAILABLE_PROFILERS))
fp = profiler_class(fn, skip=skip, filename=filename,
immediate=immediate, dirs=dirs,
sort=sort, entries=entries)
# fp = HotShotFuncProfile(fn, skip=skip, filename=filename, ...)
# or HotShotFuncProfile
# We cannot return fp or fp.__call__ directly as that would break method
# definitions, instead we need to return a plain function.
def new_fn(*args, **kw):
return fp(*args, **kw)
new_fn.__doc__ = fn.__doc__
new_fn.__name__ = fn.__name__
new_fn.__dict__ = fn.__dict__
new_fn.__module__ = fn.__module__
return new_fn
def coverage(fn):
"""Mark `fn` for line coverage analysis.
Results will be printed to sys.stdout on program termination.
Usage::
def fn(...):
...
fn = coverage(fn)
If you are using Python 2.4, you should be able to use the decorator
syntax::
@coverage
def fn(...):
...
"""
fp = TraceFuncCoverage(fn) # or HotShotFuncCoverage
# We cannot return fp or fp.__call__ directly as that would break method
# definitions, instead we need to return a plain function.
def new_fn(*args, **kw):
return fp(*args, **kw)
new_fn.__doc__ = fn.__doc__
new_fn.__name__ = fn.__name__
new_fn.__dict__ = fn.__dict__
new_fn.__module__ = fn.__module__
return new_fn
def coverage_with_hotshot(fn):
"""Mark `fn` for line coverage analysis.
Uses the 'hotshot' module for fast coverage analysis.
BUG: Produces inaccurate results.
See the docstring of `coverage` for usage examples.
"""
fp = HotShotFuncCoverage(fn)
# We cannot return fp or fp.__call__ directly as that would break method
# definitions, instead we need to return a plain function.
def new_fn(*args, **kw):
return fp(*args, **kw)
new_fn.__doc__ = fn.__doc__
new_fn.__name__ = fn.__name__
new_fn.__dict__ = fn.__dict__
new_fn.__module__ = fn.__module__
return new_fn
class FuncProfile(object):
"""Profiler for a function (uses profile)."""
# This flag is shared between all instances
in_profiler = False
Profile = Profile
def __init__(self, fn, skip=0, filename=None, immediate=False, dirs=False,
sort=None, entries=40):
"""Creates a profiler for a function.
Every profiler has its own log file (the name of which is derived
from the function name).
FuncProfile registers an atexit handler that prints profiling
information to sys.stderr when the program terminates.
"""
self.fn = fn
self.skip = skip
self.filename = filename
self.immediate = immediate
self.dirs = dirs
self.sort = sort or ('cumulative', 'time', 'calls')
if isinstance(self.sort, str):
self.sort = (self.sort, )
self.entries = entries
self.reset_stats()
atexit.register(self.atexit)
def __call__(self, *args, **kw):
"""Profile a singe call to the function."""
self.ncalls += 1
if self.skip > 0:
self.skip -= 1
self.skipped += 1
return self.fn(*args, **kw)
if FuncProfile.in_profiler:
# handle recursive calls
return self.fn(*args, **kw)
# You cannot reuse the same profiler for many calls and accumulate
# stats that way. :-/
profiler = self.Profile()
try:
FuncProfile.in_profiler = True
return profiler.runcall(self.fn, *args, **kw)
finally:
FuncProfile.in_profiler = False
self.stats.add(profiler)
if self.immediate:
self.print_stats()
self.reset_stats()
def print_stats(self):
"""Print profile information to sys.stdout."""
funcname = self.fn.__name__
filename = self.fn.func_code.co_filename
lineno = self.fn.func_code.co_firstlineno
print
print "*** PROFILER RESULTS ***"
print "%s (%s:%s)" % (funcname, filename, lineno)
print "function called %d times" % self.ncalls,
if self.skipped:
print "(%d calls not profiled)" % self.skipped
else:
print
print
stats = self.stats
if self.filename:
stats.dump_stats(self.filename)
if not self.dirs:
stats.strip_dirs()
stats.sort_stats(*self.sort)
stats.print_stats(self.entries)
def reset_stats(self):
"""Reset accumulated profiler statistics."""
# Note: not using self.Profile, since pstats.Stats() fails then
self.stats = pstats.Stats(Profile())
self.ncalls = 0
self.skipped = 0
def atexit(self):
"""Stop profiling and print profile information to sys.stdout.
This function is registered as an atexit hook.
"""
if not self.immediate:
self.print_stats()
AVAILABLE_PROFILERS['profile'] = FuncProfile
if cProfile is not None:
class CProfileFuncProfile(FuncProfile):
"""Profiler for a function (uses cProfile)."""
Profile = cProfile.Profile
AVAILABLE_PROFILERS['cProfile'] = CProfileFuncProfile
if hotshot is not None:
class HotShotFuncProfile(object):
"""Profiler for a function (uses hotshot)."""
# This flag is shared between all instances
in_profiler = False
def __init__(self, fn, skip=0, filename=None):
"""Creates a profiler for a function.
Every profiler has its own log file (the name of which is derived
from the function name).
HotShotFuncProfile registers an atexit handler that prints
profiling information to sys.stderr when the program terminates.
The log file is not removed and remains there to clutter the
current working directory.
"""
self.fn = fn
self.filename = filename
if self.filename:
self.logfilename = filename + ".raw"
else:
self.logfilename = fn.__name__ + ".prof"
self.profiler = hotshot.Profile(self.logfilename)
self.ncalls = 0
self.skip = skip
self.skipped = 0
atexit.register(self.atexit)
def __call__(self, *args, **kw):
"""Profile a singe call to the function."""
self.ncalls += 1
if self.skip > 0:
self.skip -= 1
self.skipped += 1
return self.fn(*args, **kw)
if HotShotFuncProfile.in_profiler:
# handle recursive calls
return self.fn(*args, **kw)
try:
HotShotFuncProfile.in_profiler = True
return self.profiler.runcall(self.fn, *args, **kw)
finally:
HotShotFuncProfile.in_profiler = False
def atexit(self):
"""Stop profiling and print profile information to sys.stderr.
This function is registered as an atexit hook.
"""
self.profiler.close()
funcname = self.fn.__name__
filename = self.fn.func_code.co_filename
lineno = self.fn.func_code.co_firstlineno
print
print "*** PROFILER RESULTS ***"
print "%s (%s:%s)" % (funcname, filename, lineno)
print "function called %d times" % self.ncalls,
if self.skipped:
print "(%d calls not profiled)" % self.skipped
else:
print
print
stats = hotshot.stats.load(self.logfilename)
# hotshot.stats.load takes ages, and the .prof file eats megabytes, but
# a saved stats object is small and fast
if self.filename:
stats.dump_stats(self.filename)
# it is best to save before strip_dirs
stats.strip_dirs()
stats.sort_stats('cumulative', 'time', 'calls')
stats.print_stats(40)
AVAILABLE_PROFILERS['hotshot'] = HotShotFuncProfile
class HotShotFuncCoverage:
"""Coverage analysis for a function (uses _hotshot).
HotShot coverage is reportedly faster than trace.py, but it appears to
have problems with exceptions; also line counts in coverage reports
are generally lower from line counts produced by TraceFuncCoverage.
Is this my bug, or is it a problem with _hotshot?
"""
def __init__(self, fn):
"""Creates a profiler for a function.
Every profiler has its own log file (the name of which is derived
from the function name).
HotShotFuncCoverage registers an atexit handler that prints
profiling information to sys.stderr when the program terminates.
The log file is not removed and remains there to clutter the
current working directory.
"""
self.fn = fn
self.logfilename = fn.__name__ + ".cprof"
self.profiler = _hotshot.coverage(self.logfilename)
self.ncalls = 0
atexit.register(self.atexit)
def __call__(self, *args, **kw):
"""Profile a singe call to the function."""
self.ncalls += 1
return self.profiler.runcall(self.fn, args, kw)
def atexit(self):
"""Stop profiling and print profile information to sys.stderr.
This function is registered as an atexit hook.
"""
self.profiler.close()
funcname = self.fn.__name__
filename = self.fn.func_code.co_filename
lineno = self.fn.func_code.co_firstlineno
print
print "*** COVERAGE RESULTS ***"
print "%s (%s:%s)" % (funcname, filename, lineno)
print "function called %d times" % self.ncalls
print
fs = FuncSource(self.fn)
reader = hotshot.log.LogReader(self.logfilename)
for what, (filename, lineno, funcname), tdelta in reader:
if filename != fs.filename:
continue
if what == hotshot.log.LINE:
fs.mark(lineno)
if what == hotshot.log.ENTER:
# hotshot gives us the line number of the function definition
# and never gives us a LINE event for the first statement in
# a function, so if we didn't perform this mapping, the first
# statement would be marked as never executed
if lineno == fs.firstlineno:
lineno = fs.firstcodelineno
fs.mark(lineno)
reader.close()
print fs
class TraceFuncCoverage:
"""Coverage analysis for a function (uses trace module).
HotShot coverage analysis is reportedly faster, but it appears to have
problems with exceptions.
"""
# Shared between all instances so that nested calls work
tracer = trace.Trace(count=True, trace=False,
ignoredirs=[sys.prefix, sys.exec_prefix])
# This flag is also shared between all instances
tracing = False
def __init__(self, fn):
"""Creates a profiler for a function.
Every profiler has its own log file (the name of which is derived
from the function name).
TraceFuncCoverage registers an atexit handler that prints
profiling information to sys.stderr when the program terminates.
The log file is not removed and remains there to clutter the
current working directory.
"""
self.fn = fn
self.logfilename = fn.__name__ + ".cprof"
self.ncalls = 0
atexit.register(self.atexit)
def __call__(self, *args, **kw):
"""Profile a singe call to the function."""
self.ncalls += 1
if TraceFuncCoverage.tracing:
return self.fn(*args, **kw)
try:
TraceFuncCoverage.tracing = True
return self.tracer.runfunc(self.fn, *args, **kw)
finally:
TraceFuncCoverage.tracing = False
def atexit(self):
"""Stop profiling and print profile information to sys.stderr.
This function is registered as an atexit hook.
"""
funcname = self.fn.__name__
filename = self.fn.func_code.co_filename
lineno = self.fn.func_code.co_firstlineno
print
print "*** COVERAGE RESULTS ***"
print "%s (%s:%s)" % (funcname, filename, lineno)
print "function called %d times" % self.ncalls
print
fs = FuncSource(self.fn)
for (filename, lineno), count in self.tracer.counts.items():
if filename != fs.filename:
continue
fs.mark(lineno, count)
print fs
never_executed = fs.count_never_executed()
if never_executed:
print "%d lines were not executed." % never_executed
class FuncSource:
"""Source code annotator for a function."""
blank_rx = re.compile(r"^\s*finally:\s*(#.*)?$")
def __init__(self, fn):
self.fn = fn
self.filename = inspect.getsourcefile(fn)
self.source, self.firstlineno = inspect.getsourcelines(fn)
self.sourcelines = {}
self.firstcodelineno = self.firstlineno
self.find_source_lines()
def find_source_lines(self):
"""Mark all executable source lines in fn as executed 0 times."""
strs = trace.find_strings(self.filename)
lines = trace.find_lines_from_code(self.fn.func_code, strs)
self.firstcodelineno = sys.maxint
for lineno in lines:
self.firstcodelineno = min(self.firstcodelineno, lineno)
self.sourcelines.setdefault(lineno, 0)
if self.firstcodelineno == sys.maxint:
self.firstcodelineno = self.firstlineno
def mark(self, lineno, count=1):
"""Mark a given source line as executed count times.
Multiple calls to mark for the same lineno add up.
"""
self.sourcelines[lineno] = self.sourcelines.get(lineno, 0) + count
def count_never_executed(self):
"""Count statements that were never executed."""
lineno = self.firstlineno
counter = 0
for line in self.source:
if self.sourcelines.get(lineno) == 0:
if not self.blank_rx.match(line):
counter += 1
lineno += 1
return counter
def __str__(self):
"""Return annotated source code for the function."""
lines = []
lineno = self.firstlineno
for line in self.source:
counter = self.sourcelines.get(lineno)
if counter is None:
prefix = ' ' * 7
elif counter == 0:
if self.blank_rx.match(line):
prefix = ' ' * 7
else:
prefix = '>' * 6 + ' '
else:
prefix = '%5d: ' % counter
lines.append(prefix + line)
lineno += 1
return ''.join(lines)
def timecall(fn=None, immediate=True, timer=time.time):
"""Wrap `fn` and print its execution time.
Example::
@timecall
def somefunc(x, y):
time.sleep(x * y)
somefunc(2, 3)
will print the time taken by somefunc on every call. If you want just
a summary at program termination, use
@timecall(immediate=False)
You can also choose a timing method other than the default ``time.time()``,
e.g.:
@timecall(timer=time.clock)
"""
if fn is None: # @timecall() syntax -- we are a decorator maker
def decorator(fn):
return timecall(fn, immediate=immediate, timer=timer)
return decorator
# @timecall syntax -- we are a decorator.
fp = FuncTimer(fn, immediate=immediate, timer=timer)
# We cannot return fp or fp.__call__ directly as that would break method
# definitions, instead we need to return a plain function.
def new_fn(*args, **kw):
return fp(*args, **kw)
new_fn.__doc__ = fn.__doc__
new_fn.__name__ = fn.__name__
new_fn.__dict__ = fn.__dict__
new_fn.__module__ = fn.__module__
return new_fn
class FuncTimer(object):
def __init__(self, fn, immediate, timer):
self.fn = fn
self.ncalls = 0
self.totaltime = 0
self.immediate = immediate
self.timer = timer
if not immediate:
atexit.register(self.atexit)
def __call__(self, *args, **kw):
"""Profile a singe call to the function."""
fn = self.fn
timer = self.timer
self.ncalls += 1
try:
start = timer()
return fn(*args, **kw)
finally:
duration = timer() - start
self.totaltime += duration
if self.immediate:
funcname = fn.__name__
filename = fn.func_code.co_filename
lineno = fn.func_code.co_firstlineno
print >> sys.stderr, "\n %s (%s:%s):\n %.3f seconds\n" % (
funcname, filename, lineno, duration)
def atexit(self):
if not self.ncalls:
return
funcname = self.fn.__name__
filename = self.fn.func_code.co_filename
lineno = self.fn.func_code.co_firstlineno
print ("\n %s (%s:%s):\n"
" %d calls, %.3f seconds (%.3f seconds per call)\n" % (
funcname, filename, lineno, self.ncalls,
self.totaltime, self.totaltime / self.ncalls))

View File

@@ -59,6 +59,7 @@ started = False
DATA_DIR = None
CONFIG = None
CONFIG_FILE = None
DB_FILE = None
@@ -73,17 +74,21 @@ UMASK = None
POLLING_FAILOVER = False
DEV = False
def initialize(config_file):
with INIT_LOCK:
global CONFIG
global CONFIG_FILE
global _INITIALIZED
global CURRENT_VERSION
global LATEST_VERSION
global UMASK
global POLLING_FAILOVER
CONFIG = plexpy.config.Config(config_file)
CONFIG_FILE = config_file
assert CONFIG is not None
@@ -100,7 +105,7 @@ def initialize(config_file):
if not CONFIG.HTTPS_KEY:
CONFIG.HTTPS_KEY = os.path.join(DATA_DIR, 'server.key')
if not CONFIG.LOG_DIR.startswith(os.path.abspath(DATA_DIR)):
if not CONFIG.LOG_DIR:
CONFIG.LOG_DIR = os.path.join(DATA_DIR, 'logs')
if not os.path.exists(CONFIG.LOG_DIR):
@@ -117,14 +122,21 @@ def initialize(config_file):
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
verbose=VERBOSE)
if not CONFIG.CACHE_DIR.startswith(os.path.abspath(DATA_DIR)):
# Put the cache dir in the data dir for now
if not CONFIG.BACKUP_DIR:
CONFIG.BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
if not os.path.exists(CONFIG.BACKUP_DIR):
try:
os.makedirs(CONFIG.BACKUP_DIR)
except OSError as e:
logger.error("Could not create backup dir '%s': %s", BACKUP_DIR, e)
if not CONFIG.CACHE_DIR:
CONFIG.CACHE_DIR = os.path.join(DATA_DIR, 'cache')
if not os.path.exists(CONFIG.CACHE_DIR):
try:
os.makedirs(CONFIG.CACHE_DIR)
except OSError as e:
logger.error("Could not create cache dir '%s': %s", DATA_DIR, e)
logger.error("Could not create cache dir '%s': %s", CACHE_DIR, e)
# Initialize the database
logger.info('Checking to see if the database has all tables....')
@@ -176,7 +188,7 @@ def initialize(config_file):
plextv.refresh_users()
# Refresh the libraries list on startup
if CONFIG.PMS_TOKEN and CONFIG.REFRESH_LIBRARIES_ON_STARTUP:
if CONFIG.PMS_IP and CONFIG.PMS_TOKEN and CONFIG.REFRESH_LIBRARIES_ON_STARTUP:
pmsconnect.refresh_libraries()
# Store the original umask
@@ -186,7 +198,6 @@ def initialize(config_file):
_INITIALIZED = True
return True
def daemonize():
if threading.activeCount() != 1:
logger.warn(
@@ -282,10 +293,10 @@ def initialize_scheduler():
else:
seconds = 0
if CONFIG.PMS_IP and CONFIG.PMS_TOKEN and CONFIG.UPDATE_SECTION_IDS != -1:
schedule_job(plextv.get_real_pms_url, 'Refresh Plex Server URLs',
if CONFIG.PMS_IP and CONFIG.PMS_TOKEN:
schedule_job(plextv.get_real_pms_url, 'Refresh Plex server URLs',
hours=12, minutes=0, seconds=0)
schedule_job(pmsconnect.get_server_friendly_name, 'Refresh Plex Server Name',
schedule_job(pmsconnect.get_server_friendly_name, 'Refresh Plex server name',
hours=12, minutes=0, seconds=0)
if CONFIG.NOTIFY_RECENTLY_ADDED:
@@ -295,11 +306,18 @@ def initialize_scheduler():
schedule_job(activity_pinger.check_recently_added, 'Check for recently added items',
hours=0, minutes=0, seconds=0)
if CONFIG.MONITOR_PMS_UPDATES:
schedule_job(activity_pinger.check_server_updates, 'Check for Plex updates',
hours=12, minutes=0, seconds=0)
else:
schedule_job(activity_pinger.check_server_updates, 'Check for Plex updates',
hours=0, minutes=0, seconds=0)
if CONFIG.MONITOR_REMOTE_ACCESS:
schedule_job(activity_pinger.check_server_response, 'Check for server response',
schedule_job(activity_pinger.check_server_response, 'Check for Plex remote access',
hours=0, minutes=0, seconds=seconds)
else:
schedule_job(activity_pinger.check_server_response, 'Check for server response',
schedule_job(activity_pinger.check_server_response, 'Check for Plex remote access',
hours=0, minutes=0, seconds=0)
# If we're not using websockets then fall back to polling
@@ -322,6 +340,8 @@ def initialize_scheduler():
schedule_job(pmsconnect.refresh_libraries, 'Refresh libraries list',
hours=hours, minutes=0, seconds=0)
schedule_job(database.make_backup, 'Backup PlexPy database', hours=6, minutes=0, seconds=0, args=(True, True))
# Start scheduler
if start_jobs and len(SCHED.get_jobs()):
try:
@@ -333,7 +353,7 @@ def initialize_scheduler():
#SCHED.print_jobs()
def schedule_job(function, name, hours=0, minutes=0, seconds=0):
def schedule_job(function, name, hours=0, minutes=0, seconds=0, args=None):
"""
Start scheduled job if starting or restarting plexpy.
Reschedule job if Interval Settings have changed.
@@ -348,11 +368,11 @@ def schedule_job(function, name, hours=0, minutes=0, seconds=0):
logger.info("Removed background task: %s", name)
elif job.trigger.interval != datetime.timedelta(hours=hours, minutes=minutes):
SCHED.reschedule_job(name, trigger=IntervalTrigger(
hours=hours, minutes=minutes, seconds=seconds))
hours=hours, minutes=minutes, seconds=seconds), args=args)
logger.info("Re-scheduled background task: %s", name)
elif hours > 0 or minutes > 0 or seconds > 0:
SCHED.add_job(function, id=name, trigger=IntervalTrigger(
hours=hours, minutes=minutes, seconds=seconds))
hours=hours, minutes=minutes, seconds=seconds), args=args)
logger.info("Scheduled background task: %s", name)
@@ -376,8 +396,8 @@ def dbcheck():
# sessions table :: This is a temp table that logs currently active sessions
c_db.execute(
'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'session_key INTEGER, rating_key INTEGER, section_id INTEGER, media_type TEXT, started INTEGER, '
'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, session_key INTEGER, '
'transcode_key TEXT, rating_key INTEGER, section_id INTEGER, media_type TEXT, started INTEGER, stopped INTEGER, '
'paused_counter INTEGER DEFAULT 0, state TEXT, user_id INTEGER, user TEXT, friendly_name TEXT, '
'ip_address TEXT, machine_id TEXT, player TEXT, platform TEXT, title TEXT, parent_title TEXT, '
'grandparent_title TEXT, parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
@@ -400,8 +420,8 @@ def dbcheck():
# session_history_media_info table :: This is a table which logs each session's media info
c_db.execute(
'CREATE TABLE IF NOT EXISTS session_history_media_info (id INTEGER PRIMARY KEY, '
'rating_key INTEGER, video_decision TEXT, audio_decision TEXT, duration INTEGER DEFAULT 0, width INTEGER, '
'CREATE TABLE IF NOT EXISTS session_history_media_info (id INTEGER PRIMARY KEY, rating_key INTEGER, '
'video_decision TEXT, audio_decision TEXT, transcode_decision TEXT, duration INTEGER DEFAULT 0, width INTEGER, '
'height INTEGER, container TEXT, video_codec TEXT, audio_codec TEXT, bitrate INTEGER, video_resolution TEXT, '
'video_framerate TEXT, aspect_ratio TEXT, audio_channels INTEGER, transcode_protocol TEXT, '
'transcode_container TEXT, transcode_video_codec TEXT, transcode_audio_codec TEXT, '
@@ -430,10 +450,10 @@ def dbcheck():
# notify_log table :: This is a table which logs notifications sent
c_db.execute(
'CREATE TABLE IF NOT EXISTS notify_log (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'session_key INTEGER, rating_key INTEGER, user_id INTEGER, user TEXT, '
'agent_id INTEGER, agent_name TEXT, on_play INTEGER, on_stop INTEGER, on_watched INTEGER, '
'on_pause INTEGER, on_resume INTEGER, on_buffer INTEGER, on_created INTEGER)'
'CREATE TABLE IF NOT EXISTS notify_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
'session_key INTEGER, rating_key INTEGER, parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
'user_id INTEGER, user TEXT, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
'subject_text TEXT, body_text TEXT, script_args TEXT, poster_url TEXT)'
)
# library_sections table :: This table keeps record of the servers library sections
@@ -601,6 +621,24 @@ def dbcheck():
'ALTER TABLE sessions ADD COLUMN section_id INTEGER'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT stopped FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN stopped INTEGER'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT transcode_key FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN transcode_key TEXT'
)
# Upgrade session_history table from earlier versions
try:
c_db.execute('SELECT reference_id FROM session_history')
@@ -651,6 +689,21 @@ def dbcheck():
'ALTER TABLE session_history_metadata ADD COLUMN section_id INTEGER'
)
# Upgrade session_history_media_info table from earlier versions
try:
c_db.execute('SELECT transcode_decision FROM session_history_media_info')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table session_history_media_info.")
c_db.execute(
'ALTER TABLE session_history_media_info ADD COLUMN transcode_decision TEXT'
)
c_db.execute(
'UPDATE session_history_media_info SET transcode_decision = (CASE '
'WHEN video_decision = "transcode" OR audio_decision = "transcode" THEN "transcode" '
'WHEN video_decision = "copy" OR audio_decision = "copy" THEN "copy" '
'WHEN video_decision = "direct play" OR audio_decision = "direct play" THEN "direct play" END)'
)
# Upgrade users table from earlier versions
try:
c_db.execute('SELECT do_notify FROM users')
@@ -689,32 +742,55 @@ def dbcheck():
# Upgrade notify_log table from earlier versions
try:
c_db.execute('SELECT on_pause FROM notify_log')
c_db.execute('SELECT poster_url FROM notify_log')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table notify_log.")
c_db.execute(
'ALTER TABLE notify_log ADD COLUMN on_pause INTEGER'
)
c_db.execute(
'ALTER TABLE notify_log ADD COLUMN on_resume INTEGER'
)
c_db.execute(
'ALTER TABLE notify_log ADD COLUMN on_buffer INTEGER'
'ALTER TABLE notify_log ADD COLUMN poster_url TEXT'
)
# Upgrade notify_log table from earlier versions
# Upgrade notify_log table from earlier versions (populate table with data from notify_log)
try:
c_db.execute('SELECT on_created FROM notify_log')
c_db.execute('SELECT timestamp FROM notify_log')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table notify_log.")
c_db.execute(
'ALTER TABLE notify_log ADD COLUMN on_created INTEGER'
'CREATE TABLE IF NOT EXISTS notify_log_temp (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
'session_key INTEGER, rating_key INTEGER, parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
'user_id INTEGER, user TEXT, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
'subject_text TEXT, body_text TEXT, script_args TEXT, poster_url TEXT)'
)
c_db.execute(
'INSERT INTO notify_log_temp (session_key, rating_key, user_id, user, agent_id, agent_name, '
'poster_url, timestamp, notify_action) '
'SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, timestamp, '
'notify_action FROM notify_log_temp '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_play, "play" FROM notify_log WHERE on_play '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_stop, "stop" FROM notify_log WHERE on_stop '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_watched, "watched" FROM notify_log WHERE on_watched '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_pause, "pause" FROM notify_log WHERE on_pause '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_resume, "resume" FROM notify_log WHERE on_resume '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_buffer, "buffer" FROM notify_log WHERE on_buffer '
'UNION ALL SELECT session_key, rating_key, user_id, user, agent_id, agent_name, poster_url, '
'on_created, "created" FROM notify_log WHERE on_created '
'ORDER BY timestamp ')
c_db.execute(
'DROP TABLE notify_log'
)
c_db.execute(
'ALTER TABLE notify_log_temp RENAME TO notify_log'
)
# Upgrade library_sections table from earlier versions (remove UNIQUE constraint on section_id)
try:
result = c_db.execute('PRAGMA index_xinfo("sqlite_autoindex_library_sections_1")')
if result and 'server_id' not in [row[2] for row in result]:
result = c_db.execute('SELECT SQL FROM sqlite_master WHERE type="table" AND name="library_sections"').fetchone()
if 'section_id INTEGER UNIQUE' in result[0]:
logger.debug(u"Altering database. Removing unique constraint on section_id from library_sections table.")
c_db.execute(
'CREATE TABLE library_sections_temp (id INTEGER PRIMARY KEY AUTOINCREMENT, '
@@ -739,7 +815,7 @@ def dbcheck():
'ALTER TABLE library_sections_temp RENAME TO library_sections'
)
except sqlite3.OperationalError:
logger.debug(u"Unable to remove section_id unique constraint from library_sections.")
logger.warn(u"Unable to remove section_id unique constraint from library_sections.")
try:
c_db.execute(
'DROP TABLE library_sections_temp'
@@ -747,10 +823,21 @@ def dbcheck():
except:
pass
# Upgrade library_sections table from earlier versions (remove duplicated libraries)
try:
result = c_db.execute('SELECT * FROM library_sections WHERE server_id = ""')
if result.rowcount > 0:
logger.debug(u"Altering database. Removing duplicate libraries from library_sections table.")
c_db.execute(
'DELETE FROM library_sections WHERE server_id = ""'
)
except sqlite3.OperationalError:
logger.warn(u"Unable to remove duplicate libraries from library_sections table.")
# Upgrade users table from earlier versions (remove UNIQUE constraint on username)
try:
result = c_db.execute('PRAGMA index_xinfo("sqlite_autoindex_users_2")')
if result and 'username' in [row[2] for row in result]:
result = c_db.execute('SELECT SQL FROM sqlite_master WHERE type="table" AND name="users"').fetchone()
if 'username TEXT NOT NULL UNIQUE' in result[0]:
logger.debug(u"Altering database. Removing unique constraint on username from users table.")
c_db.execute(
'CREATE TABLE users_temp (id INTEGER PRIMARY KEY AUTOINCREMENT, '
@@ -773,7 +860,7 @@ def dbcheck():
'ALTER TABLE users_temp RENAME TO users'
)
except sqlite3.OperationalError:
logger.debug(u"Unable to remove username unique constraint from users.")
logger.warn(u"Unable to remove username unique constraint from users.")
try:
c_db.execute(
'DROP TABLE users_temp'
@@ -790,6 +877,7 @@ def dbcheck():
conn_db.commit()
c_db.close()
def shutdown(restart=False, update=False):
cherrypy.engine.exit()
SCHED.shutdown(wait=False)
@@ -818,10 +906,17 @@ def shutdown(restart=False, update=False):
if '--nolaunch' not in args:
args += ['--nolaunch']
logger.info('Restarting PlexPy with %s', args)
# os.execv fails with spaced names on Windows
# https://bugs.python.org/issue19066
if os.name == 'nt':
subprocess.Popen(args, cwd=os.getcwd())
else:
os.execv(exe, args)
os._exit(0)
def generate_uuid():
logger.debug(u"Generating UUID...")
return uuid.uuid4().hex

View File

@@ -16,7 +16,7 @@
import time
import plexpy
from plexpy import logger, pmsconnect, activity_processor, threading, notification_handler, helpers
from plexpy import logger, pmsconnect, activity_processor, threading, notification_handler, helpers, notifiers
class ActivityHandler(object):
@@ -57,6 +57,8 @@ class ActivityHandler(object):
if self.is_valid_session() and self.get_live_session():
logger.debug(u"PlexPy ActivityHandler :: Session %s has started." % str(self.get_session_key()))
# Check if any notification agents have notifications enabled
if any(d['on_play'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=self.get_live_session(), notify_action='play')).start()
@@ -77,11 +79,14 @@ class ActivityHandler(object):
if not force_stop:
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'])
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
# Check if any notification agents have notifications enabled
if any(d['on_stop'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=db_session, notify_action='stop')).start()
@@ -91,6 +96,7 @@ class ActivityHandler(object):
monitor_proc.write_session_history(session=db_session)
# Remove the session from our temp session table
logger.debug(u"PlexPy ActivityHandler :: Removing session %s from session queue" % str(self.get_session_key()))
ap.delete_session(session_key=self.get_session_key())
def on_pause(self):
@@ -109,6 +115,8 @@ class ActivityHandler(object):
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
# Check if any notification agents have notifications enabled
if any(d['on_pause'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=db_session, notify_action='pause')).start()
@@ -129,6 +137,8 @@ class ActivityHandler(object):
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
# Check if any notification agents have notifications enabled
if any(d['on_resume'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=db_session, notify_action='resume')).start()
@@ -156,9 +166,12 @@ class ActivityHandler(object):
(self.get_session_key(), buffer_last_triggered))
time_since_last_trigger = int(time.time()) - int(buffer_last_triggered)
if current_buffer_count >= plexpy.CONFIG.BUFFER_THRESHOLD and time_since_last_trigger == 0 or \
time_since_last_trigger >= plexpy.CONFIG.BUFFER_WAIT:
if plexpy.CONFIG.BUFFER_THRESHOLD > 0 and (current_buffer_count >= plexpy.CONFIG.BUFFER_THRESHOLD and \
time_since_last_trigger == 0 or time_since_last_trigger >= plexpy.CONFIG.BUFFER_WAIT):
ap.set_session_buffer_trigger_time(session_key=self.get_session_key())
# Check if any notification agents have notifications enabled
if any(d['on_buffer'] for d in notifiers.available_notification_agents()):
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=db_stream, notify_action='buffer')).start()
@@ -202,10 +215,17 @@ class ActivityHandler(object):
# Monitor if the stream has reached the watch percentage for notifications
# The only purpose of this is for notifications
# Check if any notification agents have notifications enabled
notify_agents = [d['id'] for d in notifiers.available_notification_agents() if d['on_watched']]
# Get the current states for notifications from our db
notified_agents = [d['agent_id'] for d in notification_handler.get_notify_state(session=db_session)
if d['notify_action'] == 'watched'] if notify_agents else []
if any(a not in notified_agents for a in notify_agents):
progress_percent = helpers.get_percent(self.timeline['viewOffset'], db_session['duration'])
if progress_percent >= plexpy.CONFIG.NOTIFY_WATCHED_PERCENT and this_state != 'buffering':
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=db_session, notify_action='watched')).start()
# Rather not put this on it's own thread so we know it completes before our next event.
notification_handler.notify(stream_data=db_session, notify_action='watched')
else:
# We don't have this session in our table yet, start a new one.

View File

@@ -13,7 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import logger, pmsconnect, plextv, notification_handler, database, helpers, activity_processor, libraries
from plexpy import logger, pmsconnect, plextv, notification_handler, database, helpers, activity_processor, libraries, notifiers
import threading
import plexpy
@@ -38,6 +38,9 @@ def check_active_sessions(ws_request=False):
if session_list:
if int_ping_count >= 3:
logger.info(u"PlexPy Monitor :: The Plex Media Server is back up.")
# Check if any notification agents have notifications enabled
if any(d['on_intup'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(notify_action='intup')).start()
@@ -58,12 +61,20 @@ def check_active_sessions(ws_request=False):
# Here we can check the play states
if session['state'] != stream['state']:
if session['state'] == 'paused':
logger.debug(u"PlexPy Monitor :: Session %s has been paused." % stream['session_key'])
# Check if any notification agents have notifications enabled
if any(d['on_pause'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=stream, notify_action='pause')).start()
if session['state'] == 'playing' and stream['state'] == 'paused':
logger.debug(u"PlexPy Monitor :: Session %s has been resumed." % stream['session_key'])
# Check if any notification agents have notifications enabled
if any(d['on_resume'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
@@ -105,6 +116,10 @@ def check_active_sessions(ws_request=False):
'WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
# Check if any notification agents have notifications enabled
if any(d['on_buffer'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=stream, notify_action='buffer')).start()
else:
@@ -119,11 +134,16 @@ def check_active_sessions(ws_request=False):
'WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
# Check if any notification agents have notifications enabled
if any(d['on_buffer'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=stream, notify_action='buffer')).start()
logger.debug(u"PlexPy Monitor :: Stream buffering. Count is now %s. Last triggered %s."
% (buffer_values[0]['buffer_count'],
logger.debug(u"PlexPy Monitor :: Session %s is buffering. Count is now %s. Last triggered %s."
% (stream['session_key'],
buffer_values[0]['buffer_count'],
buffer_values[0]['buffer_last_triggered']))
# Check if the user has reached the offset in the media we defined as the "watched" percent
@@ -132,6 +152,8 @@ def check_active_sessions(ws_request=False):
if session['view_offset'] and session['duration'] and session['state'] != 'buffering':
if helpers.get_percent(session['view_offset'],
session['duration']) > plexpy.CONFIG.NOTIFY_WATCHED_PERCENT:
# Check if any notification agents have notifications enabled
if any(d['on_watched'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
@@ -139,30 +161,52 @@ def check_active_sessions(ws_request=False):
else:
# The user has stopped playing a stream
logger.debug(u"PlexPy Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
if stream['state'] != 'stopped':
logger.debug(u"PlexPy Monitor :: Session %s has stopped." % stream['session_key'])
# Set the stream stop time
stream['stopped'] = int(time.time())
monitor_db.action('UPDATE sessions SET stopped = ?, state = ? '
'WHERE session_key = ? AND rating_key = ?',
[stream['stopped'], 'stopped', stream['session_key'], stream['rating_key']])
# Check if the user has reached the offset in the media we defined as the "watched" percent
if stream['view_offset'] and stream['duration']:
if helpers.get_percent(stream['view_offset'],
stream['duration']) > plexpy.CONFIG.NOTIFY_WATCHED_PERCENT:
# Check if any notification agents have notifications enabled
if any(d['on_watched'] for d in notifiers.available_notification_agents()):
# Push any notifications -
# Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=stream, notify_action='watched')).start()
# Check if any notification agents have notifications enabled
if any(d['on_stop'] for d in notifiers.available_notification_agents()):
# Push any notifications - Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=stream, notify_action='stop')).start()
# Write the item history on playback stop
monitor_process.write_session_history(session=stream)
success = monitor_process.write_session_history(session=stream)
if success:
# If session is written to the databaase successfully, remove the session from the session table
logger.debug(u"PlexPy Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
else:
logger.warn(u"PlexPy Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \
"Will try again on the next pass." % (stream['session_key'], stream['rating_key']))
# Process the newly received session data
for session in media_container:
monitor_process.write_session(session)
new_session = monitor_process.write_session(session)
if new_session:
logger.debug(u"PlexPy Monitor :: Session %s has started." % session['session_key'])
else:
logger.debug(u"PlexPy Monitor :: Unable to read session list.")
@@ -171,6 +215,8 @@ def check_active_sessions(ws_request=False):
% str(int_ping_count))
if int_ping_count == 3:
# Check if any notification agents have notifications enabled
if any(d['on_intdown'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(notify_action='intdown')).start()
@@ -225,6 +271,9 @@ def check_recently_added():
if 0 < time_threshold - int(item['added_at']) <= time_interval:
logger.debug(u"PlexPy Monitor :: Library item %s has been added to Plex." % str(item['rating_key']))
# Check if any notification agents have notifications enabled
if any(d['on_created'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(timeline_data=item, notify_action='created')).start()
@@ -243,6 +292,9 @@ def check_recently_added():
% str(item['rating_key']))
logger.debug(u"PlexPy Monitor :: Library item %s has been added to Plex." % str(item['rating_key']))
# Check if any notification agents have notifications enabled
if any(d['on_created'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(timeline_data=item, notify_action='created')).start()
@@ -275,12 +327,44 @@ def check_server_response():
else:
if ext_ping_count >= 3:
logger.info(u"PlexPy Monitor :: Plex remote access is back up.")
# Check if any notification agents have notifications enabled
if any(d['on_extup'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(notify_action='extup')).start()
ext_ping_count = 0
if ext_ping_count == 3:
# Check if any notification agents have notifications enabled
if any(d['on_extdown'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(notify_action='extdown')).start()
def check_server_updates():
with monitor_lock:
logger.info(u"PlexPy Monitor :: Checking for PMS updates...")
pms_connect = pmsconnect.PmsConnect()
server_identity = pms_connect.get_server_identity()
update_status = pms_connect.get_update_staus()
if server_identity and update_status:
version = server_identity['version']
logger.info(u"PlexPy Monitor :: Current PMS version: %s", version)
if update_status['state'] == 'available':
update_version = update_status['version']
logger.info(u"PlexPy Monitor :: PMS update available version: %s", update_version)
# Check if any notification agents have notifications enabled
if any(d['on_pmsupdate'] for d in notifiers.available_notification_agents()):
# Fire off notifications
threading.Thread(target=notification_handler.notify_timeline,
kwargs=dict(notify_action='pmsupdate')).start()
else:
logger.info(u"PlexPy Monitor :: No PMS update available.")

View File

@@ -13,7 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import logger, pmsconnect, notification_handler, log_reader, database
from plexpy import logger, pmsconnect, notification_handler, log_reader, database, notifiers
import threading
import plexpy
@@ -29,6 +29,7 @@ class ActivityProcessor(object):
def write_session(self, session=None, notify=True):
if session:
values = {'session_key': session['session_key'],
'transcode_key': session['transcode_key'],
'section_id': session['section_id'],
'rating_key': session['rating_key'],
'media_type': session['media_type'],
@@ -78,9 +79,10 @@ class ActivityProcessor(object):
result = self.db.upsert('sessions', values, keys)
if result == 'insert':
# Push any notifications - Push it on it's own thread so we don't hold up our db actions
if notify:
# Check if any notification agents have notifications enabled
if notify and any(d['on_play'] for d in notifiers.available_notification_agents()):
values.update({'ip_address': session['ip_address']})
# Push any notifications - Push it on it's own thread so we don't hold up our db actions
threading.Thread(target=notification_handler.notify,
kwargs=dict(stream_data=values, notify_action='play')).start()
@@ -97,17 +99,24 @@ class ActivityProcessor(object):
ip_address = {'ip_address': ip_address}
self.db.upsert('sessions', ip_address, keys)
return True
def write_session_history(self, session=None, import_metadata=None, is_import=False, import_ignore_interval=0):
from plexpy import users, libraries
section_id = session['section_id'] if not is_import else import_metadata['section_id']
if not is_import:
user_data = users.Users()
user_details = user_data.get_details(user_id=session['user_id'])
section_id = session['section_id'] if not is_import else import_metadata['section_id']
library_data = libraries.Libraries()
library_details = library_data.get_details(section_id=section_id)
# Return false if failed to retrieve user or library details
if not user_details or not library_details:
return False
if session:
logging_enabled = False
@@ -116,8 +125,13 @@ class ActivityProcessor(object):
stopped = int(session['stopped'])
else:
stopped = int(time.time())
elif session['stopped']:
stopped = int(session['stopped'])
else:
stopped = int(time.time())
self.set_session_state(session_key=session['session_key'],
state='stopped',
stopped=stopped)
if plexpy.CONFIG.MOVIE_LOGGING_ENABLE and str(session['rating_key']).isdigit() and \
session['media_type'] == 'movie':
@@ -137,14 +151,14 @@ class ActivityProcessor(object):
else:
real_play_time = stopped - session['started']
if plexpy.CONFIG.LOGGING_IGNORE_INTERVAL and not is_import:
if not is_import and plexpy.CONFIG.LOGGING_IGNORE_INTERVAL:
if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \
(real_play_time < int(plexpy.CONFIG.LOGGING_IGNORE_INTERVAL)):
logging_enabled = False
logger.debug(u"PlexPy ActivityProcessor :: Play duration for ratingKey %s is %s secs which is less than %s "
u"seconds, so we're not logging it." %
(session['rating_key'], str(real_play_time), plexpy.CONFIG.LOGGING_IGNORE_INTERVAL))
if session['media_type'] == 'track' and not is_import:
if not is_import and session['media_type'] == 'track':
if real_play_time < 15 and session['duration'] >= 30:
logging_enabled = False
logger.debug(u"PlexPy ActivityProcessor :: Play duration for ratingKey %s is %s secs, "
@@ -156,17 +170,29 @@ class ActivityProcessor(object):
logging_enabled = False
logger.debug(u"PlexPy ActivityProcessor :: Play duration for ratingKey %s is %s secs which is less than %s "
u"seconds, so we're not logging it." %
(session['rating_key'], str(real_play_time),
import_ignore_interval))
(session['rating_key'], str(real_play_time), import_ignore_interval))
if not user_details['keep_history'] and not is_import:
if not is_import and not user_details['keep_history']:
logging_enabled = False
logger.debug(u"PlexPy ActivityProcessor :: History logging for user '%s' is disabled." % user_details['username'])
elif not library_details['keep_history'] and not is_import:
elif not is_import and not library_details['keep_history']:
logging_enabled = False
logger.debug(u"PlexPy ActivityProcessor :: History logging for library '%s' is disabled." % library_details['section_name'])
if logging_enabled:
# Fetch metadata first so we can return false if it fails
if not is_import:
logger.debug(u"PlexPy ActivityProcessor :: Fetching metadata for item ratingKey %s" % session['rating_key'])
pms_connect = pmsconnect.PmsConnect()
result = pms_connect.get_metadata_details(rating_key=str(session['rating_key']))
if result:
metadata = result['metadata']
else:
return False
else:
metadata = import_metadata
# logger.debug(u"PlexPy ActivityProcessor :: Attempting to write to session_history table...")
query = 'INSERT INTO session_history (started, stopped, rating_key, parent_rating_key, ' \
'grandparent_rating_key, media_type, user_id, user, ip_address, paused_counter, player, ' \
@@ -182,7 +208,7 @@ class ActivityProcessor(object):
self.db.action(query=query, args=args)
# Check if we should group the session, select the last two rows from the user
query = 'SELECT id, rating_key, user_id, reference_id FROM session_history \
query = 'SELECT id, rating_key, view_offset, user_id, reference_id FROM session_history \
WHERE user_id = ? ORDER BY id DESC LIMIT 2 '
args = [session['user_id']]
@@ -191,6 +217,7 @@ class ActivityProcessor(object):
new_session = {'id': result[0]['id'],
'rating_key': result[0]['rating_key'],
'view_offset': result[0]['view_offset'],
'user_id': result[0]['user_id'],
'reference_id': result[0]['reference_id']}
@@ -199,12 +226,14 @@ class ActivityProcessor(object):
else:
prev_session = {'id': result[1]['id'],
'rating_key': result[1]['rating_key'],
'view_offset': result[1]['view_offset'],
'user_id': result[1]['user_id'],
'reference_id': result[1]['reference_id']}
query = 'UPDATE session_history SET reference_id = ? WHERE id = ? '
# If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id
if (prev_session is not None) and (prev_session['rating_key'] == new_session['rating_key']):
if (prev_session is not None) and (prev_session['rating_key'] == new_session['rating_key'] \
and prev_session['view_offset'] <= new_session['view_offset']):
args = [prev_session['reference_id'], new_session['id']]
else:
args = [new_session['id'], new_session['id']]
@@ -215,13 +244,22 @@ class ActivityProcessor(object):
# % last_id)
# Write the session_history_media_info table
# Generate a combined transcode decision value
if session['video_decision'] == 'transcode' or session['audio_decision'] == 'transcode':
transcode_decision = 'transcode'
elif session['video_decision'] == 'copy' or session['audio_decision'] == 'copy':
transcode_decision = 'copy'
else:
transcode_decision = 'direct play'
# logger.debug(u"PlexPy ActivityProcessor :: Attempting to write to session_history_media_info table...")
query = 'INSERT INTO session_history_media_info (id, rating_key, video_decision, audio_decision, ' \
'duration, width, height, container, video_codec, audio_codec, bitrate, video_resolution, ' \
'video_framerate, aspect_ratio, audio_channels, transcode_protocol, transcode_container, ' \
'transcode_video_codec, transcode_audio_codec, transcode_audio_channels, transcode_width, ' \
'transcode_height) VALUES ' \
'(last_insert_rowid(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'transcode_height, transcode_decision) VALUES ' \
'(last_insert_rowid(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
args = [session['rating_key'], session['video_decision'], session['audio_decision'],
session['duration'], session['width'], session['height'], session['container'],
@@ -229,19 +267,12 @@ class ActivityProcessor(object):
session['video_resolution'], session['video_framerate'], session['aspect_ratio'],
session['audio_channels'], session['transcode_protocol'], session['transcode_container'],
session['transcode_video_codec'], session['transcode_audio_codec'],
session['transcode_audio_channels'], session['transcode_width'], session['transcode_height']]
session['transcode_audio_channels'], session['transcode_width'], session['transcode_height'],
transcode_decision]
# logger.debug(u"PlexPy ActivityProcessor :: Writing session_history_media_info transaction...")
self.db.action(query=query, args=args)
if not is_import:
logger.debug(u"PlexPy ActivityProcessor :: Fetching metadata for item ratingKey %s" % session['rating_key'])
pms_connect = pmsconnect.PmsConnect()
result = pms_connect.get_metadata_details(rating_key=str(session['rating_key']))
metadata = result['metadata']
else:
metadata = import_metadata
# Write the session_history_metadata table
directors = ";".join(metadata['directors'])
writers = ";".join(metadata['writers'])
@@ -277,6 +308,9 @@ class ActivityProcessor(object):
# logger.debug(u"PlexPy ActivityProcessor :: Writing session_history_metadata transaction...")
self.db.action(query=query, args=args)
# Return true when the session is successfully written to the database
return True
def find_session_ip(self, rating_key=None, machine_id=None):
logger.debug(u"PlexPy ActivityProcessor :: Requesting log lines...")
@@ -348,12 +382,16 @@ class ActivityProcessor(object):
return None
def set_session_state(self, session_key=None, state=None, view_offset=0):
if str(session_key).isdigit() and str(view_offset).isdigit():
values = {'view_offset': int(view_offset)}
def set_session_state(self, session_key=None, state=None, **kwargs):
if str(session_key).isdigit():
values = {}
if state:
values['state'] = state
for k,v in kwargs.iteritems():
values[k] = v
keys = {'session_key': session_key}
result = self.db.upsert('sessions', values, keys)

491
plexpy/api2.py Normal file
View File

@@ -0,0 +1,491 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of PlexPy.
#
# PlexPy 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.
#
# PlexPy 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 PlexPy. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import inspect
import json
import os
import random
import re
import time
import traceback
import cherrypy
import xmltodict
import database
import logger
import plexpy
class API2:
def __init__(self, **kwargs):
self._api_valid_methods = self._api_docs().keys()
self._api_authenticated = False
self._api_out_type = 'json' # default
self._api_msg = None
self._api_debug = None
self._api_cmd = None
self._api_apikey = None
self._api_callback = None # JSONP
self._api_result_type = 'failed'
self._api_profileme = None # For profiling the api call
self._api_kwargs = None # Cleaned kwargs
def _api_docs(self, md=False):
""" Makes the api docs """
docs = {}
for f, _ in inspect.getmembers(self, predicate=inspect.ismethod):
if not f.startswith('_') and not f.startswith('_api'):
if md is True:
docs[f] = inspect.getdoc(getattr(self, f)) if inspect.getdoc(getattr(self, f)) else None
else:
docs[f] = ' '.join(inspect.getdoc(getattr(self, f)).split()) if inspect.getdoc(getattr(self, f)) else None
return docs
def docs_md(self):
""" Return a API.md to simplify api docs because of the decorator. """
return self._api_make_md()
def docs(self):
""" Returns a dict where commands are keys, docstring are value. """
return self._api_docs()
def _api_validate(self, *args, **kwargs):
""" sets class vars and remove unneeded parameters. """
if not plexpy.CONFIG.API_ENABLED:
self._api_msg = 'API not enabled'
elif not plexpy.CONFIG.API_KEY:
self._api_msg = 'API key not generated'
elif len(plexpy.CONFIG.API_KEY) != 32:
self._api_msg = 'API key not generated correctly'
elif 'apikey' not in kwargs:
self._api_msg = 'Parameter apikey is required'
elif kwargs.get('apikey', '') != plexpy.CONFIG.API_KEY:
self._api_msg = 'Invalid apikey'
elif 'cmd' not in kwargs:
self._api_msg = 'Parameter cmd is required. Possible commands are: %s' % ', '.join(self._api_valid_methods)
elif 'cmd' in kwargs and kwargs.get('cmd') not in self._api_valid_methods:
self._api_msg = 'Unknown command: %s. Possible commands are: %s' % (kwargs.get('cmd', ''), ', '.join(self._api_valid_methods))
self._api_callback = kwargs.pop('callback', None)
self._api_apikey = kwargs.pop('apikey', None)
self._api_cmd = kwargs.pop('cmd', None)
self._api_debug = kwargs.pop('debug', False)
self._api_profileme = kwargs.pop('profileme', None)
# Allow override for the api.
self._api_out_type = kwargs.pop('out_type', 'json')
if self._api_apikey == plexpy.CONFIG.API_KEY and plexpy.CONFIG.API_ENABLED and self._api_cmd in self._api_valid_methods:
self._api_authenticated = True
self._api_msg = None
self._api_kwargs = kwargs
elif self._api_cmd in ('get_apikey', 'docs', 'docs_md') and plexpy.CONFIG.API_ENABLED:
self._api_authenticated = True
# Remove the old error msg
self._api_msg = None
self._api_kwargs = kwargs
logger.debug(u'PlexPy APIv2 :: Cleaned kwargs %s' % self._api_kwargs)
return self._api_kwargs
def get_logs(self, sort='', search='', order='desc', regex='', start=0, end=0, **kwargs):
"""
Returns the log
Args:
sort(string, optional): time, thread, msg, loglevel
search(string, optional): 'string'
order(string, optional): desc, asc
regex(string, optional): 'regexstring'
start(int, optional): int
end(int, optional): int
Returns:
```{"response":
{"msg": "Hey",
"result": "success"},
"data": [
{"time": "29-sept.2015",
"thread: "MainThread",
"msg: "Called x from y",
"loglevel": "DEBUG"
}
]
}
```
"""
logfile = os.path.join(plexpy.CONFIG.LOG_DIR, 'plexpy.log')
templog = []
start = int(kwargs.get('start', 0))
end = int(kwargs.get('end', 0))
if regex:
logger.debug(u'PlexPy APIv2 :: Filtering log using regex %s' % regex)
reg = re.compile('u' + regex, flags=re.I)
for line in open(logfile, 'r').readlines():
temp_loglevel_and_time = None
try:
temp_loglevel_and_time = line.split('- ')
loglvl = temp_loglevel_and_time[1].split(' :')[0].strip()
tl_tread = line.split(' :: ')
if loglvl is None:
msg = line.replace('\n', '')
else:
msg = line.split(' : ')[1].replace('\n', '')
thread = tl_tread[1].split(' : ')[0]
except IndexError:
# We assume this is a traceback
tl = (len(templog) - 1)
templog[tl]['msg'] += line.replace('\n', '')
continue
if len(line) > 1 and temp_loglevel_and_time is not None and loglvl in line:
d = {
'time': temp_loglevel_and_time[0],
'loglevel': loglvl,
'msg': msg.replace('\n', ''),
'thread': thread
}
templog.append(d)
if end > 0 or start > 0:
logger.debug(u'PlexPy APIv2 :: Slicing the log from %s to %s' % (start, end))
templog = templog[start:end]
if sort:
logger.debug(u'PlexPy APIv2 :: Sorting log based on %s' % sort)
templog = sorted(templog, key=lambda k: k[sort])
if search:
logger.debug(u'PlexPy APIv2 :: Searching log values for %s' % search)
tt = [d for d in templog for k, v in d.items() if search.lower() in v.lower()]
if len(tt):
templog = tt
if regex:
tt = []
for l in templog:
stringdict = ' '.join('{}{}'.format(k, v) for k, v in l.items())
if reg.search(stringdict):
tt.append(l)
if len(tt):
templog = tt
if order == 'desc':
templog = templog[::-1]
self.data = templog
return templog
def get_settings(self, key=''):
""" Fetches all settings from the config file
Args:
key(string, optional): 'Run the it without args to see all args'
Returns:
json:
```
{General: {api_enabled: true, ...}
Advanced: {cache_sizemb: "32", ...}}
```
"""
interface_dir = os.path.join(plexpy.PROG_DIR, 'data/interfaces/')
interface_list = [name for name in os.listdir(interface_dir) if
os.path.isdir(os.path.join(interface_dir, name))]
conf = plexpy.CONFIG._config
config = {}
# Truthify the dict
for k, v in conf.iteritems():
if isinstance(v, dict):
d = {}
for kk, vv in v.iteritems():
if vv == '0' or vv == '1':
d[kk] = bool(vv)
else:
d[kk] = vv
config[k] = d
if k == 'General':
config[k]['interface'] = interface_dir
config[k]['interface_list'] = interface_list
if key:
return config.get(key, None)
return config
def sql(self, query=''):
""" Query the db with raw sql, makes backup of
the db if the backup is older then 24h
"""
if not plexpy.CONFIG.API_SQL or not query:
return
# allow the user to shoot them self
# in the foot but not in the head..
if not len(os.listdir(plexpy.BACKUP_DIR)):
self.backupdb()
else:
# If the backup is less then 24 h old lets make a backup
if any([os.path.getctime(os.path.join(plexpy.BACKUP_DIR, file_)) <
(time.time() - 86400) for file_ in os.listdir(plexpy.BACKUP_DIR)]):
self.backupdb()
db = database.MonitorDatabase()
rows = db.select(query)
self.data = rows
return rows
def backupdb(self):
""" Creates a manual backup of the plexpy.db file """
data = database.make_backup()
if data:
self.result_type = 'success'
else:
self.result_type = 'failed'
return data
def restart(self, **kwargs):
""" Restarts plexpy """
plexpy.SIGNAL = 'restart'
self.msg = 'Restarting plexpy'
self.result_type = 'success'
def update(self, **kwargs):
""" Check for updates on Github """
plexpy.SIGNAL = 'update'
self.msg = 'Updating plexpy'
self.result_type = 'success'
def _api_make_md(self):
""" Tries to make a API.md to simplify the api docs """
head = '''# API Reference\n
The API is still pretty new and needs some serious cleaning up on the backend but should be reasonably functional. There are no error codes yet.
## General structure
The API endpoint is `http://ip:port + HTTP_ROOT + /api?apikey=$apikey&cmd=$command`
Response example
```
{
"response": {
"data": [
{
"loglevel": "INFO",
"msg": "Signal 2 caught, saving and exiting...",
"thread": "MainThread",
"time": "22-sep-2015 01:42:56 "
}
],
"message": null,
"result": "success"
}
}
```
General parameters:
out_type: 'xml',
callback: 'pong',
'debug': 1
## API methods'''
body = ''
doc = self._api_docs(md=True)
for k in sorted(doc):
v = doc.get(k)
body += '### %s\n' % k
body += '' if not v else v + '\n'
body += '\n\n'
result = head + '\n\n' + body
return '<div style="white-space: pre-wrap">' + result + '</div>'
def get_apikey(self, username='', password=''):
""" Fetches apikey
Args:
username(string, optional): Your username
password(string, optional): Your password
Returns:
string: Apikey, args are required if auth is enabled
makes and saves the apikey it does not exist
"""
apikey = hashlib.sha224(str(random.getrandbits(256))).hexdigest()[0:32]
if plexpy.CONFIG.HTTP_USERNAME and plexpy.CONFIG.HTTP_PASSWORD:
if username == plexpy.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
if plexpy.CONFIG.API_KEY:
self.data = plexpy.CONFIG.API_KEY
else:
self.data = apikey
plexpy.CONFIG.API_KEY = apikey
plexpy.CONFIG.write()
else:
self.msg = 'Authentication is enabled, please add the correct username and password to the parameters'
else:
if plexpy.CONFIG.API_KEY:
self.data = plexpy.CONFIG.API_KEY
else:
# Make a apikey if the doesn't exist
self.data = apikey
plexpy.CONFIG.API_KEY = apikey
plexpy.CONFIG.write()
return self.data
def _api_responds(self, result_type='success', data=None, msg=''):
""" Formats the result to a predefined dict so we can hange it the to
the desired output by _api_out_as """
if data is None:
data = {}
return {"response": {"result": result_type, "message": msg, "data": data}}
def _api_out_as(self, out):
""" Formats the response to the desired output """
if self._api_cmd == 'docs_md':
return out['response']['data']
if self._api_out_type == 'json':
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
try:
if self._api_debug:
out = json.dumps(out, indent=4, sort_keys=True)
else:
out = json.dumps(out)
if self._api_callback is not None:
cherrypy.response.headers['Content-Type'] = 'application/javascript'
# wrap with JSONP call if requested
out = self._api_callback + '(' + out + ');'
# if we fail to generate the output fake an error
except Exception as e:
logger.info(u'PlexPy APIv2 :: ' + traceback.format_exc())
out['message'] = traceback.format_exc()
out['result'] = 'error'
elif self._api_out_type == 'xml':
cherrypy.response.headers['Content-Type'] = 'application/xml'
try:
out = xmltodict.unparse(out, pretty=True)
except Exception as e:
logger.error(u'PlexPy APIv2 :: Failed to parse xml result')
try:
out['message'] = e
out['result'] = 'error'
out = xmltodict.unparse(out, pretty=True)
except Exception as e:
logger.error(u'PlexPy APIv2 :: Failed to parse xml result error message %s' % e)
out = '''<?xml version="1.0" encoding="utf-8"?>
<response>
<message>%s</message>
<data></data>
<result>error</result>
</response>
''' % e
return out
def _api_run(self, *args, **kwargs):
""" handles the stuff from the handler """
result = {}
logger.debug(u'PlexPy APIv2 :: Original kwargs was %s' % kwargs)
self._api_validate(**kwargs)
if self._api_cmd and self._api_authenticated:
call = getattr(self, self._api_cmd)
# Profile is written to console.
if self._api_profileme:
from profilehooks import profile
call = profile(call, immediate=True)
# We allow this to fail so we get a
# traceback in the browser
if self._api_debug:
result = call(**self._api_kwargs)
else:
try:
result = call(**self._api_kwargs)
except Exception as e:
logger.error(u'PlexPy APIv2 :: Failed to run %s %s %s' % (self._api_cmd, self._api_kwargs, e))
ret = None
# The api decorated function can return different result types.
# convert it to a list/dict before we change it to the users
# wanted output
try:
if isinstance(result, (dict, list)):
ret = result
else:
raise
except:
try:
ret = json.loads(result)
except (ValueError, TypeError):
try:
ret = xmltodict.parse(result, attr_prefix='')
except:
pass
# Fallback if we cant "parse the reponse"
if ret is None:
ret = result
if ret or self._api_result_type == 'success':
# To allow override for restart etc
# if the call returns some data we are gonna assume its a success
self._api_result_type = 'success'
else:
self._api_result_type = 'error'
return self._api_out_as(self._api_responds(result_type=self._api_result_type, msg=self._api_msg, data=ret))

View File

@@ -58,3 +58,15 @@ MEDIA_FLAGS_VIDEO = {'avc1': 'h264',
'wmv(1|2)': 'wmv',
'wmv3': 'wmvhd'
}
SCHEDULER_LIST = ['Check GitHub for updates',
'Check for active sessions',
'Check for recently added items',
'Check for Plex updates',
'Check for Plex remote access',
'Refresh users list',
'Refresh libraries list',
'Refresh Plex server URLs',
'Refresh Plex server name',
'Backup PlexPy database'
]

View File

@@ -25,13 +25,15 @@ _CONFIG_DEFINITIONS = {
'PMS_NAME': (unicode, 'PMS', ''),
'PMS_PORT': (int, 'PMS', 32400),
'PMS_TOKEN': (str, 'PMS', ''),
'PMS_SSL': (int, 'General', 0),
'PMS_SSL': (int, 'PMS', 0),
'PMS_URL': (str, 'PMS', ''),
'PMS_USE_BIF': (int, 'PMS', 0),
'PMS_UUID': (str, 'PMS', ''),
'TIME_FORMAT': (str, 'General', 'HH:mm'),
'ANON_REDIRECT': (str, 'General', 'http://dereferer.org/?'),
'API_ENABLED': (int, 'General', 0),
'API_KEY': (str, 'General', ''),
'API_SQL': (int, 'General', 0),
'BOXCAR_ENABLED': (int, 'Boxcar', 0),
'BOXCAR_TOKEN': (str, 'Boxcar', ''),
'BOXCAR_SOUND': (str, 'Boxcar', ''),
@@ -46,8 +48,10 @@ _CONFIG_DEFINITIONS = {
'BOXCAR_ON_INTDOWN': (int, 'Boxcar', 0),
'BOXCAR_ON_EXTUP': (int, 'Boxcar', 0),
'BOXCAR_ON_INTUP': (int, 'Boxcar', 0),
'BOXCAR_ON_PMSUPDATE': (int, 'Boxcar', 0),
'BUFFER_THRESHOLD': (int, 'Monitoring', 3),
'BUFFER_WAIT': (int, 'Monitoring', 900),
'BACKUP_DIR': (str, 'General', ''),
'CACHE_DIR': (str, 'General', ''),
'CACHE_SIZEMB': (int, 'Advanced', 32),
'CHECK_GITHUB': (int, 'General', 1),
@@ -78,6 +82,7 @@ _CONFIG_DEFINITIONS = {
'EMAIL_ON_INTDOWN': (int, 'Email', 0),
'EMAIL_ON_EXTUP': (int, 'Email', 0),
'EMAIL_ON_INTUP': (int, 'Email', 0),
'EMAIL_ON_PMSUPDATE': (int, 'Email', 0),
'ENABLE_HTTPS': (int, 'General', 0),
'FACEBOOK_ENABLED': (int, 'Facebook', 0),
'FACEBOOK_REDIRECT_URI': (str, 'Facebook', ''),
@@ -85,6 +90,9 @@ _CONFIG_DEFINITIONS = {
'FACEBOOK_APP_SECRET': (str, 'Facebook', ''),
'FACEBOOK_TOKEN': (str, 'Facebook', ''),
'FACEBOOK_GROUP': (str, 'Facebook', ''),
'FACEBOOK_INCL_PMSLINK': (int, 'Facebook', 0),
'FACEBOOK_INCL_POSTER': (int, 'Facebook', 1),
'FACEBOOK_INCL_SUBJECT': (int, 'Facebook', 1),
'FACEBOOK_ON_PLAY': (int, 'Facebook', 0),
'FACEBOOK_ON_STOP': (int, 'Facebook', 0),
'FACEBOOK_ON_PAUSE': (int, 'Facebook', 0),
@@ -96,12 +104,14 @@ _CONFIG_DEFINITIONS = {
'FACEBOOK_ON_INTDOWN': (int, 'Facebook', 0),
'FACEBOOK_ON_EXTUP': (int, 'Facebook', 0),
'FACEBOOK_ON_INTUP': (int, 'Facebook', 0),
'FACEBOOK_ON_PMSUPDATE': (int, 'Facebook', 0),
'FIRST_RUN_COMPLETE': (int, 'General', 0),
'FREEZE_DB': (int, 'General', 0),
'GET_FILE_SIZES': (int, 'General', 0),
'GET_FILE_SIZES_HOLD': (dict, 'General', {'section_ids': [], 'rating_keys': []}),
'GIT_BRANCH': (str, 'General', 'master'),
'GIT_PATH': (str, 'General', ''),
'GIT_TOKEN': (str, 'General', ''),
'GIT_USER': (str, 'General', 'drzoidberg33'),
'GRAPH_TYPE': (str, 'General', 'plays'),
'GRAPH_DAYS': (int, 'General', 30),
@@ -121,14 +131,19 @@ _CONFIG_DEFINITIONS = {
'GROWL_ON_INTDOWN': (int, 'Growl', 0),
'GROWL_ON_EXTUP': (int, 'Growl', 0),
'GROWL_ON_INTUP': (int, 'Growl', 0),
'GROWL_ON_PMSUPDATE': (int, 'Growl', 0),
'HOME_LIBRARY_CARDS': (list, 'General', ['first_run']),
'HOME_STATS_LENGTH': (int, 'General', 30),
'HOME_STATS_TYPE': (int, 'General', 0),
'HOME_STATS_COUNT': (int, 'General', 5),
'HOME_STATS_CARDS': (list, 'General', ['top_tv', 'popular_tv', 'top_movies', 'popular_movies', 'top_music', \
'popular_music', 'last_watched', 'top_users', 'top_platforms', 'most_concurrent']),
'HTTPS_CREATE_CERT': (int, 'General', 1),
'HTTPS_CERT': (str, 'General', ''),
'HTTPS_KEY': (str, 'General', ''),
'HTTPS_DOMAIN': (str, 'General', 'localhost'),
'HTTPS_IP': (str, 'General', '127.0.0.1'),
'HTTP_ENVIRONMENT': (str, 'General', 'production'),
'HTTP_HOST': (str, 'General', '0.0.0.0'),
'HTTP_PASSWORD': (str, 'General', ''),
'HTTP_PORT': (int, 'General', 8181),
@@ -151,8 +166,10 @@ _CONFIG_DEFINITIONS = {
'IFTTT_ON_INTDOWN': (int, 'IFTTT', 0),
'IFTTT_ON_EXTUP': (int, 'IFTTT', 0),
'IFTTT_ON_INTUP': (int, 'IFTTT', 0),
'IFTTT_ON_PMSUPDATE': (int, 'IFTTT', 0),
'JOURNAL_MODE': (str, 'Advanced', 'wal'),
'LAUNCH_BROWSER': (int, 'General', 1),
'LOG_BLACKLIST': (int, 'General', 1),
'LOG_DIR': (str, 'General', ''),
'LOGGING_IGNORE_INTERVAL': (int, 'Monitoring', 120),
'MOVIE_LOGGING_ENABLE': (int, 'Monitoring', 1),
@@ -165,6 +182,7 @@ _CONFIG_DEFINITIONS = {
'MUSIC_NOTIFY_ON_START': (int, 'Monitoring', 1),
'MUSIC_NOTIFY_ON_STOP': (int, 'Monitoring', 0),
'MUSIC_NOTIFY_ON_PAUSE': (int, 'Monitoring', 0),
'MONITOR_PMS_UPDATES': (int, 'Monitoring', 0),
'MONITOR_REMOTE_ACCESS': (int, 'Monitoring', 0),
'MONITORING_INTERVAL': (int, 'Monitoring', 60),
'MONITORING_USE_WEBSOCKET': (int, 'Monitoring', 0),
@@ -182,7 +200,9 @@ _CONFIG_DEFINITIONS = {
'NMA_ON_INTDOWN': (int, 'NMA', 0),
'NMA_ON_EXTUP': (int, 'NMA', 0),
'NMA_ON_INTUP': (int, 'NMA', 0),
'NMA_ON_PMSUPDATE': (int, 'NMA', 0),
'NOTIFY_CONSECUTIVE': (int, 'Monitoring', 1),
'NOTIFY_UPLOAD_POSTERS': (int, 'Monitoring', 0),
'NOTIFY_RECENTLY_ADDED': (int, 'Monitoring', 0),
'NOTIFY_RECENTLY_ADDED_GRANDPARENT': (int, 'Monitoring', 0),
'NOTIFY_RECENTLY_ADDED_DELAY': (int, 'Monitoring', 60),
@@ -209,6 +229,8 @@ _CONFIG_DEFINITIONS = {
'NOTIFY_ON_EXTUP_BODY_TEXT': (unicode, 'Monitoring', 'The Plex Media Server remote access is back up.'),
'NOTIFY_ON_INTUP_SUBJECT_TEXT': (unicode, 'Monitoring', 'PlexPy ({server_name})'),
'NOTIFY_ON_INTUP_BODY_TEXT': (unicode, 'Monitoring', 'The Plex Media Server is back up.'),
'NOTIFY_ON_PMSUPDATE_SUBJECT_TEXT': (unicode, 'Monitoring', 'PlexPy ({server_name})'),
'NOTIFY_ON_PMSUPDATE_BODY_TEXT': (unicode, 'Monitoring', 'An update is available for the Plex Media Server (version {update_version}).'),
'NOTIFY_SCRIPTS_ARGS_TEXT': (unicode, 'Monitoring', ''),
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/PlexPy'),
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
@@ -223,6 +245,7 @@ _CONFIG_DEFINITIONS = {
'OSX_NOTIFY_ON_INTDOWN': (int, 'OSX_Notify', 0),
'OSX_NOTIFY_ON_EXTUP': (int, 'OSX_Notify', 0),
'OSX_NOTIFY_ON_INTUP': (int, 'OSX_Notify', 0),
'OSX_NOTIFY_ON_PMSUPDATE': (int, 'OSX_Notify', 0),
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
'PLEX_ENABLED': (int, 'Plex', 0),
'PLEX_PASSWORD': (str, 'Plex', ''),
@@ -238,6 +261,7 @@ _CONFIG_DEFINITIONS = {
'PLEX_ON_INTDOWN': (int, 'Plex', 0),
'PLEX_ON_EXTUP': (int, 'Plex', 0),
'PLEX_ON_INTUP': (int, 'Plex', 0),
'PLEX_ON_PMSUPDATE': (int, 'Plex', 0),
'PROWL_ENABLED': (int, 'Prowl', 0),
'PROWL_KEYS': (str, 'Prowl', ''),
'PROWL_PRIORITY': (int, 'Prowl', 0),
@@ -252,6 +276,7 @@ _CONFIG_DEFINITIONS = {
'PROWL_ON_INTDOWN': (int, 'Prowl', 0),
'PROWL_ON_EXTUP': (int, 'Prowl', 0),
'PROWL_ON_INTUP': (int, 'Prowl', 0),
'PROWL_ON_PMSUPDATE': (int, 'Prowl', 0),
'PUSHALOT_APIKEY': (str, 'Pushalot', ''),
'PUSHALOT_ENABLED': (int, 'Pushalot', 0),
'PUSHALOT_ON_PLAY': (int, 'Pushalot', 0),
@@ -265,6 +290,7 @@ _CONFIG_DEFINITIONS = {
'PUSHALOT_ON_INTDOWN': (int, 'Pushalot', 0),
'PUSHALOT_ON_EXTUP': (int, 'Pushalot', 0),
'PUSHALOT_ON_INTUP': (int, 'Pushalot', 0),
'PUSHALOT_ON_PMSUPDATE': (int, 'Pushalot', 0),
'PUSHBULLET_APIKEY': (str, 'PushBullet', ''),
'PUSHBULLET_DEVICEID': (str, 'PushBullet', ''),
'PUSHBULLET_CHANNEL_TAG': (str, 'PushBullet', ''),
@@ -280,8 +306,10 @@ _CONFIG_DEFINITIONS = {
'PUSHBULLET_ON_INTDOWN': (int, 'PushBullet', 0),
'PUSHBULLET_ON_EXTUP': (int, 'PushBullet', 0),
'PUSHBULLET_ON_INTUP': (int, 'PushBullet', 0),
'PUSHBULLET_ON_PMSUPDATE': (int, 'PushBullet', 0),
'PUSHOVER_APITOKEN': (str, 'Pushover', ''),
'PUSHOVER_ENABLED': (int, 'Pushover', 0),
'PUSHOVER_HTML_SUPPORT': (int, 'Pushover', 1),
'PUSHOVER_KEYS': (str, 'Pushover', ''),
'PUSHOVER_PRIORITY': (int, 'Pushover', 0),
'PUSHOVER_SOUND': (str, 'Pushover', ''),
@@ -296,6 +324,7 @@ _CONFIG_DEFINITIONS = {
'PUSHOVER_ON_INTDOWN': (int, 'Pushover', 0),
'PUSHOVER_ON_EXTUP': (int, 'Pushover', 0),
'PUSHOVER_ON_INTUP': (int, 'Pushover', 0),
'PUSHOVER_ON_PMSUPDATE': (int, 'Pushover', 0),
'REFRESH_LIBRARIES_INTERVAL': (int, 'Monitoring', 12),
'REFRESH_LIBRARIES_ON_STARTUP': (int, 'Monitoring', 1),
'REFRESH_USERS_INTERVAL': (int, 'Monitoring', 12),
@@ -304,6 +333,7 @@ _CONFIG_DEFINITIONS = {
'SLACK_HOOK': (str, 'Slack', ''),
'SLACK_CHANNEL': (str, 'Slack', ''),
'SLACK_ICON_EMOJI': (str, 'Slack', ''),
'SLACK_INCL_SUBJECT': (int, 'Slack', 1),
'SLACK_USERNAME': (str, 'Slack', ''),
'SLACK_ON_PLAY': (int, 'Slack', 0),
'SLACK_ON_STOP': (int, 'Slack', 0),
@@ -316,6 +346,7 @@ _CONFIG_DEFINITIONS = {
'SLACK_ON_INTDOWN': (int, 'Slack', 0),
'SLACK_ON_EXTUP': (int, 'Slack', 0),
'SLACK_ON_INTUP': (int, 'Slack', 0),
'SLACK_ON_PMSUPDATE': (int, 'Slack', 0),
'SCRIPTS_ENABLED': (int, 'Scripts', 0),
'SCRIPTS_FOLDER': (unicode, 'Scripts', ''),
'SCRIPTS_ON_PLAY': (int, 'Scripts', 0),
@@ -329,6 +360,7 @@ _CONFIG_DEFINITIONS = {
'SCRIPTS_ON_EXTUP': (int, 'Scripts', 0),
'SCRIPTS_ON_INTDOWN': (int, 'Scripts', 0),
'SCRIPTS_ON_INTUP': (int, 'Scripts', 0),
'SCRIPTS_ON_PMSUPDATE': (int, 'Scripts', 0),
'SCRIPTS_ON_PLAY_SCRIPT': (unicode, 'Scripts', ''),
'SCRIPTS_ON_STOP_SCRIPT': (unicode, 'Scripts', ''),
'SCRIPTS_ON_PAUSE_SCRIPT': (unicode, 'Scripts', ''),
@@ -340,9 +372,11 @@ _CONFIG_DEFINITIONS = {
'SCRIPTS_ON_EXTUP_SCRIPT': (unicode, 'Scripts', ''),
'SCRIPTS_ON_INTDOWN_SCRIPT': (unicode, 'Scripts', ''),
'SCRIPTS_ON_INTUP_SCRIPT': (unicode, 'Scripts', ''),
'SCRIPTS_ON_PMSUPDATE_SCRIPT': (unicode, 'Scripts', ''),
'TELEGRAM_BOT_TOKEN': (str, 'Telegram', ''),
'TELEGRAM_ENABLED': (int, 'Telegram', 0),
'TELEGRAM_CHAT_ID': (str, 'Telegram', ''),
'TELEGRAM_INCL_SUBJECT': (int, 'Telegram', 1),
'TELEGRAM_ON_PLAY': (int, 'Telegram', 0),
'TELEGRAM_ON_STOP': (int, 'Telegram', 0),
'TELEGRAM_ON_PAUSE': (int, 'Telegram', 0),
@@ -354,15 +388,18 @@ _CONFIG_DEFINITIONS = {
'TELEGRAM_ON_INTDOWN': (int, 'Telegram', 0),
'TELEGRAM_ON_EXTUP': (int, 'Telegram', 0),
'TELEGRAM_ON_INTUP': (int, 'Telegram', 0),
'TELEGRAM_ON_PMSUPDATE': (int, 'Telegram', 0),
'TV_LOGGING_ENABLE': (int, 'Monitoring', 1),
'TV_NOTIFY_ENABLE': (int, 'Monitoring', 0),
'TV_NOTIFY_ON_START': (int, 'Monitoring', 1),
'TV_NOTIFY_ON_STOP': (int, 'Monitoring', 0),
'TV_NOTIFY_ON_PAUSE': (int, 'Monitoring', 0),
'TWITTER_ENABLED': (int, 'Twitter', 0),
'TWITTER_PASSWORD': (str, 'Twitter', ''),
'TWITTER_PREFIX': (str, 'Twitter', 'PlexPy'),
'TWITTER_USERNAME': (str, 'Twitter', ''),
'TWITTER_ACCESS_TOKEN': (str, 'Twitter', ''),
'TWITTER_ACCESS_TOKEN_SECRET': (str, 'Twitter', ''),
'TWITTER_CONSUMER_KEY': (str, 'Twitter', ''),
'TWITTER_CONSUMER_SECRET': (str, 'Twitter', ''),
'TWITTER_INCL_SUBJECT': (int, 'Twitter', 1),
'TWITTER_ON_PLAY': (int, 'Twitter', 0),
'TWITTER_ON_STOP': (int, 'Twitter', 0),
'TWITTER_ON_PAUSE': (int, 'Twitter', 0),
@@ -374,6 +411,7 @@ _CONFIG_DEFINITIONS = {
'TWITTER_ON_INTDOWN': (int, 'Twitter', 0),
'TWITTER_ON_EXTUP': (int, 'Twitter', 0),
'TWITTER_ON_INTUP': (int, 'Twitter', 0),
'TWITTER_ON_PMSUPDATE': (int, 'Twitter', 0),
'UPDATE_DB_INTERVAL': (int, 'General', 24),
'UPDATE_SECTION_IDS': (int, 'General', 1),
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
@@ -392,9 +430,13 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_EXTDOWN': (int, 'XBMC', 0),
'XBMC_ON_INTDOWN': (int, 'XBMC', 0),
'XBMC_ON_EXTUP': (int, 'XBMC', 0),
'XBMC_ON_INTUP': (int, 'XBMC', 0)
'XBMC_ON_INTUP': (int, 'XBMC', 0),
'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0)
}
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID']
_WHITELIST_KEYS = ['HTTPS_KEY', 'UPDATE_SECTION_IDS']
# pylint:disable=R0902
# it might be nice to refactor for fewer instance variables
@@ -408,6 +450,19 @@ class Config(object):
for key in _CONFIG_DEFINITIONS.keys():
self.check_setting(key)
self._upgrade()
self._blacklist()
def _blacklist(self):
""" Add tokens and passwords to blacklisted words in logger """
blacklist = []
for key, subkeys in self._config.iteritems():
for subkey, value in subkeys.iteritems():
if isinstance(value, basestring) and len(value.strip()) > 5 and \
subkey.upper() not in _WHITELIST_KEYS and any(bk in subkey.upper() for bk in _BLACKLIST_KEYS):
blacklist.append(value.strip())
plexpy.logger._BLACKLIST_WORDS = blacklist
def _define(self, name):
key = name.upper()
@@ -467,6 +522,8 @@ class Config(object):
except IOError as e:
plexpy.logger.error("Error writing configuration file: %s", e)
self._blacklist()
def __getattr__(self, name):
"""
Returns something from the ini unless it is a real property
@@ -511,6 +568,7 @@ class Config(object):
self.MOVIE_LOGGING_ENABLE = 0
self.TV_LOGGING_ENABLE = 0
self.CONFIG_VERSION = '1'
if self.CONFIG_VERSION == '1':
# Change home_stats_cards to list
if self.HOME_STATS_CARDS:
@@ -525,3 +583,19 @@ class Config(object):
home_library_cards.remove('library_statistics')
self.HOME_LIBRARY_CARDS = home_library_cards
self.CONFIG_VERSION = '2'
if self.CONFIG_VERSION == '2':
self.NOTIFY_ON_START_SUBJECT_TEXT = self.NOTIFY_ON_START_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_START_BODY_TEXT = self.NOTIFY_ON_START_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_STOP_SUBJECT_TEXT = self.NOTIFY_ON_STOP_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_STOP_BODY_TEXT = self.NOTIFY_ON_STOP_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_PAUSE_SUBJECT_TEXT = self.NOTIFY_ON_PAUSE_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_PAUSE_BODY_TEXT = self.NOTIFY_ON_PAUSE_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_RESUME_SUBJECT_TEXT = self.NOTIFY_ON_RESUME_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_RESUME_BODY_TEXT = self.NOTIFY_ON_RESUME_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_BUFFER_SUBJECT_TEXT = self.NOTIFY_ON_BUFFER_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_BUFFER_BODY_TEXT = self.NOTIFY_ON_BUFFER_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_WATCHED_SUBJECT_TEXT = self.NOTIFY_ON_WATCHED_SUBJECT_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_ON_WATCHED_BODY_TEXT = self.NOTIFY_ON_WATCHED_BODY_TEXT.replace('{progress}','{progress_duration}')
self.NOTIFY_SCRIPTS_ARGS_TEXT = self.NOTIFY_SCRIPTS_ARGS_TEXT.replace('{progress}','{progress_duration}')
self.CONFIG_VERSION = '3'

Some files were not shown because too many files have changed in this diff Show More