Compare commits

...

262 Commits

Author SHA1 Message Date
JonnyWong16
da8d41868d v2.6.1 2020-11-03 17:51:38 -08:00
JonnyWong16
e9db43ebf6 Remove tqdm 2020-11-02 23:06:59 -08:00
JonnyWong16
c0453eae47 Fix unique img_hash in database 2020-11-02 19:49:33 -08:00
JonnyWong16
a8863a5aeb Remove cherrypy engine log filter 2020-11-02 18:39:50 -08:00
JonnyWong16
a8adad7dbb v2.6.0 2020-10-31 17:05:51 -07:00
JonnyWong16
4cfa5ac10b Remove encoding from Growl message body 2020-10-30 21:37:30 -07:00
JonnyWong16
55090ddeaa Clean up start.sh 2020-10-30 21:30:28 -07:00
JonnyWong16
14346b0e69 Improve Docker exec user 2020-10-30 21:27:39 -07:00
JonnyWong16
ac24acf9ce Publish Docker image to GitHub Container Registry 2020-10-29 21:44:27 -07:00
JonnyWong16
4cde62fde9 Update Android platform icon 2020-10-27 18:34:21 -07:00
JonnyWong16
7489bc8d98 Merge pull request #1383 from zheileman/apple-data-detectors
Fix styling of Apple data-detectors in newsletters
2020-10-25 14:00:15 -07:00
JonnyWong16
cde9287d85 Update favicon to circle logo 2020-10-25 13:42:00 -07:00
JonnyWong16
558023e18e Improve startup speed by refreshing on a separate thread 2020-10-25 13:07:42 -07:00
JonnyWong16
8157ee7811 Cache GitHub update check on startup
* Fixes Tautulli/Tautulli-Issues#184
2020-10-25 11:39:48 -07:00
JonnyWong16
d746d2913f Fix mobile device table migration 2020-10-25 10:51:32 -07:00
JonnyWong16
0136fc6436 Update plexapi.LibrarySection subclasses 2020-10-23 23:29:53 -07:00
JonnyWong16
7ce280cb92 Fix ratings on info page for new Plex Movie agent 2020-10-23 17:51:48 -07:00
JonnyWong16
0209fa87aa Update rating notification parameters for new Plex Movie agent 2020-10-23 17:46:11 -07:00
JonnyWong16
62cc2f769f Fix docker build args 2020-10-21 19:37:14 -07:00
JonnyWong16
a49d44c880 Add logger message for missing server identifier when refreshing users 2020-10-21 19:33:38 -07:00
JonnyWong16
dab288380a Change jquery .width to .css for activity progress bar
* For some reason jquery 3.5 isn't accepting `.width(progress + '%')`
2020-10-21 14:26:05 -07:00
Jesus Laiz
2ac5c35065 Fix styling of Apple data-detectors in newsletters
The existing style was not properly targetting the links Apple inject when (wrongly, in this case) detecting phone numbers in newsletters.

This has no effect in any other platform or device.
The numbers are still clickable, couldn't fine a way to disable the functionality completely (tried the `format-detection` meta tag with no luck), but at least the styles are not changed anymore.

I tested this on iPhone and iPad and you can see how it looks before and after the change below.
2020-10-21 11:47:04 +01:00
JonnyWong16
ec9e2fe0f0 Patch plexapi.library.Collections to PlexPartialObject 2020-10-20 15:49:29 -07:00
JonnyWong16
ecbe79b5b9 Add intro markers to exporter 2020-10-19 09:23:50 -07:00
JonnyWong16
c4ac03738b Add plexapi.media.Marker to plexapi.video.Episode 2020-10-19 09:21:40 -07:00
JonnyWong16
352dbd9bc8 Update brand logo colours 2020-10-17 21:25:17 -07:00
JonnyWong16
393b395df0 Add delete_synced_item to the API 2020-10-16 19:49:34 -07:00
JonnyWong16
1a96da04a1 Add sync_id parameter to get_metadata 2020-10-16 14:03:02 -07:00
JonnyWong16
615b98955a v2.6.0-beta 2020-10-16 13:27:04 -07:00
JonnyWong16
11b2b67f9d Remove set-env from release workflow 2020-10-16 13:18:52 -07:00
JonnyWong16
0e44255e6a Export photo locations 2020-10-16 12:31:34 -07:00
JonnyWong16
a649d2ec12 Add locations for plexapi.photo.Photo and plexapi.video.Clip 2020-10-16 12:31:18 -07:00
JonnyWong16
8a953e789c Add all locations to m3u8 playlists 2020-10-16 11:47:44 -07:00
JonnyWong16
317d32eb0c Merge pull request #1381 from MichaIng/patch-1
Avoid daemon forking with systemd
2020-10-16 11:40:14 -07:00
JonnyWong16
b2ddedc0ae Update API register device doc string 2020-10-15 22:47:56 -07:00
JonnyWong16
620d2cf730 Merge pull request #1382 from dotsam/margin-fix-better
Fix margins on dashboard cards
2020-10-15 22:47:21 -07:00
JonnyWong16
2578592cc7 Move ratingKey, title, and titleSort to front of csv headers 2020-10-15 22:10:42 -07:00
JonnyWong16
44c643d7da Add progress percent to export table 2020-10-15 21:56:20 -07:00
JonnyWong16
39d6edd581 Fix memory leak in exporter 2020-10-15 21:19:42 -07:00
JonnyWong16
5a14b5bc35 Add all Plex server and Tautulli info to register device response 2020-10-15 11:16:15 -07:00
JonnyWong16
82d9719eee Fix default library export type 2020-10-15 10:56:28 -07:00
JonnyWong16
401b75a76b Update docker build workflow 2020-10-15 10:15:08 -07:00
JonnyWong16
ab75628cf7 Make export threads advanced config setting 2020-10-14 23:48:58 -07:00
JonnyWong16
57d08e231c Fix typo in plexapi includeBandwidths 2020-10-14 23:25:41 -07:00
JonnyWong16
f6b800c372 Remove horizontal rule in exporter docs 2020-10-14 20:01:28 -07:00
JonnyWong16
26773ac67f Reword export individual files in export modal 2020-10-14 20:01:10 -07:00
JonnyWong16
5b63cb38ae Timestamp required for checking if export exists 2020-10-14 14:13:55 -07:00
JonnyWong16
30b655a32a Refactor saving xml and m3u8 files 2020-10-14 14:02:08 -07:00
JonnyWong16
f3fa9601c0 Rework exporter to allow exporting individual files from library 2020-10-14 12:48:08 -07:00
JonnyWong16
034ad05383 Add some more metadata to m3u8 exports 2020-10-13 18:48:21 -07:00
JonnyWong16
7b936fd664 Add include_activity parameter to get_history API 2020-10-13 16:26:35 -07:00
JonnyWong16
a120f52e0d Add grandparentTitle to m3u8 export 2020-10-13 09:15:45 -07:00
Sam Edwards
58f2d22ef4 Swap left/right margin on cards and make cards fit better on mobile 2020-10-12 23:55:13 -07:00
JonnyWong16
962777284a Add collectionSort value for custom sort 2020-10-12 21:32:16 -07:00
JonnyWong16
d4c8066209 Cast plexapi.Collections.collectionMode and collectionSort to int 2020-10-12 21:31:46 -07:00
JonnyWong16
9a9db88efd Add Dolby vision to export attributes 2020-10-12 21:13:29 -07:00
JonnyWong16
f5ad9cfe14 Add Dolby vision attributes to plexapi.media.VideoStream 2020-10-12 21:13:10 -07:00
JonnyWong16
d268a7aa23 Add back in tautulli_version to register device 2020-10-12 14:33:43 -07:00
JonnyWong16
1896239bd3 Add link to exporter guide in export modal 2020-10-12 13:08:53 -07:00
JonnyWong16
07c71750d5 Move API docs to the wiki 2020-10-12 12:53:16 -07:00
JonnyWong16
d5171109f5 Add anchor to children / item in exporter docs 2020-10-12 12:09:49 -07:00
JonnyWong16
36aa795c52 Add min_version parameter to register_device API command 2020-10-12 11:56:55 -07:00
JonnyWong16
7914f56ec3 Always close mobile device QR modal on successful registration 2020-10-12 11:54:36 -07:00
JonnyWong16
a39c6c1047 Improve sorting of json and xml export attributes 2020-10-11 16:03:16 -07:00
JonnyWong16
270e07341a Add m3u8 to export API docs 2020-10-11 14:53:09 -07:00
JonnyWong16
da7c66f414 Add stat_id and stats_start options to get_home_stats API 2020-10-11 14:31:34 -07:00
JonnyWong16
d97b87d9cc Allow deleting mobile device using the registered device_id 2020-10-11 13:55:50 -07:00
JonnyWong16
bee3361ace Return Tautulli version in register device API 2020-10-11 13:54:24 -07:00
JonnyWong16
276ea4dd98 Only build tables for exporter docs 2020-10-10 10:06:25 -07:00
JonnyWong16
dd45b47032 Filter out Cherrypy engine serving log message 2020-10-10 10:05:06 -07:00
JonnyWong16
1fd4ec3ca3 Add moment-duration-format.js plugin for graphs 2020-10-10 09:49:29 -07:00
JonnyWong16
1e807af2d4 Fix graphs tab not saving 2020-10-10 09:48:55 -07:00
JonnyWong16
88a5db05b7 Move file format to top of export modal 2020-10-09 01:24:21 -07:00
JonnyWong16
e6c8bd0c13 Add auto-generation of exporter docs 2020-10-09 00:49:17 -07:00
JonnyWong16
3be9c84f2b Add attribute sort helper function 2020-10-08 19:53:57 -07:00
JonnyWong16
881f37f731 Change default sig of human duration function 2020-10-08 19:53:32 -07:00
JonnyWong16
4d37f2bab2 Add none to export level 0 description 2020-10-08 16:14:48 -07:00
JonnyWong16
3217c2da0b Enable export of season/album artwork if selected 2020-10-08 13:33:51 -07:00
JonnyWong16
280ae04b3d Set art to None for plexapi.video.Season/Episode 2020-10-08 13:32:25 -07:00
JonnyWong16
d5705a52e9 Use collection.title if collection.sortTitle is None 2020-10-08 12:41:47 -07:00
JonnyWong16
43ab2f22a8 Update wording on image export levels 2020-10-08 11:57:41 -07:00
JonnyWong16
8bb40036bc Update API docs for export thumb_level and art_level 2020-10-08 11:21:58 -07:00
JonnyWong16
8ee934404f Reword image levels in export modal 2020-10-08 11:21:39 -07:00
JonnyWong16
aa9dbafa28 Check images exported after export 2020-10-08 11:13:00 -07:00
JonnyWong16
2b8fea8bf8 Allow addition custom export image fields 2020-10-08 11:12:09 -07:00
JonnyWong16
985f4293b3 Export images based on level 2020-10-08 10:12:28 -07:00
JonnyWong16
22b162b3c4 Cast plexapi.media.Poster selected to bool 2020-10-08 10:12:28 -07:00
JonnyWong16
7f3d8cfb8d Change thumb_level and art_level to int 2020-10-08 10:12:28 -07:00
JonnyWong16
9a7627e35e Rename include_thumb/art to thumb/art_level 2020-10-08 09:14:37 -07:00
JonnyWong16
f141c67ceb Load key before _details_key for plexapi.audio.Artist 2020-10-07 22:01:01 -07:00
JonnyWong16
e4372644e1 Better csv fields sorting 2020-10-07 20:42:04 -07:00
JonnyWong16
8552b00be4 Always try to delete export images folder 2020-10-07 20:42:04 -07:00
JonnyWong16
978fea5dde Hide collections tab for music libraries 2020-10-07 18:50:22 -07:00
JonnyWong16
b3eeaeeda5 Don't return the main child attribute as a custom field 2020-10-07 18:29:16 -07:00
JonnyWong16
0f2ac5104e Fix parsing custom fields 2020-10-07 18:25:48 -07:00
JonnyWong16
f7766fff14 Log cherrypy errors 2020-10-07 00:06:23 -07:00
JonnyWong16
b7c2e42190 Fix overflow in track list/playlist 2020-10-06 21:24:35 -07:00
JonnyWong16
56472f8dd5 Fix loading custom fields for show/artist 2020-10-06 21:18:11 -07:00
JonnyWong16
969934b8c0 Update bootstrap.min.css 2020-10-04 19:42:30 -07:00
JonnyWong16
62f153acd2 Update jquery on other pages 2020-10-04 19:37:17 -07:00
JonnyWong16
b53f16645c Set Patreon image width 2020-10-04 16:52:37 -07:00
JonnyWong16
6c2786dd78 Add noreferrer to all external links 2020-10-04 16:52:37 -07:00
JonnyWong16
64a9b0e622 Update bootstrap to v3.3.7
* Partially addresses Tautulli/Tautulli-Issues#127. Updating to bootstrap 4 requires a major rewrite.
2020-10-04 16:30:11 -07:00
JonnyWong16
3d05a74ef4 Update moment.js to 2.29.0
* Addresses Tautulli/Tautulli-Issues#127
2020-10-04 16:17:04 -07:00
JonnyWong16
3a1c92944f Update navigation tabs to work with jquery update 2020-10-04 16:10:51 -07:00
JonnyWong16
6b34b82f52 Update jquery to 3.5.1
* Fixes Tautulli/Tautulli-Issues#280
* Addresses Tautulli/Tautulli-Issues#127
2020-10-04 16:07:55 -07:00
JonnyWong16
322c090d8a Automatically show HTTP root in Public Tautulli Domain input box 2020-10-04 14:15:06 -07:00
JonnyWong16
4bb49f9836 Add jquery InputAffix 2020-10-04 14:14:20 -07:00
JonnyWong16
20566168a1 Improve photoalbum export 2020-10-04 13:01:05 -07:00
JonnyWong16
03bf4a9ef8 Add default stream state icon 2020-10-04 12:11:05 -07:00
JonnyWong16
842a76aae1 Update dict_to_xml helper function 2020-10-04 01:35:11 -07:00
JonnyWong16
e3214946a3 Fix exporter for photo albums and clips 2020-10-04 01:21:44 -07:00
JonnyWong16
36f877c7ff Update plexapi.video.Clip and plexapi.photo.Photoalbum 2020-10-04 01:18:05 -07:00
JonnyWong16
6e41b7ef3d Hide tab bar on info page for guests 2020-10-03 11:29:28 -07:00
JonnyWong16
1fc9a9bcea Add downloads badge to readme 2020-10-03 10:57:42 -07:00
JonnyWong16
3a439cb81c Update API docs 2020-10-03 10:29:50 -07:00
JonnyWong16
e8b0de0320 Improve image export help text on export modal 2020-10-03 10:27:40 -07:00
JonnyWong16
4d033bb379 Add custom fields for manually exporting season/episode images 2020-10-03 10:04:43 -07:00
JonnyWong16
fffd1ffda3 Fix missing select columns button for user IP address table 2020-10-02 23:41:16 -07:00
JonnyWong16
151f23fd92 Move common buttons to nav bar on info page 2020-10-02 23:34:30 -07:00
JonnyWong16
25572d6a5b Change key for plexapi.LibrarySection.collection 2020-10-02 23:25:50 -07:00
JonnyWong16
e27efb3946 Add kwargs to plexapi playlists fetchItems 2020-10-02 23:25:23 -07:00
JonnyWong16
ca69293d8b Fix footer text on collections and playlists tables 2020-10-02 23:19:36 -07:00
JonnyWong16
f7fa773ec7 Add thumb popover to collections and playlists tables 2020-10-02 22:03:33 -07:00
JonnyWong16
f84c4ca73c Add thumb popover to lists on info page 2020-10-02 21:58:13 -07:00
JonnyWong16
f9d828ea67 Fix import statement 2020-10-02 21:35:23 -07:00
JonnyWong16
739c977cd7 Merge branch 'v2.5-export' into nightly 2020-10-02 20:45:11 -07:00
JonnyWong16
34c9ede9c9 Add m3u8 export file format 2020-10-02 19:42:24 -07:00
JonnyWong16
be9f06795d Fix loading playlists for user not on Plex server 2020-10-02 13:05:14 -07:00
JonnyWong16
ea9904bd56 Add playlist export for users 2020-10-02 12:54:07 -07:00
JonnyWong16
501f08dd5e Add Playlist tab to user page 2020-10-02 11:57:13 -07:00
JonnyWong16
c8b0ff22f6 Update guest access for collection and playlist changes 2020-10-02 11:11:15 -07:00
JonnyWong16
3cc8c1f8c5 Fix loading Live TV library page 2020-10-02 10:27:59 -07:00
JonnyWong16
5e8b946571 Improve nav pills css 2020-10-02 10:27:45 -07:00
JonnyWong16
dd4c0d24b7 Add tabs for history and export on info pages 2020-10-02 10:14:00 -07:00
JonnyWong16
fc39f1521d Change library and user tabs to nav pills 2020-10-02 10:13:41 -07:00
JonnyWong16
60cadb1e11 Go to export tab after exporting 2020-10-02 09:56:30 -07:00
JonnyWong16
28c745c19c Patch DataTables ColVis to fix dropdown extending past bottom of page 2020-10-02 00:22:19 -07:00
JonnyWong16
900b524672 Add hidden Custom Fields column to export table 2020-10-01 21:42:15 -07:00
JonnyWong16
627129dd95 Improve XML export 2020-10-01 21:13:07 -07:00
JonnyWong16
8d18e98ca7 Fix pms_image_proxy for composites 2020-10-01 20:22:52 -07:00
JonnyWong16
0ba755e463 Make collection titles searchable in table 2020-10-01 19:43:10 -07:00
JonnyWong16
72215a9f44 Update library export API docs 2020-10-01 16:15:00 -07:00
JonnyWong16
2803a6095b Add export for all collections and all playlists 2020-10-01 14:40:28 -07:00
JonnyWong16
d1172f4975 Add playlist method to plexapi.library.LibrarySection 2020-10-01 14:40:12 -07:00
JonnyWong16
478c4540b1 Add browsing photo info pages 2020-10-01 11:46:11 -07:00
JonnyWong16
cc1076e122 Add photo playlist items on info page 2020-10-01 10:38:32 -07:00
JonnyWong16
15e928ecf2 Add items suffix to collections and playlist table 2020-10-01 09:57:01 -07:00
JonnyWong16
2c360b6472 Fix searching in collections and playlist table 2020-09-30 22:41:28 -07:00
JonnyWong16
f5c99f712a Add get_collections_table and get_playlists_table to API docs 2020-09-30 22:10:06 -07:00
JonnyWong16
f151bb1451 Add datatables processing to collections and playlists tables 2020-09-30 22:09:19 -07:00
JonnyWong16
1061c334ae Improve look of collections and playlists tables 2020-09-30 21:01:14 -07:00
JonnyWong16
c5ea50d480 Improve playlist view on info page 2020-09-30 15:54:18 -07:00
JonnyWong16
84207effab Initial collections and playlists table to library page 2020-09-30 15:44:23 -07:00
JonnyWong16
b568af0a90 Add playlist info page 2020-09-30 14:00:57 -07:00
JonnyWong16
11f2f8ff81 Don't convert requiredBandwidths to list 2020-09-30 11:18:12 -07:00
JonnyWong16
454235dd9a Add XML export format 2020-09-30 00:04:27 -07:00
JonnyWong16
ad8dee3c47 Export locations attribute is media info 2020-09-29 23:21:32 -07:00
JonnyWong16
86699ece8e Change custom fields to input instead of select 2020-09-29 20:22:16 -07:00
JonnyWong16
e9f464e34d Add get_export_fields to API docs 2020-09-29 19:41:53 -07:00
JonnyWong16
97775e2a3b Add custom fields for collections and playlists 2020-09-29 19:40:53 -07:00
JonnyWong16
54433c43e6 Disable custom fields for selected export levels 2020-09-29 19:03:40 -07:00
JonnyWong16
3376908710 Add selectize disable-options plugin
* Modified to add a `disableField` option
2020-09-29 19:02:47 -07:00
JonnyWong16
284f77b9ae Add custom fields tree for shows and artists 2020-09-29 19:01:21 -07:00
JonnyWong16
2fe49f316f Fix typo in export table footer 2020-09-29 00:40:52 -07:00
JonnyWong16
1428a2485f Delay library.section.all() to speed up export return 2020-09-29 00:28:28 -07:00
JonnyWong16
3a1b4e34aa Don't show export table fetching message when auto-refreshing 2020-09-29 00:18:24 -07:00
JonnyWong16
26fb9a6803 Add option for custom fields to the export modal 2020-09-28 23:47:32 -07:00
JonnyWong16
31e6f4282d Move selectize.js to base.html 2020-09-28 20:48:00 -07:00
JonnyWong16
6bc7de7a6d Separate export poster and art 2020-09-28 20:47:28 -07:00
JonnyWong16
0203a1d4dc Use relative image path for export 2020-09-28 12:21:22 -07:00
JonnyWong16
9fc4dbc6d6 Fix parsing custom fields for root media type 2020-09-27 17:17:38 -07:00
JonnyWong16
5dade92221 Add image export for collections and playlists 2020-09-27 17:11:08 -07:00
JonnyWong16
4e29960238 Save metadata and media info export level to database 2020-09-27 16:56:40 -07:00
JonnyWong16
3973c57020 Fix refresh export table button on info page 2020-09-27 16:55:50 -07:00
JonnyWong16
dd1dc00430 Rename API command to get_exports_table 2020-09-27 15:40:11 -07:00
JonnyWong16
9a7d6ea7d7 Hide rating key column on media export table 2020-09-27 15:39:39 -07:00
JonnyWong16
02d4a3b9fe Improve exporter API error messages 2020-09-27 15:30:10 -07:00
JonnyWong16
0a60d5f2b2 Add custom export level 0 2020-09-27 15:10:08 -07:00
JonnyWong16
13ff8f3a84 Refactor movie export levels 2020-09-27 14:39:22 -07:00
JonnyWong16
3efee000ce Add external guids to movie export 2020-09-27 14:32:10 -07:00
JonnyWong16
5915937975 Add collection labels to exporter 2020-09-27 14:28:09 -07:00
JonnyWong16
c7621a9e36 Add Guid tags to plexapi.video.Movie 2020-09-27 14:27:02 -07:00
JonnyWong16
28e2463c4f Add labels to plexapi.library.Collections 2020-09-27 14:26:32 -07:00
JonnyWong16
7016d3feea Remove extra export metadata button on collection page 2020-09-27 14:10:01 -07:00
JonnyWong16
44b4c10bf9 Update exporter API docs 2020-09-27 14:01:51 -07:00
JonnyWong16
be82c8f6d9 Add export table to info page 2020-09-27 13:59:11 -07:00
JonnyWong16
acebf96d2f Skip blank custom fields 2020-09-27 13:57:56 -07:00
JonnyWong16
27c5061d17 Add log message for processing custom fields 2020-09-27 13:30:10 -07:00
JonnyWong16
fcd034da00 Set export include_images if custom fields includes images 2020-09-27 13:27:37 -07:00
JonnyWong16
4ee9dbab41 Add ability to export custom fields 2020-09-27 13:26:04 -07:00
JonnyWong16
112811190e Fix exporting collections 2020-09-27 11:52:24 -07:00
JonnyWong16
746295aa16 Only export images for supported media types 2020-09-27 11:51:27 -07:00
JonnyWong16
693c0ba658 Refactor export get_any_hdr and get_image 2020-09-27 11:41:35 -07:00
JonnyWong16
e9f37d578e Refactor exporter 2020-09-27 11:27:16 -07:00
JonnyWong16
8f4da14611 Add collection export levels 2020-09-27 00:08:55 -07:00
JonnyWong16
063b7ce7cc Add note for export image media types 2020-09-26 23:09:18 -07:00
JonnyWong16
b7243271f3 Add photo album and photo export levels 2020-09-26 23:08:59 -07:00
JonnyWong16
395ab97191 Add titleSort to plexapi.photo.Photoalbum and plexapi.photo.Photo 2020-09-26 23:02:15 -07:00
JonnyWong16
76da200794 Add fields to plexapi.photo.Photoalbum and plexapi.photo.Photo 2020-09-26 22:53:39 -07:00
JonnyWong16
47695debdd Add artist, album, track export levels
* Refactor child levels
2020-09-26 22:48:47 -07:00
JonnyWong16
a5a2ba9d85 Export child images 2020-09-26 22:48:02 -07:00
JonnyWong16
3fa601db3e Add fields to plexapi.audio.Tracks 2020-09-26 22:40:37 -07:00
JonnyWong16
b60dcb2a23 Check path exists for adding exported image file size 2020-09-26 22:38:48 -07:00
JonnyWong16
1e173c6eeb Add working export sub media types 2020-09-26 21:42:51 -07:00
JonnyWong16
adb11db317 Use string format for export preview 2020-09-26 20:44:02 -07:00
JonnyWong16
068cb51635 Add zip file download for export with images 2020-09-26 20:36:51 -07:00
JonnyWong16
b1eab8bb0d Add include images to exporter 2020-09-26 19:26:24 -07:00
MichaIng
51d1dccb42 Avoid daemon forking with systemd
systemd units allow to run processes in foreground while daemonization is done on systemd service level when using Type=simple (default). This allows systemd to reliably track the service state, signals and could catch outputs, i.e. it is possible to remove "--quiet" to have Tautulli logging to systemd journal (journalctl) additionally or alternatively to log files.

In case of Type=forking, a PID file is required to allow system reliably determine the service state, which would be an alternative, but has no real advantage. The solution with "GuessMainPID=no" allows systemd to correctly determine the service active state, but e.g. when it is killed, it is seen as "Succeeded." since systemd cannot track the exit code or signal.
2020-09-21 23:58:30 +02:00
JonnyWong16
6f362ee2ad Rename export level options 2020-09-20 21:34:33 -07:00
JonnyWong16
f77bbda5ac Delete exported images folder 2020-09-20 21:31:48 -07:00
JonnyWong16
dceeaa77c5 Add checkbox to export images 2020-09-20 21:22:03 -07:00
JonnyWong16
d7c96d46e0 Refactor export get all metadata attrs 2020-09-20 21:21:37 -07:00
JonnyWong16
b9f5251188 Export art and thumb images 2020-09-20 21:04:57 -07:00
JonnyWong16
75cdc2c5e8 Add art and thumb url to plexapi collections 2020-09-20 21:03:05 -07:00
JonnyWong16
7eedb14834 Refactor exporter into Export class 2020-09-20 20:34:31 -07:00
JonnyWong16
ca06154805 Separate metadata and media export levels 2020-09-20 13:02:02 -07:00
JonnyWong16
35cdef1340 Add hdr attribute to media export 2020-09-20 12:27:42 -07:00
JonnyWong16
d609c0daeb Add export metadata button to collections 2020-09-20 11:23:19 -07:00
JonnyWong16
db0b157d43 Add admin auth to export modal 2020-09-20 11:23:19 -07:00
JonnyWong16
906aedd2f1 Add movie export levels 2020-09-20 11:23:19 -07:00
JonnyWong16
07a9bdbde3 Add more stream export attributes 2020-09-20 11:23:19 -07:00
JonnyWong16
588b1b1bc3 Add more stream attributes to plexapi 2020-09-20 11:23:19 -07:00
JonnyWong16
eb63f89b1f Add helper functions for export levels 2020-09-20 11:23:19 -07:00
JonnyWong16
fb81d1b6f3 Add human duration and file size to export attributes 2020-09-20 11:23:19 -07:00
JonnyWong16
b897212050 Update human_file_size helper function 2020-09-20 11:23:19 -07:00
JonnyWong16
3f6612fe9a Update human_duration helper function 2020-09-20 11:23:19 -07:00
JonnyWong16
bf1a59c5c0 Remove traceback for export 2020-09-20 11:23:19 -07:00
JonnyWong16
6fb3a3a3c8 Update export json and csv for Python 2 2020-09-20 11:23:19 -07:00
JonnyWong16
ed454b2a4a Add backports.csv 1.0.7 2020-09-20 11:23:19 -07:00
JonnyWong16
27f828e619 Message to view export on library page 2020-09-20 11:23:19 -07:00
JonnyWong16
bde0ce20d8 Fix export modal file format not working on library page 2020-09-20 11:23:19 -07:00
JonnyWong16
28c6163a31 Exporter check rating key first 2020-09-20 11:23:19 -07:00
JonnyWong16
14bb377794 Add link to export filename to view in browser 2020-09-20 11:23:19 -07:00
JonnyWong16
61c692ad4e Cancel processing exports on startup 2020-09-20 11:23:19 -07:00
JonnyWong16
42856e5ac8 Fix export modal selected file format 2020-09-20 11:23:19 -07:00
JonnyWong16
e82ad09a8d Rename row_id to exporr_id 2020-09-20 11:23:19 -07:00
JonnyWong16
40fbc55ab3 Add file size to export table 2020-09-20 11:23:19 -07:00
JonnyWong16
a27a5b023b Add file format to export modal 2020-09-20 11:23:19 -07:00
JonnyWong16
a7eb563c2e Move export thread so table can be refreshed 2020-09-20 11:23:19 -07:00
JonnyWong16
43fefcf748 Add export modal and buttons to library and info page 2020-09-20 11:23:19 -07:00
JonnyWong16
621fb95227 Auto-refresh export table if an item is processing 2020-09-20 11:23:19 -07:00
JonnyWong16
d3704fcee6 Add ability for custom calculated attributes for exporting 2020-09-20 11:23:19 -07:00
JonnyWong16
55100dfb7a Fix select columns button not showing for export table 2020-09-20 11:23:19 -07:00
JonnyWong16
010fefcbbc Link rating key in export table to info page 2020-09-20 11:23:19 -07:00
JonnyWong16
7627f025ed Change button colours on export table 2020-09-20 11:23:19 -07:00
JonnyWong16
e256d2080d Make export work on Python 2 and set failed state 2020-09-20 11:23:19 -07:00
JonnyWong16
58292067f0 Fix export csv missing dict keys 2020-09-20 11:23:19 -07:00
JonnyWong16
d5e91801d6 Fix downloading export file 2020-09-20 11:23:19 -07:00
JonnyWong16
91d545f480 Disable export delete button if still processing 2020-09-20 11:23:19 -07:00
JonnyWong16
9c2599acbe No export section_id for playlists 2020-09-20 11:23:19 -07:00
JonnyWong16
06341ee632 Fix export doc strings 2020-09-20 11:23:19 -07:00
JonnyWong16
bcc693e4c7 Add error message for failed export download 2020-09-20 11:23:19 -07:00
JonnyWong16
8b8afacaea Add function to delete exported files 2020-09-20 11:23:19 -07:00
JonnyWong16
deb49d7ff9 Add function to download exported files 2020-09-20 11:23:19 -07:00
JonnyWong16
6334ffa197 Add export directory 2020-09-20 11:23:19 -07:00
JonnyWong16
b872cce2a4 Check exported file exists 2020-09-20 11:23:19 -07:00
JonnyWong16
de2e2ee962 Add file_format to exports table 2020-09-20 11:23:19 -07:00
JonnyWong16
5468676811 Add table to list exported items 2020-09-20 11:23:19 -07:00
JonnyWong16
c102020698 Add metadata export function 2020-09-20 11:23:19 -07:00
JonnyWong16
0ff363b6ee Add export helper functions 2020-09-20 11:23:19 -07:00
JonnyWong16
c324cf69ed Merge custom plexapi 3.6.0-tautulli 2020-09-20 11:23:19 -07:00
111 changed files with 7857 additions and 9228 deletions

View File

@@ -27,17 +27,21 @@ jobs:
fi
echo ::set-output name=commit::${GITHUB_SHA}
echo ::set-output name=build_date::$(date -u +'%Y-%m-%dT%H:%M:%SZ')
echo ::set-output name=docker_platforms::linux/amd64,linux/arm64,linux/arm
echo ::set-output name=docker_platforms::linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
echo ::set-output name=docker_image::${{ secrets.DOCKER_REPO }}/tautulli
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v3
uses: docker/setup-buildx-action@v1
with:
buildx-version: latest
version: latest
- name: Cache Docker Layers
id: cache
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
@@ -45,45 +49,39 @@ jobs:
restore-keys: |
${{ runner.os }}-buildx-
- name: Docker Buildx (no push)
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform ${{ steps.prepare.outputs.docker_platforms }} \
--output "type=image,push=false" \
--build-arg "TAG=${{ steps.prepare.outputs.tag }}" \
--build-arg "BRANCH=${{ steps.prepare.outputs.branch }}" \
--build-arg "COMMIT=${{ steps.prepare.outputs.commit }}" \
--build-arg "BUILD_DATE=${{ steps.prepare.outputs.build_date }}" \
--tag "${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}" \
--file Dockerfile .
- name: Docker Login
- name: Login to DockerHub
uses: docker/login-action@v1
if: success()
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "${DOCKER_PASSWORD}" | docker login --username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Buildx (push)
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
if: success()
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--platform ${{ steps.prepare.outputs.docker_platforms }} \
--output "type=image,push=true" \
--build-arg "TAG=${{ steps.prepare.outputs.tag }}" \
--build-arg "BRANCH=${{ steps.prepare.outputs.branch }}" \
--build-arg "COMMIT=${{ steps.prepare.outputs.commit }}" \
--build-arg "BUILD_DATE=${{ steps.prepare.outputs.build_date }}" \
--tag "${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}" \
--file Dockerfile .
with:
registry: ghcr.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Clear
if: always()
run: |
rm -f ${HOME}/.docker/config.json
- name: Docker Build and Push
uses: docker/build-push-action@v2
if: success()
with:
context: .
file: ./Dockerfile
push: true
platforms: ${{ steps.prepare.outputs.docker_platforms }}
build-args: |
TAG=${{ steps.prepare.outputs.tag }}
BRANCH=${{ steps.prepare.outputs.branch }}
COMMIT=${{ steps.prepare.outputs.commit }}
BUILD_DATE=${{ steps.prepare.outputs.build_date }}
tags: |
${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
ghcr.io/${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -83,11 +83,11 @@ jobs:
shell: bash
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
echo ::set-env name=VERSION::${GITHUB_REF#refs/tags/v}
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/v}
echo ::set-output name=RELEASE_VERSION::${GITHUB_REF#refs/tags/}
else
echo ::set-env name=VERSION::0.0.0
echo "VERSION=0.0.0" >> $GITHUB_ENV
echo ::set-output name=VERSION::0.0.0
echo ::set-output name=RELEASE_VERSION::${GITHUB_SHA::7}
fi

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ version.lock
logs/*
backups/*
cache/*
exports/*
newsletters/*
*.mmdb
version.txt

2960
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,40 @@
# Changelog
## v2.6.1 (2020-11-03)
* Other:
* Fix: High CPU/memory usage in some instances.
* Fix: Logger error preventing Tautulli from starting.
* Fix: Database issue with non-unique image hashes.
## v2.6.0 (2020-10-31)
* Exporter:
* New: New exporter feature that allows you to export the metadata and images for any library, collection, playlist, or media item to csv, json, xml, or m3u8. Refer to the Exporter Guide in the wiki for more details.
* UI:
* Fix: Margin on the homepage activity and statistic/library cards. (Thanks @dotsam)
* Fix: Movie ratings not showing on the info page for the new Plex Movie agent.
* New: Added ability to browse collections and playlists from the library and user pages.
* Change: Updated platform brand logos and colours.
* API:
* New: Added export_metadata, download_export, and delete_export API commands.
* New: Added get_collections_table, and get_playlists_table API commands.
* New: Added min_version parameter to the register_device API command.
* New: Added include_activity parameter to the get_history API command.
* New: Added sync_id parameter to the get_metadata API command.
* New: Added delete_synced_item API command.
* New: Added a stat_id and stats_start parameters to the get_home_stats API command.
* New: Allow deleting a mobile device using the registration device_id for the delete_mobile_device API command.
* Change: Return Plex server info and Tautulli info from the register_device command.
* Other:
* New: The Docker container is now also built for the arm32v6 architecture.
* New: The Docker container is also published to the GitHub Container Registry at ghcr.io/tautulli/tautulli.
* Change: Tautulli is now using a forked version of plexapi 3.6.0. This is to support the exporter feature while still maintaining Python 2 compatibility.
* Change: Updated systemd script to remove process forking. (Thanks @MichaIng)
* Change: Cache GitHub update check on startup.
## v2.5.6 (2020-10-02)
* Activity:

View File

@@ -11,13 +11,16 @@ ENV TZ=UTC
WORKDIR /app
RUN \
groupadd -g 1000 tautulli && \
useradd -u 1000 -g 1000 tautulli && \
echo ${BRANCH} > /app/branch.txt && \
echo ${COMMIT} > /app/version.txt
COPY . /app
CMD [ "python", "Tautulli.py", "--datadir", "/config" ]
ENTRYPOINT [ "./start.sh" ]
VOLUME /config
EXPOSE 8181
HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1
HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1

View File

@@ -32,6 +32,7 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
[![Python](https://img.shields.io/badge/python-2.7.17,%203.6,%203.7,%203.8-blue?style=flat-square)](https://python.org/downloads)
[![Docker Pulls](https://img.shields.io/docker/pulls/tautulli/tautulli?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli)
[![Docker Stars](https://img.shields.io/docker/stars/tautulli/tautulli?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli)
[![Downloads](https://img.shields.io/github/downloads/Tautulli/Tautulli/total?style=flat-square)](https://github.com/Tautulli/Tautulli/releases/latest)
| Status | Branch: `master` | Branch: `beta` | Branch: `nightly` |
| --- | --- | --- | --- |

View File

@@ -13,8 +13,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">
@@ -22,21 +24,21 @@
${next.headIncludes()}
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.5">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.5">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.5">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.6.0">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.6.0">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS -->
<!-- Android -->
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.0.5" crossorigin="use-credentials">
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
<meta name="theme-color" content="#282a2d">
<!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.0.5" color="#282a2d">
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.6.0" color="#282a2d">
<meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft -->
<meta name="application-name" content="Tautulli">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.0.5">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.6.0">
</head>
<body class="content">
@@ -48,17 +50,17 @@
% if plexpy.UPDATE_AVAILABLE is None:
You are running an unknown version of Tautulli.<br />
% elif plexpy.UPDATE_AVAILABLE == 'release':
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank">
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank" rel="noreferrer">
new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
% elif plexpy.UPDATE_AVAILABLE == 'commit':
A <a href="${anon_url('https://github.com/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
A <a href="${anon_url('https://github.com/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank" rel="noreferrer">
newer version</a> of Tautulli is available!<br />
You are ${plexpy.COMMITS_BEHIND} commit${'s' if plexpy.COMMITS_BEHIND > 1 else ''} behind.<br />
% endif
% if plexpy.INSTALL_TYPE == 'docker':
Update your Docker container or <a href="#" id="updateDismiss">Dismiss</a>
% elif plexpy.INSTALL_TYPE in ('windows', 'macos'):
<a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank">Download</a> and install the latest version or <a href="#" id="updateDismiss">Dismiss</a>
<a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank" rel="noreferrer">Download</a> and install the latest version or <a href="#" id="updateDismiss">Dismiss</a>
% else:
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
% endif
@@ -136,7 +138,7 @@
<li><a href="settings"><i class="fa fa-fw fa-cogs"></i> Settings</a></li>
<li role="separator" class="divider"></li>
<li><a href="logs"><i class="fa fa-fw fa-list-alt"></i> View Logs</a></li>
<li><a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank"><i class="fa fa-fw fa-question-circle"></i> FAQ</a></li>
<li><a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer"><i class="fa fa-fw fa-question-circle"></i> FAQ</a></li>
<li><a href="support"><i class="fa fa-fw fa-comment"></i> Support</a></li>
<li role="separator" class="divider"></li>
<li><a href="#" data-target="#donate-modal" data-toggle="modal"><i class="fa fa-fw fa-heart"></i> Donate</a></li>
@@ -239,7 +241,7 @@ ${next.modalIncludes()}
<p>
Click the button below to continue to GitHub.
</p>
<a href="${anon_url('https://github.com/sponsors/JonnyWong16')}" target="_blank" class="btn btn-sm btn-default" style="font-weight: 600;">
<a href="${anon_url('https://github.com/sponsors/JonnyWong16')}" target="_blank" rel="noreferrer" class="btn btn-sm btn-default" style="font-weight: 600;">
<i class="fa fa-heart fa-sm" style="color: #ea4aaa;"></i>&nbsp; Sponsor
</a>
</div>
@@ -247,15 +249,15 @@ ${next.modalIncludes()}
<p>
Click the button below to continue to Patreon.
</p>
<a href="${anon_url('https://www.patreon.com/join/tautulli')}" target="_blank">
<img src="images/become_a_patron_button.png" alt="Become a Patron" height="40">
<a href="${anon_url('https://www.patreon.com/join/tautulli')}" target="_blank" rel="noreferrer">
<img src="images/become_a_patron_button.png" alt="Become a Patron" width="170" height="40">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="paypal-donation" style="text-align: center">
<p>
Click the button below to continue to PayPal.
</p>
<a href="${anon_url('https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=6XPPKTDSX9QFL&lc=US&item_name=Tautulli&currency_code=USD&bn=PP%2dDonationsBF%3abtn_donate_LG%2egif%3aNonHosted')}" target="_blank">
<a href="${anon_url('https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=6XPPKTDSX9QFL&lc=US&item_name=Tautulli&currency_code=USD&bn=PP%2dDonationsBF%3abtn_donate_LG%2egif%3aNonHosted')}" target="_blank" rel="noreferrer">
<img src="images/gold-rect-paypal-34px.png" alt="PayPal">
</a>
</div>
@@ -288,14 +290,17 @@ ${next.modalIncludes()}
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script src="${http_root}js/jquery-3.5.1.min.js"></script>
<script src="${http_root}js/bootstrap.min.js"></script>
<script src="${http_root}js/bootstrap-hover-dropdown.min.js"></script>
<script src="${http_root}js/moment-with-locales.min.js"></script>
<script src="${http_root}js/moment-duration-format.min.js"></script>
<script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/platform.min.js"></script>
<script src="${http_root}js/ipaddr.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/ajaxNotifications.js"></script>
<script>
% if _session['user_group'] == 'admin':
@@ -325,15 +330,15 @@ ${next.modalIncludes()}
if (result.update === null) {
msg = 'You are running an unknown version of Tautulli.<br />';
} else if (result.update === true && result.release === true) {
msg = 'A <a href="' + result.release_url + '" target="_blank">new release (' + result.latest_release + ')</a> of Tautulli is available!<br />';
msg = 'A <a href="' + result.release_url + '" target="_blank" rel="noreferrer">new release (' + result.latest_release + ')</a> of Tautulli is available!<br />';
} else if (result.update === true && result.release === false) {
msg = 'A <a href="' + result.compare_url + '" target="_blank">newer version</a> of Tautulli is available!<br />' +
msg = 'A <a href="' + result.compare_url + '" target="_blank" rel="noreferrer">newer version</a> of Tautulli is available!<br />' +
'You are '+ result.commits_behind + ' commit' + (result.commits_behind > 1 ? 's' : '') + ' behind.<br />';
}
if (result.install_type === 'docker') {
msg += 'Update your Docker container or <a href="#" id="updateDismiss">Dismiss</a>';
} else if (result.install_type === 'windows' || result.install_type === 'macos') {
msg += '<a href="' + result.release_url + '" target="_blank">Download</a> and install the latest version or <a href="#" id="updateDismiss">Dismiss</a>'
msg += '<a href="' + result.release_url + '" target="_blank" rel="noreferrer">Download</a> and install the latest version or <a href="#" id="updateDismiss">Dismiss</a>'
} else {
msg += '<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
}

View File

@@ -22,11 +22,11 @@ DOCUMENTATION :: END
% if plexpy.CURRENT_VERSION:
<tr>
<td>Git Branch:</td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}" target="_blank">${plexpy.CONFIG.GIT_BRANCH}</a></td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}" target="_blank" rel="noreferrer">${plexpy.CONFIG.GIT_BRANCH}</a></td>
</tr>
<tr>
<td>Git Commit Hash:</td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}" target="_blank">${plexpy.CURRENT_VERSION}</a></td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}" target="_blank" rel="noreferrer">${plexpy.CURRENT_VERSION}</a></td>
</tr>
% endif
<tr>
@@ -49,6 +49,10 @@ DOCUMENTATION :: END
<td>Cache Directory:</td>
<td>${plexpy.CONFIG.CACHE_DIR}</td>
</tr>
<tr>
<td>Export Directory:</td>
<td>${plexpy.CONFIG.EXPORT_DIR}</td>
</tr>
<tr>
<td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
@@ -74,19 +78,19 @@ DOCUMENTATION :: END
<tr>
<td class="top-line">Resources:</td>
<td class="top-line">
<a class="no-highlight" href="${anon_url('https://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('http://feathub.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="feature request">FeatHub Feature Requests</a>
<a class="no-highlight" href="${anon_url('https://tautulli.com')}" target="_blank" rel="noreferrer">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" rel="noreferrer" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">GitHub Wiki</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('http://feathub.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" rel="noreferrer" data-id="feature request">FeatHub Feature Requests</a>
</td>
</tr>
<tr>
<td>Support:</td>
<td>
<a class="no-highlight support-modal-link" href="${anon_url('https://tautulli.com/discord')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank">Tautulli Subreddit</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/t/tautulli-monitor-your-plex-media-server/225242')}" target="_blank">Plex Forums</a>
<a class="no-highlight support-modal-link" href="${anon_url('https://tautulli.com/discord')}" target="_blank" rel="noreferrer">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank" rel="noreferrer">Tautulli Subreddit</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/t/tautulli-monitor-your-plex-media-server/225242')}" target="_blank" rel="noreferrer">Plex Forums</a>
</td>
</tr>
</tbody>

File diff suppressed because one or more lines are too long

View File

@@ -71,7 +71,7 @@ ul.ColVis_collection {
list-style: none;
width: 150px;
padding: 8px 8px 4px 8px;
margin: 10px 0px 0px 0px;
margin: 10px 0px 10px 0px;
background-color: #444;
overflow: hidden;
z-index: 2002;

View File

@@ -217,6 +217,10 @@ select.form-control:focus,
.selectize-dropdown .optgroup-header {
font-weight: bold;
}
.selectize-dropdown [data-selectable].option-disabled {
color: #aaa;
cursor: default;
}
select.form-control option {
color: #555;
background-color: #eee;
@@ -746,16 +750,17 @@ a .users-poster-face:hover {
position: relative;
}
#dashboard-checking-activity,
#dashboard-no-activity {
#dashboard-no-activity,
#dashboard-checking-recently-added,
#dashboard-no-recently-added {
margin-bottom: 20px;
}
.dashboard-activity-instance {
float: left;
position: relative;
height: 290px;
min-width: 350px;
max-width: 500px;
margin-right: 25px;
margin-left: 25px;
margin-bottom: 25px;
}
.dashboard-activity-container {
@@ -1156,9 +1161,8 @@ a .dashboard-activity-metadata-user-thumb:hover {
float: left;
position: relative;
height: 160px;
min-width: 350px;
max-width: 500px;
margin-right: 25px;
margin-left: 25px;
margin-bottom: 25px;
}
.dashboard-stats-container {
@@ -1444,9 +1448,6 @@ a:hover .dashboard-stats-square {
-moz-box-shadow: inset 0 0 0 2px #e9a049;
box-shadow: inset 0 0 0 2px #e9a049;
}
#dashboard-no-recently-added {
margin-bottom: 20px;
}
.dashboard-recent-media-row {
width: 100%;
margin: 0 auto;
@@ -1750,6 +1751,7 @@ a:hover .dashboard-recent-media-cover {
box-shadow: inset 0 0 0 2px #e9a049;
opacity: 0;
transition: opacity .2s;
z-index: 2;
}
.summary-poster-face-overlay span {
display: block;
@@ -1963,7 +1965,10 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span {
.item-children-instance {
list-style: none;
margin: 0;
overflow: hidden;
overflow: auto;
}
.item-children-instance.max-height {
max-height: 700px;
}
.item-children-instance li {
float: left;
@@ -2099,7 +2104,7 @@ a:hover .item-children-poster {
}
.item-children-list-item-title {
display: inline-block;
width: calc(100% - 110px);
width: calc(100% - 125px);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -2109,9 +2114,17 @@ a:hover .item-children-poster {
color: #777;
text-align: right;
display: inline-block;
width: 40px;
width: 60px;
margin-right: 20px;
}
.nav-list {
float: left;
list-style: none;
padding: 0;
}
.nav-list.nav-pills > li > a {
margin-bottom: 0;
}
#new_title h3 {
color: #E5A00D;
font-size: 14px;
@@ -2185,32 +2198,17 @@ li.advanced-setting {
.user-info-username {
font-size: 24px;
color: #eee;
padding-top: 27px;
padding-top: 15px;
padding-left: 105px;
}
.user-info-nav {
margin-top: 15px;
}
.user-info-nav > .active > a {
color: #cc7b19;
padding-left: 105px;
}
.nav-tabs > .active > a:hover,
.nav-tabs > .active > a:focus {
color: #e9a049;
}
.user-info-nav a:hover {
color: #e9a049;
text-decoration: none;
}
.user-info-nav ul {
list-style: none;
padding: 0;
}
.user-info-nav li {
float: left;
margin-left: 10px;
margin-right: 10px;
}
.user-overview-stats-wrapper {
}
.user-overview-stats-wrapper ul {
@@ -3294,23 +3292,36 @@ pre::-webkit-scrollbar-track {
pre::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,.15);
}
#currentActivity,
#home-stats,
#library-stats {
margin-left: -25px;
}
#currentActivity > *,
#home-stats > *,
#library-stats > * {
margin-left: 25px;
}
@media only screen
and (min-device-width: 300px)
and (max-device-width: 450px) {
and (min-width: 300px)
and (max-width: 450px) {
.home-platforms-instance {
width: calc(100% - 20px);
}
.dashboard-activity-instance {
width: 100%;
}
.dashboard-activity-instance,
.dashboard-stats-instance {
width: 100%;
}
#currentActivity,
#home-stats,
#library-stats {
margin-right: 25px;
}
}
@media only screen
and (min-device-width: 300px)
and (max-device-width: 740px) {
and (min-width: 300px)
and (max-width: 740px) {
.header-bar {
display: block;
float: none !important;
@@ -3337,8 +3348,8 @@ pre::-webkit-scrollbar-thumb {
}
}
@media only screen
and (min-device-width: 740px)
and (max-device-width: 1024px) {
and (min-width: 740px)
and (max-width: 1024px) {
.button-bar {
float: right !important;
}
@@ -3485,6 +3496,9 @@ pre::-webkit-scrollbar-thumb {
.selectize-input input[type='text'] {
height: 20px;
}
.selectize-input.disabled, .selectize-input.disabled * {
cursor: not-allowed !important;
}
.small-muted {
font-size: small;
color: #777;
@@ -3707,6 +3721,20 @@ a:hover .overlay-refresh-image {
a:hover .overlay-refresh-image:hover {
opacity: .9;
}
.smart-playlist-image {
float: left;
position: absolute;
top: 5px;
left: 5px;
background-color: #8e6191;
border-radius: 4px;
color: #fff;
font-size: 16px;
z-index: 1;
width: 32px;
padding: 5px;
text-align: center;
}
#ip_error, #isp_error {
color: #aaa;
display: none;
@@ -3821,19 +3849,19 @@ a:hover .overlay-refresh-image:hover {
background-position: center !important;
}
.platform-android {
background-color: #a4ca39;
background-color: #3ddc84;
background-image: url(../images/platforms/android.svg);
}
.platform-atv {
background-color: #858487;
background-color: #a2aaad;
background-image: url(../images/platforms/atv.svg);
}
.platform-chrome {
background-color: #ed5e50;
background-color: #db4437;
background-image: url(../images/platforms/chrome.svg);
}
.platform-chromecast {
background-color: #10a4e8;
background-color: #4285f4;
background-image: url(../images/platforms/chromecast.svg);
}
.platform-default {
@@ -3841,11 +3869,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/default.svg);
}
.platform-dlna {
background-color: #0cb14b;
background-color: #4ba32f;
background-image: url(../images/platforms/dlna.svg);
}
.platform-firefox {
background-color: #e67817;
background-color: #ff7139;
background-image: url(../images/platforms/firefox.svg);
}
.platform-gtv {
@@ -3853,27 +3881,27 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/gtv.svg);
}
.platform-ie {
background-color: #00599e;
background-color: #18bcef;
background-image: url(../images/platforms/ie.svg);
}
.platform-ios {
background-color: #858487;
background-color: #a2aaad;
background-image: url(../images/platforms/ios.svg);
}
.platform-kodi {
background-color: #31afe1;
background-color: #30aada;
background-image: url(../images/platforms/kodi.svg);
}
.platform-lg {
background-color: #a50034;
background-color: #990033;
background-image: url(../images/platforms/lg.svg);
}
.platform-linux {
background-color: #1793d0;
background-color: #0099cc;
background-image: url(../images/platforms/linux.svg);
}
.platform-macos {
background-color: #858487;
background-color: #a2aaad;
background-image: url(../images/platforms/macos.svg);
}
.platform-msedge {
@@ -3881,11 +3909,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/msedge.svg);
}
.platform-opera {
background-color: #ff1b2d;
background-color: #fa1e4e;
background-image: url(../images/platforms/opera.svg);
}
.platform-playstation {
background-color: #034da2;
background-color: #003087;
background-image: url(../images/platforms/playstation.svg);
}
.platform-plex {
@@ -3897,11 +3925,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/plexamp.svg);
}
.platform-roku {
background-color: #6d3c97;
background-color: #673293;
background-image: url(../images/platforms/roku.svg);
}
.platform-safari {
background-color: #00a9ec;
background-color: #00d3f9;
background-image: url(../images/platforms/safari.svg);
}
.platform-samsung {
@@ -3921,7 +3949,7 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/wiiu.svg);
}
.platform-windows {
background-color: #2fc0f5;
background-color: #0078d7;
background-image: url(../images/platforms/windows.svg);
}
.platform-wp {
@@ -3937,55 +3965,55 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/xbox.svg);
}
.platform-android-rgba {
background-color: rgba(164, 202, 57, 0.40);
background-color: rgba(61, 220, 132, 0.40);
}
.platform-atv-rgba {
background-color: rgba(133, 132, 135, 0.40);
background-color: rgba(162, 170, 173, 0.40);
}
.platform-chrome-rgba {
background-color: rgba(237, 94, 80, 0.40);
background-color: rgba(219, 68, 55, 0.40);
}
.platform-chromecast-rgba {
background-color: rgba(16, 164, 232, 0.40);
background-color: rgba(66, 133, 244, 0.40);
}
.platform-default-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-dlna-rgba {
background-color: rgba(12, 177, 75, 0.40);
background-color: rgba(75, 163, 47, 0.40);
}
.platform-firefox-rgba {
background-color: rgba(230, 120, 23, 0.40);
background-color: rgba(255, 113, 57, 0.40);
}
.platform-gtv-rgba {
background-color: rgba(0, 139, 207, 0.40);
}
.platform-ie-rgba {
background-color: rgba(0, 89, 158, 0.40);
background-color: rgba(24, 188, 239, 0.40);
}
.platform-ios-rgba {
background-color: rgba(133, 132, 135, 0.40);
background-color: rgba(162, 170, 173, 0.40);
}
.platform-kodi-rgba {
background-color: rgba(49, 175, 225, 0.40);
background-color: rgba(48, 170, 218, 0.40);
}
.platform-lg-rgba {
background-color: rgba(165, 0, 52, 0.40);
background-color: rgba(153, 0, 51, 0.40);
}
.platform-linux-rgba {
background-color: rgba(23, 147, 208, 0.40);
background-color: rgba(0, 153, 204, 0.40);
}
.platform-macos-rgba {
background-color: rgba(133, 132, 135, 0.40);
background-color: rgba(162, 170, 173, 0.40);
}
.platform-msedge-rgba {
background-color: rgba(0, 120, 215, 0.40);
}
.platform-opera-rgba {
background-color: rgba(255, 27, 45, 0.40);
background-color: rgba(250, 30, 78, 0.40);
}
.platform-playstation-rgba {
background-color: rgba(3, 77, 162, 0.40);
background-color: rgba(0, 48, 135, 0.40);
}
.platform-plex-rgba {
background-color: rgba(229, 160, 13, 0.40);
@@ -3994,10 +4022,10 @@ a:hover .overlay-refresh-image:hover {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-roku-rgba {
background-color: rgba(109, 60, 151, 0.40);
background-color: rgba(103, 50, 147, 0.40);
}
.platform-safari-rgba {
background-color: rgba(0, 169, 236, 0.40);
background-color: rgba(0, 211, 249, 0.40);
}
.platform-samsung-rgba {
background-color: rgba(3, 78, 162, 0.40);
@@ -4012,7 +4040,7 @@ a:hover .overlay-refresh-image:hover {
background-color: rgba(3, 169, 244, 0.40);
}
.platform-windows-rgba {
background-color: rgba(47, 192, 245, 0.40);
background-color: rgba(0, 120, 215, 0.40);
}
.platform-wp-rgba {
background-color: rgba(104, 33, 122, 0.40);

View File

@@ -410,6 +410,8 @@ DOCUMENTATION :: END
<i class="fa fa-fw fa-spinner"></i>&nbsp;
% elif data['state'] == 'error':
<i class="fa fa-fw fa-exclamation-triangle"></i>&nbsp;
% else:
<i class="fa fa-fw fa-question-circle"></i>&nbsp;
% endif
</div>
<div class="dashboard-activity-metadata-title">

View File

@@ -0,0 +1,289 @@
<%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: export_modal.html
Version: 0.1
Variable names: data [list]
data :: Usable parameters
== Global keys ==
DOCUMENTATION :: END
</%doc>
<%
import plexpy
from plexpy import exporter
from plexpy.helpers import anon_url
export = exporter.Export()
thumb_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[0]])
art_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[1]])
%>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="info-modal-title">
${title}
</h4>
</div>
<div class="modal-body">
<form method="post" class="form" id="export_metadata_form">
<input type="hidden" id="export_section_id" name="export_section_id" value="${section_id or ''}" />
<input type="hidden" id="export_user_id" name="export_user_id" value="${user_id or ''}" />
<input type="hidden" id="export_rating_key" name="export_rating_key" value="${rating_key or ''}" />
<input type="hidden" id="export_media_type" name="export_media_type" value="${media_type or ''}" />
<input type="hidden" id="export_sub_media_type" name="export_sub_media_type" value="${sub_media_type or ''}" />
<input type="hidden" id="export_export_type" name="export_export_type" value="${export_type or ''}" />
<div class="form-group">
<label>Instructions</label>
<p class="help-block">
Please see the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Exporter-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">Exporter Guide</a> for more details about each option.
</p>
</div>
<div class="form-group">
<label for="export_file_format">Data File Format</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_file_format" name="export_file_format">
% for format in file_formats:
<option value="${format}">${format.upper()}</option>
% endfor
</select>
</div>
</div>
<p class="help-block">Select the export data file format.</p>
</div>
% if not rating_key:
<div class="checkbox">
<label>
<input type="checkbox" id="export_individual_files" name="export_individual_files" value="1"> Export Individual Files
</label>
<p class="help-block">Enable to export one file for each ${media_type} instead of a single file containing all ${media_type}s.</p>
</div>
% endif
<div class="form-group">
<label for="export_metadata_level">Metadata Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_metadata_level" name="export_metadata_level">
<option value="0">Level 0 - None / Custom</option>
<option value="1" selected>Level 1 - Basic Metadata</option>
<option value="2">Level 2 - Extended Metadata</option>
<option value="3">Level 3 - Advanced Metadata</option>
<option value="9">Level 9 - All Metadata</option>
</select>
</div>
</div>
<p class="help-block">Select the metadata export level. Higher levels include all fields from the lower levels.</p>
</div>
<div class="form-group">
<label for="export_custom_metadata_fields">Custom Metadata Fields</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="export_custom_metadata_fields" name="export_custom_metadata_fields" data-field_type="Metadata">
</div>
</div>
<p class="help-block">Add additional fields to the selected metadata export level.</p>
</div>
<div class="form-group">
<label for="export_media_info_level">Media Info Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_media_info_level" name="export_media_info_level">
<option value="0">Level 0 - None / Custom</option>
<option value="1" selected>Level 1 - Basic Media Info</option>
<option value="2">Level 2 - Extended Media Info</option>
<option value="3">Level 3 - Advanced Media Info</option>
<option value="9">Level 9 - All Media Info</option>
</select>
</div>
</div>
<p class="help-block">Select the media info export level. Higher levels include all fields from the lower levels.</p>
</div>
<div class="form-group">
<label for="export_custom_media_info_fields">Custom Media Info Fields</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="export_custom_media_info_fields" name="export_custom_media_info_fields" data-field_type="Media Info">
</div>
</div>
<p class="help-block">Add additional fields to the selected media info export level.</p>
</div>
<div class="form-group">
<label for="export_thumb_level">Poster and Cover Image Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_thumb_level" name="export_thumb_level">
<option value="0" selected>Level 0 - None / Custom</option>
<option value="1">Level 1 - Uploaded and Selected Posters and Covers Only</option>
<option value="2">Level 2 - Selected and Locked Posters and Covers Only</option>
<option value="9">Level 9 - All Selected Posters and Covers</option>
</select>
</div>
</div>
<p class="help-block">
Select the level to export poster and cover image files.<br>Note: Only applies to ${thumb_media_types}.
</p>
</div>
<div class="form-group">
<label for="export_art_level">Background Artwork Image Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_art_level" name="export_art_level">
<option value="0" selected>Level 0 - None / Custom</option>
<option value="1">Level 1 - Uploaded and Selected Artwork Only</option>
<option value="2">Level 2 - Selected and Locked Artwork Only</option>
<option value="9">Level 9 - All Selected Artwork</option>
</select>
</div>
</div>
<p class="help-block">
Select the level to export background artwork image files.<br>Note: Only applies to ${art_media_types}.
</p>
</div>
<p class="help-block">
Warning: Exporting images may take a long time! Images will be saved to a folder alongside the data file.
</p>
</form>
</div>
<div class="modal-footer">
<div>
<input type="button" class="btn btn-bright btn-ok" data-dismiss="modal" id="export_metadata" value="Export">
</div>
</div>
</div>
</div>
<script src="${http_root}js/selectize.plugin.disable-options.js"></script>
<script>
$('#export_metadata_form').submit(function(e) {
e.preventDefault();
})
var optgroups = (function () {
var optgroups = [];
for (var i = 0; i <= 9; i++) {
optgroups.push({$order: i+1, value: i});
}
return optgroups
})()
var $export_custom_fields = $('#export_custom_metadata_fields, #export_custom_media_info_fields').selectize({
plugins: {
'remove_button': {},
'disable_options': {
disableField: 'level'
}
},
maxItems: null,
valueField: 'field',
labelField: 'field',
sortField: 'field',
searchField: ['field'],
optgroupField: 'level',
optgroups: optgroups,
lockOptgroupOrder: true,
render: {
optgroup_header: function(data, escape) {
return '<div class="optgroup-header">' + escape(this.$input.data('field_type') + ' Level: ' + data.value) + '</div>';
},
option: function (item, escape) {
return '<div data-field="' + escape(item.field) + '" data-level="' + escape(item.level) + '">' + escape(item.field) +'</div>';
}
}
});
var export_custom_metadata_fields = $export_custom_fields[0].selectize;
var export_custom_media_info_fields = $export_custom_fields[1].selectize;
function setDisabledFields() {
var metadata_export_level = $('#export_metadata_level option:selected').val();
var media_info_export_level = $('#export_media_info_level option:selected').val();
export_custom_metadata_fields.setDisabledOptions([...Array(parseInt(metadata_export_level) + 1).keys()]);
export_custom_media_info_fields.setDisabledOptions([...Array(parseInt(media_info_export_level) + 1).keys()]);
}
$('#export_metadata_level, #export_media_info_level').on('change', setDisabledFields);
function getExportFields() {
$.ajax({
url: 'get_export_fields',
async: true,
data: {
media_type: $('#export_media_type').val(),
sub_media_type: $('#export_sub_media_type').val()
},
success: function (result) {
if (result) {
export_custom_metadata_fields.addOption(result.metadata_fields);
export_custom_media_info_fields.addOption(result.media_info_fields);
setDisabledFields();
}
}
})
}
getExportFields();
$('#export_file_format').on('change', function() {
if ($(this).val() === 'm3u8') {
$('#export_metadata_level').prop('disabled', true);
$('#export_media_info_level').prop('disabled', true);
$("#export_thumb_level").prop('disabled', true);
$("#export_art_level").prop('disabled', true);
export_custom_metadata_fields.disable();
export_custom_media_info_fields.disable();
} else {
$('#export_metadata_level').prop('disabled', false);
$('#export_media_info_level').prop('disabled', false);
$("#export_thumb_level").prop('disabled', false);
$("#export_art_level").prop('disabled', false);
export_custom_metadata_fields.enable();
export_custom_media_info_fields.enable();
}
})
$("#export_metadata").click(function() {
var section_id = $('#export_section_id').val();
var user_id = $('#export_user_id').val();
var rating_key = $('#export_rating_key').val();
var metadata_export_level = $('#export_metadata_level option:selected').val();
var media_info_export_level = $('#export_media_info_level option:selected').val();
var file_format = $('#export_file_format option:selected').val();
var thumb_level = $("#export_thumb_level option:selected").val();
var art_level = $("#export_art_level option:selected").val();
var custom_fields = [
$('#export_custom_metadata_fields').val(),
$('#export_custom_media_info_fields').val()
].filter(Boolean).join(',');
var export_type = $('#export_export_type').val()
var individual_files = $('#export_individual_files').is(':checked')
$.ajax({
url: 'export_metadata',
data: {
section_id: section_id,
user_id: user_id,
rating_key: rating_key,
metadata_level: metadata_export_level,
media_info_level: media_info_export_level,
file_format: file_format,
thumb_level: thumb_level,
art_level: art_level,
custom_fields: custom_fields,
export_type: export_type,
individual_files: individual_files
},
async: true,
success: function (data) {
if (data.result === 'success') {
$("#nav-tabs-export").click();
redrawExportTable();
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});
});
</script>

View File

@@ -40,14 +40,14 @@
</div>
</div>
</div>
<div class='table-card-back'>
<ul class="nav nav-pills" role="tablist" id="graph-tabs">
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by Period</a></li>
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
<li role="presentation"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
<div class="table-card-back">
<ul class="nav nav-list nav-pills" role="tablist" id="graph-tabs">
<li role="presentation"><a id="nav-tabs-plays" href="#tabs-plays" aria-controls="tabs-plays" data-toggle="tab" role="tab">Plays by Period</a></li>
<li role="presentation"><a id="nav-tabs-stream" href="#tabs-stream" aria-controls="tabs-stream" data-toggle="tab" role="tab">Stream Info</a></li>
<li role="presentation"><a id="nav-tabs-total" href="#tabs-total" aria-controls="tabs-total" data-toggle="tab" role="tab">Play Totals</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane" id="tabs-1">
<div role="tabpanel" class="tab-pane" id="tabs-plays">
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-history"></i> Daily <span class="yaxis-text">Play count</span> <small>Last <span class="days">30</span> days</small></h4>
@@ -123,7 +123,7 @@
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-2">
<div role="tabpanel" class="tab-pane" id="tabs-stream">
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-video-camera"></i> Daily Stream type breakdown <small>Last <span class="days">30</span> days</small></h4>
@@ -195,7 +195,7 @@
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-3">
<div role="tabpanel" class="tab-pane" id="tabs-total">
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-calendar"></i> Plays by month <small>Last <span class="months">12</span> months</small></h4>
@@ -225,8 +225,6 @@
</%def>
<%def name="javascriptIncludes()">
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/moment-duration-format.js"></script>
<script src="${http_root}js/highcharts/js/highcharts.js"></script>
<script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
@@ -341,14 +339,29 @@
var yaxis = getLocalStorage('graph_type', 'plays');
var current_day_range = getLocalStorage('graph_days', 30);
var current_month_range = getLocalStorage('graph_months', 12);
var current_tab = '#' + getLocalStorage('graph_tab', 'tabs-1');
var current_tab = '#' + getLocalStorage('graph_tab', 'tabs-plays');
// Update tab values from upgrading
switch (current_tab) {
case '#tabs-1':
current_tab = '#tabs-plays'
break
case '#tabs-2':
current_tab = '#tabs-stream'
break
case '#tabs-3':
current_tab = '#tabs-total'
break
default:
break
}
$('#yaxis-' + yaxis).prop('checked', true);
$('#yaxis-' + yaxis).closest('label').addClass('active');
$('#graph-days').val(current_day_range);
$('#graph-months').val(current_month_range);
$('#graph-tabs a[href="' + current_tab + '"]').closest('li').addClass('active');
$(current_tab).addClass('active');
$('#nav-' + current_tab.replace('#', '')).tab('show').trigger('show.bs.tab');
//$(current_tab).addClass('active');
$('.days').html(current_day_range);
@@ -469,7 +482,7 @@
}
});
$('#graph-tabs a[href="#tabs-1"]').tab('show')
$('#nav-tabs-plays').tab('show');
}
function loadGraphsTab2(time_range, yaxis) {
@@ -562,7 +575,7 @@
}
});
$('#graph-tabs a[href="#tabs-2"]').tab('show')
$('#nav-tabs-2').tab('show');
}
function loadGraphsTab3(time_range, yaxis) {
@@ -586,16 +599,16 @@
}
});
$('#graph-tabs a[href="#tabs-3"]').tab('show')
$('#nav-tabs-total').tab('show');
}
// Set initial state
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
// Tab1 opened
$('#graph-tabs a[href="#tabs-1"]').on('shown.bs.tab', function (e) {
$('#nav-tabs-plays').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
@@ -603,7 +616,7 @@
});
// Tab2 opened
$('#graph-tabs a[href="#tabs-2"]').on('shown.bs.tab', function (e) {
$('#nav-tabs-stream').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
@@ -611,7 +624,7 @@
});
// Tab3 opened
$('#graph-tabs a[href="#tabs-3"]').on('shown.bs.tab', function (e) {
$('#nav-tabs-total').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
@@ -624,8 +637,8 @@
forceMinMax($(this));
current_day_range = $(this).val();
setLocalStorage('graph_days', current_day_range);
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
$('.days').html(current_day_range);
});
@@ -635,25 +648,25 @@
forceMinMax($(this));
current_month_range = $(this).val();
setLocalStorage('graph_months', current_month_range);
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
$('.months').html(current_month_range);
});
// User changed
$('#graph-user').on('change', function() {
selected_user_id = $(this).val() || null;
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
});
// Y-axis changed
$('#yaxis-selection').on('change', function() {
yaxis = $('input[name=yaxis-options]:checked', '#yaxis-selection').val();
setLocalStorage('graph_type', yaxis);
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
});
function setGraphFormat(type) {

View File

@@ -117,7 +117,6 @@
<script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script>
$(document).ready(function () {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -2,7 +2,7 @@
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="mstile-150x150.png?v=2.0.5"/>
<square150x150logo src="mstile-150x150.png?v=2.6.0"/>
<TileColor>#282a2d</TileColor>
</tile>
</msapplication>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -6,12 +6,12 @@
"scope": "../../",
"icons": [
{
"src": "android-chrome-192x192.png?v=2.0.5",
"src": "android-chrome-192x192.png?v=2.6.0",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-256x256.png?v=2.0.5",
"src": "android-chrome-256x256.png?v=2.6.0",
"sizes": "256x256",
"type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1 +1,32 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" preserveAspectRatio="xMidYMid meet"><g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none"><path d="M5695 6555 c-135 -34 -244 -94 -342 -189 -40 -39 -73 -76 -73 -83 0 -7 -4 -13 -10 -13 -14 0 -87 -156 -106 -225 -22 -83 -26 -234 -8 -320 17 -79 86 -230 133 -288 l30 -39 -48 -71 c-39 -57 -159 -228 -251 -357 -69 -97 -398 -564 -416 -590 -13 -19 -60 -87 -105 -150 -45 -63 -107 -151 -138 -195 -30 -44 -59 -84 -63 -90 -7 -9 -251 -354 -346 -490 -92 -131 -173 -245 -175 -245 -1 0 -34 9 -72 21 -130 38 -325 31 -454 -18 -168 -63 -313 -196 -385 -354 -39 -87 -65 -183 -68 -256 0 -24 -3 -43 -4 -43 -2 0 -43 46 -91 102 -49 57 -100 117 -115 133 -14 17 -128 149 -253 295 -125 146 -251 292 -279 324 -56 65 -77 89 -108 126 -58 68 -152 178 -172 200 -12 14 -50 57 -83 96 l-61 71 27 44 c58 93 91 217 92 342 2 161 -38 294 -125 412 -133 181 -316 279 -542 292 -470 27 -833 -434 -699 -887 74 -251 275 -437 530 -490 132 -28 334 -6 421 45 l42 24 173 -197 c96 -108 186 -210 200 -227 15 -16 163 -187 330 -380 458 -529 491 -567 526 -605 18 -19 31 -35 30 -36 -6 -5 -265 -161 -277 -167 -8 -4 -34 -20 -58 -35 -194 -124 -634 -382 -651 -382 -12 0 -46 20 -75 44 -60 49 -180 112 -242 127 -21 5 -48 12 -59 15 -11 4 -65 9 -121 11 -81 4 -117 1 -182 -15 -261 -66 -462 -270 -528 -537 -10 -40 -11 -217 -2 -258 5 -23 11 -51 14 -61 29 -145 147 -312 284 -403 123 -82 224 -114 370 -118 83 -3 124 2 240 29 36 9 133 57 187 94 60 41 111 91 153 152 14 19 28 37 32 40 19 15 71 140 89 217 17 73 20 107 16 198 -4 61 -7 121 -9 134 -3 28 -46 0 482 321 179 108 379 228 444 265 104 59 120 65 133 52 13 -13 12 -22 -10 -78 -49 -123 -58 -165 -62 -262 -7 -149 25 -286 89 -383 47 -72 91 -128 125 -158 19 -17 39 -36 45 -42 27 -25 136 -94 150 -94 8 0 17 -4 20 -9 3 -5 16 -11 28 -14 13 -3 50 -12 83 -21 74 -19 278 -15 345 7 198 65 358 196 435 358 16 34 20 36 49 28 17 -4 49 -10 71 -14 22 -3 99 -16 170 -30 72 -13 144 -26 160 -29 28 -5 101 -18 170 -31 17 -3 80 -14 140 -25 61 -11 124 -22 140 -25 17 -4 49 -9 72 -12 40 -5 42 -7 48 -47 14 -98 29 -147 73 -235 36 -75 61 -110 121 -171 154 -154 280 -210 480 -213 134 -2 180 5 273 40 212 83 371 262 427 481 24 93 25 255 2 342 -64 241 -245 428 -481 501 -62 18 -97 23 -200 22 -107 0 -136 -4 -205 -26 -44 -15 -109 -43 -145 -64 -83 -48 -208 -171 -250 -245 -17 -32 -35 -60 -38 -61 -4 -2 -46 4 -93 13 -48 10 -104 20 -125 23 -22 3 -46 8 -54 11 -8 3 -33 7 -55 10 -38 5 -58 9 -122 21 -16 3 -53 10 -83 15 -30 6 -66 12 -79 15 -13 2 -103 19 -200 36 -169 30 -207 42 -196 60 10 16 -28 155 -62 224 -19 39 -54 96 -78 127 l-45 58 40 52 c96 125 143 266 143 433 1 164 -27 263 -108 391 -19 30 -35 57 -35 61 0 3 31 49 69 102 57 81 450 638 625 889 28 40 62 88 76 107 14 18 194 274 400 568 291 414 379 534 393 531 10 -2 27 -6 37 -9 78 -25 240 -29 338 -9 433 87 677 573 489 974 -93 200 -255 332 -478 389 -87 22 -227 25 -304 6z"/></g></svg>
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="350.000000pt" height="350.000000pt" viewBox="0 0 350.000000 350.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,350.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1566 3489 c-433 -46 -867 -274 -1141 -601 -404 -481 -526 -1100
-334 -1688 91 -278 283 -569 498 -756 676 -589 1646 -589 2322 0 215 187 407
478 498 756 142 436 113 895 -84 1305 -320 666 -1027 1061 -1759 984z m1147
-604 c87 -36 146 -118 154 -214 10 -111 -39 -203 -137 -254 -49 -26 -63 -28
-131 -25 l-76 3 -109 -154 c-60 -85 -190 -269 -290 -409 l-181 -255 26 -46
c22 -38 26 -59 26 -121 0 -63 -5 -84 -29 -132 -27 -54 -28 -59 -13 -76 22 -24
47 -86 47 -117 0 -14 6 -28 13 -30 6 -3 91 -16 187 -30 157 -23 175 -24 183
-10 38 68 115 118 199 130 103 15 220 -51 268 -151 26 -52 29 -154 6 -207 -19
-48 -82 -114 -129 -138 -151 -77 -346 22 -373 189 -7 46 15 39 -222 74 -142
20 -155 21 -163 6 -65 -116 -225 -163 -347 -102 -116 58 -167 187 -126 323 8
29 13 55 11 57 -3 3 -65 -33 -138 -79 -74 -46 -162 -100 -196 -120 l-62 -38 6
-47 c11 -100 -46 -207 -136 -254 -43 -23 -66 -28 -121 -28 -77 0 -124 16 -175
62 -48 41 -76 99 -82 167 -7 72 9 129 50 183 85 112 256 132 372 44 l31 -24
174 109 c96 60 180 111 185 113 6 2 -2 16 -16 32 -35 39 -412 468 -414 471 0
1 -21 -5 -45 -13 -57 -20 -142 -14 -196 14 -162 84 -197 288 -71 419 102 108
291 101 386 -14 62 -75 78 -185 40 -273 l-21 -49 23 -28 c13 -16 102 -118 198
-227 l175 -198 20 61 c26 78 64 125 124 155 63 31 117 39 177 26 49 -11 51
-11 72 17 21 26 533 749 548 773 4 6 -4 28 -17 48 -88 133 -44 307 94 376 61
31 163 36 221 11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,8 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<title>android</title>
<path fill="#fff" d="M31.944 21.318c5.556 0 11.113 0 16.67 0 0.042 0 0.084-0 0.126 0.001 0.548 0.012 0.554 0.012 0.554 0.555 0.002 2.526 0.001 5.052 0.001 7.577 0 5.789 0.003 11.577-0.002 17.365-0.001 1.197-0.344 2.274-1.205 3.155-0.759 0.777-1.671 1.191-2.753 1.22-0.757 0.019-1.515 0.011-2.273 0.016-0.772 0.005-0.774 0.006-0.774 0.751-0.001 2.505-0.032 5.010 0.013 7.514 0.024 1.305-0.386 2.363-1.302 3.29-1.214 1.23-3.457 1.485-4.769 0.396-1.051-0.873-1.725-1.978-1.715-3.423 0.019-2.547 0.010-5.093 0.003-7.64-0.003-1.010 0.144-0.869-0.858-0.876-1.158-0.008-2.315-0.005-3.473-0.001-0.829 0.003-0.76-0.103-0.76 0.794-0.002 2.505-0.027 5.010 0.010 7.514 0.019 1.278-0.377 2.325-1.281 3.235-1.199 1.208-3.371 1.494-4.716 0.437-1.067-0.838-1.779-1.932-1.77-3.386 0.017-2.61 0.005-5.219 0.005-7.829 0-0.147-0.008-0.295 0-0.442 0.013-0.24-0.092-0.339-0.334-0.335-0.736 0.012-1.473 0.002-2.209 0.022-0.575 0.015-1.129-0.058-1.673-0.251-1.682-0.597-2.691-2.017-2.737-3.858-0.063-2.566-0.031-5.135-0.035-7.703-0.007-5.304-0.010-10.608-0.016-15.912-0.001-0.568-0.017-1.136-0.018-1.704-0-0.464 0.006-0.472 0.494-0.479 0.989-0.013 1.978-0.023 2.968-0.023 4.609-0.002 9.219-0.001 13.829-0.001-0.001 0.006-0.001 0.014-0.001 0.021z"></path>
<path fill="#fff" d="M31.944 19.89c-5.535 0-11.071 0.002-16.606-0.002-0.717-0-0.772 0.153-0.687-0.747 0.189-2.003 0.58-3.948 1.437-5.784 1.041-2.228 2.47-4.152 4.433-5.648 0.864-0.658 1.646-1.43 2.624-1.932 0.216-0.111 0.25-0.23 0.129-0.443-0.363-0.64-0.715-1.286-1.059-1.937-0.441-0.835-0.877-1.674-1.302-2.518-0.247-0.491-0.206-0.765 0.103-0.941 0.342-0.194 0.625-0.077 0.892 0.415 0.721 1.329 1.429 2.664 2.142 3.997 0.069 0.13 0.141 0.258 0.215 0.386 0.226 0.39 0.228 0.394 0.671 0.218 2.478-0.987 5.051-1.43 7.715-1.338 2.143 0.074 4.214 0.501 6.214 1.273 0.118 0.045 0.241 0.081 0.35 0.142 0.186 0.102 0.303 0.067 0.405-0.126 0.534-1.023 1.075-2.043 1.617-3.062 0.297-0.557 0.592-1.115 0.908-1.66 0.189-0.325 0.514-0.408 0.809-0.253 0.292 0.153 0.366 0.43 0.175 0.817-0.39 0.79-0.791 1.575-1.204 2.353-0.383 0.725-0.789 1.438-1.18 2.159-0.19 0.351-0.181 0.348 0.158 0.573 1.666 1.102 3.266 2.297 4.577 3.814 1.895 2.192 3.115 4.723 3.574 7.598 0.119 0.746 0.175 1.503 0.266 2.254 0.038 0.311-0.097 0.421-0.393 0.394-0.146-0.014-0.295-0.002-0.442-0.002-5.514 0-11.028 0-16.543 0zM25.561 12.038c-0.063-1.117-0.623-1.553-1.433-1.566-0.833-0.014-1.419 0.462-1.455 1.603-0.025 0.776 0.66 1.407 1.463 1.409 0.79 0.001 1.421-0.64 1.424-1.445zM39.872 13.483c0.788-0.007 1.497-0.676 1.439-1.441-0.076-0.997-0.486-1.549-1.506-1.576-0.841-0.022-1.403 0.67-1.386 1.605 0.016 0.816 0.635 1.418 1.453 1.411z"></path>
<path fill="#fff" d="M50.587 32.655c0-2.715-0.003-5.429 0.001-8.143 0.003-1.77 0.853-2.959 2.453-3.698 0.717-0.331 1.433-0.52 2.172-0.287 0.794 0.251 1.537 0.649 2.123 1.273 0.519 0.552 0.839 1.207 0.944 1.957 0.052 0.374 0.082 0.754 0.083 1.131 0.005 5.282-0.005 10.564 0.010 15.846 0.004 1.249-0.402 2.288-1.278 3.179-1.245 1.267-3.35 1.546-4.76 0.479-1.076-0.815-1.719-1.943-1.745-3.342-0.019-1.010-0.013-2.020-0.014-3.030-0.002-1.789-0.001-3.578-0.001-5.366 0.004-0 0.008-0 0.012-0z"></path>
<path fill="#fff" d="M13.369 32.464c0 2.335-0.001 4.669 0.001 7.004 0 0.63 0.047 1.263 0.002 1.889-0.072 1.003-0.541 1.811-1.23 2.554-0.931 1.004-2.059 1.18-3.323 1.058-1.55-0.15-3.156-2.028-3.181-3.665-0.004-0.231-0.015-0.462-0.014-0.694 0.003-5.406 0.007-10.812 0.011-16.218 0.001-1.655 0.863-2.749 2.268-3.501 0.683-0.366 1.397-0.602 2.158-0.402 1.622 0.427 3.305 1.697 3.292 3.834-0.016 2.713-0.004 5.427-0.004 8.141 0.007-0 0.013-0 0.020 0z"></path>
<path fill="#fff" d="M46.73 40.88c-0.003 0-0.007 0-0.010 0-1.475 0-2.67-1.195-2.67-2.67s1.195-2.67 2.67-2.67c1.475 0 2.67 1.195 2.67 2.67v0c0 0 0 0 0 0 0 1.471-1.19 2.664-2.659 2.67h-0.001zM17.27 40.88c-1.475 0-2.67-1.195-2.67-2.67s1.195-2.67 2.67-2.67c1.475 0 2.67 1.195 2.67 2.67v0c0 0.003 0 0.007 0 0.010 0 1.469-1.191 2.66-2.66 2.66-0.003 0-0.007 0-0.011 0h0.001zM47.68 24.83l5.32-9.23c0.095-0.159 0.151-0.351 0.151-0.557 0-0.405-0.219-0.76-0.546-0.951l-0.005-0.003c-0.16-0.095-0.354-0.152-0.56-0.152-0.407 0-0.764 0.22-0.957 0.547l-0.003 0.005-5.38 9.34c-4.027-1.851-8.738-2.93-13.7-2.93s-9.673 1.079-13.909 3.016l0.209-0.086-5.39-9.34c-0.204-0.28-0.531-0.46-0.9-0.46-0.613 0-1.11 0.497-1.11 1.11 0 0.167 0.037 0.325 0.103 0.467l-0.003-0.007 5.33 9.23c-9.153 5.047-15.453 14.286-16.323 25.059l-0.007 0.111h64c-0.875-10.883-7.171-20.121-16.158-25.088l-0.162-0.082z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -24,17 +24,13 @@
</div>
<div id="currentActivity">
% if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i>&nbsp; Checking for activity...</div>
% elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div>
% elif not config['first_run_complete']:
<div id="dashboard-no-activity" class="text-muted">The Tautulli setup wizard has not been completed. Please click <a href="welcome">here</a> to go to the setup wizard.</div>
% else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
% endif
</div>
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i>&nbsp; Tautulli is connecting to the Plex server...</div>
% endif
</div>
</div>
@@ -65,7 +61,7 @@
<div class="row">
<div class="col-md-12">
<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>&nbsp; Loading stats...</div>
<br>
</div>
</div>
@@ -84,7 +80,7 @@
<div class="row">
<div class="col-md-12">
<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>&nbsp; Loading stats...</div>
<br>
</div>
</div>
@@ -132,17 +128,12 @@
<div class="col-md-12">
<div id="recentlyAdded" style="margin-right: -15px;">
% if PLEX_SERVER_UP:
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Looking for new items...</div>
<div id="dashboard-checking-recently-added" class="text-muted"><i class="fa fa-refresh fa-spin"></i>&nbsp; Looking for new items...</div>
% elif config['pms_is_cloud']:
<div class="text-muted">Plex Cloud server is sleeping.</div>
% else:
<div class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
% endif
</div>
<div id="dashboard-no-recently-added" class="text-muted"><i class="fa fa-refresh fa-spin"></i>&nbsp; Tautulli is connecting to your Plex server...</div>
% endif
<br>
</div>
</div>
</div>
@@ -229,8 +220,6 @@
</%def>
<%def name="javascriptIncludes()">
<% from plexpy import PLEX_SERVER_UP %>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script>
<script>
@@ -260,8 +249,33 @@
}
});
}
% if _session['user_group'] == 'admin':
var msg_settings = ' Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
% else:
var msg_settings = '';
% endif
var error_msg = 'There was an error communicating with your Plex Server.' + msg_settings;
var server_status;
server_status = setInterval(function() {
$.getJSON('server_status', function (data) {
if (data.connected === true) {
clearInterval(server_status);
$('#currentActivity').html('<div id="dashboard-checking-activity" class="text-muted"><i class="fa fa-refresh fa-spin"></i>&nbsp; Checking for activity...</div>');
$('#recentlyAdded').html('<div id="dashboard-checking-recently-added" class="text-muted"><i class="fa fa-refresh fa-spin"></i>&nbsp; Looking for new items...</div>');
activityConnected();
recentlyAddedConnected();
} else if (data.connected === false) {
clearInterval(server_status);
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">' + error_msg + '</div>');
$('#recentlyAdded').html('<div id="dashboard-no-recently-added" class="text-muted">' + error_msg + '</div>');
}
});
}, 1000);
</script>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
% if 'current_activity' in config['home_sections']:
<script>
var defaultHandler = {
get: function(target, name) {
@@ -272,7 +286,7 @@
var create_instances = [];
var activity_ready = true;
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
function getCurrentActivity() {
activity_ready = false;
@@ -298,13 +312,8 @@
}
if (!(current_activity)) {
% if _session['user_group'] == 'admin':
var msg_settings = ' Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
% else:
var msg_settings = '';
% endif
$('#currentActivityHeader').hide();
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.' + msg_settings + '</div>');
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">' + error_msg + '</div>');
return
}
@@ -549,7 +558,7 @@
}
// Update the progress bars, percent - 3 because of 3px padding-right
$('#buffer-bar-' + key).width(parseInt(s.transcode_progress) - 3 + '%').html(s.transcode_progress + '%')
$('#buffer-bar-' + key).css({width: parseInt(s.transcode_progress) - 3 + '%'}).html(s.transcode_progress + '%')
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
if (s.live !== 1) {
var progress_bar = $('#progress-bar-' + key);
@@ -626,34 +635,36 @@
});
}
getCurrentActivity();
setInterval(function () {
if (!(create_instances.length) && activity_ready) {
getCurrentActivity();
}
}, ${config['home_refresh_interval'] * 1000});
function activityConnected() {
getCurrentActivity();
setInterval(function () {
if (!(create_instances.length) && activity_ready) {
getCurrentActivity();
}
}, ${config['home_refresh_interval'] * 1000});
setInterval(function(){
$('.progress_time_offset').each(function () {
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var timestamp = millisecondsToMinutes(Math.min(view_offset, stream_duration), false);
$(this).html(timestamp).data('view_offset', Math.min(view_offset + 1000, stream_duration))
}
});
$('.progress-bar').each(function () {
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.floor(view_offset / stream_duration * 100);
progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100;
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
}
});
}, 1000);
setInterval(function(){
$('.progress_time_offset').each(function () {
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var timestamp = millisecondsToMinutes(Math.min(view_offset, stream_duration), false);
$(this).html(timestamp).data('view_offset', Math.min(view_offset + 1000, stream_duration))
}
});
$('.progress-bar').each(function () {
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.floor(view_offset / stream_duration * 100);
progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100;
$(this).css({width: progress_percent - 3 + '%'}).html(progress_percent + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
}
});
}, 1000);
}
$('#currentActivity').on('click', '.external_ip-modal', function () {
$.get('get_ip_address_details', {
@@ -877,7 +888,7 @@
getLibraryStats();
</script>
% endif
% if 'recently_added' in config['home_sections'] and PLEX_SERVER_UP:
% if 'recently_added' in config['home_sections']:
<script>
function recentlyAdded(recently_added_count, recently_added_type) {
showMsg("Loading recently added items...", true, false, 0);
@@ -905,7 +916,9 @@
$('#recently-added-toggle-' + recently_added_type).closest('label').addClass('active');
$('#recently-added-count').val(recently_added_count);
recentlyAdded(recently_added_count, recently_added_type);
function recentlyAddedConnected() {
recentlyAdded(recently_added_count, recently_added_type);
}
function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller");

View File

@@ -41,7 +41,7 @@ DOCUMENTATION :: END
from plexpy import notifiers
from plexpy.common import MEDIA_TYPE_HEADERS, MEDIA_FLAGS_AUDIO, MEDIA_FLAGS_VIDEO
from plexpy.helpers import page, get_percent
from plexpy.helpers import page, get_percent, cast_to_int
# Get audio codec file
def af(codec):
@@ -84,8 +84,10 @@ DOCUMENTATION :: END
%>
<div class="container-fluid">
<div class="row">
% if data['media_type'] not in ('photo_album', 'photo', 'playlist'):
<% fallback = 'art-live-full' if data['live'] else None %>
<div class="art-face" style="background-image:url(${page('pms_image_proxy', data['art'], data['rating_key'], 1920, 1080, fallback=fallback)})"></div>
% endif
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image info-art" title="Refresh background image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
@@ -150,6 +152,29 @@ DOCUMENTATION :: END
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">Track ${data['media_index']} - ${data['title']}</li>
% elif data['media_type'] == 'photo_album':
<li><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% if data['parent_title']:
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% endif
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] in ('photo', 'clip'):
<li class="hidden-xs hidden-sm"><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'playlist':
% if user_info.get('user_id'):
<li><a href="${page('user', user_info.get('user_id'))}">${user_info.get('friendly_name')}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% elif data['section_id']:
<li><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% endif
<li class="active metadata-xml">${data['title']}</li>
% endif
</ul>
</div>
@@ -158,10 +183,13 @@ DOCUMENTATION :: END
<div class="summary-content-title-wrapper">
<div class="col-md-9">
<div class="summary-content-poster hidden-xs hidden-sm">
% if data['media_type'] == 'track':
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}" target="_blank" title="View on Plex Web">
<% legacy = '&legacy=1' if data['media_type'] in ('photo_album', 'photo', 'clip') else '' %>
% if data['media_type'] in ('track', 'photo'):
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}${legacy}" target="_blank" rel="noreferrer" title="View on Plex Web">
% elif data['media_type'] == 'playlist':
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/playlist?key=%2Fplaylists%2F${data['rating_key']}" target="_blank" rel="noreferrer" title="View on Plex Web">
% elif not data['live']:
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}" target="_blank" title="View on Plex Web">
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}${legacy}" target="_blank" rel="noreferrer" title="View on Plex Web">
% endif
% if data['live']:
<div class="summary-poster-face" style="background-image: url(${page('pms_image_proxy', data['grandparent_thumb'] or data['thumb'], data['rating_key'], 300, 450, fallback='poster-live')});">
@@ -179,11 +207,14 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
% elif data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
% elif data['media_type'] in ('artist', 'album', 'track', 'playlist', 'photo_album', 'photo', 'clip'):
<div class="summary-poster-face-track" style="background-image: url(${page('pms_image_proxy', data['thumb'], data['rating_key'], 500, 500, fallback='cover')});">
<div class="summary-poster-face-overlay">
<span></span>
</div>
% if data['media_type'] == 'playlist' and data['smart']:
<span class="smart-playlist-image" title="Smart Playlist"><i class="fa fa-cog"></i></span>
% endif
</div>
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
@@ -214,7 +245,7 @@ DOCUMENTATION :: END
<h3 class="hidden-xs">S${data['parent_media_index']} &middot; E${data['media_index']}</h3>
% endif
% endif
% elif data['media_type'] in ('movie', 'show', 'artist', 'collection'):
% elif data['media_type'] in ('movie', 'show', 'artist', 'collection', 'playlist', 'photo_album'):
<h1>&nbsp;</h1><h1>${data['title']}</h1>
% elif data['media_type'] == 'season':
<h1>&nbsp;</h1><h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
@@ -230,26 +261,30 @@ DOCUMENTATION :: END
<h1><a href="${page('info', data['grandparent_rating_key'])}">${data['original_title'] or data['grandparent_title']}</a></h1>
<h2><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a> - ${data['title']}</h2>
<h3 class="hidden-xs">T${data['media_index']}</h3>
% elif data['media_type'] in ('photo', 'clip'):
<h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2>
% endif
</div>
</div>
</div>
<div class="summary-content-wrapper">
<div class="col-md-9">
% if data['media_type'] == 'movie' or data['live']:
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 305px;">
% elif data['media_type'] in ('show', 'season', 'collection'):
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 270px;">
% elif data['media_type'] == 'episode':
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 70px;">
% elif data['media_type'] == 'artist' or data['media_type'] == 'album':
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 150px;">
% elif data['media_type'] == 'track':
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 180px;">
% else:
<div class="summary-content-padding hidden-xs hidden-sm">
% endif
% if data['media_type'] in ('movie', 'episode', 'track'):
<%
padding_height = ''
if data['media_type'] == 'movie' or data['live']:
padding_height = 'height: 305px;'
elif data['media_type'] in ('show', 'season', 'collection'):
padding_height = 'height: 270px;'
elif data['media_type'] == 'episode':
padding_height = 'height: 70px;'
elif data['media_type'] in ('artist', 'album', 'playlist', 'photo_album', 'photo'):
padding_height = 'height: 150px;'
elif data['media_type'] in ('track', 'clip'):
padding_height = 'height: 180px;'
%>
<div class="summary-content-padding hidden-xs hidden-sm" style="${padding_height}">
% if data['media_type'] in ('movie', 'episode', 'track', 'clip'):
<div class="summary-content-media-info-wrapper">
% if data['media_type'] != 'track' and media_info['video_codec']:
<img class="summary-content-media-flag" title="${media_info['video_codec']}" src="${http_root}images/media_flags/video_codec/${media_info['video_codec'] | vf}.png" />
@@ -268,16 +303,17 @@ DOCUMENTATION :: END
</div>
<div class="summary-content">
<div class="summary-content-details-wrapper">
% if data['rating']:
% if data['rating_image']:
% if data['rating_image'].startswith('imdb://'):
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}">
<span class="rating-image rating-imdb"><strong>${data['rating']}</strong></span>
<% rating = data['rating'] or data['audience_rating'] %>
% if rating:
% if data['audience_rating_image']:
% if data['audience_rating_image'].startswith('imdb://'):
<div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<span class="rating-image rating-imdb"><strong>${rating}</strong></span>
</div>
% endif
% if data['rating_image'].startswith('themoviedb://'):
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}">
<span class="rating-image rating-themoviedb"><strong>${get_percent(data['rating'], 10)}%</strong></span>
% if data['audience_rating_image'].startswith('themoviedb://'):
<div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<span class="rating-image rating-themoviedb"><strong>${get_percent(rating, 10)}%</strong></span>
</div>
% endif
% if data['audience_rating_image'].startswith('rottentomatoes://'):
@@ -291,11 +327,24 @@ DOCUMENTATION :: END
</div>
% endif
% else:
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}">
<i class="star-icon fa fa-star"></i> <strong>${get_percent(data['rating'], 10)}%</strong>
<div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<i class="star-icon fa fa-star"></i> <strong>${get_percent(rating, 10)}%</strong>
</div>
% endif
% endif
<div class="summary-content-details-tag">
% if data['media_type'] in ('collection', 'playlist') and data['children_count']:
<%
if data['media_type'] == 'collection':
suffix = MEDIA_TYPE_HEADERS[data['sub_media_type']]
elif data['media_type'] == 'playlist':
suffix = MEDIA_TYPE_HEADERS[data['playlist_type']]
if data['children_count'] == 1:
suffix = suffix[:-1]
%>
Items <strong> ${data['children_count']} ${suffix} </strong>
% endif
</div>
<div class="summary-content-details-tag">
% if data['directors']:
Directed by <strong> ${data['directors'][0]}</strong>
@@ -315,6 +364,8 @@ DOCUMENTATION :: END
Aired <strong> <span id="airdate">${data['originally_available_at']}</span></strong>
% elif data['media_type'] == 'album' or data['media_type'] == 'track':
Released <strong> ${data['year']}</strong>
% elif data['media_type'] in ('photo', 'clip'):
Taken <strong> <span id="airdate">${data['originally_available_at']}</span></strong>
% elif data['media_type'] == 'collection':
Year <strong> ${data['min_year']} - ${data['max_year']}</strong>
% elif data['year']:
@@ -323,7 +374,7 @@ DOCUMENTATION :: END
</div>
<div class="summary-content-details-tag">
% if data['duration']:
Runtime <strong> <span id="runtime">${data['duration']}</span> mins</strong>
Runtime <strong> <span id="runtime">${data['duration']}</span></strong>
% endif
</div>
<div class="summary-content-details-tag">
@@ -439,6 +490,17 @@ DOCUMENTATION :: END
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading track list...</div>
</div>
</div>
% elif data['media_type'] == 'photo_album':
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>Photo List for <strong>${data['title']}</strong></span>
</div>
</div>
<div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading photo list...</div>
</div>
</div>
% elif data['media_type'] == 'collection':
<div class="col-md-12">
<div class="table-card-header">
@@ -447,30 +509,36 @@ DOCUMENTATION :: END
</div>
</div>
<div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading movies list...</div>
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading collection items...</div>
</div>
</div>
<div id="collection-related-list-container" style="display: none;">
</div>
% endif
% if data['media_type'] != 'collection':
% elif data['media_type'] == 'playlist':
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
% if data['media_type'] in ('artist', 'album', 'track'):
<span>Play History for <strong>${data['title']}</strong></span>
% else:
<span>Watch History for <strong>${data['title']}</strong></span>
% endif
<span>${MEDIA_TYPE_HEADERS[data['playlist_type']]} List for <strong>${data['title']}</strong></span>
</div>
</div>
<div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading playlist items...</div>
</div>
</div>
% endif
<%
history_type = data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track')
history_active = 'active' if history_type else ''
export_active = 'active' if not history_type else ''
%>
% if history_type and _session['user_group'] == 'admin':
<div class="col-md-12">
<div class="table-card-header">
<ul class="nav nav-list nav-pills" role="tablist">
<li class="${history_active}"><a id="nav-tabs-history" href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
<li class="${export_active}"><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
</ul>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect rows to delete. Data is deleted upon exiting delete mode.</div>
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>&nbsp;
</div>
% if source == 'history':
<div class="btn-group">
<a href="update_metadata?rating_key=${data['rating_key']}&update=True" class="btn btn-danger btn-edit" id="fix-metadata">
@@ -510,37 +578,117 @@ DOCUMENTATION :: END
</button>
</div>
% endif
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-history-button" id="refresh-history-list"><i class="fa fa-refresh"></i> Refresh history</button>
</div>
<div class="btn-group colvis-button-bar"></div>
</div>
</div>
<div class="table-card-back">
<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="date">Date</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="product">Product</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>
</table>
</div>
</div>
% endif
<div class="tab-content">
% if history_type:
<div role="tabpanel" class="tab-pane ${history_active}" id="tabs-history">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
% if data['media_type'] in ('artist', 'album', 'track'):
<span>Play History for <strong>${data['title']}</strong></span>
% else:
<span>Watch History for <strong>${data['title']}</strong></span>
% endif
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect rows to delete. Data is deleted upon exiting delete mode.</div>
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>&nbsp;
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-history-button" id="refresh-history-list"><i class="fa fa-refresh"></i> Refresh history</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-history"></div>
</div>
</div>
<div class="table-card-back">
<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="date">Date</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="product">Product</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>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
% if not data['live'] and _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane ${export_active}" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>Metadata Exports for <strong>${data['title']}</strong></span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-rating_key="${data['rating_key']}"
data-media_type="${data['media_type']}" data-sub_media_type="${data['sub_media_type'] or data['playlist_type'] or ''}">
<i class="fa fa-file-export"></i> Export metadata
</button>
</div>
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-RK-${data['rating_key']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
</div>
</div>
</div>
</div>
@@ -629,6 +777,8 @@ DOCUMENTATION :: END
</div>
</div>
% endif
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def>
<%def name="javascriptIncludes()">
@@ -636,13 +786,14 @@ DOCUMENTATION :: END
<script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
% if metadata:
<%
data = defaultdict(None, **metadata)
history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id']
%>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
% if data['live']:
<script>
function get_history() {
@@ -653,7 +804,7 @@ DOCUMENTATION :: END
return {
json_data: JSON.stringify( d ),
guid: "${data['guid']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
user_id: "${history_user_id}"
};
}
}
@@ -669,7 +820,7 @@ DOCUMENTATION :: END
return {
json_data: JSON.stringify( d ),
grandparent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
user_id: "${history_user_id}"
};
}
}
@@ -685,7 +836,7 @@ DOCUMENTATION :: END
return {
json_data: JSON.stringify( d ),
parent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
user_id: "${history_user_id}"
};
}
}
@@ -701,96 +852,43 @@ DOCUMENTATION :: END
return {
json_data: JSON.stringify( d ),
rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
user_id: "${history_user_id}"
};
}
}
}
</script>
% endif
% if data['media_type'] != 'collection':
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'):
<script>
$(document).ready(function () {
function loadHistoryTable() {
get_history();
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, 12] });
$(colvis.button()).appendTo('div.colvis-button-bar');
$(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table-RK-${data["rating_key"]}', history_table);
}
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST',
data: { row_ids: history_to_delete.join(',') },
async: true,
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
});
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
}
});
$(document).ready(function () {
loadHistoryTable();
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
// Send recently added notification
$('#send-recently-added-notification').on('click', function () {
var rating_key = $(this).data('id');
$('#send-recently-added-modal').modal();
$('#send-recently-added-modal').one('click', '#confirm-send-notification', function () {
$.ajax({
url: 'send_manual_on_created',
data: {
rating_key: rating_key,
notifier_id: $('#send-notification-notifier option:selected').val()
},
async: true,
success: function (data) {
if (data.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});
});
});
</script>
% endif
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection'):
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'photo_album', 'collection', 'playlist'):
<script>
$.ajax({
url: 'get_item_children',
type: 'GET',
async: true,
data: { rating_key : "${data['rating_key']}" },
data: {
rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}"
},
complete: function(xhr, status) {
$("#children-list").html(xhr.responseText);
}
@@ -804,7 +902,7 @@ DOCUMENTATION :: END
type: 'GET',
async: true,
data: {
rating_key : "${data['rating_key']}",
rating_key: "${data['rating_key']}",
title: "${data['title']}"
},
complete: function(xhr, status) {
@@ -814,12 +912,29 @@ DOCUMENTATION :: END
</script>
% endif
<script>
$('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}");
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-list #nav-' + hash.replace('#' + prefix, "")).tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
});
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
});
var airdate = $("#airdate")
var runtime = $("#runtime")
airdate.html(moment(airdate.text()).format('MMM DD, YYYY'));
runtime.html(humanDuration(runtime.text()));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
$('#channel-icon').popover({
selector: '[data-toggle=popover]',
@@ -833,6 +948,127 @@ DOCUMENTATION :: END
}
});
</script>
% if _session['user_group'] == 'admin':
<script>
$("#toggle-export-modal").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
section_id: $(this).data('section_id'),
rating_key: $(this).data('rating_key'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
rating_key: "${data['rating_key']}"
};
}
};
export_table = $('#export_table-RK-${data["rating_key"]}').DataTable(export_table_options);
export_table.columns([2, 7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-RK-${data["rating_key"]}', export_table);
}
$('#nav-tabs-export').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$(document).ready(function () {
if (!($('#tabs-history').length)) {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST',
data: { row_ids: history_to_delete.join(',') },
async: true,
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
});
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
}
});
// Send recently added notification
$('#send-recently-added-notification').on('click', function () {
var rating_key = $(this).data('id');
$('#send-recently-added-modal').modal();
$('#send-recently-added-modal').one('click', '#confirm-send-notification', function () {
$.ajax({
url: 'send_manual_on_created',
data: {
rating_key: rating_key,
notifier_id: $('#send-notification-notifier option:selected').val()
},
async: true,
success: function (data) {
if (data.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});
});
});
$('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}");
});
</script>
% endif
% if data.get('poster_url'):
<script>
$('#hosted-poster').popover({

View File

@@ -28,14 +28,15 @@ DOCUMENTATION :: END
% if data != None:
<%
from plexpy.helpers import page
from plexpy.helpers import cast_to_int, page
%>
% if data['children_count'] > 0:
<div class="item-children-wrapper">
<ul class="item-children-instance list-unstyled">
<% max_height ='max-height' if data['children_type'] in ('track', 'photo') or media_type == 'playlist' else '' %>
<ul class="item-children-instance ${max_height} list-unstyled">
% for child in data['children_list']:
% if child['rating_key']:
% if data['children_type'] == 'track':
% if data['children_type'] in ('track', 'photo') or media_type == 'playlist':
<li class="item-children-list-item">
% else:
<li>
@@ -123,37 +124,144 @@ DOCUMENTATION :: END
</h3>
</div>
% elif data['children_type'] == 'track':
% if loop.index % 2 == 0:
<div class="item-children-list-item-even">
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
% if child['original_title']:
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">${child['media_index']}</span>
<span class="item-children-list-item-title">
<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['parent_thumb'], child['parent_rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['title']}
</span>
</a>
% if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span>
% endif
% endif
</span>
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
<% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span>
</div>
% else:
<div class="item-children-list-item-odd">
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
% if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span>
% endif
% elif data['children_type'] == 'photo':
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">${loop.index + 1}</span>
<span class="item-children-list-item-title">
% if child['media_type'] == 'photo_album':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-camera fa-fw"></i></span>
% elif child['media_type'] == 'clip':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-video-camera fa-fw"></i></span>
% else:
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>
% endif
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['thumb'], child['rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['title']}
</span>
</a>
</span>
% if child['duration']:
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
<% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span>
% endif
</div>
% elif media_type == 'playlist':
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">${loop.index + 1}</span>
<span class="item-children-list-item-title">
% if child['media_type'] == 'movie':
<span class="media-type-tooltip" data-toggle="tooltip" title="Movie"><i class="fa fa-film fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['thumb'], child['rating_key'], 300, 450, fallback='poster')}" data-height="120" data-width="80">
${child['title']}
</span>
</a>
<span class="text-muted"> (${child['year']})</span>
% elif child['media_type'] == 'episode':
<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['grandparent_thumb'], child['grandparent_rating_key'], 300, 450, fallback='poster')}" data-height="120" data-width="80">
${child['grandparent_title']}
</span>
</a> -
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['parent_thumb'], child['parent_rating_key'], 300, 450, fallback='poster')}" data-height="120" data-width="80">
${child['title']}
</span>
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">S${child['parent_media_index']}</a> &middot; <a class="no-highlight" href="${page('info', child['rating_key'])}" title="${child['title']}">E${child['media_index']}</a>)</span>
% elif child['media_type'] == 'track':
<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['parent_thumb'], child['parent_rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['title']}
</span>
</a> -
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['grandparent_thumb'], child['grandparent_rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['grandparent_title']}
</span>
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% elif child['media_type'] == 'photo':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['thumb'], child['rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['title']}
</span>
</a>
% if child['grandparent_title']:
- <a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['grandparent_thumb'], child['grandparent_rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['grandparent_title']}
</span>
</a>
% endif
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% elif child['media_type'] == 'clip':
<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['thumb'], child['rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
${child['title']}
</span>
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% endif
</span>
% if child['duration']:
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span>
% endif
</div>
% endif
% endif
</li>
% endif
% endfor
</ul>
</div>
<script>
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
$('body').popover({
selector: '[data-toggle="popover"]',
html: true,
container: 'body',
trigger: 'hover',
placement: 'right',
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;" />';
}
});
</script>
% endif
% endif

File diff suppressed because one or more lines are too long

View File

@@ -790,6 +790,9 @@ ColVis.prototype = {
oStyle.top = oPos.top+"px";
oStyle.left = iDivX+"px";
var iDocWidth = $(document).width();
var iDocHeight = $(document).height();
document.body.appendChild( nBackground );
document.body.appendChild( nHidden );
document.body.appendChild( this.dom.catcher );
@@ -819,12 +822,17 @@ ColVis.prototype = {
var iDivWidth = $(nHidden).outerWidth();
var iDivHeight = $(nHidden).outerHeight();
var iDocWidth = $(document).width();
var iDivMarginTop = parseInt($(nHidden).css("marginTop"), 10);
var iDivMarginBottom = parseInt($(nHidden).css("marginBottom"), 10);
if ( iLeft + iDivWidth > iDocWidth )
{
nHidden.style.left = (iDocWidth-iDivWidth)+"px";
}
if ( iDivY + iDivHeight > iDocHeight )
{
nHidden.style.top = (oPos.top - iDivHeight - iDivMarginTop - iDivMarginBottom)+"px";
}
}
this.s.hidden = false;
@@ -846,7 +854,8 @@ ColVis.prototype = {
this.s.hidden = true;
$(this.dom.collection).animate({"opacity": 0}, that.s.iOverlayFade, function (e) {
this.style.display = "none";
// this.style.display = "none";
document.body.removeChild( this );
} );
$(this.dom.background).animate({"opacity": 0}, that.s.iOverlayFade, function (e) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,482 +0,0 @@
/*! Moment Duration Format v1.3.0
* https://github.com/jsmreese/moment-duration-format
* Date: 2014-07-15
*
* Duration format plugin function for the Moment.js library
* http://momentjs.com/
*
* Copyright 2014 John Madhavan-Reese
* Released under the MIT license
*/
(function (root, undefined) {
// repeatZero(qty)
// returns "0" repeated qty times
function repeatZero(qty) {
var result = "";
// exit early
// if qty is 0 or a negative number
// or doesn't coerce to an integer
qty = parseInt(qty, 10);
if (!qty || qty < 1) { return result; }
while (qty) {
result += "0";
qty -= 1;
}
return result;
}
// padZero(str, len [, isRight])
// pads a string with zeros up to a specified length
// will not pad a string if its length is aready
// greater than or equal to the specified length
// default output pads with zeros on the left
// set isRight to `true` to pad with zeros on the right
function padZero(str, len, isRight) {
if (str == null) { str = ""; }
str = "" + str;
return (isRight ? str : "") + repeatZero(len - str.length) + (isRight ? "" : str);
}
// isArray
function isArray(array) {
return Object.prototype.toString.call(array) === "[object Array]";
}
// isObject
function isObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
// findLast
function findLast(array, callback) {
var index = array.length;
while (index -= 1) {
if (callback(array[index])) { return array[index]; }
}
}
// find
function find(array, callback) {
var index = 0,
max = array.length,
match;
if (typeof callback !== "function") {
match = callback;
callback = function (item) {
return item === match;
};
}
while (index < max) {
if (callback(array[index])) { return array[index]; }
index += 1;
}
}
// each
function each(array, callback) {
var index = 0,
max = array.length;
if (!array || !max) { return; }
while (index < max) {
if (callback(array[index], index) === false) { return; }
index += 1;
}
}
// map
function map(array, callback) {
var index = 0,
max = array.length,
ret = [];
if (!array || !max) { return ret; }
while (index < max) {
ret[index] = callback(array[index], index);
index += 1;
}
return ret;
}
// pluck
function pluck(array, prop) {
return map(array, function (item) {
return item[prop];
});
}
// compact
function compact(array) {
var ret = [];
each(array, function (item) {
if (item) { ret.push(item); }
});
return ret;
}
// unique
function unique(array) {
var ret = [];
each(array, function (_a) {
if (!find(ret, _a)) { ret.push(_a); }
});
return ret;
}
// intersection
function intersection(a, b) {
var ret = [];
each(a, function (_a) {
each(b, function (_b) {
if (_a === _b) { ret.push(_a); }
});
});
return unique(ret);
}
// rest
function rest(array, callback) {
var ret = [];
each(array, function (item, index) {
if (!callback(item)) {
ret = array.slice(index);
return false;
}
});
return ret;
}
// initial
function initial(array, callback) {
var reversed = array.slice().reverse();
return rest(reversed, callback).reverse();
}
// extend
function extend(a, b) {
for (var key in b) {
if (b.hasOwnProperty(key)) { a[key] = b[key]; }
}
return a;
}
// define internal moment reference
var moment;
if (typeof require === "function") {
try { moment = require('moment'); }
catch (e) {}
}
if (!moment && root.moment) {
moment = root.moment;
}
if (!moment) {
throw "Moment Duration Format cannot find Moment.js";
}
// moment.duration.format([template] [, precision] [, settings])
moment.duration.fn.format = function () {
var tokenizer, tokens, types, typeMap, momentTypes, foundFirst, trimIndex,
args = [].slice.call(arguments),
settings = extend({}, this.format.defaults),
// keep a shadow copy of this moment for calculating remainders
remainder = moment.duration(this);
// add a reference to this duration object to the settings for use
// in a template function
settings.duration = this;
// parse arguments
each(args, function (arg) {
if (typeof arg === "string" || typeof arg === "function") {
settings.template = arg;
return;
}
if (typeof arg === "number") {
settings.precision = arg;
return;
}
if (isObject(arg)) {
extend(settings, arg);
}
});
// types
types = settings.types = (isArray(settings.types) ? settings.types : settings.types.split(" "));
// template
if (typeof settings.template === "function") {
settings.template = settings.template.apply(settings);
}
// tokenizer regexp
tokenizer = new RegExp(map(types, function (type) {
return settings[type].source;
}).join("|"), "g");
// token type map function
typeMap = function (token) {
return find(types, function (type) {
return settings[type].test(token);
});
};
// tokens array
tokens = map(settings.template.match(tokenizer), function (token, index) {
var type = typeMap(token),
length = token.length;
return {
index: index,
length: length,
// replace escaped tokens with the non-escaped token text
token: (type === "escape" ? token.replace(settings.escape, "$1") : token),
// ignore type on non-moment tokens
type: ((type === "escape" || type === "general") ? null : type)
// calculate base value for all moment tokens
//baseValue: ((type === "escape" || type === "general") ? null : this.as(type))
};
}, this);
// unique moment token types in the template (in order of descending magnitude)
momentTypes = intersection(types, unique(compact(pluck(tokens, "type"))));
// exit early if there are no momentTypes
if (!momentTypes.length) {
return pluck(tokens, "token").join("");
}
// calculate values for each token type in the template
each(momentTypes, function (momentType, index) {
var value, wholeValue, decimalValue, isLeast, isMost;
// calculate integer and decimal value portions
value = remainder.as(momentType);
wholeValue = (value > 0 ? Math.floor(value) : Math.ceil(value));
decimalValue = value - wholeValue;
// is this the least-significant moment token found?
isLeast = ((index + 1) === momentTypes.length);
// is this the most-significant moment token found?
isMost = (!index);
// update tokens array
// using this algorithm to not assume anything about
// the order or frequency of any tokens
each(tokens, function (token) {
if (token.type === momentType) {
extend(token, {
value: value,
wholeValue: wholeValue,
decimalValue: decimalValue,
isLeast: isLeast,
isMost: isMost
});
if (isMost) {
// note the length of the most-significant moment token:
// if it is greater than one and forceLength is not set, default forceLength to `true`
if (settings.forceLength == null && token.length > 1) {
settings.forceLength = true;
}
// rationale is this:
// if the template is "h:mm:ss" and the moment value is 5 minutes, the user-friendly output is "5:00", not "05:00"
// shouldn't pad the `minutes` token even though it has length of two
// if the template is "hh:mm:ss", the user clearly wanted everything padded so we should output "05:00"
// if the user wanted the full padded output, they can set `{ trim: false }` to get "00:05:00"
}
}
});
// update remainder
remainder.subtract(wholeValue, momentType);
});
// trim tokens array
if (settings.trim) {
tokens = (settings.trim === "left" ? rest : initial)(tokens, function (token) {
// return `true` if:
// the token is not the least moment token (don't trim the least moment token)
// the token is a moment token that does not have a value (don't trim moment tokens that have a whole value)
return !(token.isLeast || (token.type != null && token.wholeValue));
});
}
// build output
// the first moment token can have special handling
foundFirst = false;
// run the map in reverse order if trimming from the right
if (settings.trim === "right") {
tokens.reverse();
}
tokens = map(tokens, function (token) {
var val,
decVal;
if (!token.type) {
// if it is not a moment token, use the token as its own value
return token.token;
}
// apply negative precision formatting to the least-significant moment token
if (token.isLeast && (settings.precision < 0)) {
val = (Math.floor(token.wholeValue * Math.pow(10, settings.precision)) * Math.pow(10, -settings.precision)).toString();
} else {
val = token.wholeValue.toString();
}
// remove negative sign from the beginning
val = val.replace(/^\-/, "");
// apply token length formatting
// special handling for the first moment token that is not the most significant in a trimmed template
if (token.length > 1 && (foundFirst || token.isMost || settings.forceLength)) {
val = padZero(val, token.length);
}
// add decimal value if precision > 0
if (token.isLeast && (settings.precision > 0)) {
decVal = token.decimalValue.toString().replace(/^\-/, "").split(/\.|e\-/);
switch (decVal.length) {
case 1:
val += "." + padZero(decVal[0], settings.precision, true).slice(0, settings.precision);
break;
case 2:
val += "." + padZero(decVal[1], settings.precision, true).slice(0, settings.precision);
break;
case 3:
val += "." + padZero(repeatZero((+decVal[2]) - 1) + (decVal[0] || "0") + decVal[1], settings.precision, true).slice(0, settings.precision);
break;
default:
throw "Moment Duration Format: unable to parse token decimal value.";
}
}
// add a negative sign if the value is negative and token is most significant
if (token.isMost && token.value < 0) {
val = "-" + val;
}
foundFirst = true;
return val;
});
// undo the reverse if trimming from the right
if (settings.trim === "right") {
tokens.reverse();
}
return tokens.join("");
};
moment.duration.fn.format.defaults = {
// token definitions
escape: /\[(.+?)\]/,
years: /[Yy]+/,
months: /M+/,
weeks: /[Ww]+/,
days: /[Dd]+/,
hours: /[Hh]+/,
minutes: /m+/,
seconds: /s+/,
milliseconds: /S+/,
general: /.+?/,
// token type names
// in order of descending magnitude
// can be a space-separated token name list or an array of token names
types: "escape years months weeks days hours minutes seconds milliseconds general",
// format options
// trim
// "left" - template tokens are trimmed from the left until the first moment token that has a value >= 1
// "right" - template tokens are trimmed from the right until the first moment token that has a value >= 1
// (the final moment token is not trimmed, regardless of value)
// `false` - template tokens are not trimmed
trim: "left",
// precision
// number of decimal digits to include after (to the right of) the decimal point (positive integer)
// or the number of digits to truncate to 0 before (to the left of) the decimal point (negative integer)
precision: 0,
// force first moment token with a value to render at full length even when template is trimmed and first moment token has length of 1
forceLength: null,
// template used to format duration
// may be a function or a string
// template functions are executed with the `this` binding of the settings object
// so that template strings may be dynamically generated based on the duration object
// (accessible via `this.duration`)
// or any of the other settings
template: function () {
var types = this.types,
dur = this.duration,
lastType = findLast(types, function (type) {
return dur._data[type];
});
// default template strings for each duration dimension type
switch (lastType) {
case "seconds":
return "h:mm:ss";
case "minutes":
return "d[d] h:mm";
case "hours":
return "d[d] h[h]";
case "days":
return "M[m] d[d]";
case "weeks":
return "y[y] w[w]";
case "months":
return "y[y] M[m]";
case "years":
return "y[y]";
default:
return "y[y] M[m] d[d] h:mm:ss";
}
}
};
})(this);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -330,31 +330,24 @@ function humanTime(seconds) {
}
}
function humanTimeClean(seconds) {
var text;
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();
});
};
function getPercent(value1, value2) {
value1 = parseFloat(value1) | 0
value2 = parseFloat(value2) | 0
var percent = 0;
if (value1 !== 0 && value2 !== 0) {
percent = (value1 / value2) * 100
}
return Math.round(percent)
}
function millisecondsToMinutes(ms, roundToMinute) {
if (ms > 0) {
var minutes = Math.floor(ms / 60000);
@@ -372,6 +365,61 @@ function millisecondsToMinutes(ms, roundToMinute) {
}
}
}
function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) {
var factors = {
d: 86400000,
h: 3600000,
m: 60000,
s: 1000,
ms: 1
}
ms = parseInt(ms);
var d, h, m, s;
if (ms > 0) {
if (return_seconds && ms < return_seconds) {
sig = 'dhms'
}
ms = ms * factors[units];
h = ms % factors['d'];
d = Math.trunc(ms / factors['d']);
m = h % factors['h'];
h = Math.trunc(h / factors['h']);
s = m % factors['m'];
m = Math.trunc(m / factors['m']);
ms = s % factors['s'];
s = Math.trunc(s / factors['s']);
var hd_list = [];
if (sig >= 'd' && d > 0) {
d = (sig === 'd' && h >= 12) ? d + 1 : d;
hd_list.push(d.toString() + ' day' + ((d > 1) ? 's' : ''));
}
if (sig >= 'dh' && h > 0) {
h = (sig === 'dh' && m >= 30) ? h + 1 : h;
hd_list.push(h.toString() + ' hr' + ((h > 1) ? 's' : ''));
}
if (sig >= 'dhm' && m > 0) {
m = (sig === 'dhm' && s >= 30) ? m + 1 : m;
hd_list.push(m.toString() + ' min' + ((m > 1) ? 's' : ''));
}
if (sig >= 'dhms' && s > 0) {
hd_list.push(s.toString() + ' sec' + ((s > 1) ? 's' : ''));
}
return hd_list.join(' ')
} else {
return '0'
}
}
// Our countdown plugin takes a callback, a duration, and an optional message
$.fn.countdown = function (callback, duration, message) {
// If no message is provided, we use an empty string
@@ -555,16 +603,18 @@ function PopupCenter(url, title, w, h) {
function setLocalStorage(key, value, path) {
var key_path = key;
if (path !== false) {
key = key + '_' + window.location.pathname;
key_path = key_path + '_' + window.location.pathname;
}
localStorage.setItem(key, value);
localStorage.setItem(key_path, value);
}
function getLocalStorage(key, default_value, path) {
var key_path = key;
if (path !== false) {
key = key + '_' + window.location.pathname;
key_path = key_path + '_' + window.location.pathname;
}
var value = localStorage.getItem(key);
var value = localStorage.getItem(key_path);
if (value !== null) {
return value
} else if (default_value !== undefined) {
@@ -803,3 +853,16 @@ function user_page(user_id, user) {
return params;
}
MEDIA_TYPE_HEADERS = {
'movie': 'Movies',
'show': 'TV Shows',
'season': 'Seasons',
'episode': 'Episodes',
'artist': 'Artists',
'album': 'Albums',
'track': 'Tracks',
'video': 'Videos',
'audio': 'Tracks',
'photo': 'Photos'
}

View File

@@ -0,0 +1,87 @@
/**
* Plugin: "disable_options" (selectize.js)
* Copyright (c) 2013 Mondo Robot & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at:
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*
* @authors Jake Myers <jmyers0022@gmail.com>, Vaughn Draughon <vaughn@rocksolidwebdesign.com>
*/
Selectize.define('disable_options', function(options) {
var self = this;
options = $.extend({
'disableField': '',
'disableOptions': []
}, options);
self.refreshOptions = (function() {
var original = self.refreshOptions;
return function() {
original.apply(this, arguments);
$.each(options.disableOptions, function(index, option) {
self.$dropdown_content.find('[data-' + options.disableField + '="' + String(option) + '"]').addClass('option-disabled');
});
};
})();
self.onOptionSelect = (function() {
var original = self.onOptionSelect;
return function(e) {
var value, $target, $option;
if (e.preventDefault) {
e.preventDefault();
e.stopPropagation();
}
$target = $(e.currentTarget);
if ($target.hasClass('option-disabled')) {
return;
}
return original.apply(this, arguments);
};
})();
self.disabledOptions = function() {
return options.disableOptions;
}
self.setDisabledOptions = function( values ) {
options.disableOptions = values
}
self.disableOptions = function( values ) {
if ( ! ( values instanceof Array ) ) {
values = [ values ]
}
values.forEach( function( val ) {
if ( options.disableOptions.indexOf( val ) == -1 ) {
options.disableOptions.push( val )
}
} );
}
self.enableOptions = function( values ) {
if ( ! ( values instanceof Array ) ) {
values = [ values ]
}
values.forEach( function( val ) {
var remove = options.disableOptions.indexOf( val );
if ( remove + 1 ) {
options.disableOptions.splice( remove, 1 );
}
} );
}
});

View File

@@ -0,0 +1,111 @@
collections_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ collections",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'asc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "titleSort",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['ratingKey'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<a href="' + page('info', rowData['ratingKey']) + '"><i class="fa fa-blank fa-fw"></i>' + thumb_popover + '</a>');
}
},
"width": "50%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "collectionMode",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var mode = '';
if (cellData === -1) {
mode = 'Library default';
} else if (cellData === 0) {
mode = 'Hide collection';
} else if (cellData === 1) {
mode = 'Hide items in this collection';
} else if (cellData === 2) {
mode = 'Show this collection and its items';
}
$(td).html(mode);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "collectionSort",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var sort = '';
if (cellData === 0) {
sort = 'Release date';
} else if (cellData === 1) {
sort = 'Alphabetical';
}
$(td).html(sort);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [3],
"data": "childCount",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var type = MEDIA_TYPE_HEADERS[rowData['subtype']] || '';
if (rowData['childCount'] == 1) {
type = type.slice(0, -1);
}
$(td).html(cellData + ' ' + type);
}
},
"width": "10%",
"className": "no-wrap"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
$('body').popover({
selector: '[data-toggle="popover"]',
html: true,
container: 'body',
trigger: 'hover',
placement: 'right',
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;" />';
}
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0);
},
"rowCallback": function (row, rowData, rowIndex) {
}
};

View File

@@ -0,0 +1,251 @@
var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a';
$.ajax({
url: 'get_date_formats',
type: 'GET',
success: function (data) {
date_format = data.date_format;
time_format = data.time_format;
}
});
export_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ export items",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'desc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "timestamp",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(moment(cellData, "X").format(date_format + ' ' + time_format));
}
},
"width": "8%",
"className": "no-wrap",
"searchable": false
},
{
"targets": [1],
"data": "media_type_title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "7%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "rating_key",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html('<a href="' + page('info', rowData['rating_key']) + '">' + cellData + '</a>');
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [3],
"data": "title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var tooltip;
var filename;
if (!rowData['individual_files']) {
tooltip = '<span data-toggle="tooltip" title="Single File"><i class="fa fa-file-alt fa-fw"></i></span>';
filename = cellData + '.' + rowData['file_format']
} else {
tooltip = '<span data-toggle="tooltip" title="Multiple Files"><i class="fa fa-folder fa-fw"></i></span>';
filename = cellData
}
if (rowData['complete'] === 1 && rowData['exists'] && !rowData['individual_files']) {
$(td).html('<a href="view_export?export_id=' + rowData['export_id'] + '" target="_blank">' + tooltip + '&nbsp;' + filename + '</a>');
} else {
$(td).html(tooltip + '&nbsp;' + filename);
}
}
},
"width": "40%",
"className": "no-wrap"
},
{
"targets": [4],
"data": "file_format",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var images = '';
if (rowData['thumb_level'] || rowData['art_level']) {
images = ' + images';
}
$(td).html(cellData + images);
}
},
"width": "7%",
"className": "no-wrap"
},
{
"targets": [5],
"data": "metadata_level",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html(cellData);
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [6],
"data": "media_info_level",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html(cellData);
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [7],
"data": "custom_fields",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData.replace(/,/g, ', '));
}
},
"width": "6%",
"className": "datatable-wrap"
},
{
"targets": [8],
"data": "file_size",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '' && cellData !== null) {
$(td).html(humanFileSize(cellData));
}
},
"width": "6%",
"className": "no-wrap",
"searchable": false
},
{
"targets": [9],
"data": "complete",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData === 1 && rowData['exists']) {
var tooltip_title = '';
var icon = '';
if (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) {
tooltip_title = 'Zip Archive';
icon = 'fa-file-archive';
} else {
tooltip_title = rowData['file_format'].toUpperCase() + ' File';
icon = 'fa-file-download';
}
var icon = (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) ? 'fa-file-archive' : 'fa-file-download';
$(td).html('<button class="btn btn-xs btn-success pull-left" data-id="' + rowData['export_id'] + '"><span data-toggle="tooltip" data-placement="left" title="' + tooltip_title + '"><i class="fa ' + icon + ' fa-fw"></i> Download</span></button>');
} else if (cellData === 0) {
var percent = Math.min(getPercent(rowData['exported_items'], rowData['total_items']), 99)
$(td).html('<span class="btn btn-xs btn-dark pull-left export-processing" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-spinner fa-spin fa-fw"></i> ' + percent + '%</span>');
} else if (cellData === -1) {
$(td).html('<span class="btn btn-xs btn-dark pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-exclamation-circle fa-fw"></i> Failed</span>');
} else {
$(td).html('<span class="btn btn-xs btn-dark pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-question-circle fa-fw"></i> Not Found</span>');
}
},
"width": "7%",
"className": "export_download",
"searchable": false
},
{
"targets": [10],
"data": null,
"createdCell": function (td, cellData, rowData, row, col) {
if (rowData['complete'] !== 0) {
$(td).html('<button class="btn btn-xs btn-danger pull-left" data-id="' + rowData['export_id'] + '"><i class="fa fa-trash-o fa-fw"></i> Delete</button>');
} else {
$(td).html('<span class="btn btn-xs btn-danger pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-trash-o fa-fw"></i> Delete</span>');
}
},
"width": "7%",
"className": "export_delete",
"searchable": false
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
// Create the tooltips.
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
if (export_processing_timer) {
clearTimeout(export_processing_timer);
}
if ($('.export-processing').length) {
export_processing_timer = setTimeout(redrawExportTable.bind(null, false), 2000);
}
},
"preDrawCallback": function(settings) {
if (!export_processing_timer) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
},
"rowCallback": function (row, rowData, rowIndex) {
if (rowData['complete'] === 0) {
$(row).addClass('current-activity-row');
}
}
};
$('.export_table').on('click', '> tbody > tr > td.export_download > button', function (e) {
var tr = $(this).closest('tr');
var row = export_table.row(tr);
var rowData = row.data();
e.preventDefault();
window.location.href = 'download_export?export_id=' + rowData['export_id'];
});
$('.export_table').on('click', '> tbody > tr > td.export_delete > button', function (e) {
var tr = $(this).closest('tr');
var row = export_table.row(tr);
var rowData = row.data();
var msg = 'Are you sure you want to delete the following export?<br /><br /><strong>' + rowData['title'] + '</strong>';
var url = 'delete_export?export_id=' + rowData['export_id'];
confirmAjaxCall(url, msg, null, null, redrawExportTable);
});
function redrawExportTable(paging) {
export_table.draw(paging);
}
var export_processing_timer;

View File

@@ -55,15 +55,17 @@ history_table_options = {
if (rowData['state'] !== null) {
var state = '';
if (rowData['state'] === 'playing') {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Playing"><i class="fa fa-play fa-fw"></i></span>';
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Playing"><i class="fa fa-fw fa-play"></i></span>';
} else if (rowData['state'] === 'paused') {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Paused"><i class="fa fa-pause fa-fw"></i></span>';
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Paused"><i class="fa fa-fw fa-pause"></i></span>';
} else if (rowData['state'] === 'buffering') {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Buffering"><i class="fa fa-spinner fa-fw"></i></span>';
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Buffering"><i class="fa fa-fw fa-spinner"></i></span>';
} else if (rowData['state'] === 'error') {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Playback Error"><i class="fa fa-exclamation-triangle fa-fw"></i></span>';
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Playback Error"><i class="fa fa-fw fa-exclamation-triangle"></i></span>';
} else if (rowData['state'] === 'stopped') {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Stopped"><i class="fa fa-stop fa-fw"></i></span>';
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Currently Stopped"><i class="fa fa-fw fa-stop"></i></span>';
} else {
state = '<span class="current-activity-tooltip" data-toggle="tooltip" title="Unknown"><i class="fa fa-fw fa-question-circle"></i></span>';
}
$(td).html('<div><div style="float: left;">' + state + '&nbsp;' + date + '</div></div>');
} else if (rowData['group_count'] > 1) {

View File

@@ -192,7 +192,7 @@ libraries_list_table_options = {
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData));
$(td).html(humanDuration(cellData, 'dhm', 's'));
}
},
"searchable": false,

View File

@@ -107,15 +107,15 @@ media_info_table_options = {
} else if (rowData['media_type'] === 'photo_album') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo Album"><i class="fa fa-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
$(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'photo') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
$(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'clip') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 500, 280, null, null, null, 'art') + '" data-height="80" data-width="140">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
$(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else {
$(td).html(cellData);
}

View File

@@ -0,0 +1,100 @@
playlists_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ playlists",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'asc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var smart = '<i class="fa fa-blank fa-fw"></i>';
if (rowData['smart']) {
smart = '<span class="media-type-tooltip" data-toggle="tooltip" title="Smart Playlist"><i class="fa fa-cog fa-fw"></i></span>&nbsp;'
}
var breadcrumb = '';
if (rowData['userID']) {
breadcrumb = '&user_id=' + rowData['userID'];
} else if (rowData['librarySectionID']) {
breadcrumb = '&section_id=' + rowData['librarySectionID'];
}
var thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['composite'], rowData['ratingKey'], 300, 300, null, null, null, 'cover') + '" data-height="80" data-width="80">' + smart + cellData + '</span>';
$(td).html('<a href="' + page('info', rowData['ratingKey']) + breadcrumb +'">' + thumb_popover + '</a>');
}
},
"width": "60%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "leafCount",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var type = MEDIA_TYPE_HEADERS[rowData['playlistType']] || '';
if (rowData['leafCount'] === 1) {
type = type.slice(0, -1);
}
$(td).html(cellData + ' ' + type);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(humanDuration(cellData, 'dhm'));
}
},
"width": "20%",
"className": "no-wrap"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
// Create the tooltips.
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
$('body').popover({
selector: '[data-toggle="popover"]',
html: true,
container: 'body',
trigger: 'hover',
placement: 'right',
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;" />';
}
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0);
$('[data-toggle="tooltip"]').tooltip('destroy');
},
"rowCallback": function (row, rowData, rowIndex) {
}
};

View File

@@ -212,7 +212,7 @@ users_list_table_options = {
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData));
$(td).html(humanDuration(cellData, 'dhm', 's'));
}
},
"searchable": false,

View File

@@ -79,7 +79,6 @@
<script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/libraries.js${cache_param}"></script>
<script>
$(document).ready(function () {

View File

@@ -87,12 +87,19 @@ DOCUMENTATION :: END
% endif
</div>
<div class="user-info-nav">
<ul class="user-info-nav" role="tablist">
<li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="history-tab-btn" href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
% if _session['user_group'] == 'admin':
<ul class="nav nav-list nav-pills" role="tablist">
<li class="active"><a id="nav-tabs-profile" href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="nav-tabs-history" href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
% if data['section_id'] != LIVE_TV_SECTION_ID:
<li><a id="media-info-tab-btn" href="#tabs-mediainfo" role="tab" data-toggle="tab">Media Info</a></li>
% if _session['user_group'] == 'admin':
<li><a id="nav-tabs-mediainfo" href="#tabs-mediainfo" role="tab" data-toggle="tab">Media Info</a></li>
% endif
% if data['section_type'] != 'artist':
<li><a id="nav-tabs-collections" href="#tabs-collections" role="tab" data-toggle="tab">Collections</a></li>
% endif
<li><a id="nav-tabs-playlists" href="#tabs-playlists" role="tab" data-toggle="tab">Playlists</a></li>
% if _session['user_group'] == 'admin':
<li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif
% endif
</ul>
@@ -242,23 +249,22 @@ DOCUMENTATION :: END
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-mediainfo">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
% if config['get_file_sizes'] and data['section_id'] in config['get_file_sizes_hold']['section_ids']:
<div id="get_file_sizes_message" style="text-align: center; margin-top: 20px;">
% else:
<div id="get_file_sizes_message" style="text-align: center; margin-top: 20px; display: none;">
% endif
<i class="fa fa-refresh fa-spin"></i>&nbsp; Tautulli is calculating the file sizes for the library's media info. This could take a few minutes depending on the size of your library.
<br />
You may leave this page and check back later.
</div>
% endif
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-history"></i> Media Info for <strong>
<i class="fa fa-info-circle"></i> Media Info for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
@@ -305,6 +311,157 @@ DOCUMENTATION :: END
</div>
</div>
</div>
% endif
% if data['section_type'] != 'artist':
<div role="tabpanel" class="tab-pane" id="tabs-collections">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-folder-open"></i> Collections for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="collection" data-sub_media_type="${data['section_type']}"
data-export_type="collection">
<i class="fa fa-file-export"></i> Export collections
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-collections-table-button" id="refresh-collections-table">
<i class="fa fa-refresh"></i> Refresh collections
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-collections"></div>
</div>
</div>
<div class="table-card-back">
<table class="display collections_table" id="collections_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="collectionTitle">Collection Title</th>
<th align="left" id="collectionMode">Collection Mode</th>
<th align="left" id="collectionSort">Collection Sort</th>
<th align="left" id="collectionItems">Collection Items</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
<div role="tabpanel" class="tab-pane" id="tabs-playlists">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-list-alt"></i> Playlists for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<% playlist_sub_media_type = {'movie': 'video', 'show': 'video', 'artist': 'audio', 'photo': 'photo'} %>
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="playlist" data-sub_media_type="${playlist_sub_media_type.get(data['section_type'])}"
data-export_type="playlist">
<i class="fa fa-file-export"></i> Export playlists
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-playlists-table-button" id="refresh-playlists-table">
<i class="fa fa-refresh"></i> Refresh playlists
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-playlists"></div>
</div>
</div>
<div class="table-card-back">
<table class="display playlists_table" id="playlists_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="playlistTitle">Playlist Title</th>
<th align="left" id="playlistLeafCount">Playlist Items</th>
<th align="left" id="playlistDuration">Playlist Duration</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-file-export"></i> Metadata Exports for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="${'photoalbum' if data['section_type'] == 'photo' else data['section_type']}"
data-export_type="all">
<i class="fa fa-file-export"></i> Export metadata
</button>
</div>
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
</div>
</div>
</div>
@@ -335,8 +492,7 @@ DOCUMENTATION :: END
</%def>
<%def name="modalIncludes()">
<div id="edit-library-modal" class="modal fade" tabindex="-1" role="dialog"
aria-labelledby="edit-library-modal">
<div id="edit-library-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="edit-library-modal">
</div>
<div class="modal fade" id="info-modal" tabindex="-1" role="dialog" aria-labelledby="info-modal">
</div>
@@ -360,6 +516,8 @@ DOCUMENTATION :: END
</div>
</div>
</div>
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def>
<%def name="javascriptIncludes()">
@@ -369,6 +527,9 @@ DOCUMENTATION :: END
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
% if data:
<% from plexpy.common import LIVE_TV_SECTION_ID %>
<%
history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id']
%>
<script>
% if str(data['section_id']).isdigit():
var section_id = ${data['section_id']};
@@ -384,14 +545,18 @@ DOCUMENTATION :: END
var get_file_sizes = null;
% endif
</script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/media_info_table.js${cache_param}"></script>
<script src="${http_root}js/tables/collections_table.js${cache_param}"></script>
<script src="${http_root}js/tables/playlists_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
<script>
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
});
$(".inactive-library-tooltip").tooltip();
function loadHistoryTable() {
// Build watch history table
history_table_options.ajax = {
@@ -401,7 +566,7 @@ DOCUMENTATION :: END
return {
json_data: JSON.stringify( d ),
section_id: section_id,
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
user_id: "${history_user_id}"
};
}
};
@@ -413,7 +578,7 @@ DOCUMENTATION :: END
clearSearchButton('history_table-SID-${data["section_id"]}', history_table);
}
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
$('#nav-tabs-history').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
loadHistoryTable();
}
@@ -422,97 +587,70 @@ DOCUMENTATION :: END
$("#refresh-history-list").click(function () {
history_table.draw();
});
$(".inactive-library-tooltip").tooltip();
% if _session['user_group'] == 'admin':
function loadMediaInfoTable() {
// Build media info table
media_info_table_options.ajax = {
url: 'get_library_media_info',
</script>
% if data['section_type'] != 'artist':
<script>
function loadCollectionsTable() {
// Build collections table
collections_table_options.ajax = {
url: 'get_collections_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id,
refresh: refresh_table
section_id: section_id
};
}
};
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options);
collections_table = $('#collections_table-SID-${data["section_id"]}').DataTable(collections_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');
var colvis = new $.fn.dataTable.ColVis(collections_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-collections');
clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table);
clearSearchButton('collections_table-SID-${data["section_id"]}', collections_table);
}
$('a[href="#tabs-mediainfo"]').on('shown.bs.tab', function() {
if (typeof(media_info_table) === 'undefined') {
loadMediaInfoTable();
$('#nav-tabs-collections').on('shown.bs.tab', function() {
if (typeof(collections_table) === 'undefined') {
loadCollectionsTable();
}
});
$("#refresh-media-info-table").click(function () {
media_info_child_table = {};
refresh_table = true;
refresh_child_tables = true;
media_info_table.draw();
refresh_table = false;
$("#refresh-collections-table").click(function () {
collections_table.draw();
});
$("#edit-library-tooltip").tooltip();
// Load edit library modal
$("#toggle-edit-library-modal").click(function() {
$("#edit-library-tooltip").tooltip('hide');
$.ajax({
url: 'edit_library_dialog',
data: { section_id: section_id },
cache: false,
async: true,
complete: function(xhr, status) {
$("#edit-library-modal").html(xhr.responseText);
</script>
% endif
<script>
function loadPlaylistsTable() {
// Build playlists table
playlists_table_options.ajax = {
url: 'get_playlists_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id
};
}
});
});
};
playlists_table = $('#playlists_table-SID-${data["section_id"]}').DataTable(playlists_table_options);
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-playlists');
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST',
data: { row_ids: history_to_delete.join(',') },
async: true,
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
});
}
clearSearchButton('playlists_table-SID-${data["section_id"]}', playlists_table);
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
$('#nav-tabs-playlists').on('shown.bs.tab', function() {
if (typeof(playlists_table) === 'undefined') {
loadPlaylistsTable();
}
});
% endif
$("#refresh-playlists-table").click(function () {
playlists_table.draw();
});
function recentlyWatched() {
// Populate recently watched
@@ -634,11 +772,11 @@ DOCUMENTATION :: END
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.user-info-nav a[href='+hash.replace(prefix,"")+']').tab('show').trigger('show.bs.tab');
$('.nav-list #nav-' + hash.replace('#' + prefix, "")).tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.user-info-nav a').on('shown.bs.tab', function (e) {
$('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
@@ -664,5 +802,143 @@ DOCUMENTATION :: END
});
</script>
% if _session['user_group'] == 'admin':
<script>
function loadMediaInfoTable() {
// Build media info table
media_info_table_options.ajax = {
url: 'get_library_media_info',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id,
refresh: refresh_table
};
}
};
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options);
var colvis = new $.fn.dataTable.ColVis(media_info_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-media-info');
clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table);
}
$('#nav-tabs-mediainfo').on('shown.bs.tab', function() {
if (typeof(media_info_table) === 'undefined') {
loadMediaInfoTable();
}
});
$("#refresh-media-info-table").click(function () {
media_info_child_table = {};
refresh_table = true;
refresh_child_tables = true;
media_info_table.draw();
refresh_table = false;
});
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id
};
}
};
export_table = $('#export_table-SID-${data["section_id"]}').DataTable(export_table_options);
export_table.columns([7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-SID-${data["section_id"]}', export_table);
}
$('#nav-tabs-export').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
$("#edit-library-tooltip").tooltip();
// Load edit library modal
$("#toggle-edit-library-modal").click(function() {
$("#edit-library-tooltip").tooltip('hide');
$.ajax({
url: 'edit_library_dialog',
data: { section_id: section_id },
cache: false,
async: true,
complete: function(xhr, status) {
$("#edit-library-modal").html(xhr.responseText);
}
});
});
$(".export-button").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
section_id: $(this).data('section_id'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type'),
export_type: $(this).data('export_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST',
data: { row_ids: history_to_delete.join(',') },
async: true,
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
});
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
}
});
</script>
% endif
% endif
</%def>

View File

@@ -11,28 +11,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.5">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.5">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.5">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.6.0">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.6.0">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS -->
<!-- Android -->
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.0.5" crossorigin="use-credentials">
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
<meta name="theme-color" content="#282a2d">
<!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.0.5" color="#282a2d">
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.6.0" color="#282a2d">
<meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft -->
<meta name="application-name" content="Tautulli">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.0.5">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.6.0">
</head>
<body style="margin: 0; overflow: auto;">
@@ -109,7 +109,7 @@
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script src="${http_root}js/jquery-3.5.1.min.js"></script>
<script src="${http_root}js/platform.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script>

View File

@@ -208,7 +208,6 @@
<script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/logs.js${cache_param}"></script>
<script src="${http_root}js/tables/plex_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/notification_logs.js${cache_param}"></script>

View File

@@ -8,7 +8,7 @@
<meta charset="utf-8">
<title>Tautulli - ${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">

View File

@@ -50,7 +50,7 @@
</div>
<p class="help-block">
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank">custom crontab</a>. Only standard cron values are valid.</span>
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>. Only standard cron values are valid.</span>
</p>
</div>
<div class="form-group">

View File

@@ -8,9 +8,9 @@
<meta charset="utf-8">
<title>Tautulli - ${title} | ${server_name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.5">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.5">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.5">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.6.0">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.6.0">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<style>
* {
@@ -31,7 +31,7 @@
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script src="${http_root}js/jquery-3.5.1.min.js"></script>
<script>
$(document).ready(function () {
var frame = $('<iframe></iframe>', {

View File

@@ -17,8 +17,6 @@
</%def>
<%def name="headerIncludes()">
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
</%def>
<%def name="body()">
@@ -48,17 +46,17 @@
<!-- Nav tabs -->
<div class="col-md-3">
<ul class="nav-settings list-unstyled" role="tablist">
<li role="presentation" class="active"><a href="#tabs-help_info" aria-controls="tabs-help_info" role="tab" data-toggle="tab">Help & Info</a></li>
<li role="presentation"><a href="#tabs-general" aria-controls="tabs-general" role="tab" data-toggle="tab">General</a></li>
<li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li>
<li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li>
<li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li>
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
<li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
<li role="presentation"><a href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
<li role="presentation"><a href="#tabs-3rd_party_apis" aria-controls="tabs-3rd_party_apis" role="tab" data-toggle="tab">3rd Party APIs</a></li>
<li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
<li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
<li role="presentation" class="active"><a id="nav-help_info" href="#tabs-help_info" aria-controls="tabs-help_info" role="tab" data-toggle="tab">Help & Info</a></li>
<li role="presentation"><a id="nav-tabs-general" href="#tabs-general" aria-controls="tabs-general" role="tab" data-toggle="tab">General</a></li>
<li role="presentation"><a id="nav-tabs-homepage" href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li>
<li role="presentation"><a id="nav-tabs-web_interface" href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li>
<li role="presentation"><a id="nav-tabs-plex_media_server" href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li>
<li role="presentation"><a id="nav-tabs-notifications" href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
<li role="presentation"><a id="nav-tabs-notification_agents" href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
<li role="presentation"><a id="nav-tabs-newsletter_agents" href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
<li role="presentation"><a id="nav-tabs-3rd_party_apis" href="#tabs-3rd_party_apis" aria-controls="tabs-3rd_party_apis" role="tab" data-toggle="tab">3rd Party APIs</a></li>
<li role="presentation"><a id="nav-tabs-import_backups" href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
<li role="presentation"><a id="nav-tabs-android_app" href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
</ul>
</div>
<div class="col-md-9">
@@ -830,7 +828,7 @@
<p class="help-block">
Optional: Set your Plex logs folder to use Tautulli as a log viewer. Plex logs are not needed for Tautulli to function.
A complete folder path is required, shortcuts are not recognized, and the logs must be accessible from the machine where Tautulli is installed.
<a href="${anon_url('https://support.plex.tv/hc/en-us/articles/200250417-Plex-Media-Server-Log-Files')}" target="_blank">Click here</a> for help.
<a href="${anon_url('https://support.plex.tv/hc/en-us/articles/200250417-Plex-Media-Server-Log-Files')}" target="_blank" rel="noreferrer">Click here</a> for help.
</p>
</div>
<div class="checkbox advanced-setting">
@@ -1179,7 +1177,7 @@
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
</p>
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>Notification Agents Guide</a> for instructions on setting up each notification agent.
Please see the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">Notification Agents Guide</a> for instructions on setting up each notification agent.
</p>
<br />
<div id="plexpy-notifiers-table">
@@ -1241,7 +1239,7 @@
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<div class="form-group">
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up Imgur.<br>
Please see the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">3rd Party APIs Guide</a> for instructions on setting up Imgur.<br>
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
</div>
@@ -1264,7 +1262,7 @@
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<div class="form-group">
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up Cloudinary.
Please see the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">3rd Party APIs Guide</a> for instructions on setting up Cloudinary.
</p>
</div>
<div class="form-group">
@@ -1457,6 +1455,22 @@
</div>
</div>
</div>
<div class="form-group">
<label for="export_dir">Export Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-7">
<div class="input-group">
<input type="text" class="form-control directory-settings" id="export_dir" name="export_dir" value="${config['export_dir']}" ${docker_setting}>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="export_dir_browse" data-toggle="browse" data-filter=".folderonly" data-target="#export_dir" ${docker_setting}>Browse</button>
</span>
</div>
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_exports">Clear All Exports</button>
</div>
</div>
</div>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
@@ -1471,9 +1485,9 @@
<div class="form-group">
<label>Get the App</label>
<p class="help-block">
Get the <a href="${anon_url('https://play.google.com/store/apps/details?id=com.williamcomartin.plexpyremote')}" target="_blank">Tautulli Remote</a> app on Google Play<sup>TM</sup> to access Tautulli from your Android device!<br />
Get the <a href="${anon_url('https://play.google.com/store/apps/details?id=com.williamcomartin.plexpyremote')}" target="_blank" rel="noreferrer">Tautulli Remote</a> app on Google Play<sup>TM</sup> to access Tautulli from your Android device!<br />
<span class="google-play-badge">
<a href="${anon_url('https://play.google.com/store/apps/details?id=com.williamcomartin.plexpyremote')}" target="_blank"><img alt="Get it on Google Play" src="images/en-play-badge.png" /></a>
<a href="${anon_url('https://play.google.com/store/apps/details?id=com.williamcomartin.plexpyremote')}" target="_blank" rel="noreferrer"><img alt="Get it on Google Play" src="images/en-play-badge.png" /></a>
</span>
</p>
</div>
@@ -1509,14 +1523,14 @@
</div>
<div class="modal-body">
<div style="text-align: center; margin-top: 20px; margin-bottom: 20px;">
<strong>Please read the <a href="${anon_url('https://github.com/%s/%s-Issues/blob/master/README.md' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">guidelines</a>
<strong>Please read the <a href="${anon_url('https://github.com/%s/%s-Issues/blob/master/README.md' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">guidelines</a>
in the README document <br />before submitting a new <span id="guidelines-type"></span>!</strong>
<br /><br />
Your post may be removed for failure to follow the guidelines.
</div>
</div>
<div class="modal-footer">
<a href="#" target="_blank" id="guidelines-continue" class="btn btn-bright">Continue</a>
<a href="#" target="_blank" rel="noreferrer" id="guidelines-continue" class="btn btn-bright">Continue</a>
</div>
</div>
</div>
@@ -1531,12 +1545,12 @@
</div>
<div class="modal-body">
<div style="text-align: center; margin-top: 20px; margin-bottom: 20px;">
<strong>Please read the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">FAQ</a>
<strong>Please read the <a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">FAQ</a>
before asking for help!</strong>
</div>
</div>
<div class="modal-footer">
<a href="#" target="_blank" id="support-continue" class="btn btn-bright">Continue</a>
<a href="#" target="_blank" rel="noreferrer" id="support-continue" class="btn btn-bright">Continue</a>
</div>
</div>
</div>
@@ -1986,8 +2000,7 @@ Rating: {rating}/10 --> Rating: /10
<%def name="javascriptIncludes()">
<script src="${http_root}js/parsley.min.js"></script>
<script src="${http_root}js/Sortable.min.js"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.inputaffix.min.js"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script>
<script>
function getConfigurationTable() {
@@ -2139,9 +2152,9 @@ $(document).ready(function() {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
var prefix = "tabs_";
if (hash) {
$('.nav-settings a[href='+hash.replace(prefix,"")+']').tab('show');
$('.nav-settings #nav-' + hash.replace('#' + prefix, "")).tab('show');
}
// Change hash for page-reload
@@ -2166,6 +2179,7 @@ $(document).ready(function() {
function preSaveChecks(_callback) {
verifyPMSWebURL();
setBaseURLSuffix(true);
if (serverChanged) {
verifyServer(_callback);
} else if (typeof _callback === "function") {
@@ -2185,6 +2199,7 @@ $(document).ready(function() {
getNewslettersTable();
getMobileDevicesTable();
loadUpdateDistros();
setBaseURLSuffix();
settingsChanged = false;
}
@@ -2307,6 +2322,12 @@ $(document).ready(function() {
confirmAjaxCall(url, msg);
});
$("#clear_exports").click(function () {
var msg = 'Are you sure you want to clear the Tautulli metadata exports?';
var url = 'delete_export?delete_all=true';
confirmAjaxCall(url, msg);
});
$("#clear_logs").click(function () {
var msg = 'Are you sure you want to clear the Tautulli logs?';
var url = 'delete_logs';
@@ -2894,6 +2915,19 @@ $(document).ready(function() {
$(this).val($(this).val().replace(/\/*$/, ''));
});
$('#http_root').change(function() {
setBaseURLSuffix();
});
function setBaseURLSuffix(clear) {
if (clear){
$('#http_base_url').suffix("");
} else {
$('#http_base_url').suffix($('#http_root').val());
}
}
setBaseURLSuffix();
function apiEnabled() {
var api_enabled = $('#api_enabled').prop('checked');
$('#app_api_msg').toggle(!(api_enabled));
@@ -3012,7 +3046,7 @@ $(document).ready(function() {
});
function gotoSetting(tab, setting){
$("a[href=#tabs-" + tab + "]").click();
$("#nav-tabs-" + tab).click();
if (setting) {
_setting = '#' + setting;
if ($(_setting).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {

View File

@@ -16,10 +16,10 @@
</div>
<div class="button-bar">
<div class="btn-group">
<a class="btn btn-dark" href="${anon_url('https://tautulli.com/discord')}" target="_blank"><i class="fab fa-discord"></i>&nbsp; Join Discord</a>
<a class="btn btn-dark" href="${anon_url('https://tautulli.com/discord')}" target="_blank" rel="noreferrer"><i class="fab fa-discord"></i>&nbsp; Join Discord</a>
</div>
<div class="btn-group">
<a class="btn btn-dark" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank"><i class="fab fa-reddit"></i>&nbsp; Join Reddit</a>
<a class="btn btn-dark" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank" rel="noreferrer"><i class="fab fa-reddit"></i>&nbsp; Join Reddit</a>
</div>
<div class="btn-group">
<button class="btn btn-dark" id="popout-iframe-button"><i class="fa fa-external-link"></i> Pop Out Chat</button>

View File

@@ -43,7 +43,9 @@ DOCUMENTATION :: END
<div class="summary-navbar">
<div class="col-md-12">
<div class="summary-navbar-list">
<ul class="list-unstyled breadcrumb"></ul>
<ul class="list-unstyled breadcrumb">
<li class="active">${data['friendly_name']}</li>
</ul>
</div>
</div>
</div>
@@ -67,12 +69,16 @@ DOCUMENTATION :: END
% endif
</div>
<div class="user-info-nav">
<ul class="user-info-nav" role="tablist">
<li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="history-tab-btn" href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
<li><a id="sync-tab-btn" href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li>
<li><a id="ip-tab-btn" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li>
<li><a id="login-tab-btn" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
<ul class="nav nav-list nav-pills" role="tablist">
<li class="active"><a id="nav-tabs-profile" href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="nav-tabs-history" href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
<li><a id="nav-tabs-playlists" href="#tabs-playlists" role="tab" data-toggle="tab">Playlists</a></li>
% if _session['user_group'] == 'admin':
<li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif
<li><a id="nav-tabs-synceditems" href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li>
<li><a id="nav-tabs-ipaddresses" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li>
<li><a id="nav-tabs-tautullilogins" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
</ul>
</div>
</div>
@@ -210,6 +216,99 @@ DOCUMENTATION :: END
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-playlists">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-list-alt"></i> Playlists for <strong>
<span class="set-username">${data['friendly_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-user_id="${data['user_id']}" data-media_type="playlist" data-sub_media_type="video,audio,photo"
data-export_type="playlist">
<i class="fa fa-file-export"></i> Export playlists
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-playlists-table-button" id="refresh-playlists-table">
<i class="fa fa-refresh"></i> Refresh playlists
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-playlists"></div>
</div>
</div>
<div class="table-card-back">
<table class="display playlists_table" id="playlists_table-SID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="playlistTitle">Playlist Title</th>
<th align="left" id="playlistLeafCount">Playlist Items</th>
<th align="left" id="playlistDuration">Playlist Duration</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-file-export"></i> Metadata Exports for <strong>
<span class="set-username">${data['friendly_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-SID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
<div role="tabpanel" class="tab-pane" id="tabs-synceditems">
<div class="container-fluid">
<div class="row">
@@ -278,6 +377,7 @@ DOCUMENTATION :: END
<div class="btn-group">
<button class="btn btn-dark refresh-ip-address-button" id="refresh-ip-address-list"><i class="fa fa-refresh"></i> Refresh IP addresses</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-ip-address"></div>
</div>
</div>
<div class="table-card-back">
@@ -393,6 +493,8 @@ DOCUMENTATION :: END
</div>
</div>
</div>
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def>
<%def name="javascriptIncludes()">
@@ -410,8 +512,9 @@ DOCUMENTATION :: END
var username = '${data['username'].replace("'", "\\'")}';
</script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/playlists_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
<script src="${http_root}js/tables/user_ips.js${cache_param}"></script>
<script src="${http_root}js/tables/sync_table.js${cache_param}"></script>
<script src="${http_root}js/tables/login_logs.js${cache_param}"></script>
@@ -420,6 +523,8 @@ DOCUMENTATION :: END
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
});
$(".inactive-user-tooltip").tooltip();
function loadHistoryTable(media_type) {
// Build watch history table
history_table_options.ajax = {
@@ -451,6 +556,49 @@ DOCUMENTATION :: END
});
}
$('#nav-tabs-history').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type);
}
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
function loadPlaylistsTable() {
// Build playlists table
playlists_table_options.ajax = {
url: 'get_playlists_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
user_id: user_id
};
}
};
playlists_table = $('#playlists_table-SID-${data["user_id"]}').DataTable(playlists_table_options);
var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-playlists');
clearSearchButton('playlists_table-SID-${data["user_id"]}', playlists_table);
}
$('#nav-tabs-playlists').on('shown.bs.tab', function() {
if (typeof(playlists_table) === 'undefined') {
loadPlaylistsTable();
}
});
$("#refresh-playlists-table").click(function () {
playlists_table.draw();
});
function loadSyncTable() {
// Build user sync table
sync_table_options.ajax = {
@@ -466,6 +614,16 @@ DOCUMENTATION :: END
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
}
$('#nav-tabs-synceditems').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
function loadIPAddressTable() {
// Build user IP table
user_ip_table_options.ajax = {
@@ -480,9 +638,22 @@ DOCUMENTATION :: END
};
user_ip_table = $('#user_ip_table-UID-${data["user_id"]}').DataTable(user_ip_table_options);
var colvis_user_ip = new $.fn.dataTable.ColVis( user_ip_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark'} );
$( colvis_user_ip.button() ).appendTo('#button-bar-ip-address');
clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table);
}
$('#nav-tabs-ipaddresses').on('shown.bs.tab', function() {
if (typeof(user_ip_table) === 'undefined') {
loadIPAddressTable(user_id);
}
});
$("#refresh-ip-address-list").click(function () {
user_ip_table.draw();
});
function loadLoginTable() {
// Build user login table
login_log_table_options.ajax = {
@@ -504,52 +675,141 @@ DOCUMENTATION :: END
clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table);
}
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type);
}
});
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
if (typeof(user_ip_table) === 'undefined') {
loadIPAddressTable(user_id);
}
});
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
$('#nav-tabs-tautullilogins').on('shown.bs.tab', function() {
if (typeof(login_log_table) === 'undefined') {
loadLoginTable(user_id);
}
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
$("#refresh-ip-address-list").click(function () {
user_ip_table.draw();
});
$("#refresh-login-list").click(function () {
login_log_table.draw();
});
$(".inactive-user-tooltip").tooltip();
function recentlyWatched() {
// Populate recently watched
$.ajax({
url: 'get_user_recently_watched',
async: true,
data: {
user_id: user_id,
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() {
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");
}
});
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-list #nav-' + hash.replace('#' + prefix, "")).tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
// Populate watch time stats
$.ajax({
url: 'user_watch_time_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-time-stats").html(xhr.responseText);
}
});
// Populate platform stats
$.ajax({
url: 'user_player_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-player-stats").html(xhr.responseText);
}
});
});
</script>
% if _session['user_group'] == 'admin':
<script>
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
user_id: user_id
};
}
};
export_table = $('#export_table-SID-${data["user_id"]}').DataTable(export_table_options);
export_table.columns([2, 7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-SID-${data["user_id"]}', export_table);
}
$('#nav-tabs-export').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
% if _session['user_group'] == 'admin':
$("#edit-user-tooltip").tooltip();
// Load edit user modal
@@ -566,6 +826,23 @@ DOCUMENTATION :: END
});
});
$(".export-button").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
user_id: $(this).data('user_id'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type'),
export_type: $(this).data('export_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
@@ -644,100 +921,7 @@ DOCUMENTATION :: END
});
}
});
% endif
function recentlyWatched() {
// Populate recently watched
$.ajax({
url: 'get_user_recently_watched',
async: true,
data: {
user_id: user_id,
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() {
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");
}
});
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.user-info-nav a[href='+hash.replace(prefix,"")+']').tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.user-info-nav a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
// Populate watch time stats
$.ajax({
url: 'user_watch_time_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-time-stats").html(xhr.responseText);
}
});
// Populate platform stats
$.ajax({
url: 'user_player_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-player-stats").html(xhr.responseText);
}
});
});
</script>
% endif
% endif
</%def>

View File

@@ -82,7 +82,6 @@
<script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/users.js${cache_param}"></script>
<script>
$(document).ready(function () {

View File

@@ -12,7 +12,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet">
@@ -21,21 +21,21 @@
<link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.5">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.5">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.5">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.6.0">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.6.0">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS -->
<!-- Android -->
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.0.5" crossorigin="use-credentials">
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
<meta name="theme-color" content="#282a2d">
<!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.0.5" color="#282a2d">
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.6.0" color="#282a2d">
<meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft -->
<meta name="application-name" content="Tautulli">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.0.5">
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.6.0">
</head>
<body>
@@ -248,7 +248,7 @@
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script src="${http_root}js/jquery-3.5.1.min.js"></script>
<script src="${http_root}js/bootstrap.min.js"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/platform.min.js"></script>

View File

@@ -521,7 +521,7 @@
line-height: 100%;
}
.apple-link a {
a[x-apple-data-detectors] {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
@@ -546,7 +546,7 @@
<span class="preheader" style="color: transparent;display: none;height: 0;max-height: 0;max-width: 0;opacity: 0;overflow: hidden;mso-hide: all;visibility: hidden;width: 0;">Tautulli Newsletter - ${subject}</span>
% if base_url and not preview:
<div class="view-full" style="clear: both;color: #282A2D;font-size: 12px;margin-bottom: 10px;text-align: center;width: 100%;"> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank" style="text-decoration: underline;color: #282A2D;">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank" rel="noreferrer" style="text-decoration: underline;color: #282A2D;">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
</div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="3" cellspacing="0" class="main" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background: #282A2D;border-radius: 3px;color: #ffffff;">
@@ -599,7 +599,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + movie['thumb_hash']) if base_url_image else movie['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" style="text-decoration: underline;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" rel="noreferrer" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
@@ -610,7 +610,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${movie['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" rel="noreferrer" style="text-decoration: none;color: #ffffff;">${movie['title']}</a>
</td>
</tr>
<tr>
@@ -728,7 +728,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + show['thumb_hash']) if base_url_image else show['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" style="text-decoration: underline;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" rel="noreferrer" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
@@ -739,7 +739,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${show['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" rel="noreferrer" style="text-decoration: none;color: #ffffff;">${show['title']}</a>
</td>
</tr>
<tr>
@@ -872,7 +872,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + album['thumb_hash']) if base_url_image else album['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" style="text-decoration: underline;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" rel="noreferrer" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-cover.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-cover.png'}" width="150" height="150" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
@@ -883,7 +883,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${album['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" rel="noreferrer" style="text-decoration: none;color: #ffffff;">${album['title']}</a>
</td>
</tr>
<tr>
@@ -986,7 +986,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" style="text-decoration: underline;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" rel="noreferrer" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
@@ -997,7 +997,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${video['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" rel="noreferrer" style="text-decoration: none;color: #ffffff;">${video['title']}</a>
</td>
</tr>
<tr>
@@ -1087,4 +1087,4 @@
</table>
</body>
</html>
% endif
% endif

View File

@@ -521,7 +521,7 @@
line-height: 100%;
}
.apple-link a {
a[x-apple-data-detectors] {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
@@ -546,7 +546,7 @@
<span class="preheader">Tautulli Newsletter - ${subject}</span>
% if base_url and not preview:
<div class="view-full"> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank" rel="noreferrer">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
</div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="3" cellspacing="0" class="main">
@@ -599,7 +599,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + movie['thumb_hash']) if base_url_image else movie['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" rel="noreferrer">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
@@ -610,7 +610,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank">${movie['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" rel="noreferrer">${movie['title']}</a>
</td>
</tr>
<tr>
@@ -728,7 +728,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + show['thumb_hash']) if base_url_image else show['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" rel="noreferrer">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
@@ -739,7 +739,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank">${show['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" rel="noreferrer">${show['title']}</a>
</td>
</tr>
<tr>
@@ -872,7 +872,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + album['thumb_hash']) if base_url_image else album['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" rel="noreferrer">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-cover.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-cover.png'}" width="150" height="150">
</a>
</td>
@@ -883,7 +883,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank">${album['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" rel="noreferrer">${album['title']}</a>
</td>
</tr>
<tr>
@@ -986,7 +986,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" rel="noreferrer">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
@@ -997,7 +997,7 @@
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">${video['title']}</a>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" rel="noreferrer">${video['title']}</a>
</td>
</tr>
<tr>
@@ -1087,4 +1087,4 @@
</table>
</body>
</html>
% endif
% endif

View File

@@ -52,9 +52,7 @@ Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/Tautulli/Tautulli.py --config /opt/Tautulli/config.ini --datadir /opt/Tautulli --quiet --daemon --nolaunch
GuessMainPID=no
Type=forking
ExecStart=/usr/bin/python3 /opt/Tautulli/Tautulli.py --config /opt/Tautulli/config.ini --datadir /opt/Tautulli --quiet --nolaunch
User=tautulli
Group=tautulli
Restart=on-abnormal

View File

@@ -1 +1 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
__import__('pkg_resources').declare_namespace(__name__)

979
lib/backports/csv.py Normal file
View File

@@ -0,0 +1,979 @@
# -*- coding: utf-8 -*-
"""A port of Python 3's csv module to Python 2.
The API of the csv module in Python 2 is drastically different from
the csv module in Python 3. This is due, for the most part, to the
difference between str in Python 2 and Python 3.
The semantics of Python 3's version are more useful because they support
unicode natively, while Python 2's csv does not.
"""
from __future__ import unicode_literals, absolute_import
__all__ = [ "QUOTE_MINIMAL", "QUOTE_ALL", "QUOTE_NONNUMERIC", "QUOTE_NONE",
"Error", "Dialect", "__doc__", "excel", "excel_tab",
"field_size_limit", "reader", "writer",
"register_dialect", "get_dialect", "list_dialects", "Sniffer",
"unregister_dialect", "__version__", "DictReader", "DictWriter" ]
import re
import numbers
from io import StringIO
from csv import (
QUOTE_MINIMAL, QUOTE_ALL, QUOTE_NONNUMERIC, QUOTE_NONE,
__version__, __doc__, Error, field_size_limit,
)
# Stuff needed from six
import sys
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str
text_type = str
binary_type = bytes
unichr = chr
else:
string_types = basestring
text_type = unicode
binary_type = str
class QuoteStrategy(object):
quoting = None
def __init__(self, dialect):
if self.quoting is not None:
assert dialect.quoting == self.quoting
self.dialect = dialect
self.setup()
escape_pattern_quoted = r'({quotechar})'.format(
quotechar=re.escape(self.dialect.quotechar or '"'))
escape_pattern_unquoted = r'([{specialchars}])'.format(
specialchars=re.escape(self.specialchars))
self.escape_re_quoted = re.compile(escape_pattern_quoted)
self.escape_re_unquoted = re.compile(escape_pattern_unquoted)
def setup(self):
"""Optional method for strategy-wide optimizations."""
def quoted(self, field=None, raw_field=None, only=None):
"""Determine whether this field should be quoted."""
raise NotImplementedError(
'quoted must be implemented by a subclass')
@property
def specialchars(self):
"""The special characters that need to be escaped."""
raise NotImplementedError(
'specialchars must be implemented by a subclass')
def escape_re(self, quoted=None):
if quoted:
return self.escape_re_quoted
return self.escape_re_unquoted
def escapechar(self, quoted=None):
if quoted and self.dialect.doublequote:
return self.dialect.quotechar
return self.dialect.escapechar
def prepare(self, raw_field, only=None):
field = text_type(raw_field if raw_field is not None else '')
quoted = self.quoted(field=field, raw_field=raw_field, only=only)
escape_re = self.escape_re(quoted=quoted)
escapechar = self.escapechar(quoted=quoted)
if escape_re.search(field):
escapechar = '\\\\' if escapechar == '\\' else escapechar
if not escapechar:
raise Error('No escapechar is set')
escape_replace = r'{escapechar}\1'.format(escapechar=escapechar)
field = escape_re.sub(escape_replace, field)
if quoted:
field = '{quotechar}{field}{quotechar}'.format(
quotechar=self.dialect.quotechar, field=field)
return field
class QuoteMinimalStrategy(QuoteStrategy):
quoting = QUOTE_MINIMAL
def setup(self):
self.quoted_re = re.compile(r'[{specialchars}]'.format(
specialchars=re.escape(self.specialchars)))
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == self.dialect.quotechar and not self.dialect.doublequote:
# If the only character in the field is the quotechar, and
# doublequote is false, then just escape without outer quotes.
return False
return field == '' and only or bool(self.quoted_re.search(field))
class QuoteAllStrategy(QuoteStrategy):
quoting = QUOTE_ALL
@property
def specialchars(self):
return self.dialect.quotechar
def quoted(self, **kwargs):
return True
class QuoteNonnumericStrategy(QuoteStrategy):
quoting = QUOTE_NONNUMERIC
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, raw_field, **kwargs):
return not isinstance(raw_field, numbers.Number)
class QuoteNoneStrategy(QuoteStrategy):
quoting = QUOTE_NONE
@property
def specialchars(self):
return (
self.dialect.lineterminator +
(self.dialect.quotechar or '') +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == '' and only:
raise Error('single empty field record must be quoted')
return False
class writer(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
if fileobj is None:
raise TypeError('fileobj must be file-like, not None')
self.fileobj = fileobj
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
strategies = {
QUOTE_MINIMAL: QuoteMinimalStrategy,
QUOTE_ALL: QuoteAllStrategy,
QUOTE_NONNUMERIC: QuoteNonnumericStrategy,
QUOTE_NONE: QuoteNoneStrategy,
}
self.strategy = strategies[self.dialect.quoting](self.dialect)
def writerow(self, row):
if row is None:
raise Error('row must be an iterable')
row = list(row)
only = len(row) == 1
row = [self.strategy.prepare(field, only=only) for field in row]
line = self.dialect.delimiter.join(row) + self.dialect.lineterminator
return self.fileobj.write(line)
def writerows(self, rows):
for row in rows:
self.writerow(row)
START_RECORD = 0
START_FIELD = 1
ESCAPED_CHAR = 2
IN_FIELD = 3
IN_QUOTED_FIELD = 4
ESCAPE_IN_QUOTED_FIELD = 5
QUOTE_IN_QUOTED_FIELD = 6
EAT_CRNL = 7
AFTER_ESCAPED_CRNL = 8
class reader(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
self.input_iter = iter(fileobj)
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
self.fields = None
self.field = None
self.line_num = 0
def parse_reset(self):
self.fields = []
self.field = []
self.state = START_RECORD
self.numeric_field = False
def parse_save_field(self):
field = ''.join(self.field)
self.field = []
if self.numeric_field:
field = float(field)
self.numeric_field = False
self.fields.append(field)
def parse_add_char(self, c):
if len(self.field) >= field_size_limit():
raise Error('field size limit exceeded')
self.field.append(c)
def parse_process_char(self, c):
switch = {
START_RECORD: self._parse_start_record,
START_FIELD: self._parse_start_field,
ESCAPED_CHAR: self._parse_escaped_char,
AFTER_ESCAPED_CRNL: self._parse_after_escaped_crnl,
IN_FIELD: self._parse_in_field,
IN_QUOTED_FIELD: self._parse_in_quoted_field,
ESCAPE_IN_QUOTED_FIELD: self._parse_escape_in_quoted_field,
QUOTE_IN_QUOTED_FIELD: self._parse_quote_in_quoted_field,
EAT_CRNL: self._parse_eat_crnl,
}
return switch[self.state](c)
def _parse_start_record(self, c):
if c == '\0':
return
elif c == '\n' or c == '\r':
self.state = EAT_CRNL
return
self.state = START_FIELD
return self._parse_start_field(c)
def _parse_start_field(self, c):
if c == '\n' or c == '\r' or c == '\0':
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
self.state = IN_QUOTED_FIELD
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == ' ' and self.dialect.skipinitialspace:
pass # Ignore space at start of field
elif c == self.dialect.delimiter:
# Save empty field
self.parse_save_field()
else:
# Begin new unquoted field
if self.dialect.quoting == QUOTE_NONNUMERIC:
self.numeric_field = True
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_escaped_char(self, c):
if c == '\n' or c == '\r':
self.parse_add_char(c)
self.state = AFTER_ESCAPED_CRNL
return
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_after_escaped_crnl(self, c):
if c == '\0':
return
return self._parse_in_field(c)
def _parse_in_field(self, c):
# In unquoted field
if c == '\n' or c == '\r' or c == '\0':
# End of line - return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
else:
# Normal character - save in field
self.parse_add_char(c)
def _parse_in_quoted_field(self, c):
if c == '\0':
pass
elif c == self.dialect.escapechar:
self.state = ESCAPE_IN_QUOTED_FIELD
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
if self.dialect.doublequote:
self.state = QUOTE_IN_QUOTED_FIELD
else:
self.state = IN_FIELD
else:
self.parse_add_char(c)
def _parse_escape_in_quoted_field(self, c):
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
def _parse_quote_in_quoted_field(self, c):
if (self.dialect.quoting != QUOTE_NONE and
c == self.dialect.quotechar):
# save "" as "
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
elif c == '\n' or c == '\r' or c == '\0':
# End of line = return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif not self.dialect.strict:
self.parse_add_char(c)
self.state = IN_FIELD
else:
# illegal
raise Error("{delimiter}' expected after '{quotechar}".format(
delimiter=self.dialect.delimiter,
quotechar=self.dialect.quotechar,
))
def _parse_eat_crnl(self, c):
if c == '\n' or c == '\r':
pass
elif c == '\0':
self.state = START_RECORD
else:
raise Error('new-line character seen in unquoted field - do you '
'need to open the file in universal-newline mode?')
def __iter__(self):
return self
def __next__(self):
self.parse_reset()
while True:
try:
lineobj = next(self.input_iter)
except StopIteration:
if len(self.field) != 0 or self.state == IN_QUOTED_FIELD:
if self.dialect.strict:
raise Error('unexpected end of data')
self.parse_save_field()
if self.fields:
break
raise
if not isinstance(lineobj, text_type):
typ = type(lineobj)
typ_name = 'bytes' if typ == bytes else typ.__name__
err_str = ('iterator should return strings, not {0}'
' (did you open the file in text mode?)')
raise Error(err_str.format(typ_name))
self.line_num += 1
for c in lineobj:
if c == '\0':
raise Error('line contains NULL byte')
self.parse_process_char(c)
self.parse_process_char('\0')
if self.state == START_RECORD:
break
fields = self.fields
self.fields = None
return fields
next = __next__
_dialect_registry = {}
def register_dialect(name, dialect='excel', **fmtparams):
if not isinstance(name, text_type):
raise TypeError('"name" must be a string')
dialect = Dialect.extend(dialect, fmtparams)
try:
Dialect.validate(dialect)
except:
raise TypeError('dialect is invalid')
assert name not in _dialect_registry
_dialect_registry[name] = dialect
def unregister_dialect(name):
try:
_dialect_registry.pop(name)
except KeyError:
raise Error('"{name}" not a registered dialect'.format(name=name))
def get_dialect(name):
try:
return _dialect_registry[name]
except KeyError:
raise Error('Could not find dialect {0}'.format(name))
def list_dialects():
return list(_dialect_registry)
class Dialect(object):
"""Describe a CSV dialect.
This must be subclassed (see csv.excel). Valid attributes are:
delimiter, quotechar, escapechar, doublequote, skipinitialspace,
lineterminator, quoting, strict.
"""
_name = ""
_valid = False
# placeholders
delimiter = None
quotechar = None
escapechar = None
doublequote = None
skipinitialspace = None
lineterminator = None
quoting = None
strict = None
def __init__(self):
self.validate(self)
if self.__class__ != Dialect:
self._valid = True
@classmethod
def validate(cls, dialect):
dialect = cls.extend(dialect)
if not isinstance(dialect.quoting, int):
raise Error('"quoting" must be an integer')
if dialect.delimiter is None:
raise Error('delimiter must be set')
cls.validate_text(dialect, 'delimiter')
if dialect.lineterminator is None:
raise Error('lineterminator must be set')
if not isinstance(dialect.lineterminator, text_type):
raise Error('"lineterminator" must be a string')
if dialect.quoting not in [
QUOTE_NONE, QUOTE_MINIMAL, QUOTE_NONNUMERIC, QUOTE_ALL]:
raise Error('Invalid quoting specified')
if dialect.quoting != QUOTE_NONE:
if dialect.quotechar is None and dialect.escapechar is None:
raise Error('quotechar must be set if quoting enabled')
if dialect.quotechar is not None:
cls.validate_text(dialect, 'quotechar')
@staticmethod
def validate_text(dialect, attr):
val = getattr(dialect, attr)
if not isinstance(val, text_type):
if type(val) == bytes:
raise Error('"{0}" must be string, not bytes'.format(attr))
raise Error('"{0}" must be string, not {1}'.format(
attr, type(val).__name__))
if len(val) != 1:
raise Error('"{0}" must be a 1-character string'.format(attr))
@staticmethod
def defaults():
return {
'delimiter': ',',
'doublequote': True,
'escapechar': None,
'lineterminator': '\r\n',
'quotechar': '"',
'quoting': QUOTE_MINIMAL,
'skipinitialspace': False,
'strict': False,
}
@classmethod
def extend(cls, dialect, fmtparams=None):
if isinstance(dialect, string_types):
dialect = get_dialect(dialect)
if fmtparams is None:
return dialect
defaults = cls.defaults()
if any(param not in defaults for param in fmtparams):
raise TypeError('Invalid fmtparam')
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in cls.defaults()
)
specified.update(fmtparams)
return type(str('ExtendedDialect'), (cls,), specified)
@classmethod
def combine(cls, dialect, fmtparams):
"""Create a new dialect with defaults and added parameters."""
dialect = cls.extend(dialect, fmtparams)
defaults = cls.defaults()
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in defaults
if getattr(dialect, attr, None) is not None or
attr in ['quotechar', 'delimiter', 'lineterminator', 'quoting']
)
defaults.update(specified)
dialect = type(str('CombinedDialect'), (cls,), defaults)
cls.validate(dialect)
return dialect()
def __delattr__(self, attr):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__delattr__(attr)
def __setattr__(self, attr, value):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__setattr__(attr, value)
class excel(Dialect):
"""Describe the usual properties of Excel-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
register_dialect("excel", excel)
class excel_tab(excel):
"""Describe the usual properties of Excel-generated TAB-delimited files."""
delimiter = '\t'
register_dialect("excel-tab", excel_tab)
class unix_dialect(Dialect):
"""Describe the usual properties of Unix-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\n'
quoting = QUOTE_ALL
register_dialect("unix", unix_dialect)
class DictReader(object):
def __init__(self, f, fieldnames=None, restkey=None, restval=None,
dialect="excel", *args, **kwds):
self._fieldnames = fieldnames # list of keys for the dict
self.restkey = restkey # key to catch long rows
self.restval = restval # default value for short rows
self.reader = reader(f, dialect, *args, **kwds)
self.dialect = dialect
self.line_num = 0
def __iter__(self):
return self
@property
def fieldnames(self):
if self._fieldnames is None:
try:
self._fieldnames = next(self.reader)
except StopIteration:
pass
self.line_num = self.reader.line_num
return self._fieldnames
@fieldnames.setter
def fieldnames(self, value):
self._fieldnames = value
def __next__(self):
if self.line_num == 0:
# Used only for its side effect.
self.fieldnames
row = next(self.reader)
self.line_num = self.reader.line_num
# unlike the basic reader, we prefer not to return blanks,
# because we will typically wind up with a dict full of None
# values
while row == []:
row = next(self.reader)
d = dict(zip(self.fieldnames, row))
lf = len(self.fieldnames)
lr = len(row)
if lf < lr:
d[self.restkey] = row[lf:]
elif lf > lr:
for key in self.fieldnames[lr:]:
d[key] = self.restval
return d
next = __next__
class DictWriter(object):
def __init__(self, f, fieldnames, restval="", extrasaction="raise",
dialect="excel", *args, **kwds):
self.fieldnames = fieldnames # list of keys for the dict
self.restval = restval # for writing short dicts
if extrasaction.lower() not in ("raise", "ignore"):
raise ValueError("extrasaction (%s) must be 'raise' or 'ignore'"
% extrasaction)
self.extrasaction = extrasaction
self.writer = writer(f, dialect, *args, **kwds)
def writeheader(self):
header = dict(zip(self.fieldnames, self.fieldnames))
self.writerow(header)
def _dict_to_list(self, rowdict):
if self.extrasaction == "raise":
wrong_fields = [k for k in rowdict if k not in self.fieldnames]
if wrong_fields:
raise ValueError("dict contains fields not in fieldnames: "
+ ", ".join([repr(x) for x in wrong_fields]))
return (rowdict.get(key, self.restval) for key in self.fieldnames)
def writerow(self, rowdict):
return self.writer.writerow(self._dict_to_list(rowdict))
def writerows(self, rowdicts):
return self.writer.writerows(map(self._dict_to_list, rowdicts))
# Guard Sniffer's type checking against builds that exclude complex()
try:
complex
except NameError:
complex = float
class Sniffer(object):
'''
"Sniffs" the format of a CSV file (i.e. delimiter, quotechar)
Returns a Dialect object.
'''
def __init__(self):
# in case there is more than one possible delimiter
self.preferred = [',', '\t', ';', ' ', ':']
def sniff(self, sample, delimiters=None):
"""
Returns a dialect (or None) corresponding to the sample
"""
quotechar, doublequote, delimiter, skipinitialspace = \
self._guess_quote_and_delimiter(sample, delimiters)
if not delimiter:
delimiter, skipinitialspace = self._guess_delimiter(sample,
delimiters)
if not delimiter:
raise Error("Could not determine delimiter")
class dialect(Dialect):
_name = "sniffed"
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
# escapechar = ''
dialect.doublequote = doublequote
dialect.delimiter = delimiter
# _csv.reader won't accept a quotechar of ''
dialect.quotechar = quotechar or '"'
dialect.skipinitialspace = skipinitialspace
return dialect
def _guess_quote_and_delimiter(self, data, delimiters):
"""
Looks for text enclosed between two identical quotes
(the probable quotechar) which are preceded and followed
by the same character (the probable delimiter).
For example:
,'some text',
The quote with the most wins, same with the delimiter.
If there is no quotechar the delimiter can't be determined
this way.
"""
matches = []
for restr in ('(?P<delim>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?P=delim)', # ,".*?",
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?P<delim>[^\w\n"\'])(?P<space> ?)', # ".*?",
'(?P<delim>>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?:$|\n)', # ,".*?"
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?:$|\n)'): # ".*?" (no delim, no space)
regexp = re.compile(restr, re.DOTALL | re.MULTILINE)
matches = regexp.findall(data)
if matches:
break
if not matches:
# (quotechar, doublequote, delimiter, skipinitialspace)
return ('', False, None, 0)
quotes = {}
delims = {}
spaces = 0
groupindex = regexp.groupindex
for m in matches:
n = groupindex['quote'] - 1
key = m[n]
if key:
quotes[key] = quotes.get(key, 0) + 1
try:
n = groupindex['delim'] - 1
key = m[n]
except KeyError:
continue
if key and (delimiters is None or key in delimiters):
delims[key] = delims.get(key, 0) + 1
try:
n = groupindex['space'] - 1
except KeyError:
continue
if m[n]:
spaces += 1
quotechar = max(quotes, key=quotes.get)
if delims:
delim = max(delims, key=delims.get)
skipinitialspace = delims[delim] == spaces
if delim == '\n': # most likely a file with a single column
delim = ''
else:
# there is *no* delimiter, it's a single column of quoted data
delim = ''
skipinitialspace = 0
# if we see an extra quote between delimiters, we've got a
# double quoted format
dq_regexp = re.compile(
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
if dq_regexp.search(data):
doublequote = True
else:
doublequote = False
return (quotechar, doublequote, delim, skipinitialspace)
def _guess_delimiter(self, data, delimiters):
"""
The delimiter /should/ occur the same number of times on
each row. However, due to malformed data, it may not. We don't want
an all or nothing approach, so we allow for small variations in this
number.
1) build a table of the frequency of each character on every line.
2) build a table of frequencies of this frequency (meta-frequency?),
e.g. 'x occurred 5 times in 10 rows, 6 times in 1000 rows,
7 times in 2 rows'
3) use the mode of the meta-frequency to determine the /expected/
frequency for that character
4) find out how often the character actually meets that goal
5) the character that best meets its goal is the delimiter
For performance reasons, the data is evaluated in chunks, so it can
try and evaluate the smallest portion of the data possible, evaluating
additional chunks as necessary.
"""
data = list(filter(None, data.split('\n')))
ascii = [unichr(c) for c in range(127)] # 7-bit ASCII
# build frequency tables
chunkLength = min(10, len(data))
iteration = 0
charFrequency = {}
modes = {}
delims = {}
start, end = 0, min(chunkLength, len(data))
while start < len(data):
iteration += 1
for line in data[start:end]:
for char in ascii:
metaFrequency = charFrequency.get(char, {})
# must count even if frequency is 0
freq = line.count(char)
# value is the mode
metaFrequency[freq] = metaFrequency.get(freq, 0) + 1
charFrequency[char] = metaFrequency
for char in charFrequency.keys():
items = list(charFrequency[char].items())
if len(items) == 1 and items[0][0] == 0:
continue
# get the mode of the frequencies
if len(items) > 1:
modes[char] = max(items, key=lambda x: x[1])
# adjust the mode - subtract the sum of all
# other frequencies
items.remove(modes[char])
modes[char] = (modes[char][0], modes[char][1]
- sum(item[1] for item in items))
else:
modes[char] = items[0]
# build a list of possible delimiters
modeList = modes.items()
total = float(chunkLength * iteration)
# (rows of consistent data) / (number of rows) = 100%
consistency = 1.0
# minimum consistency threshold
threshold = 0.9
while len(delims) == 0 and consistency >= threshold:
for k, v in modeList:
if v[0] > 0 and v[1] > 0:
if ((v[1]/total) >= consistency and
(delimiters is None or k in delimiters)):
delims[k] = v
consistency -= 0.01
if len(delims) == 1:
delim = list(delims.keys())[0]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
# analyze another chunkLength lines
start = end
end += chunkLength
if not delims:
return ('', 0)
# if there's more than one, fall back to a 'preferred' list
if len(delims) > 1:
for d in self.preferred:
if d in delims.keys():
skipinitialspace = (data[0].count(d) ==
data[0].count("%c " % d))
return (d, skipinitialspace)
# nothing else indicates a preference, pick the character that
# dominates(?)
items = [(v,k) for (k,v) in delims.items()]
items.sort()
delim = items[-1][1]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
def has_header(self, sample):
# Creates a dictionary of types of data in each column. If any
# column is of a single type (say, integers), *except* for the first
# row, then the first row is presumed to be labels. If the type
# can't be determined, it is assumed to be a string in which case
# the length of the string is the determining factor: if all of the
# rows except for the first are the same length, it's a header.
# Finally, a 'vote' is taken at the end for each column, adding or
# subtracting from the likelihood of the first row being a header.
rdr = reader(StringIO(sample), self.sniff(sample))
header = next(rdr) # assume first row is header
columns = len(header)
columnTypes = {}
for i in range(columns): columnTypes[i] = None
checked = 0
for row in rdr:
# arbitrary number of rows to check, to keep it sane
if checked > 20:
break
checked += 1
if len(row) != columns:
continue # skip rows that have irregular number of columns
for col in list(columnTypes.keys()):
for thisType in [int, float, complex]:
try:
thisType(row[col])
break
except (ValueError, OverflowError):
pass
else:
# fallback to length of string
thisType = len(row[col])
if thisType != columnTypes[col]:
if columnTypes[col] is None: # add new column type
columnTypes[col] = thisType
else:
# type is inconsistent, remove column from
# consideration
del columnTypes[col]
# finally, compare results against first row and "vote"
# on whether it's a header
hasHeader = 0
for col, colType in columnTypes.items():
if type(colType) == type(0): # it's a length
if len(header[col]) != colType:
hasHeader += 1
else:
hasHeader -= 1
else: # attempt typecast
try:
colType(header[col])
except (ValueError, TypeError):
hasHeader += 1
else:
hasHeader -= 1
return hasHeader > 0

View File

@@ -36,6 +36,8 @@ class Audio(PlexPartialObject):
self.key = data.attrib.get('key')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@@ -120,17 +122,26 @@ class Artist(Audio):
TAG = 'Directory'
TYPE = 'artist'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
self.key = self.key.replace('/children', '') # FIX_BUG_50
self._details_key = self.key + self._include
self.art = data.attrib.get('art')
self.guid = data.attrib.get('guid')
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.locations = self.listAttrs(data, 'path', etag='Location')
self.countries = self.findItems(data, media.Country)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.similar = self.findItems(data, media.Similar)
self.collections = self.findItems(data, media.Collection)
self.moods = self.findItems(data, media.Mood)
self.styles = self.findItems(data, media.Style)
def __iter__(self):
for album in self.albums():
@@ -217,17 +228,26 @@ class Album(Audio):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
self.art = data.attrib.get('art')
self.key = self.key.replace('/children', '') # fixes bug #50
self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
self.genres = self.findItems(data, media.Genre)
self.collections = self.findItems(data, media.Collection)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.labels = self.findItems(data, media.Label)
self.moods = self.findItems(data, media.Mood)
self.styles = self.findItems(data, media.Style)
def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@@ -312,20 +332,28 @@ class Track(Audio, Playable):
TAG = 'Track'
TYPE = 'track'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
Playable._loadData(self, data)
self._details_key = self.key + self._include
self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid')
self.originalTitle = data.attrib.get('originalTitle')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
@@ -338,6 +366,7 @@ class Track(Audio, Playable):
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
self.moods = self.findItems(data, media.Mood)
self.fields = self.findItems(data, media.Field)
def _prettyfilename(self):
""" Returns a filename for use in download. """
@@ -351,6 +380,13 @@ class Track(Audio, Playable):
""" Return this track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Track
"""
return [part.file for part in self.iterParts() if part]
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)

View File

@@ -334,7 +334,7 @@ class PlexPartialObject(PlexObject):
search result for a movie often only contain a portion of the attributes a full
object (main url) for that movie contain.
"""
return not self.key or self.key == self._initpath
return not self.key or (self._details_key or self.key) == self._initpath
def isPartialObject(self):
""" Returns True if this is not a full object. """

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.base import PlexObject
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media
from plexapi.base import PlexObject, PlexPartialObject
from plexapi.compat import quote, quote_plus, unquote, urlencode
from plexapi.exceptions import BadRequest, NotFound
from plexapi.media import MediaTag
@@ -765,10 +765,23 @@ class MovieSection(LibrarySection):
METADATA_TYPE = 'movie'
CONTENT_TYPE = 'video'
def all(self, **kwargs):
""" Returns a list of all items from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype='movie', **kwargs)
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
""" Returns a list of collections from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key, **kwargs)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Movie library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@@ -845,10 +858,23 @@ class ShowSection(LibrarySection):
"""
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
def all(self, libtype='show', **kwargs):
""" Returns a list of all items from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype=libtype, **kwargs)
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
""" Returns a list of collections from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key, **kwargs)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Show library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@@ -926,10 +952,23 @@ class MusicSection(LibrarySection):
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='track', **kwargs)
def all(self, libtype='artist', **kwargs):
""" Returns a list of all items from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype=libtype, **kwargs)
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
""" Returns a list of collections from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key, **kwargs)
def sync(self, bitrate, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@@ -991,6 +1030,17 @@ class PhotoSection(LibrarySection):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='photo', title=title, **kwargs)
def all(self, libtype='photoalbum', **kwargs):
""" Returns a list of all items from this library section.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
"""
return self.search(libtype=libtype, **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key, **kwargs)
def sync(self, resolution, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@@ -1083,7 +1133,35 @@ class Hub(PlexObject):
@utils.registerPlexObject
class Collections(PlexObject):
class Collections(PlexPartialObject):
""" Represents a single Collection.
Attributes:
TAG (str): 'Directory'
TYPE (str): 'collection'
ratingKey (int): Unique key identifying this item.
addedAt (datetime): Datetime this item was added to the library.
childCount (int): Count of child object(s)
collectionMode (str): How the items in the collection are displayed.
collectionSort (str): How to sort the items in the collection.
contentRating (str) Content rating (PG-13; NR; TV-G).
fields (list): List of :class:`~plexapi.media.Field`.
guid (str): Plex GUID (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
index (int): Unknown
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of field objects.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): API URL (/library/sections/<sectionkey>).
librarySectionTitle (str): Section Title
maxYear (int): Year
minYear (int): Year
subtype (str): Media type
summary (str): Summary of the collection
thumb (str): URL to thumbnail image.
title (str): Collection Title
titleSort (str): Title to use when sorting (defaults to title).
type (str): Hardcoded 'collection'
updatedAt (datatime): Datetime this item was updated.
"""
TAG = 'Directory'
TYPE = 'collection'
@@ -1091,26 +1169,45 @@ class Collections(PlexObject):
def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include)
self.key = data.attrib.get('key')
self.type = data.attrib.get('type')
self.title = data.attrib.get('title')
self.key = data.attrib.get('key').replace('/children', '') # FIX_BUG_50
self._details_key = self.key + self._include
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collectionMode = utils.cast(int, data.attrib.get('collectionMode'))
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort'))
self.contentRating = data.attrib.get('contentRating')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.labels = self.findItems(data, media.Label)
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.minYear = utils.cast(int, data.attrib.get('minYear'))
self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary')
self.index = utils.cast(int, data.attrib.get('index'))
self.thumb = data.attrib.get('thumb')
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.minYear = utils.cast(int, data.attrib.get('minYear'))
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.collectionMode = data.attrib.get('collectionMode')
self.collectionSort = data.attrib.get('collectionSort')
@property
def children(self):
return self.fetchItems(self.key)
@property
def thumbUrl(self):
""" Return the thumbnail url for the collection."""
return self._server.url(self.thumb, includeToken=True) if self.thumb else None
@property
def artUrl(self):
""" Return the art url for the collection."""
return self._server.url(self.art, includeToken=True) if self.art else None
def __len__(self):
return self.childCount
@@ -1120,14 +1217,12 @@ class Collections(PlexObject):
def modeUpdate(self, mode=None):
""" Update Collection Mode
Parameters:
mode: default (Library default)
hide (Hide Collection)
hideItems (Hide Items in this Collection)
showItems (Show this Collection and its Items)
Example:
collection = 'plexapi.library.Collections'
collection.updateMode(mode="hide")
"""
@@ -1143,13 +1238,10 @@ class Collections(PlexObject):
def sortUpdate(self, sort=None):
""" Update Collection Sorting
Parameters:
sort: realease (Order Collection by realease dates)
alpha (Order Collection Alphabetically)
Example:
colleciton = 'plexapi.library.Collections'
collection.updateSort(mode="alpha")
"""

View File

@@ -5,7 +5,7 @@ import xml
from plexapi import compat, log, settings, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest
from plexapi.utils import cast
from plexapi.utils import cast, SEARCHTYPES
@utils.registerPlexObject
@@ -45,6 +45,7 @@ class Media(PlexObject):
self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec')
self.audioProfile = data.attrib.get('videoProfile')
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.container = data.attrib.get('container')
self.duration = cast(int, data.attrib.get('duration'))
@@ -60,6 +61,16 @@ class Media(PlexObject):
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = self.findItems(data, MediaPart)
self.proxyType = cast(int, data.attrib.get('proxyType'))
self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion']
# For Photo only
self.aperture = data.attrib.get('aperture')
self.exposure = data.attrib.get('exposure')
self.iso = cast(int, data.attrib.get('iso'))
self.lens = data.attrib.get('lens')
self.make = data.attrib.get('make')
self.model = data.attrib.get('model')
def delete(self):
part = self._initpath + '/media/%s' % self.id
@@ -96,26 +107,35 @@ class MediaPart(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container')
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
self.duration = cast(int, data.attrib.get('duration'))
self.file = data.attrib.get('file')
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
self.id = cast(int, data.attrib.get('id'))
self.indexes = data.attrib.get('indexes')
self.key = data.attrib.get('key')
self.size = cast(int, data.attrib.get('size'))
self.decision = data.attrib.get('decision')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.packetLength = cast(int, data.attrib.get('packetLength'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
self.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile')
self.streams = self._buildStreams(data)
self.exists = cast(bool, data.attrib.get('exists'))
self.accessible = cast(bool, data.attrib.get('accessible'))
# For Photo only
self.orientation = cast(int, data.attrib.get('orientation'))
def _buildStreams(self, data):
streams = []
for elem in data:
for cls in (VideoStream, AudioStream, SubtitleStream):
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
streams.append(cls(self._server, elem, self._initpath))
return streams
@@ -132,6 +152,10 @@ class MediaPart(PlexObject):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
def lyricStreams(self):
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
@@ -177,7 +201,8 @@ class MediaPartStream(PlexObject):
languageCode (str): Ascii code for language (ex: eng, tha).
selected (bool): True if this stream is selected.
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`).
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
4=:class:`~plexapi.media.LyricStream`).
type (int): Alias for streamType.
"""
@@ -186,18 +211,22 @@ class MediaPartStream(PlexObject):
self._data = data
self.codec = data.attrib.get('codec')
self.codecID = data.attrib.get('codecID')
self.default = cast(bool, data.attrib.get('selected', '0'))
self.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.id = cast(int, data.attrib.get('id'))
self.index = cast(int, data.attrib.get('index', '-1'))
self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode')
self.selected = cast(bool, data.attrib.get('selected', '0'))
self.streamType = cast(int, data.attrib.get('streamType'))
self.title = data.attrib.get('title')
self.type = cast(int, data.attrib.get('streamType'))
@staticmethod
def parse(server, data, initpath): # pragma: no cover seems to be dead code.
""" Factory method returns a new MediaPartStream from xml data. """
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream}
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream, 4: LyricStream}
stype = cast(int, data.attrib.get('streamType'))
cls = STREAMCLS.get(stype, MediaPartStream)
return cls(server, data, initpath)
@@ -233,21 +262,39 @@ class VideoStream(MediaPartStream):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(VideoStream, self)._loadData(data)
self.anamorphic = data.attrib.get('anamorphic')
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.cabac = cast(int, data.attrib.get('cabac'))
self.chromaLocation = data.attrib.get('chromaLocation')
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
self.codedHeight = data.attrib.get('codedHeight')
self.codedWidth = data.attrib.get('codedWidth')
self.colorPrimaries = data.attrib.get('colorPrimaries')
self.colorRange = data.attrib.get('colorRange')
self.colorSpace = data.attrib.get('colorSpace')
self.colorTrc = data.attrib.get('colorTrc')
self.DOVIBLCompatID = cast(int, data.attrib.get('DOVIBLCompatID'))
self.DOVIBLPresent = cast(bool, data.attrib.get('DOVIBLPresent'))
self.DOVIELPresent = cast(bool, data.attrib.get('DOVIELPresent'))
self.DOVILevel = cast(int, data.attrib.get('DOVILevel'))
self.DOVIPresent = cast(bool, data.attrib.get('DOVIPresent'))
self.DOVIProfile = cast(int, data.attrib.get('DOVIProfile'))
self.DOVIRPUPresent = cast(bool, data.attrib.get('DOVIRPUPresent'))
self.DOVIVersion = cast(float, data.attrib.get('DOVIVersion'))
self.duration = cast(int, data.attrib.get('duration'))
self.frameRate = cast(float, data.attrib.get('frameRate'))
self.frameRateMode = data.attrib.get('frameRateMode')
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix'))
self.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix'))
self.height = cast(int, data.attrib.get('height'))
self.level = cast(int, data.attrib.get('level'))
self.profile = data.attrib.get('profile')
self.refFrames = cast(int, data.attrib.get('refFrames'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
self.pixelFormat = data.attrib.get('pixelFormat')
self.scanType = data.attrib.get('scanType')
self.title = data.attrib.get('title')
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
self.width = cast(int, data.attrib.get('width'))
@@ -281,8 +328,20 @@ class AudioStream(MediaPartStream):
self.channels = cast(int, data.attrib.get('channels'))
self.dialogNorm = cast(int, data.attrib.get('dialogNorm'))
self.duration = cast(int, data.attrib.get('duration'))
self.profile = data.attrib.get('profile')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
self.title = data.attrib.get('title')
# For Track only
self.albumGain = cast(float, data.attrib.get('albumGain'))
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
self.albumRange = cast(float, data.attrib.get('albumRange'))
self.endRamp = data.attrib.get('endRamp')
self.gain = cast(float, data.attrib.get('gain'))
self.loudness = cast(float, data.attrib.get('loudness'))
self.lra = cast(float, data.attrib.get('lra'))
self.peak = cast(float, data.attrib.get('peak'))
self.startRamp = data.attrib.get('startRamp')
@utils.registerPlexObject
@@ -303,10 +362,36 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data)
self.container = data.attrib.get('container')
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression')
self.key = data.attrib.get('key')
self.title = data.attrib.get('title')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
@utils.registerPlexObject
class LyricStream(MediaPartStream):
""" Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 4
format (str): Lyric format (ex: lrc).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this lyric stream.
"""
TAG = 'Stream'
STREAMTYPE = 4
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(LyricStream, self)._loadData(data)
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.minLines = cast(int, data.attrib.get('minLines'))
self.provider = data.attrib.get('provider')
self.timed = cast(bool, data.attrib.get('timed', '0'))
@utils.registerPlexObject
@@ -510,6 +595,29 @@ class MediaTag(PlexObject):
return self.fetchItems(self.key)
class GuidTag(PlexObject):
""" Base class for guid tags used only for Guids, as they contain only a string identifier
Attributes:
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems
to plex identifiers, like imdb and tmdb).
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.id = data.attrib.get('id')
self.tag = data.attrib.get('tag')
def items(self, *args, **kwargs):
""" Return the list of items within this tag. This function is only applicable
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
"""
if not self.key:
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
return self.fetchItems(self.key)
@utils.registerPlexObject
class Collection(MediaTag):
""" Represents a single Collection media tag.
@@ -589,6 +697,12 @@ class Genre(MediaTag):
FILTER = 'genre'
@utils.registerPlexObject
class Guid(GuidTag):
""" Represents a single Guid media tag. """
TAG = "Guid"
@utils.registerPlexObject
class Mood(MediaTag):
""" Represents a single Mood media tag.
@@ -601,6 +715,18 @@ class Mood(MediaTag):
FILTER = 'mood'
@utils.registerPlexObject
class Style(MediaTag):
""" Represents a single Style media tag.
Attributes:
TAG (str): 'Style'
FILTER (str): 'style'
"""
TAG = 'Style'
FILTER = 'style'
@utils.registerPlexObject
class Poster(PlexObject):
""" Represents a Poster.
@@ -614,7 +740,7 @@ class Poster(PlexObject):
self._data = data
self.key = data.attrib.get('key')
self.ratingKey = data.attrib.get('ratingKey')
self.selected = data.attrib.get('selected')
self.selected = cast(bool, data.attrib.get('selected'))
self.thumb = data.attrib.get('thumb')
def select(self):
@@ -689,11 +815,33 @@ class Chapter(PlexObject):
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
self.tag = data.attrib.get('tag')
self.title = self.tag
self.thumb = data.attrib.get('thumb')
self.index = cast(int, data.attrib.get('index'))
self.start = cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset'))
@utils.registerPlexObject
class Marker(PlexObject):
""" Represents a single Marker media tag.
Attributes:
TAG (str): 'Marker'
"""
TAG = 'Marker'
def __repr__(self):
name = self._clean(self.firstAttr('type'))
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
return '<%s:%s %s - %s>' % (self.__class__.__name__, name, start, end)
def _loadData(self, data):
self._data = data
self.type = data.attrib.get('type')
self.start = cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset'))
@utils.registerPlexObject
class Field(PlexObject):
""" Represents a single Field.

View File

@@ -38,14 +38,18 @@ class Photoalbum(PlexPartialObject):
self.composite = data.attrib.get('composite')
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key')
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.fields = self.findItems(data, media.Field)
def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
@@ -71,6 +75,11 @@ class Photoalbum(PlexPartialObject):
return photo
raise NotFound('Unable to find photo: %s' % title)
def clips(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Clip` objects in this album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, etag='Video', **kwargs)
@utils.registerPlexObject
class Photo(PlexPartialObject):
@@ -99,25 +108,43 @@ class Photo(PlexPartialObject):
TYPE = 'photo'
METADATA_TYPE = 'photo'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.key = data.attrib.get('key')
self._details_key = self.key + self._include
self.listType = 'photo'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
self.tag = self.findItems(data, media.Tag)
self.fields = self.findItems(data, media.Field)
def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
@@ -132,6 +159,13 @@ class Photo(PlexPartialObject):
else:
raise BadRequest('Unable to get section for photo, can`t find librarySectionID')
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Photo
"""
return [part.file for item in self.media for part in item.parts if part]
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
""" Add current photo as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.

View File

@@ -366,11 +366,11 @@ class PlexServer(PlexObject):
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
def playlists(self):
def playlists(self, **kwargs):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
# TODO: Add sort and type options?
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
return self.fetchItems('/playlists')
return self.fetchItems('/playlists', **kwargs)
def playlist(self, title):
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.

View File

@@ -23,7 +23,8 @@ log = logging.getLogger('plexapi')
# Library Types - Populated at runtime
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'userPlaylistItem': 1001}
'playlist': 15, 'playlistFolder': 16, 'collection': 18,
'optimizedVersion': 42, 'userPlaylistItem': 1001}
PLEXOBJECTS = {}

View File

@@ -35,6 +35,8 @@ class Video(PlexPartialObject):
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@@ -264,6 +266,7 @@ class Movie(Playable, Video):
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
@@ -276,7 +279,8 @@ class Movie(Playable, Video):
METADATA_TYPE = 'movie'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1')
'&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@@ -307,6 +311,7 @@ class Movie(Playable, Video):
self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.media = self.findItems(data, media.Media)
self.producers = self.findItems(data, media.Producer)
self.roles = self.findItems(data, media.Role)
@@ -323,7 +328,7 @@ class Movie(Playable, Video):
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Movie/Show/Episode
interface to get the location of the Movie
"""
return [part.file for part in self.iterParts() if part]
@@ -397,7 +402,7 @@ class Show(Video):
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
# fix key if loaded from search
self.key = self.key.replace('/children', '')
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.art = data.attrib.get('art')
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
@@ -415,6 +420,7 @@ class Show(Video):
self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role)
self.labels = self.findItems(data, media.Label)
@@ -527,12 +533,20 @@ class Season(Video):
Video._loadData(self, data)
# fix key if loaded from search
self.key = self.key.replace('/children', '')
art = data.attrib.get('art')
self.art = art if art and str(self.ratingKey) in art else None
self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.index = utils.cast(int, data.attrib.get('index'))
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTheme = data.attrib.get('parentTheme')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.fields = self.findItems(data, media.Field)
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@@ -644,7 +658,8 @@ class Episode(Playable, Video):
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1')
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@@ -652,11 +667,13 @@ class Episode(Playable, Video):
Playable._loadData(self, data)
self._details_key = self.key + self._include
self._seasonNumber = None # cached season number
self.art = data.attrib.get('art')
art = data.attrib.get('art')
self.art = art if art and str(self.ratingKey) in art else None
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme')
@@ -665,6 +682,7 @@ class Episode(Playable, Video):
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
@@ -675,11 +693,13 @@ class Episode(Playable, Video):
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
self.collections = self.findItems(data, media.Collection)
self.chapters = self.findItems(data, media.Chapter)
self.markers = self.findItems(data, media.Marker)
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@@ -695,7 +715,7 @@ class Episode(Playable, Video):
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Movie/Show
interface to get the location of the Episode
"""
return [part.file for part in self.iterParts() if part]
@@ -711,6 +731,13 @@ class Episode(Playable, Video):
""" Returns the s00e00 string containing the season and episode. """
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2))
@property
def hasIntroMarker(self):
""" Returns True if this episode has an intro marker in the xml. """
if not self.isFullObject():
self.reload()
return any(marker.type == 'intro' for marker in self.markers)
def season(self):
"""" Return this episodes :func:`~plexapi.video.Season`.. """
return self.fetchItem(self.parentKey)
@@ -733,12 +760,15 @@ class Clip(Playable, Video):
METADATA_TYPE = 'clip'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
self._data = data
self.addedAt = data.attrib.get('addedAt')
self.duration = data.attrib.get('duration')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.ratingKey = data.attrib.get('ratingKey')
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
self.subtype = data.attrib.get('subtype')
@@ -746,4 +776,12 @@ class Clip(Playable, Video):
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.year = data.attrib.get('year')
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Clip
"""
return [part.file for part in self.iterParts() if part]

View File

@@ -1,34 +0,0 @@
from ._tqdm import tqdm
from ._tqdm import trange
from ._tqdm_gui import tqdm_gui
from ._tqdm_gui import tgrange
from ._tqdm_pandas import tqdm_pandas
from ._main import main
from ._monitor import TMonitor, TqdmSynchronisationWarning
from ._version import __version__ # NOQA
from ._tqdm import TqdmTypeError, TqdmKeyError, TqdmWarning, \
TqdmDeprecationWarning, TqdmExperimentalWarning, \
TqdmMonitorWarning
__all__ = ['tqdm', 'tqdm_gui', 'trange', 'tgrange', 'tqdm_pandas',
'tqdm_notebook', 'tnrange', 'main', 'TMonitor',
'TqdmTypeError', 'TqdmKeyError',
'TqdmWarning', 'TqdmDeprecationWarning',
'TqdmExperimentalWarning',
'TqdmMonitorWarning', 'TqdmSynchronisationWarning',
'__version__']
def tqdm_notebook(*args, **kwargs): # pragma: no cover
"""See tqdm._tqdm_notebook.tqdm_notebook for full documentation"""
from ._tqdm_notebook import tqdm_notebook as _tqdm_notebook
return _tqdm_notebook(*args, **kwargs)
def tnrange(*args, **kwargs): # pragma: no cover
"""
A shortcut for tqdm_notebook(xrange(*args), **kwargs).
On Python3+ range is used instead of xrange.
"""
from ._tqdm_notebook import tnrange as _tnrange
return _tnrange(*args, **kwargs)

View File

@@ -1,2 +0,0 @@
from ._main import main
main()

View File

@@ -1,207 +0,0 @@
from ._tqdm import tqdm, TqdmTypeError, TqdmKeyError
from ._version import __version__ # NOQA
import sys
import re
import logging
__all__ = ["main"]
def cast(val, typ):
log = logging.getLogger(__name__)
log.debug((val, typ))
if " or " in typ:
for t in typ.split(" or "):
try:
return cast(val, t)
except TqdmTypeError:
pass
raise TqdmTypeError(val + ' : ' + typ)
# sys.stderr.write('\ndebug | `val:type`: `' + val + ':' + typ + '`.\n')
if typ == 'bool':
if (val == 'True') or (val == ''):
return True
elif val == 'False':
return False
else:
raise TqdmTypeError(val + ' : ' + typ)
try:
return eval(typ + '("' + val + '")')
except:
if typ == 'chr':
return chr(ord(eval('"' + val + '"')))
else:
raise TqdmTypeError(val + ' : ' + typ)
def posix_pipe(fin, fout, delim='\n', buf_size=256,
callback=lambda int: None # pragma: no cover
):
"""
Params
------
fin : file with `read(buf_size : int)` method
fout : file with `write` (and optionally `flush`) methods.
callback : function(int), e.g.: `tqdm.update`
"""
fp_write = fout.write
# tmp = ''
if not delim:
while True:
tmp = fin.read(buf_size)
# flush at EOF
if not tmp:
getattr(fout, 'flush', lambda: None)() # pragma: no cover
return
fp_write(tmp)
callback(len(tmp))
# return
buf = ''
# n = 0
while True:
tmp = fin.read(buf_size)
# flush at EOF
if not tmp:
if buf:
fp_write(buf)
callback(1 + buf.count(delim)) # n += 1 + buf.count(delim)
getattr(fout, 'flush', lambda: None)() # pragma: no cover
return # n
while True:
try:
i = tmp.index(delim)
except ValueError:
buf += tmp
break
else:
fp_write(buf + tmp[:i + len(delim)])
callback(1) # n += 1
buf = ''
tmp = tmp[i + len(delim):]
# ((opt, type), ... )
RE_OPTS = re.compile(r'\n {8}(\S+)\s{2,}:\s*([^,]+)')
# better split method assuming no positional args
RE_SHLEX = re.compile(r'\s*(?<!\S)--?([^\s=]+)(?:\s*|=|$)')
# TODO: add custom support for some of the following?
UNSUPPORTED_OPTS = ('iterable', 'gui', 'out', 'file')
# The 8 leading spaces are required for consistency
CLI_EXTRA_DOC = r"""
Extra CLI Options
-----------------
name : type, optional
TODO: find out why this is needed.
delim : chr, optional
Delimiting character [default: '\n']. Use '\0' for null.
N.B.: on Windows systems, Python converts '\n' to '\r\n'.
buf_size : int, optional
String buffer size in bytes [default: 256]
used when `delim` is specified.
bytes : bool, optional
If true, will count bytes, ignore `delim`, and default
`unit_scale` to True, `unit_divisor` to 1024, and `unit` to 'B'.
log : str, optional
CRITICAL|FATAL|ERROR|WARN(ING)|[default: 'INFO']|DEBUG|NOTSET.
"""
def main(fp=sys.stderr):
"""
Paramters (internal use only)
---------
fp : file-like object for tqdm
"""
try:
log = sys.argv.index('--log')
except ValueError:
logLevel = 'INFO'
else:
# sys.argv.pop(log)
# logLevel = sys.argv.pop(log)
logLevel = sys.argv[log + 1]
logging.basicConfig(level=getattr(logging, logLevel),
format="%(levelname)s:%(module)s:%(lineno)d:%(message)s")
log = logging.getLogger(__name__)
d = tqdm.__init__.__doc__ + CLI_EXTRA_DOC
opt_types = dict(RE_OPTS.findall(d))
# opt_types['delim'] = 'chr'
for o in UNSUPPORTED_OPTS:
opt_types.pop(o)
log.debug(sorted(opt_types.items()))
# d = RE_OPTS.sub(r' --\1=<\1> : \2', d)
split = RE_OPTS.split(d)
opt_types_desc = zip(split[1::3], split[2::3], split[3::3])
d = ''.join('\n --{0}=<{0}> : {1}{2}'.format(*otd)
for otd in opt_types_desc if otd[0] not in UNSUPPORTED_OPTS)
d = """Usage:
tqdm [--help | options]
Options:
-h, --help Print this help and exit
-v, --version Print version and exit
""" + d.strip('\n') + '\n'
# opts = docopt(d, version=__version__)
if any(v in sys.argv for v in ('-v', '--version')):
sys.stdout.write(__version__ + '\n')
sys.exit(0)
elif any(v in sys.argv for v in ('-h', '--help')):
sys.stdout.write(d + '\n')
sys.exit(0)
argv = RE_SHLEX.split(' '.join(["tqdm"] + sys.argv[1:]))
opts = dict(zip(argv[1::2], argv[2::2]))
log.debug(opts)
opts.pop('log', True)
tqdm_args = {'file': fp}
try:
for (o, v) in opts.items():
try:
tqdm_args[o] = cast(v, opt_types[o])
except KeyError as e:
raise TqdmKeyError(str(e))
log.debug('args:' + str(tqdm_args))
except:
fp.write('\nError:\nUsage:\n tqdm [--help | options]\n')
for i in sys.stdin:
sys.stdout.write(i)
raise
else:
buf_size = tqdm_args.pop('buf_size', 256)
delim = tqdm_args.pop('delim', '\n')
delim_per_char = tqdm_args.pop('bytes', False)
if delim_per_char:
tqdm_args.setdefault('unit', 'B')
tqdm_args.setdefault('unit_scale', True)
tqdm_args.setdefault('unit_divisor', 1024)
log.debug(tqdm_args)
with tqdm(**tqdm_args) as t:
posix_pipe(sys.stdin, sys.stdout,
'', buf_size, t.update)
elif delim == '\n':
log.debug(tqdm_args)
for i in tqdm(sys.stdin, **tqdm_args):
sys.stdout.write(i)
else:
log.debug(tqdm_args)
with tqdm(**tqdm_args) as t:
posix_pipe(sys.stdin, sys.stdout,
delim, buf_size, t.update)

View File

@@ -1,93 +0,0 @@
from threading import Event, Thread
from time import time
from warnings import warn
__all__ = ["TMonitor", "TqdmSynchronisationWarning"]
class TqdmSynchronisationWarning(RuntimeWarning):
"""tqdm multi-thread/-process errors which may cause incorrect nesting
but otherwise no adverse effects"""
pass
class TMonitor(Thread):
"""
Monitoring thread for tqdm bars.
Monitors if tqdm bars are taking too much time to display
and readjusts miniters automatically if necessary.
Parameters
----------
tqdm_cls : class
tqdm class to use (can be core tqdm or a submodule).
sleep_interval : fload
Time to sleep between monitoring checks.
"""
# internal vars for unit testing
_time = None
_event = None
def __init__(self, tqdm_cls, sleep_interval):
Thread.__init__(self)
self.daemon = True # kill thread when main killed (KeyboardInterrupt)
self.was_killed = Event()
self.woken = 0 # last time woken up, to sync with monitor
self.tqdm_cls = tqdm_cls
self.sleep_interval = sleep_interval
if TMonitor._time is not None:
self._time = TMonitor._time
else:
self._time = time
if TMonitor._event is not None:
self._event = TMonitor._event
else:
self._event = Event
self.start()
def exit(self):
self.was_killed.set()
self.join()
return self.report()
def run(self):
cur_t = self._time()
while True:
# After processing and before sleeping, notify that we woke
# Need to be done just before sleeping
self.woken = cur_t
# Sleep some time...
self.was_killed.wait(self.sleep_interval)
# Quit if killed
if self.was_killed.is_set():
return
# Then monitor!
# Acquire lock (to access _instances)
with self.tqdm_cls.get_lock():
cur_t = self._time()
# Check tqdm instances are waiting too long to print
instances = self.tqdm_cls._instances.copy()
for instance in instances:
# Check event in loop to reduce blocking time on exit
if self.was_killed.is_set():
return
# Avoid race by checking that the instance started
if not hasattr(instance, 'start_t'): # pragma: nocover
continue
# Only if mininterval > 1 (else iterations are just slow)
# and last refresh exceeded maxinterval
if instance.miniters > 1 and \
(cur_t - instance.last_print_t) >= \
instance.maxinterval:
# force bypassing miniters on next iteration
# (dynamic_miniters adjusts mininterval automatically)
instance.miniters = 1
# Refresh now! (works only for manual tqdm)
instance.refresh(nolock=True)
if instances != self.tqdm_cls._instances: # pragma: nocover
warn("Set changed size during iteration" +
" (see https://github.com/tqdm/tqdm/issues/481)",
TqdmSynchronisationWarning)
def report(self):
return not self.was_killed.is_set()

File diff suppressed because it is too large Load Diff

View File

@@ -1,351 +0,0 @@
"""
GUI progressbar decorator for iterators.
Includes a default (x)range iterator printing to stderr.
Usage:
>>> from tqdm_gui import tgrange[, tqdm_gui]
>>> for i in tgrange(10): #same as: for i in tqdm_gui(xrange(10))
... ...
"""
# future division is important to divide integers and get as
# a result precise floating numbers (instead of truncated int)
from __future__ import division, absolute_import
# import compatibility functions and utilities
# import sys
from time import time
from ._utils import _range
# to inherit from the tqdm class
from ._tqdm import tqdm, TqdmExperimentalWarning
from warnings import warn
__author__ = {"github.com/": ["casperdcl", "lrq3000"]}
__all__ = ['tqdm_gui', 'tgrange']
class tqdm_gui(tqdm): # pragma: no cover
"""
Experimental GUI version of tqdm!
"""
# TODO: @classmethod: write() on GUI?
def __init__(self, *args, **kwargs):
import matplotlib as mpl
import matplotlib.pyplot as plt
from collections import deque
kwargs['gui'] = True
super(tqdm_gui, self).__init__(*args, **kwargs)
# Initialize the GUI display
if self.disable or not kwargs['gui']:
return
warn('GUI is experimental/alpha', TqdmExperimentalWarning)
self.mpl = mpl
self.plt = plt
self.sp = None
# Remember if external environment uses toolbars
self.toolbar = self.mpl.rcParams['toolbar']
self.mpl.rcParams['toolbar'] = 'None'
self.mininterval = max(self.mininterval, 0.5)
self.fig, ax = plt.subplots(figsize=(9, 2.2))
# self.fig.subplots_adjust(bottom=0.2)
if self.total:
self.xdata = []
self.ydata = []
self.zdata = []
else:
self.xdata = deque([])
self.ydata = deque([])
self.zdata = deque([])
self.line1, = ax.plot(self.xdata, self.ydata, color='b')
self.line2, = ax.plot(self.xdata, self.zdata, color='k')
ax.set_ylim(0, 0.001)
if self.total:
ax.set_xlim(0, 100)
ax.set_xlabel('percent')
self.fig.legend((self.line1, self.line2), ('cur', 'est'),
loc='center right')
# progressbar
self.hspan = plt.axhspan(0, 0.001,
xmin=0, xmax=0, color='g')
else:
# ax.set_xlim(-60, 0)
ax.set_xlim(0, 60)
ax.invert_xaxis()
ax.set_xlabel('seconds')
ax.legend(('cur', 'est'), loc='lower left')
ax.grid()
# ax.set_xlabel('seconds')
ax.set_ylabel((self.unit if self.unit else 'it') + '/s')
if self.unit_scale:
plt.ticklabel_format(style='sci', axis='y',
scilimits=(0, 0))
ax.yaxis.get_offset_text().set_x(-0.15)
# Remember if external environment is interactive
self.wasion = plt.isinteractive()
plt.ion()
self.ax = ax
def __iter__(self):
# TODO: somehow allow the following:
# if not self.gui:
# return super(tqdm_gui, self).__iter__()
iterable = self.iterable
if self.disable:
for obj in iterable:
yield obj
return
# ncols = self.ncols
mininterval = self.mininterval
maxinterval = self.maxinterval
miniters = self.miniters
dynamic_miniters = self.dynamic_miniters
unit = self.unit
unit_scale = self.unit_scale
ascii = self.ascii
start_t = self.start_t
last_print_t = self.last_print_t
last_print_n = self.last_print_n
n = self.n
# dynamic_ncols = self.dynamic_ncols
smoothing = self.smoothing
avg_time = self.avg_time
bar_format = self.bar_format
plt = self.plt
ax = self.ax
xdata = self.xdata
ydata = self.ydata
zdata = self.zdata
line1 = self.line1
line2 = self.line2
for obj in iterable:
yield obj
# Update and print the progressbar.
# Note: does not call self.update(1) for speed optimisation.
n += 1
delta_it = n - last_print_n
# check the counter first (avoid calls to time())
if delta_it >= miniters:
cur_t = time()
delta_t = cur_t - last_print_t
if delta_t >= mininterval:
elapsed = cur_t - start_t
# EMA (not just overall average)
if smoothing and delta_t:
avg_time = delta_t / delta_it \
if avg_time is None \
else smoothing * delta_t / delta_it + \
(1 - smoothing) * avg_time
# Inline due to multiple calls
total = self.total
# instantaneous rate
y = delta_it / delta_t
# overall rate
z = n / elapsed
# update line data
xdata.append(n * 100.0 / total if total else cur_t)
ydata.append(y)
zdata.append(z)
# Discard old values
# xmin, xmax = ax.get_xlim()
# if (not total) and elapsed > xmin * 1.1:
if (not total) and elapsed > 66:
xdata.popleft()
ydata.popleft()
zdata.popleft()
ymin, ymax = ax.get_ylim()
if y > ymax or z > ymax:
ymax = 1.1 * y
ax.set_ylim(ymin, ymax)
ax.figure.canvas.draw()
if total:
line1.set_data(xdata, ydata)
line2.set_data(xdata, zdata)
try:
poly_lims = self.hspan.get_xy()
except AttributeError:
self.hspan = plt.axhspan(0, 0.001, xmin=0,
xmax=0, color='g')
poly_lims = self.hspan.get_xy()
poly_lims[0, 1] = ymin
poly_lims[1, 1] = ymax
poly_lims[2] = [n / total, ymax]
poly_lims[3] = [poly_lims[2, 0], ymin]
if len(poly_lims) > 4:
poly_lims[4, 1] = ymin
self.hspan.set_xy(poly_lims)
else:
t_ago = [cur_t - i for i in xdata]
line1.set_data(t_ago, ydata)
line2.set_data(t_ago, zdata)
ax.set_title(self.format_meter(
n, total, elapsed, 0,
self.desc, ascii, unit, unit_scale,
1 / avg_time if avg_time else None, bar_format),
fontname="DejaVu Sans Mono", fontsize=11)
plt.pause(1e-9)
# If no `miniters` was specified, adjust automatically
# to the maximum iteration rate seen so far.
if dynamic_miniters:
if maxinterval and delta_t > maxinterval:
# Set miniters to correspond to maxinterval
miniters = delta_it * maxinterval / delta_t
elif mininterval and delta_t:
# EMA-weight miniters to converge
# towards the timeframe of mininterval
miniters = smoothing * delta_it * mininterval \
/ delta_t + (1 - smoothing) * miniters
else:
miniters = smoothing * delta_it + \
(1 - smoothing) * miniters
# Store old values for next call
last_print_n = n
last_print_t = cur_t
# Closing the progress bar.
# Update some internal variables for close().
self.last_print_n = last_print_n
self.n = n
self.close()
def update(self, n=1):
# if not self.gui:
# return super(tqdm_gui, self).close()
if self.disable:
return
if n < 0:
n = 1
self.n += n
delta_it = self.n - self.last_print_n # should be n?
if delta_it >= self.miniters:
# We check the counter first, to reduce the overhead of time()
cur_t = time()
delta_t = cur_t - self.last_print_t
if delta_t >= self.mininterval:
elapsed = cur_t - self.start_t
# EMA (not just overall average)
if self.smoothing and delta_t:
self.avg_time = delta_t / delta_it \
if self.avg_time is None \
else self.smoothing * delta_t / delta_it + \
(1 - self.smoothing) * self.avg_time
# Inline due to multiple calls
total = self.total
ax = self.ax
# instantaneous rate
y = delta_it / delta_t
# smoothed rate
z = self.n / elapsed
# update line data
self.xdata.append(self.n * 100.0 / total
if total else cur_t)
self.ydata.append(y)
self.zdata.append(z)
# Discard old values
if (not total) and elapsed > 66:
self.xdata.popleft()
self.ydata.popleft()
self.zdata.popleft()
ymin, ymax = ax.get_ylim()
if y > ymax or z > ymax:
ymax = 1.1 * y
ax.set_ylim(ymin, ymax)
ax.figure.canvas.draw()
if total:
self.line1.set_data(self.xdata, self.ydata)
self.line2.set_data(self.xdata, self.zdata)
try:
poly_lims = self.hspan.get_xy()
except AttributeError:
self.hspan = self.plt.axhspan(0, 0.001, xmin=0,
xmax=0, color='g')
poly_lims = self.hspan.get_xy()
poly_lims[0, 1] = ymin
poly_lims[1, 1] = ymax
poly_lims[2] = [self.n / total, ymax]
poly_lims[3] = [poly_lims[2, 0], ymin]
if len(poly_lims) > 4:
poly_lims[4, 1] = ymin
self.hspan.set_xy(poly_lims)
else:
t_ago = [cur_t - i for i in self.xdata]
self.line1.set_data(t_ago, self.ydata)
self.line2.set_data(t_ago, self.zdata)
ax.set_title(self.format_meter(
self.n, total, elapsed, 0,
self.desc, self.ascii, self.unit, self.unit_scale,
1 / self.avg_time if self.avg_time else None,
self.bar_format),
fontname="DejaVu Sans Mono", fontsize=11)
self.plt.pause(1e-9)
# If no `miniters` was specified, adjust automatically to the
# maximum iteration rate seen so far.
# e.g.: After running `tqdm.update(5)`, subsequent
# calls to `tqdm.update()` will only cause an update after
# at least 5 more iterations.
if self.dynamic_miniters:
if self.maxinterval and delta_t > self.maxinterval:
self.miniters = self.miniters * self.maxinterval \
/ delta_t
elif self.mininterval and delta_t:
self.miniters = self.smoothing * delta_it \
* self.mininterval / delta_t + \
(1 - self.smoothing) * self.miniters
else:
self.miniters = self.smoothing * delta_it + \
(1 - self.smoothing) * self.miniters
# Store old values for next call
self.last_print_n = self.n
self.last_print_t = cur_t
def close(self):
# if not self.gui:
# return super(tqdm_gui, self).close()
if self.disable:
return
self.disable = True
self._instances.remove(self)
# Restore toolbars
self.mpl.rcParams['toolbar'] = self.toolbar
# Return to non-interactive mode
if not self.wasion:
self.plt.ioff()
if not self.leave:
self.plt.close(self.fig)
def tgrange(*args, **kwargs):
"""
A shortcut for tqdm_gui(xrange(*args), **kwargs).
On Python3+ range is used instead of xrange.
"""
return tqdm_gui(_range(*args), **kwargs)

View File

@@ -1,236 +0,0 @@
"""
IPython/Jupyter Notebook progressbar decorator for iterators.
Includes a default (x)range iterator printing to stderr.
Usage:
>>> from tqdm_notebook import tnrange[, tqdm_notebook]
>>> for i in tnrange(10): #same as: for i in tqdm_notebook(xrange(10))
... ...
"""
# future division is important to divide integers and get as
# a result precise floating numbers (instead of truncated int)
from __future__ import division, absolute_import
# import compatibility functions and utilities
import sys
from ._utils import _range
# to inherit from the tqdm class
from ._tqdm import tqdm
if True: # pragma: no cover
# import IPython/Jupyter base widget and display utilities
try: # IPython 4.x
import ipywidgets
IPY = 4
except ImportError: # IPython 3.x / 2.x
IPY = 32
import warnings
with warnings.catch_warnings():
ipy_deprecation_msg = "The `IPython.html` package" \
" has been deprecated"
warnings.filterwarnings('error',
message=".*" + ipy_deprecation_msg + ".*")
try:
import IPython.html.widgets as ipywidgets
except Warning as e:
if ipy_deprecation_msg not in str(e):
raise
warnings.simplefilter('ignore')
try:
import IPython.html.widgets as ipywidgets # NOQA
except ImportError:
pass
except ImportError:
pass
try: # IPython 4.x / 3.x
if IPY == 32:
from IPython.html.widgets import IntProgress, HBox, HTML
IPY = 3
else:
from ipywidgets import IntProgress, HBox, HTML
except ImportError:
try: # IPython 2.x
from IPython.html.widgets import IntProgressWidget as IntProgress
from IPython.html.widgets import ContainerWidget as HBox
from IPython.html.widgets import HTML
IPY = 2
except ImportError:
IPY = 0
try:
from IPython.display import display # , clear_output
except ImportError:
pass
# HTML encoding
try: # Py3
from html import escape
except ImportError: # Py2
from cgi import escape
__author__ = {"github.com/": ["lrq3000", "casperdcl", "alexanderkuk"]}
__all__ = ['tqdm_notebook', 'tnrange']
class tqdm_notebook(tqdm):
"""
Experimental IPython/Jupyter Notebook widget using tqdm!
"""
@staticmethod
def status_printer(_, total=None, desc=None):
"""
Manage the printing of an IPython/Jupyter Notebook progress bar widget.
"""
# Fallback to text bar if there's no total
# DEPRECATED: replaced with an 'info' style bar
# if not total:
# return super(tqdm_notebook, tqdm_notebook).status_printer(file)
# fp = file
# Prepare IPython progress bar
if total:
pbar = IntProgress(min=0, max=total)
else: # No total? Show info style bar with no progress tqdm status
pbar = IntProgress(min=0, max=1)
pbar.value = 1
pbar.bar_style = 'info'
if desc:
pbar.description = desc
# Prepare status text
ptext = HTML()
# Only way to place text to the right of the bar is to use a container
container = HBox(children=[pbar, ptext])
display(container)
def print_status(s='', close=False, bar_style=None, desc=None):
# Note: contrary to native tqdm, s='' does NOT clear bar
# goal is to keep all infos if error happens so user knows
# at which iteration the loop failed.
# Clear previous output (really necessary?)
# clear_output(wait=1)
# Get current iteration value from format_meter string
if total:
# n = None
if s:
npos = s.find(r'/|/') # cause we use bar_format=r'{n}|...'
# Check that n can be found in s (else n > total)
if npos >= 0:
n = int(s[:npos]) # get n from string
s = s[npos + 3:] # remove from string
# Update bar with current n value
if n is not None:
pbar.value = n
# Print stats
if s: # never clear the bar (signal: s='')
s = s.replace('||', '') # remove inesthetical pipes
s = escape(s) # html escape special characters (like '?')
ptext.value = s
# Change bar style
if bar_style:
# Hack-ish way to avoid the danger bar_style being overriden by
# success because the bar gets closed after the error...
if not (pbar.bar_style == 'danger' and bar_style == 'success'):
pbar.bar_style = bar_style
# Special signal to close the bar
if close and pbar.bar_style != 'danger': # hide only if no error
try:
container.close()
except AttributeError:
container.visible = False
# Update description
if desc:
pbar.description = desc
return print_status
def __init__(self, *args, **kwargs):
# Setup default output
if kwargs.get('file', sys.stderr) is sys.stderr:
kwargs['file'] = sys.stdout # avoid the red block in IPython
# Remove the bar from the printed string, only print stats
if not kwargs.get('bar_format', None):
kwargs['bar_format'] = r'{n}/|/{l_bar}{r_bar}'
# Initialize parent class + avoid printing by using gui=True
kwargs['gui'] = True
super(tqdm_notebook, self).__init__(*args, **kwargs)
if self.disable or not kwargs['gui']:
return
# Delete first pbar generated from super() (wrong total and text)
# DEPRECATED by using gui=True
# self.sp('', close=True)
# Replace with IPython progress bar display (with correct total)
self.sp = self.status_printer(self.fp, self.total, self.desc)
self.desc = None # trick to place description before the bar
# Print initial bar state
if not self.disable:
self.sp(self.__repr__()) # same as self.refresh without clearing
def __iter__(self, *args, **kwargs):
try:
for obj in super(tqdm_notebook, self).__iter__(*args, **kwargs):
# return super(tqdm...) will not catch exception
yield obj
# NB: except ... [ as ...] breaks IPython async KeyboardInterrupt
except:
self.sp(bar_style='danger')
raise
def update(self, *args, **kwargs):
try:
super(tqdm_notebook, self).update(*args, **kwargs)
except Exception as exc:
# cannot catch KeyboardInterrupt when using manual tqdm
# as the interrupt will most likely happen on another statement
self.sp(bar_style='danger')
raise exc
def close(self, *args, **kwargs):
super(tqdm_notebook, self).close(*args, **kwargs)
# If it was not run in a notebook, sp is not assigned, check for it
if hasattr(self, 'sp'):
# Try to detect if there was an error or KeyboardInterrupt
# in manual mode: if n < total, things probably got wrong
if self.total and self.n < self.total:
self.sp(bar_style='danger')
else:
if self.leave:
self.sp(bar_style='success')
else:
self.sp(close=True)
def moveto(self, *args, **kwargs):
# void -> avoid extraneous `\n` in IPython output cell
return
def set_description(self, desc=None, **_):
"""
Set/modify description of the progress bar.
Parameters
----------
desc : str, optional
"""
self.sp(desc=desc)
def tnrange(*args, **kwargs):
"""
A shortcut for tqdm_notebook(xrange(*args), **kwargs).
On Python3+ range is used instead of xrange.
"""
return tqdm_notebook(_range(*args), **kwargs)

View File

@@ -1,46 +0,0 @@
import sys
__author__ = "github.com/casperdcl"
__all__ = ['tqdm_pandas']
def tqdm_pandas(tclass, *targs, **tkwargs):
"""
Registers the given `tqdm` instance with
`pandas.core.groupby.DataFrameGroupBy.progress_apply`.
It will even close() the `tqdm` instance upon completion.
Parameters
----------
tclass : tqdm class you want to use (eg, tqdm, tqdm_notebook, etc)
targs and tkwargs : arguments for the tqdm instance
Examples
--------
>>> import pandas as pd
>>> import numpy as np
>>> from tqdm import tqdm, tqdm_pandas
>>>
>>> df = pd.DataFrame(np.random.randint(0, 100, (100000, 6)))
>>> tqdm_pandas(tqdm, leave=True) # can use tqdm_gui, optional kwargs, etc
>>> # Now you can use `progress_apply` instead of `apply`
>>> df.groupby(0).progress_apply(lambda x: x**2)
References
----------
https://stackoverflow.com/questions/18603270/
progress-indicator-during-pandas-operations-python
"""
from tqdm import TqdmDeprecationWarning
if isinstance(tclass, type) or (getattr(tclass, '__name__', '').startswith(
'tqdm_')): # delayed adapter case
TqdmDeprecationWarning("""\
Please use `tqdm.pandas(...)` instead of `tqdm_pandas(tqdm, ...)`.
""", fp_write=getattr(tkwargs.get('file', None), 'write', sys.stderr.write))
tclass.pandas(*targs, **tkwargs)
else:
TqdmDeprecationWarning("""\
Please use `tqdm.pandas(...)` instead of `tqdm_pandas(tqdm(...))`.
""", fp_write=getattr(tclass.fp, 'write', sys.stderr.write))
type(tclass).pandas(deprecated_t=tclass)

View File

@@ -1,215 +0,0 @@
import os
import subprocess
from platform import system as _curos
CUR_OS = _curos()
IS_WIN = CUR_OS in ['Windows', 'cli']
IS_NIX = (not IS_WIN) and any(
CUR_OS.startswith(i) for i in
['CYGWIN', 'MSYS', 'Linux', 'Darwin', 'SunOS', 'FreeBSD', 'NetBSD'])
# Py2/3 compat. Empty conditional to avoid coverage
if True: # pragma: no cover
try:
_range = xrange
except NameError:
_range = range
try:
_unich = unichr
except NameError:
_unich = chr
try:
_unicode = unicode
except NameError:
_unicode = str
try:
if IS_WIN:
import colorama
colorama.init()
else:
colorama = None
except ImportError:
colorama = None
try:
from weakref import WeakSet
except ImportError:
WeakSet = set
try:
_basestring = basestring
except NameError:
_basestring = str
try: # py>=2.7,>=3.1
from collections import OrderedDict as _OrderedDict
except ImportError:
try: # older Python versions with backported ordereddict lib
from ordereddict import OrderedDict as _OrderedDict
except ImportError: # older Python versions without ordereddict lib
# Py2.6,3.0 compat, from PEP 372
from collections import MutableMapping
class _OrderedDict(dict, MutableMapping):
# Methods with direct access to underlying attributes
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at 1 argument, got %d',
len(args))
if not hasattr(self, '_keys'):
self._keys = []
self.update(*args, **kwds)
def clear(self):
del self._keys[:]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
self._keys.append(key)
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
self._keys.remove(key)
def __iter__(self):
return iter(self._keys)
def __reversed__(self):
return reversed(self._keys)
def popitem(self):
if not self:
raise KeyError
key = self._keys.pop()
value = dict.pop(self, key)
return key, value
def __reduce__(self):
items = [[k, self[k]] for k in self]
inst_dict = vars(self).copy()
inst_dict.pop('_keys', None)
return self.__class__, (items,), inst_dict
# Methods with indirect access via the above methods
setdefault = MutableMapping.setdefault
update = MutableMapping.update
pop = MutableMapping.pop
keys = MutableMapping.keys
values = MutableMapping.values
items = MutableMapping.items
def __repr__(self):
pairs = ', '.join(map('%r: %r'.__mod__, self.items()))
return '%s({%s})' % (self.__class__.__name__, pairs)
def copy(self):
return self.__class__(self)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def _is_utf(encoding):
try:
u'\u2588\u2589'.encode(encoding)
except UnicodeEncodeError: # pragma: no cover
return False
except Exception: # pragma: no cover
try:
return encoding.lower().startswith('utf-') or ('U8' == encoding)
except:
return False
else:
return True
def _supports_unicode(fp):
try:
return _is_utf(fp.encoding)
except AttributeError:
return False
def _environ_cols_wrapper(): # pragma: no cover
"""
Return a function which gets width and height of console
(linux,osx,windows,cygwin).
"""
_environ_cols = None
if IS_WIN:
_environ_cols = _environ_cols_windows
if _environ_cols is None:
_environ_cols = _environ_cols_tput
if IS_NIX:
_environ_cols = _environ_cols_linux
return _environ_cols
def _environ_cols_windows(fp): # pragma: no cover
try:
from ctypes import windll, create_string_buffer
import struct
from sys import stdin, stdout
io_handle = -12 # assume stderr
if fp == stdin:
io_handle = -10
elif fp == stdout:
io_handle = -11
h = windll.kernel32.GetStdHandle(io_handle)
csbi = create_string_buffer(22)
res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
if res:
(_bufx, _bufy, _curx, _cury, _wattr, left, _top, right, _bottom,
_maxx, _maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw)
# nlines = bottom - top + 1
return right - left # +1
except:
pass
return None
def _environ_cols_tput(*_): # pragma: no cover
"""cygwin xterm (windows)"""
try:
import shlex
cols = int(subprocess.check_call(shlex.split('tput cols')))
# rows = int(subprocess.check_call(shlex.split('tput lines')))
return cols
except:
pass
return None
def _environ_cols_linux(fp): # pragma: no cover
try:
from termios import TIOCGWINSZ
from fcntl import ioctl
from array import array
except ImportError:
return None
else:
try:
return array('h', ioctl(fp, TIOCGWINSZ, '\0' * 8))[1]
except:
try:
from os.environ import get
except ImportError:
return None
else:
return int(get('COLUMNS', 1)) - 1
def _term_move_up(): # pragma: no cover
return '' if (os.name == 'nt') and (colorama is None) else '\x1b[A'

View File

@@ -1,59 +0,0 @@
# Definition of the version number
import os
from io import open as io_open
__all__ = ["__version__"]
# major, minor, patch, -extra
version_info = 4, 21, 0
# Nice string for the version
__version__ = '.'.join(map(str, version_info))
# auto -extra based on commit hash (if not tagged as release)
scriptdir = os.path.dirname(__file__)
gitdir = os.path.abspath(os.path.join(scriptdir, "..", ".git"))
if os.path.isdir(gitdir): # pragma: nocover
extra = None
# Open config file to check if we are in tqdm project
with io_open(os.path.join(gitdir, "config"), 'r') as fh_config:
if 'tqdm' in fh_config.read():
# Open the HEAD file
with io_open(os.path.join(gitdir, "HEAD"), 'r') as fh_head:
extra = fh_head.readline().strip()
# in a branch => HEAD points to file containing last commit
if 'ref:' in extra:
# reference file path
ref_file = extra[5:]
branch_name = ref_file.rsplit('/', 1)[-1]
ref_file_path = os.path.abspath(os.path.join(gitdir, ref_file))
# check that we are in git folder
# (by stripping the git folder from the ref file path)
if os.path.relpath(
ref_file_path, gitdir).replace('\\', '/') != ref_file:
# out of git folder
extra = None
else:
# open the ref file
with io_open(ref_file_path, 'r') as fh_branch:
commit_hash = fh_branch.readline().strip()
extra = commit_hash[:8]
if branch_name != "master":
extra += '.' + branch_name
# detached HEAD mode, already have commit hash
else:
extra = extra[:8]
# Append commit hash (and branch) to version string if not tagged
if extra is not None:
try:
with io_open(os.path.join(gitdir, "refs", "tags",
'v' + __version__)) as fdv:
if fdv.readline().strip()[:8] != extra[:8]:
__version__ += '-' + extra
except Exception as e:
if "No such file" not in str(e):
raise

View File

@@ -1,94 +0,0 @@
import sys
import subprocess
from tqdm import main, TqdmKeyError, TqdmTypeError
from tests_tqdm import with_setup, pretest, posttest, _range, closing, \
UnicodeIO, StringIO
def _sh(*cmd, **kwargs):
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
**kwargs).communicate()[0].decode('utf-8')
# WARNING: this should be the last test as it messes with sys.stdin, argv
@with_setup(pretest, posttest)
def test_main():
"""Test command line pipes"""
ls_out = _sh('ls').replace('\r\n', '\n')
ls = subprocess.Popen('ls', stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
res = _sh(sys.executable, '-c', 'from tqdm import main; main()',
stdin=ls.stdout, stderr=subprocess.STDOUT)
ls.wait()
# actual test:
assert (ls_out in res.replace('\r\n', '\n'))
# semi-fake test which gets coverage:
_SYS = sys.stdin, sys.argv
with closing(StringIO()) as sys.stdin:
sys.argv = ['', '--desc', 'Test CLI-delims',
'--ascii', 'True', '--delim', r'\0', '--buf_size', '64']
sys.stdin.write('\0'.join(map(str, _range(int(1e3)))))
sys.stdin.seek(0)
main()
IN_DATA_LIST = map(str, _range(int(1e3)))
sys.stdin = IN_DATA_LIST
sys.argv = ['', '--desc', 'Test CLI pipes',
'--ascii', 'True', '--unit_scale', 'True']
import tqdm.__main__ # NOQA
IN_DATA = '\0'.join(IN_DATA_LIST)
with closing(StringIO()) as sys.stdin:
sys.stdin.write(IN_DATA)
sys.stdin.seek(0)
sys.argv = ['', '--ascii', '--bytes', '--unit_scale', 'False']
with closing(UnicodeIO()) as fp:
main(fp=fp)
assert (str(len(IN_DATA)) in fp.getvalue())
sys.stdin = IN_DATA_LIST
sys.argv = ['', '-ascii', '--unit_scale', 'False',
'--desc', 'Test CLI errors']
main()
sys.argv = ['', '-ascii', '-unit_scale', '--bad_arg_u_ment', 'foo']
try:
main()
except TqdmKeyError as e:
if 'bad_arg_u_ment' not in str(e):
raise
else:
raise TqdmKeyError('bad_arg_u_ment')
sys.argv = ['', '-ascii', '-unit_scale', 'invalid_bool_value']
try:
main()
except TqdmTypeError as e:
if 'invalid_bool_value' not in str(e):
raise
else:
raise TqdmTypeError('invalid_bool_value')
sys.argv = ['', '-ascii', '--total', 'invalid_int_value']
try:
main()
except TqdmTypeError as e:
if 'invalid_int_value' not in str(e):
raise
else:
raise TqdmTypeError('invalid_int_value')
for i in ('-h', '--help', '-v', '--version'):
sys.argv = ['', i]
try:
main()
except SystemExit:
pass
# clean up
sys.stdin, sys.argv = _SYS

View File

@@ -1,207 +0,0 @@
from nose.plugins.skip import SkipTest
from tqdm import tqdm
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing
@with_setup(pretest, posttest)
def test_pandas_series():
"""Test pandas.Series.progress_apply and .progress_map"""
try:
from numpy.random import randint
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
tqdm.pandas(file=our_file, leave=True, ascii=True)
series = pd.Series(randint(0, 50, (123,)))
res1 = series.progress_apply(lambda x: x + 10)
res2 = series.apply(lambda x: x + 10)
assert res1.equals(res2)
res3 = series.progress_map(lambda x: x + 10)
res4 = series.map(lambda x: x + 10)
assert res3.equals(res4)
expects = ['100%', '123/123']
for exres in expects:
our_file.seek(0)
if our_file.getvalue().count(exres) < 2:
our_file.seek(0)
raise AssertionError(
"\nExpected:\n{0}\nIn:\n{1}\n".format(
exres + " at least twice.", our_file.read()))
@with_setup(pretest, posttest)
def test_pandas_data_frame():
"""Test pandas.DataFrame.progress_apply and .progress_applymap"""
try:
from numpy.random import randint
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
tqdm.pandas(file=our_file, leave=True, ascii=True)
df = pd.DataFrame(randint(0, 50, (100, 200)))
def task_func(x):
return x + 1
# applymap
res1 = df.progress_applymap(task_func)
res2 = df.applymap(task_func)
assert res1.equals(res2)
# apply
for axis in [0, 1]:
res3 = df.progress_apply(task_func, axis=axis)
res4 = df.apply(task_func, axis=axis)
assert res3.equals(res4)
our_file.seek(0)
if our_file.read().count('100%') < 3:
our_file.seek(0)
raise AssertionError("\nExpected:\n{0}\nIn:\n{1}\n".format(
'100% at least three times', our_file.read()))
# apply_map, apply axis=0, apply axis=1
expects = ['20000/20000', '200/200', '100/100']
for exres in expects:
our_file.seek(0)
if our_file.getvalue().count(exres) < 1:
our_file.seek(0)
raise AssertionError(
"\nExpected:\n{0}\nIn:\n {1}\n".format(
exres + " at least once.", our_file.read()))
@with_setup(pretest, posttest)
def test_pandas_groupby_apply():
"""Test pandas.DataFrame.groupby(...).progress_apply"""
try:
from numpy.random import randint
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
tqdm.pandas(file=our_file, leave=False, ascii=True)
df = pd.DataFrame(randint(0, 50, (500, 3)))
df.groupby(0).progress_apply(lambda x: None)
dfs = pd.DataFrame(randint(0, 50, (500, 3)), columns=list('abc'))
dfs.groupby(['a']).progress_apply(lambda x: None)
our_file.seek(0)
# don't expect final output since no `leave` and
# high dynamic `miniters`
nexres = '100%|##########|'
if nexres in our_file.read():
our_file.seek(0)
raise AssertionError("\nDid not expect:\n{0}\nIn:{1}\n".format(
nexres, our_file.read()))
with closing(StringIO()) as our_file:
tqdm.pandas(file=our_file, leave=True, ascii=True)
dfs = pd.DataFrame(randint(0, 50, (500, 3)), columns=list('abc'))
dfs.loc[0] = [2, 1, 1]
dfs['d'] = 100
expects = ['500/500', '1/1', '4/4', '2/2']
dfs.groupby(dfs.index).progress_apply(lambda x: None)
dfs.groupby('d').progress_apply(lambda x: None)
dfs.groupby(dfs.columns, axis=1).progress_apply(lambda x: None)
dfs.groupby([2, 2, 1, 1], axis=1).progress_apply(lambda x: None)
our_file.seek(0)
if our_file.read().count('100%') < 4:
our_file.seek(0)
raise AssertionError("\nExpected:\n{0}\nIn:\n{1}\n".format(
'100% at least four times', our_file.read()))
for exres in expects:
our_file.seek(0)
if our_file.getvalue().count(exres) < 1:
our_file.seek(0)
raise AssertionError(
"\nExpected:\n{0}\nIn:\n {1}\n".format(
exres + " at least once.", our_file.read()))
@with_setup(pretest, posttest)
def test_pandas_leave():
"""Test pandas with `leave=True`"""
try:
from numpy.random import randint
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
df = pd.DataFrame(randint(0, 100, (1000, 6)))
tqdm.pandas(file=our_file, leave=True, ascii=True)
df.groupby(0).progress_apply(lambda x: None)
our_file.seek(0)
exres = '100%|##########| 100/100'
if exres not in our_file.read():
our_file.seek(0)
raise AssertionError(
"\nExpected:\n{0}\nIn:{1}\n".format(exres, our_file.read()))
@with_setup(pretest, posttest)
def test_pandas_apply_args_deprecation():
"""Test warning info in
`pandas.Dataframe(Series).progress_apply(func, *args)`"""
try:
from numpy.random import randint
from tqdm import tqdm_pandas
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
tqdm_pandas(tqdm(file=our_file, leave=False, ascii=True, ncols=20))
df = pd.DataFrame(randint(0, 50, (500, 3)))
df.progress_apply(lambda x: None, 1) # 1 shall cause a warning
# Check deprecation message
res = our_file.getvalue()
assert all([i in res for i in (
"TqdmDeprecationWarning", "not supported",
"keyword arguments instead")])
@with_setup(pretest, posttest)
def test_pandas_deprecation():
"""Test bar object instance as argument deprecation"""
try:
from numpy.random import randint
from tqdm import tqdm_pandas
import pandas as pd
except ImportError:
raise SkipTest
with closing(StringIO()) as our_file:
tqdm_pandas(tqdm(file=our_file, leave=False, ascii=True, ncols=20))
df = pd.DataFrame(randint(0, 50, (500, 3)))
df.groupby(0).progress_apply(lambda x: None)
# Check deprecation message
assert "TqdmDeprecationWarning" in our_file.getvalue()
assert "instead of `tqdm_pandas(tqdm(...))`" in our_file.getvalue()
with closing(StringIO()) as our_file:
tqdm_pandas(tqdm, file=our_file, leave=False, ascii=True, ncols=20)
df = pd.DataFrame(randint(0, 50, (500, 3)))
df.groupby(0).progress_apply(lambda x: None)
# Check deprecation message
assert "TqdmDeprecationWarning" in our_file.getvalue()
assert "instead of `tqdm_pandas(tqdm, ...)`" in our_file.getvalue()

View File

@@ -1,336 +0,0 @@
from __future__ import print_function, division
from nose.plugins.skip import SkipTest
from contextlib import contextmanager
import sys
from time import sleep, time
from tqdm import trange
from tqdm import tqdm
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing, _range
# Use relative/cpu timer to have reliable timings when there is a sudden load
try:
from time import process_time
except ImportError:
from time import clock
process_time = clock
def get_relative_time(prevtime=0):
return process_time() - prevtime
def cpu_sleep(t):
"""Sleep the given amount of cpu time"""
start = process_time()
while (process_time() - start) < t:
pass
def checkCpuTime(sleeptime=0.2):
"""Check if cpu time works correctly"""
if checkCpuTime.passed:
return True
# First test that sleeping does not consume cputime
start1 = process_time()
sleep(sleeptime)
t1 = process_time() - start1
# secondly check by comparing to cpusleep (where we actually do something)
start2 = process_time()
cpu_sleep(sleeptime)
t2 = process_time() - start2
if abs(t1) < 0.0001 and (t1 < t2 / 10):
return True
raise SkipTest
checkCpuTime.passed = False
@contextmanager
def relative_timer():
start = process_time()
def elapser():
return process_time() - start
yield lambda: elapser()
spent = process_time() - start
def elapser(): # NOQA
return spent
def retry_on_except(n=3):
def wrapper(fn):
def test_inner():
for i in range(1, n + 1):
try:
checkCpuTime()
fn()
except SkipTest:
if i >= n:
raise
else:
return
test_inner.__doc__ = fn.__doc__
return test_inner
return wrapper
class MockIO(StringIO):
"""Wraps StringIO to mock a file with no I/O"""
def write(self, data):
return
def simple_progress(iterable=None, total=None, file=sys.stdout, desc='',
leave=False, miniters=1, mininterval=0.1, width=60):
"""Simple progress bar reproducing tqdm's major features"""
n = [0] # use a closure
start_t = [time()]
last_n = [0]
last_t = [0]
if iterable is not None:
total = len(iterable)
def format_interval(t):
mins, s = divmod(int(t), 60)
h, m = divmod(mins, 60)
if h:
return '{0:d}:{1:02d}:{2:02d}'.format(h, m, s)
else:
return '{0:02d}:{1:02d}'.format(m, s)
def update_and_print(i=1):
n[0] += i
if (n[0] - last_n[0]) >= miniters:
last_n[0] = n[0]
if (time() - last_t[0]) >= mininterval:
last_t[0] = time() # last_t[0] == current time
spent = last_t[0] - start_t[0]
spent_fmt = format_interval(spent)
rate = n[0] / spent if spent > 0 else 0
if 0.0 < rate < 1.0:
rate_fmt = "%.2fs/it" % (1.0 / rate)
else:
rate_fmt = "%.2fit/s" % rate
frac = n[0] / total
percentage = int(frac * 100)
eta = (total - n[0]) / rate if rate > 0 else 0
eta_fmt = format_interval(eta)
# bar = "#" * int(frac * width)
barfill = " " * int((1.0 - frac) * width)
bar_length, frac_bar_length = divmod(int(frac * width * 10), 10)
bar = '#' * bar_length
frac_bar = chr(48 + frac_bar_length) if frac_bar_length \
else ' '
file.write("\r%s %i%%|%s%s%s| %i/%i [%s<%s, %s]" %
(desc, percentage, bar, frac_bar, barfill, n[0],
total, spent_fmt, eta_fmt, rate_fmt))
if n[0] == total and leave:
file.write("\n")
file.flush()
def update_and_yield():
for elt in iterable:
yield elt
update_and_print()
update_and_print(0)
if iterable is not None:
return update_and_yield()
else:
return update_and_print
@with_setup(pretest, posttest)
@retry_on_except()
def test_iter_overhead():
"""Test overhead of iteration based tqdm"""
total = int(1e6)
with closing(MockIO()) as our_file:
a = 0
with trange(total, file=our_file) as t:
with relative_timer() as time_tqdm:
for i in t:
a += i
assert (a == (total * total - total) / 2.0)
a = 0
with relative_timer() as time_bench:
for i in _range(total):
a += i
our_file.write(a)
# Compute relative overhead of tqdm against native range()
if time_tqdm() > 9 * time_bench():
raise AssertionError('trange(%g): %f, range(%g): %f' %
(total, time_tqdm(), total, time_bench()))
@with_setup(pretest, posttest)
@retry_on_except()
def test_manual_overhead():
"""Test overhead of manual tqdm"""
total = int(1e6)
with closing(MockIO()) as our_file:
with tqdm(total=total * 10, file=our_file, leave=True) as t:
a = 0
with relative_timer() as time_tqdm:
for i in _range(total):
a += i
t.update(10)
a = 0
with relative_timer() as time_bench:
for i in _range(total):
a += i
our_file.write(a)
# Compute relative overhead of tqdm against native range()
if time_tqdm() > 10 * time_bench():
raise AssertionError('tqdm(%g): %f, range(%g): %f' %
(total, time_tqdm(), total, time_bench()))
@with_setup(pretest, posttest)
@retry_on_except()
def test_iter_overhead_hard():
"""Test overhead of iteration based tqdm (hard)"""
total = int(1e5)
with closing(MockIO()) as our_file:
a = 0
with trange(total, file=our_file, leave=True, miniters=1,
mininterval=0, maxinterval=0) as t:
with relative_timer() as time_tqdm:
for i in t:
a += i
assert (a == (total * total - total) / 2.0)
a = 0
with relative_timer() as time_bench:
for i in _range(total):
a += i
our_file.write(("%i" % a) * 40)
# Compute relative overhead of tqdm against native range()
try:
assert (time_tqdm() < 60 * time_bench())
except AssertionError:
raise AssertionError('trange(%g): %f, range(%g): %f' %
(total, time_tqdm(), total, time_bench()))
@with_setup(pretest, posttest)
@retry_on_except()
def test_manual_overhead_hard():
"""Test overhead of manual tqdm (hard)"""
total = int(1e5)
with closing(MockIO()) as our_file:
t = tqdm(total=total * 10, file=our_file, leave=True, miniters=1,
mininterval=0, maxinterval=0)
a = 0
with relative_timer() as time_tqdm:
for i in _range(total):
a += i
t.update(10)
a = 0
with relative_timer() as time_bench:
for i in _range(total):
a += i
our_file.write(("%i" % a) * 40)
# Compute relative overhead of tqdm against native range()
try:
assert (time_tqdm() < 100 * time_bench())
except AssertionError:
raise AssertionError('tqdm(%g): %f, range(%g): %f' %
(total, time_tqdm(), total, time_bench()))
@with_setup(pretest, posttest)
@retry_on_except()
def test_iter_overhead_simplebar_hard():
"""Test overhead of iteration based tqdm vs simple progress bar (hard)"""
total = int(1e4)
with closing(MockIO()) as our_file:
a = 0
with trange(total, file=our_file, leave=True, miniters=1,
mininterval=0, maxinterval=0) as t:
with relative_timer() as time_tqdm:
for i in t:
a += i
assert (a == (total * total - total) / 2.0)
a = 0
s = simple_progress(_range(total), file=our_file, leave=True,
miniters=1, mininterval=0)
with relative_timer() as time_bench:
for i in s:
a += i
# Compute relative overhead of tqdm against native range()
try:
assert (time_tqdm() < 2.5 * time_bench())
except AssertionError:
raise AssertionError('trange(%g): %f, simple_progress(%g): %f' %
(total, time_tqdm(), total, time_bench()))
@with_setup(pretest, posttest)
@retry_on_except()
def test_manual_overhead_simplebar_hard():
"""Test overhead of manual tqdm vs simple progress bar (hard)"""
total = int(1e4)
with closing(MockIO()) as our_file:
t = tqdm(total=total * 10, file=our_file, leave=True, miniters=1,
mininterval=0, maxinterval=0)
a = 0
with relative_timer() as time_tqdm:
for i in _range(total):
a += i
t.update(10)
simplebar_update = simple_progress(
total=total, file=our_file, leave=True, miniters=1, mininterval=0)
a = 0
with relative_timer() as time_bench:
for i in _range(total):
a += i
simplebar_update(10)
# Compute relative overhead of tqdm against native range()
try:
assert (time_tqdm() < 2.5 * time_bench())
except AssertionError:
raise AssertionError('tqdm(%g): %f, simple_progress(%g): %f' %
(total, time_tqdm(), total, time_bench()))

View File

@@ -1,164 +0,0 @@
from __future__ import division
from tqdm import tqdm
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing
from tests_tqdm import DiscreteTimer, cpu_timify
from time import sleep
from threading import Event
from tqdm import TMonitor
class FakeSleep(object):
"""Wait until the discrete timer reached the required time"""
def __init__(self, dtimer):
self.dtimer = dtimer
def sleep(self, t):
end = t + self.dtimer.t
while self.dtimer.t < end:
sleep(0.0000001) # sleep a bit to interrupt (instead of pass)
class FakeTqdm(object):
_instances = []
def make_create_fake_sleep_event(sleep):
def wait(self, timeout=None):
if timeout is not None:
sleep(timeout)
return self.is_set()
def create_fake_sleep_event():
event = Event()
event.wait = wait
return event
return create_fake_sleep_event
@with_setup(pretest, posttest)
def test_monitor_thread():
"""Test dummy monitoring thread"""
maxinterval = 10
# Setup a discrete timer
timer = DiscreteTimer()
TMonitor._time = timer.time
# And a fake sleeper
sleeper = FakeSleep(timer)
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
# Instanciate the monitor
monitor = TMonitor(FakeTqdm, maxinterval)
# Test if alive, then killed
assert monitor.report()
monitor.exit()
timer.sleep(maxinterval * 2) # need to go out of the sleep to die
assert not monitor.report()
# assert not monitor.is_alive() # not working dunno why, thread not killed
del monitor
@with_setup(pretest, posttest)
def test_monitoring_and_cleanup():
"""Test for stalled tqdm instance and monitor deletion"""
# Note: should fix miniters for these tests, else with dynamic_miniters
# it's too complicated to handle with monitoring update and maxinterval...
maxinterval = 2
total = 1000
# Setup a discrete timer
timer = DiscreteTimer()
# And a fake sleeper
sleeper = FakeSleep(timer)
# Setup TMonitor to use the timer
TMonitor._time = timer.time
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
# Set monitor interval
tqdm.monitor_interval = maxinterval
with closing(StringIO()) as our_file:
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
maxinterval=maxinterval) as t:
cpu_timify(t, timer)
# Do a lot of iterations in a small timeframe
# (smaller than monitor interval)
timer.sleep(maxinterval / 2) # monitor won't wake up
t.update(500)
# check that our fixed miniters is still there
assert t.miniters == 500
# Then do 1 it after monitor interval, so that monitor kicks in
timer.sleep(maxinterval * 2)
t.update(1)
# Wait for the monitor to get out of sleep's loop and update tqdm..
timeend = timer.time()
while not (t.monitor.woken >= timeend and t.miniters == 1):
timer.sleep(1) # Force monitor to wake up if it woken too soon
sleep(0.000001) # sleep to allow interrupt (instead of pass)
assert t.miniters == 1 # check that monitor corrected miniters
# Note: at this point, there may be a race condition: monitor saved
# current woken time but timer.sleep() happen just before monitor
# sleep. To fix that, either sleep here or increase time in a loop
# to ensure that monitor wakes up at some point.
# Try again but already at miniters = 1 so nothing will be done
timer.sleep(maxinterval * 2)
t.update(2)
timeend = timer.time()
while not (t.monitor.woken >= timeend):
timer.sleep(1) # Force monitor to wake up if it woken too soon
sleep(0.000001)
# Wait for the monitor to get out of sleep's loop and update tqdm..
assert t.miniters == 1 # check that monitor corrected miniters
# Check that class var monitor is deleted if no instance left
tqdm.monitor_interval = 10
assert tqdm.monitor is None
@with_setup(pretest, posttest)
def test_monitoring_multi():
"""Test on multiple bars, one not needing miniters adjustment"""
# Note: should fix miniters for these tests, else with dynamic_miniters
# it's too complicated to handle with monitoring update and maxinterval...
maxinterval = 2
total = 1000
# Setup a discrete timer
timer = DiscreteTimer()
# And a fake sleeper
sleeper = FakeSleep(timer)
# Setup TMonitor to use the timer
TMonitor._time = timer.time
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
# Set monitor interval
tqdm.monitor_interval = maxinterval
with closing(StringIO()) as our_file:
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
maxinterval=maxinterval) as t1:
# Set high maxinterval for t2 so monitor does not need to adjust it
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
maxinterval=1E5) as t2:
cpu_timify(t1, timer)
cpu_timify(t2, timer)
# Do a lot of iterations in a small timeframe
timer.sleep(maxinterval / 2)
t1.update(500)
t2.update(500)
assert t1.miniters == 500
assert t2.miniters == 500
# Then do 1 it after monitor interval, so that monitor kicks in
timer.sleep(maxinterval * 2)
t1.update(1)
t2.update(1)
# Wait for the monitor to get out of sleep and update tqdm
timeend = timer.time()
while not (t1.monitor.woken >= timeend and t1.miniters == 1):
timer.sleep(1)
sleep(0.000001)
assert t1.miniters == 1 # check that monitor corrected miniters
assert t2.miniters == 500 # check that t2 was not adjusted
# Check that class var monitor is deleted if no instance left
tqdm.monitor_interval = 10
assert tqdm.monitor is None

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
import re
def test_version():
"""Test version string"""
from tqdm import __version__
version_parts = re.split('[.-]', __version__)
assert 3 <= len(version_parts) # must have at least Major.minor.patch
try:
map(int, version_parts[:3])
except ValueError:
raise TypeError('Version Major.minor.patch must be 3 integers')

View File

@@ -45,7 +45,7 @@ if PYTHON2:
import common
import database
import datafactory
import helpers
import exporter
import libraries
import logger
import mobile_app
@@ -65,7 +65,7 @@ else:
from plexpy import common
from plexpy import database
from plexpy import datafactory
from plexpy import helpers
from plexpy import exporter
from plexpy import libraries
from plexpy import logger
from plexpy import mobile_app
@@ -226,6 +226,8 @@ def initialize(config_file):
CONFIG.BACKUP_DIR, os.path.join(DATA_DIR, 'backups'), 'backups')
CONFIG.CACHE_DIR, _ = check_folder_writable(
CONFIG.CACHE_DIR, os.path.join(DATA_DIR, 'cache'), 'cache')
CONFIG.EXPORT_DIR, _ = check_folder_writable(
CONFIG.EXPORT_DIR, os.path.join(DATA_DIR, 'exports'), 'exports')
CONFIG.NEWSLETTER_DIR, _ = check_folder_writable(
CONFIG.NEWSLETTER_DIR, os.path.join(DATA_DIR, 'newsletters'), 'newsletters')
@@ -298,7 +300,7 @@ def initialize(config_file):
# Check for new versions
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
try:
versioncheck.check_update()
versioncheck.check_update(use_cache=True)
except:
logger.exception("Unhandled exception")
LATEST_VERSION = CURRENT_VERSION
@@ -332,18 +334,6 @@ def initialize(config_file):
logger.error("Unable to write current release to file '%s': %s" %
(release_file, e))
# Get the real PMS urls for SSL and remote access
if CONFIG.PMS_TOKEN and CONFIG.PMS_IP and CONFIG.PMS_PORT:
plextv.get_server_resources()
# Refresh the users list on startup
if CONFIG.PMS_TOKEN and CONFIG.REFRESH_USERS_ON_STARTUP:
users.refresh_users()
# Refresh the libraries list on startup
if CONFIG.PMS_IP and CONFIG.PMS_TOKEN and CONFIG.REFRESH_LIBRARIES_ON_STARTUP:
libraries.refresh_libraries()
# Store the original umask
UMASK = os.umask(0)
os.umask(UMASK)
@@ -521,6 +511,9 @@ def start():
global _STARTED
if _INITIALIZED:
# Start refreshes on a separate thread
threading.Thread(target=startup_refresh).start()
global SCHED
SCHED = BackgroundScheduler(timezone=pytz.UTC)
activity_handler.ACTIVITY_SCHED = BackgroundScheduler(timezone=pytz.UTC)
@@ -533,8 +526,12 @@ def start():
notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS)
notifiers.check_browser_enabled()
if CONFIG.FIRST_RUN_COMPLETE:
activity_pinger.connect_server(log=True, startup=True)
# Schedule newsletters
newsletter_handler.NEWSLETTER_SCHED.start()
newsletter_handler.schedule_newsletters()
# Cancel processing exports
exporter.cancel_exports()
if CONFIG.SYSTEM_ANALYTICS:
global TRACKER
@@ -549,13 +546,27 @@ def start():
analytics_event(category='system', action='start')
# Schedule newsletters
newsletter_handler.NEWSLETTER_SCHED.start()
newsletter_handler.schedule_newsletters()
_STARTED = True
def startup_refresh():
# Get the real PMS urls for SSL and remote access
if CONFIG.PMS_TOKEN and CONFIG.PMS_IP and CONFIG.PMS_PORT:
plextv.get_server_resources()
# Connect server after server resource is refreshed
if CONFIG.FIRST_RUN_COMPLETE:
activity_pinger.connect_server(log=True, startup=True)
# Refresh the users list on startup
if CONFIG.PMS_TOKEN and CONFIG.REFRESH_USERS_ON_STARTUP:
users.refresh_users()
# Refresh the libraries list on startup
if CONFIG.PMS_IP and CONFIG.PMS_TOKEN and CONFIG.REFRESH_LIBRARIES_ON_STARTUP:
libraries.refresh_libraries()
def sig_handler(signum=None, frame=None):
if signum is not None:
logger.info("Signal %i caught, saving and exiting...", signum)
@@ -773,7 +784,7 @@ def dbcheck():
# image_hash_lookup table :: This table keeps record of the image hash lookups
c_db.execute(
'CREATE TABLE IF NOT EXISTS image_hash_lookup (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'img_hash TEXT, img TEXT, rating_key INTEGER, width INTEGER, height INTEGER, '
'img_hash TEXT UNIQUE, img TEXT, rating_key INTEGER, width INTEGER, height INTEGER, '
'opacity INTEGER, background TEXT, blur INTEGER, fallback TEXT)'
)
@@ -789,6 +800,18 @@ def dbcheck():
'img_hash TEXT, cloudinary_title TEXT, cloudinary_url TEXT)'
)
# exports table :: This table keeps record of the exported files
c_db.execute(
'CREATE TABLE IF NOT EXISTS exports (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'timestamp INTEGER, section_id INTEGER, user_id INTEGER, rating_key INTEGER, media_type TEXT, '
'title TEXT, file_format TEXT, '
'metadata_level INTEGER, media_info_level INTEGER, '
'thumb_level INTEGER DEFAULT 0, art_level INTEGER DEFAULT 0, '
'custom_fields TEXT, individual_files INTEGER DEFAULT 0, '
'file_size INTEGER DEFAULT 0, complete INTEGER DEFAULT 0, '
'exported_items INTEGER DEFAULT 0, total_items INTEGER DEFAULT 0)'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT started FROM sessions')
@@ -2026,7 +2049,7 @@ def dbcheck():
# Update official mobile device flag
for device_id, in c_db.execute('SELECT device_id FROM mobile_devices').fetchall():
c_db.execute('UPDATE mobile_devices SET official = ? WHERE device_id = ?',
[mobile_app.validate_device_id(device_id), device_id])
[mobile_app.validate_onesignal_id(device_id), device_id])
# Upgrade mobile_devices table from earlier versions
try:
@@ -2145,6 +2168,55 @@ def dbcheck():
'UPDATE notifiers SET agent_label = "macOS Notification Center" WHERE agent_label = "OSX Notify"'
)
# Upgrade exports table from earlier versions
try:
c_db.execute('SELECT thumb_level FROM exports')
except sqlite3.OperationalError:
logger.debug("Altering database. Updating database table exports.")
c_db.execute(
'ALTER TABLE exports ADD COLUMN thumb_level INTEGER DEFAULT 0'
)
c_db.execute(
'UPDATE exports SET thumb_level = 9 WHERE include_thumb = 1'
)
c_db.execute(
'ALTER TABLE exports ADD COLUMN art_level INTEGER DEFAULT 0'
)
c_db.execute(
'UPDATE exports SET art_level = 9 WHERE include_art = 1'
)
# Upgrade exports table from earlier versions
try:
c_db.execute('SELECT title FROM exports')
except sqlite3.OperationalError:
logger.debug("Altering database. Updating database table exports.")
c_db.execute(
'ALTER TABLE exports ADD COLUMN title TEXT'
)
c_db.execute(
'ALTER TABLE exports ADD COLUMN individual_files INTEGER DEFAULT 0'
)
# Upgrade exports table from earlier versions
try:
c_db.execute('SELECT total_items FROM exports')
except sqlite3.OperationalError:
logger.debug("Altering database. Updating database table exports.")
c_db.execute(
'ALTER TABLE exports ADD COLUMN exported_items INTEGER DEFAULT 0'
)
c_db.execute(
'ALTER TABLE exports ADD COLUMN total_items INTEGER DEFAULT 0'
)
# Upgrade image_hash_lookup table from earlier versions
try:
c_db.execute('DELETE FROM image_hash_lookup '
'WHERE id NOT IN (SELECT MIN(id) FROM image_hash_lookup GROUP BY img_hash)')
except sqlite3.OperationalError:
pass
# Add "Local" user to database as default unauthenticated user.
result = c_db.execute('SELECT id FROM users WHERE username = "Local"')
if not result.fetchone():
@@ -2355,3 +2427,19 @@ def check_folder_writable(folder, fallback, name):
return folder, False
return folder, True
def get_tautulli_info():
tautulli = {
'tautulli_install_type': INSTALL_TYPE,
'tautulli_version': common.RELEASE,
'tautulli_branch': CONFIG.GIT_BRANCH,
'tautulli_commit': CURRENT_VERSION,
'tautulli_platform':common.PLATFORM,
'tautulli_platform_release': common.PLATFORM_RELEASE,
'tautulli_platform_version': common.PLATFORM_VERSION,
'tautulli_platform_linux_distro': common.PLATFORM_LINUX_DISTRO,
'tautulli_platform_device_name': common.PLATFORM_DEVICE_NAME,
'tautulli_python_version': common.PYTHON_VERSION,
}
return tautulli

View File

@@ -37,6 +37,7 @@ import xmltodict
import plexpy
if plexpy.PYTHON2:
import common
import config
import database
import helpers
@@ -47,8 +48,10 @@ if plexpy.PYTHON2:
import notifiers
import newsletter_handler
import newsletters
import plextv
import users
else:
from plexpy import common
from plexpy import config
from plexpy import database
from plexpy import helpers
@@ -59,6 +62,7 @@ else:
from plexpy import notifiers
from plexpy import newsletter_handler
from plexpy import newsletters
from plexpy import plextv
from plexpy import users
@@ -394,7 +398,8 @@ class API2(object):
return data
def register_device(self, device_id='', device_name='', friendly_name='', onesignal_id=None, **kwargs):
def register_device(self, device_id='', device_name='', friendly_name='', onesignal_id=None,
min_version='', **kwargs):
""" Registers the Tautulli Android App for notifications.
```
@@ -405,11 +410,32 @@ class API2(object):
Optional parameters:
friendly_name (str): A friendly name to identify the mobile device
onesignal_id (str): The OneSignal id for the mobile device
min_version (str): The minimum Tautulli version supported by the mobile device, e.g. v2.5.6
Returns:
json:
{"pms_name": "Winterfell-Server",
"server_id": "ds48g4r354a8v9byrrtr697g3g79w"
{"pms_identifier": "08u2phnlkdshf890bhdlksghnljsahgleikjfg9t",
"pms_ip": "10.10.10.1",
"pms_is_remote": 0,
"pms_name": "Winterfell-Server",
"pms_platform": "Windows",
"pms_plexpass": 1,
"pms_port": 32400,
"pms_ssl": 0,
"pms_url": "http://10.10.10.1:32400",
"pms_url_manual": 0,
"pms_version": "1.20.0.3133-fede5bdc7"
"server_id": "2ce060c87958445d8399a7a0c5663755",
"tautulli_install_type": "git",
"tautulli_branch": "master",
"tautulli_commit": "14b98a32e085d969f010f0249c3d2f660db50880",
"tautulli_platform": "Windows",
"tautulli_platform_device_name": "Winterfell-PC",
"tautulli_platform_linux_distro": "",
"tautulli_platform_release": "10",
"tautulli_platform_version": "10.0.18362",
"tautulli_python_version": "3.8.3"
"tautulli_version": "v2.5.6",
}
```
"""
@@ -423,6 +449,12 @@ class API2(object):
self._api_result_type = 'error'
return
elif min_version and helpers.version_to_tuple(min_version) > helpers.version_to_tuple(common.RELEASE):
self._api_msg = 'Device registration failed: Tautulli version {} ' \
'does not meet the minimum requirement of {}.'.format(common.RELEASE, min_version)
self._api_result_type = 'error'
return
## TODO: Temporary for backwards compatibility, assume device_id is onesignal_id
if device_id and onesignal_id is None:
onesignal_id = device_id
@@ -437,12 +469,14 @@ class API2(object):
self._api_msg = 'Device registration successful.'
self._api_result_type = 'success'
mobile_app.set_temp_device_token(None)
mobile_app.set_temp_device_token(True)
data = {
"pms_name": plexpy.CONFIG.PMS_NAME,
"server_id": plexpy.CONFIG.PMS_UUID
}
plex_server = plextv.get_server_resources(return_info=True)
tautulli = plexpy.get_tautulli_info()
data = {"server_id": plexpy.CONFIG.PMS_UUID}
data.update(plex_server)
data.update(tautulli)
return data
@@ -545,8 +579,7 @@ class API2(object):
def _api_make_md(self):
""" Tries to make a API.md to simplify the api docs. """
head = '''# API Reference\n
## General structure
head = '''## General structure
The API endpoint is
```
http://IP_ADDRESS:PORT + [/HTTP_ROOT] + /api/v2?apikey=$apikey&cmd=$command

View File

@@ -74,6 +74,9 @@ MEDIA_TYPE_HEADERS = {
'artist': 'Artists',
'album': 'Albums',
'track': 'Tracks',
'video': 'Videos',
'audio': 'Tracks',
'photo': 'Photos'
}
PLATFORM_NAME_OVERRIDES = {
@@ -498,7 +501,8 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'},
{'name': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},
{'name': 'Critic Rating', 'type': 'int', 'value': 'critic_rating', 'description': 'The critic rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
{'name': 'Audience Rating', 'type': 'int', 'value': 'audience_rating', 'description': 'The audience rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
{'name': 'Audience Rating', 'type': 'float', 'value': 'audience_rating', 'description': 'The audience rating for the item.', 'help_text': 'Rating out of 10 for IMDB, percentage (%) for Rotten Tomatoes and TMDB.'},
{'name': 'User Rating', 'type': 'float', 'value': 'user_rating', 'description': 'The user (star) rating (out of 10) for the item.'},
{'name': 'Duration', 'type': 'int', 'value': 'duration', 'description': 'The duration (in minutes) for the item.'},
{'name': 'Poster URL', 'type': 'str', 'value': 'poster_url', 'description': 'A URL for the movie, TV show, or album poster.'},
{'name': 'Plex ID', 'type': 'str', 'value': 'plex_id', 'description': 'The Plex ID for the item.', 'example': 'e.g. 5d7769a9594b2b001e6a6b7e'},

View File

@@ -89,6 +89,7 @@ _CONFIG_DEFINITIONS = {
'CHECK_GITHUB': (int, 'General', 1),
'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
'CHECK_GITHUB_ON_STARTUP': (int, 'General', 1),
'CHECK_GITHUB_CACHE_SECONDS': (int, 'Advanced', 3600),
'CLEANUP_FILES': (int, 'General', 0),
'CLOUDINARY_CLOUD_NAME': (str, 'Cloudinary', ''),
'CLOUDINARY_API_KEY': (str, 'Cloudinary', ''),
@@ -96,6 +97,8 @@ _CONFIG_DEFINITIONS = {
'CONFIG_VERSION': (int, 'Advanced', 0),
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
'ENABLE_HTTPS': (int, 'General', 0),
'EXPORT_DIR': (str, 'General', ''),
'EXPORT_THREADS': (int, 'Advanced', 8),
'FIRST_RUN_COMPLETE': (int, 'General', 0),
'FREEZE_DB': (int, 'General', 0),
'GET_FILE_SIZES': (int, 'General', 0),
@@ -195,7 +198,7 @@ _WHITELIST_KEYS = ['HTTPS_KEY']
_DO_NOT_IMPORT_KEYS = [
'FIRST_RUN_COMPLETE', 'GET_FILE_SIZES_HOLD', 'GIT_PATH', 'PMS_LOGS_FOLDER',
'BACKUP_DIR', 'CACHE_DIR', 'LOG_DIR', 'NEWSLETTER_DIR', 'NEWSLETTER_CUSTOM_DIR',
'BACKUP_DIR', 'CACHE_DIR', 'EXPORT_DIR', 'LOG_DIR', 'NEWSLETTER_DIR', 'NEWSLETTER_CUSTOM_DIR',
'HTTP_HOST', 'HTTP_PORT', 'HTTP_ROOT',
'HTTP_USERNAME', 'HTTP_PASSWORD', 'HTTP_HASH_PASSWORD', 'HTTP_HASHED_PASSWORD',
'ENABLE_HTTPS', 'HTTPS_CREATE_CERT', 'HTTPS_CERT', 'HTTPS_CERT_CHAIN', 'HTTPS_KEY'

View File

@@ -216,6 +216,11 @@ def delete_recently_added():
return clear_table('recently_added')
def delete_exports():
logger.info("Tautulli Database :: Clearing exported items from database.")
return clear_table('exports')
def delete_rows_from_table(table, row_ids):
if row_ids and isinstance(row_ids, str):
row_ids = list(map(helpers.cast_to_int, row_ids.split(',')))

View File

@@ -51,15 +51,18 @@ class DataFactory(object):
def __init__(self):
pass
def get_datatables_history(self, kwargs=None, custom_where=None, grouping=None):
def get_datatables_history(self, kwargs=None, custom_where=None, grouping=None, include_activity=None):
data_tables = datatables.DataTables()
if custom_where is None:
custon_where = []
custom_where = []
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
if include_activity is None:
include_activity = plexpy.CONFIG.HISTORY_TABLE_ACTIVITY
if session.get_session_user_id():
session_user_id = str(session.get_session_user_id())
added = False
@@ -125,7 +128,7 @@ class DataFactory(object):
'NULL AS session_key'
]
if plexpy.CONFIG.HISTORY_TABLE_ACTIVITY:
if include_activity:
table_name_union = 'sessions'
# Very hacky way to match the custom where parameters for the unioned table
custom_where_union = [[c[0].split('.')[-1], c[1]] for c in custom_where]
@@ -290,15 +293,18 @@ class DataFactory(object):
'recordsTotal': query['totalCount'],
'data': session.friendly_name_to_username(rows),
'draw': query['draw'],
'filter_duration': helpers.human_duration(filter_duration, sig='dhm'),
'total_duration': helpers.human_duration(total_duration, sig='dhm')
'filter_duration': helpers.human_duration(filter_duration, units='s'),
'total_duration': helpers.human_duration(total_duration, units='s')
}
return dict
def get_home_stats(self, grouping=None, time_range=30, stats_type='plays', stats_count=10, stats_cards=None):
def get_home_stats(self, grouping=None, time_range=30, stats_type='plays',
stats_start=0, stats_count=10, stat_id='', stats_cards=None):
monitor_db = database.MonitorDatabase()
if stat_id:
stats_cards = [stat_id]
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
if stats_cards is None:
@@ -331,7 +337,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.full_title, t.year ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: top_movies: %s." % e)
@@ -383,7 +389,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.full_title, t.year ' \
'ORDER BY users_watched DESC, %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: popular_movies: %s." % e)
@@ -432,7 +438,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.grandparent_title ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: top_tv: %s." % e)
@@ -484,7 +490,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.grandparent_title ' \
'ORDER BY users_watched DESC, %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: popular_tv: %s." % e)
@@ -534,7 +540,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.original_title, t.grandparent_title ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: top_music: %s." % e)
@@ -587,7 +593,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.original_title, t.grandparent_title ' \
'ORDER BY users_watched DESC, %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: popular_music: %s." % e)
@@ -637,7 +643,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.user_id ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: top_users: %s." % e)
@@ -689,7 +695,7 @@ class DataFactory(object):
' GROUP BY %s) AS t ' \
'GROUP BY t.platform ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
'LIMIT %s OFFSET %s ' % (time_range, group_by, sort_type, stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: top_platforms: %s." % e)
@@ -746,7 +752,8 @@ class DataFactory(object):
' OR t.media_type == "episode" AND percent_complete >= %s ' \
'GROUP BY t.id ' \
'ORDER BY last_watch DESC ' \
'LIMIT %s' % (time_range, group_by, movie_watched_percent, tv_watched_percent, stats_count)
'LIMIT %s OFFSET %s' % (time_range, group_by, movie_watched_percent, tv_watched_percent,
stats_count, stats_start)
result = monitor_db.select(query)
except Exception as e:
logger.warn("Tautulli DataFactory :: Unable to execute database query for get_home_stats: last_watched: %s." % e)
@@ -864,6 +871,8 @@ class DataFactory(object):
'stat_title': 'Most Concurrent Streams',
'rows': most_concurrent})
if stat_id and home_stats:
return home_stats[0]
return home_stats
def get_library_stats(self, library_cards=[]):

2399
plexpy/exporter.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
# This file is part of Tautulli.
#
@@ -27,8 +27,9 @@ import cloudinary
from cloudinary.api import delete_resources_by_tag
from cloudinary.uploader import upload
from cloudinary.utils import cloudinary_url
from collections import OrderedDict
import datetime
from functools import wraps
from functools import reduce, wraps
import hashlib
import imghdr
from future.moves.itertools import islice, zip_longest
@@ -38,6 +39,7 @@ import ipwhois.utils
from IPy import IP
import json
import math
import operator
import os
import re
import shlex
@@ -242,30 +244,47 @@ def iso_to_datetime(iso):
return arrow.get(iso).datetime
def human_duration(s, sig='dhms'):
def datetime_to_iso(dt, to_date=False):
if isinstance(dt, datetime.datetime):
if to_date:
dt = dt.date()
return dt.isoformat()
return dt
hd = ''
if str(s).isdigit() and s > 0:
d, h = divmod(s, 86400)
h, m = divmod(h, 3600)
m, s = divmod(m, 60)
def human_duration(ms, sig='dhm', units='ms', return_seconds=300000):
factors = {'d': 86400000,
'h': 3600000,
'm': 60000,
's': 1000,
'ms': 1}
if str(ms).isdigit() and ms > 0:
if return_seconds and ms < return_seconds:
sig = 'dhms'
ms = ms * factors[units]
d, h = divmod(ms, factors['d'])
h, m = divmod(h, factors['h'])
m, s = divmod(m, factors['m'])
s, ms = divmod(s, factors['s'])
hd_list = []
if sig >= 'd' and d > 0:
d = d + 1 if sig == 'd' and h >= 12 else d
hd_list.append(str(d) + ' days')
hd_list.append(str(d) + ' day' + ('s' if d > 1 else ''))
if sig >= 'dh' and h > 0:
h = h + 1 if sig == 'dh' and m >= 30 else h
hd_list.append(str(h) + ' hrs')
hd_list.append(str(h) + ' hr' + ('s' if h > 1 else ''))
if sig >= 'dhm' and m > 0:
m = m + 1 if sig == 'dhm' and s >= 30 else m
hd_list.append(str(m) + ' mins')
hd_list.append(str(m) + ' min' + ('s' if m > 1 else ''))
if sig >= 'dhms' and s > 0:
hd_list.append(str(s) + ' secs')
hd_list.append(str(s) + ' sec' + ('s' if s > 1 else ''))
hd = ' '.join(hd_list)
else:
@@ -382,6 +401,13 @@ def cleanTitle(title):
return title
def clean_filename(filename, replace='_'):
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
return cleaned_filename
def split_path(f):
"""
Split a path into components, starting with the drive letter (if any). Given
@@ -559,6 +585,64 @@ def process_json_kwargs(json_kwargs):
return params
def process_datatable_rows(rows, json_data, default_sort, search_cols=None, sort_keys=None):
if search_cols is None:
search_cols = []
if sort_keys is None:
sort_keys = {}
results = []
total_count = len(rows)
# Search results
search_value = json_data['search']['value'].lower()
if search_value:
searchable_columns = [d['data'] for d in json_data['columns'] if d['searchable']] + search_cols
for row in rows:
for k, v in row.items():
if k in sort_keys:
value = sort_keys[k].get(v, v)
else:
value = v
value = str(value).lower()
if k in searchable_columns and search_value in value:
results.append(row)
break
else:
results = rows
filtered_count = len(results)
# Sort results
results = sorted(results, key=lambda k: k[default_sort].lower())
sort_order = json_data['order']
for order in reversed(sort_order):
sort_key = json_data['columns'][int(order['column'])]['data']
reverse = True if order['dir'] == 'desc' else False
results = sorted(results, key=lambda k: sort_helper(k, sort_key, sort_keys), reverse=reverse)
# Paginate results
results = results[json_data['start']:(json_data['start'] + json_data['length'])]
data = {
'results': results,
'total_count': total_count,
'filtered_count': filtered_count
}
return data
def sort_helper(k, sort_key, sort_keys):
v = k[sort_key]
if sort_key in sort_keys:
v = sort_keys[sort_key].get(k[sort_key], v)
if isinstance(v, str):
v = v.lower()
return v
def sanitize_out(*dargs, **dkwargs):
""" Helper decorator that sanitized the output
"""
@@ -897,7 +981,7 @@ def build_datatables_json(kwargs, dt_columns, default_sort_col=None):
return json.dumps(json_data)
def humanFileSize(bytes, si=True):
def human_file_size(bytes, si=True):
if str(bytes).isdigit():
bytes = cast_to_float(bytes)
else:
@@ -919,7 +1003,7 @@ def humanFileSize(bytes, si=True):
bytes /= thresh
u += 1
return "{0:.1f} {1}".format(bytes, units[u])
return "{0:.2f} {1}".format(bytes, units[u])
def parse_condition_logic_string(s, num_cond=0):
@@ -1153,6 +1237,261 @@ def bool_true(value, return_none=False):
return False
def sort_attrs(attr):
if isinstance(attr, (list, tuple)):
a = attr[0].split('.')
else:
a = attr.split('.')
return len(a), a
def sort_obj(obj):
if isinstance(obj, list):
result_obj = []
for item in obj:
result_obj.append(sort_obj(item))
elif isinstance(obj, dict):
result_start = []
result_end = []
for k, v in obj.items():
if isinstance(v, list):
for item in v:
if isinstance(item, dict):
result_end.append([k, sort_obj(v)])
else:
result_start.append([k, sort_obj(v)])
else:
result_start.append([k, sort_obj(v)])
result_obj = OrderedDict(sorted(result_start) + sorted(result_end))
else:
result_obj = obj
return result_obj
def get_attrs_to_dict(obj, attrs):
d = {}
for attr, sub in attrs.items():
no_attr = False
if isinstance(obj, dict):
value = obj.get(attr, None)
else:
try:
value = getattr(obj, attr)
except AttributeError:
no_attr = True
value = None
if callable(value):
value = value()
if isinstance(sub, str):
if isinstance(value, list):
value = [getattr(o, sub, None) for o in value]
else:
value = getattr(value, sub, None)
elif isinstance(sub, dict):
if isinstance(value, list):
value = [get_attrs_to_dict(o, sub) for o in value]
else:
value = get_attrs_to_dict(value, sub)
elif callable(sub):
if isinstance(value, list):
value = [sub(o) for o in value]
else:
if no_attr:
value = sub(obj)
else:
value = sub(value)
d[attr] = value
return d
def flatten_dict(obj):
return flatten_tree(flatten_keys(obj))
def flatten_keys(obj, key='', sep='.'):
if isinstance(obj, list):
new_obj = [flatten_keys(o, key=key) for o in obj]
elif isinstance(obj, dict):
new_key = key + sep if key else ''
new_obj = {new_key + k: flatten_keys(v, key=new_key + k) for k, v in obj.items()}
else:
new_obj = obj
return new_obj
def flatten_tree(obj, key=''):
if isinstance(obj, list):
new_rows = []
for o in obj:
if isinstance(o, dict):
new_rows.extend(flatten_tree(o))
else:
new_rows.append({key: o})
elif isinstance(obj, dict):
common_keys = {}
all_rows = [[common_keys]]
for k, v in obj.items():
if isinstance(v, list):
all_rows.append(flatten_tree(v, k))
elif isinstance(v, dict):
common_keys.update(*flatten_tree(v))
else:
common_keys[k] = v
new_rows = [{k: v for r in row for k, v in r.items()}
for row in zip_longest(*all_rows, fillvalue={})]
else:
new_rows = []
return new_rows
# https://stackoverflow.com/a/14692747
def get_by_path(root, items):
"""Access a nested object in root by item sequence."""
return reduce(operator.getitem, items, root)
def set_by_path(root, items, value):
"""Set a value in a nested object in root by item sequence."""
get_by_path(root, items[:-1])[items[-1]] = value
def get_dict_value_by_path(root, attr):
split_attr = attr.split('.')
value = get_by_path(root, split_attr)
for _attr in reversed(split_attr):
value = {_attr: value}
return value
# https://stackoverflow.com/a/7205107
def dict_merge(a, b, path=None):
if path is None:
path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
dict_merge(a[key], b[key], path + [str(key)])
elif a[key] == b[key]:
pass
else:
pass
else:
a[key] = b[key]
return a
#https://stackoverflow.com/a/26853961
def dict_update(*dict_args):
"""
Given any number of dictionaries, shallow copy and merge into a new dict,
precedence goes to key value pairs in latter dictionaries.
"""
result = {}
for dictionary in dict_args:
result.update(dictionary)
return result
# https://stackoverflow.com/a/28703510
def escape_xml(value):
if value is None:
return ''
value = str(value) \
.replace("&", "&amp;") \
.replace("<", "&lt;") \
.replace(">", "&gt;") \
.replace('"', "&quot;") \
.replace("'", "&apos;")
return value
# https://gist.github.com/reimund/5435343/
def dict_to_xml(d, root_node=None, indent=None, level=0):
line_break = '' if indent is None else '\n'
wrap = not bool(root_node is None or isinstance(d, list))
root = root_node or 'objects'
root_singular = root[:-1] if root.endswith('s') and isinstance(d, list) else root
xml = ''
children = []
if isinstance(d, dict):
for key, value in sorted(d.items()):
if isinstance(value, dict):
children.append(dict_to_xml(value, key, indent, level + 1))
elif isinstance(value, list):
children.append(dict_to_xml(value, key, indent, level + 1))
else:
xml = '{} {}="{}"'.format(xml, key, escape_xml(value))
elif isinstance(d, list):
for value in d:
# Custom tag replacement for collections/playlists
if isinstance(value, dict) and root in ('children', 'items'):
root_singular = value.get('type', root_singular)
children.append(dict_to_xml(value, root_singular, indent, level))
else:
children.append(escape_xml(d))
end_tag = '>' if len(children) > 0 else '/>'
end_tag += line_break if isinstance(d, list) or isinstance(d, dict) else ''
spaces = ' ' * level * (indent or 0)
if wrap or isinstance(d, dict):
xml = '{}<{}{}{}'.format(spaces, root, xml, end_tag)
if len(children) > 0:
for child in children:
xml = '{}{}'.format(xml, child)
if wrap or isinstance(d, dict):
spaces = spaces if isinstance(d, dict) else ''
xml = '{}{}</{}>{}'.format(xml, spaces, root, line_break)
return xml
def move_to_front(l, value):
try:
l.insert(0, l.pop(l.index(value)))
except (ValueError, IndexError):
pass
return l
def is_hdr(bit_depth, color_space):
bit_depth = cast_to_int(bit_depth)
return bit_depth > 8 and color_space == 'bt2020nc'
def version_to_tuple(version):
return tuple(cast_to_int(v) for v in version.strip('v').split('.'))
# https://stackoverflow.com/a/1855118
def zipdir(path, ziph):
# ziph is zipfile handle
for root, dirs, files in os.walk(path):
for file in files:
ziph.write(os.path.join(root, file),
arcname=os.path.relpath(os.path.join(root, file),
os.path.join(path, '.')))
def page(endpoint, *args, **kwargs):
endpoints = {
'pms_image_proxy': pms_image_proxy,

View File

@@ -33,6 +33,8 @@ if plexpy.PYTHON2:
import plextv
import pmsconnect
import session
import users
from plex import Plex
else:
from plexpy import common
from plexpy import database
@@ -42,6 +44,8 @@ else:
from plexpy import plextv
from plexpy import pmsconnect
from plexpy import session
from plexpy import users
from plexpy.plex import Plex
def refresh_libraries():
@@ -118,7 +122,8 @@ def add_live_tv_library(refresh=False):
if result and not refresh or not result and refresh:
return
logger.info("Tautulli Libraries :: Adding Live TV library to the database.")
if not refresh:
logger.info("Tautulli Libraries :: Adding Live TV library to the database.")
section_keys = {'server_id': plexpy.CONFIG.PMS_IDENTIFIER,
'section_id': common.LIVE_TV_SECTION_ID}
@@ -142,6 +147,163 @@ def has_library_type(section_type):
return bool(result)
def get_collections(section_id=None):
plex = Plex(plexpy.CONFIG.PMS_URL, session.get_session_user_token())
library = plex.get_library(section_id)
if library.type not in ('movie', 'show', 'artist'):
return []
collections = library.collection()
collections_list = []
for collection in collections:
collection_mode = collection.collectionMode
if collection_mode is None:
collection_mode = -1
collection_sort = collection.collectionSort
if collection_sort is None:
collection_sort = 0
collection_dict = {
'addedAt': helpers.datetime_to_iso(collection.addedAt),
'art': collection.art,
'childCount': collection.childCount,
'collectionMode': collection_mode,
'collectionSort': collection_sort,
'contentRating': collection.contentRating,
'guid': collection.guid,
'librarySectionID': collection.librarySectionID,
'librarySectionTitle': collection.librarySectionTitle,
'maxYear': collection.maxYear,
'minYear': collection.minYear,
'ratingKey': collection.ratingKey,
'subtype': collection.subtype,
'summary': collection.summary,
'thumb': collection.thumb,
'title': collection.title,
'titleSort': collection.titleSort or collection.title,
'type': collection.type,
'updatedAt': helpers.datetime_to_iso(collection.updatedAt)
}
collections_list.append(collection_dict)
return collections_list
def get_collections_list(section_id=None, **kwargs):
if not section_id:
default_return = {'recordsFiltered': 0,
'recordsTotal': 0,
'draw': 0,
'data': 'null',
'error': 'Unable to get collections: missing section_id.'}
return default_return
collections = get_collections(section_id=section_id)
# Get datatables JSON data
json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data'])
search_cols = ['title']
sort_keys = {
'collectionMode': {
-1: 'Library Default',
0: 'Hide collection',
1: 'Hide items in this collection',
2: 'Show this collection and its items'
},
'collectionSort': {
0: 'Release date',
1: 'Alphabetical',
2: 'Custom'
}
}
results = helpers.process_datatable_rows(
collections, json_data, default_sort='titleSort',
search_cols=search_cols, sort_keys=sort_keys)
data = {
'recordsFiltered': results['filtered_count'],
'recordsTotal': results['total_count'],
'data': results['results'],
'draw': int(json_data['draw'])
}
return data
def get_playlists(section_id=None, user_id=None):
if user_id and not session.get_session_user_id():
user_tokens = users.Users().get_tokens(user_id=user_id)
plex_token = user_tokens['server_token']
else:
plex_token = session.get_session_user_token()
if not plex_token:
return []
plex = Plex(plexpy.CONFIG.PMS_URL, plex_token)
if user_id:
playlists = plex.plex.playlists()
else:
library = plex.get_library(section_id)
playlists = library.playlist()
playlists_list = []
for playlist in playlists:
playlist_dict = {
'addedAt': helpers.datetime_to_iso(playlist.addedAt),
'composite': playlist.composite,
'duration': playlist.duration,
'guid': playlist.guid,
'leafCount': playlist.leafCount,
'librarySectionID': section_id,
'playlistType': playlist.playlistType,
'ratingKey': playlist.ratingKey,
'smart': playlist.smart,
'summary': playlist.summary,
'title': playlist.title,
'type': playlist.type,
'updatedAt': helpers.datetime_to_iso(playlist.updatedAt),
'userID': user_id
}
playlists_list.append(playlist_dict)
return playlists_list
def get_playlists_list(section_id=None, user_id=None, **kwargs):
if not section_id and not user_id:
default_return = {'recordsFiltered': 0,
'recordsTotal': 0,
'draw': 0,
'data': 'null',
'error': 'Unable to get playlists: missing section_id.'}
return default_return
playlists = get_playlists(section_id=section_id, user_id=user_id)
# Get datatables JSON data
json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data'])
results = helpers.process_datatable_rows(
playlists, json_data, default_sort='title')
data = {
'recordsFiltered': results['filtered_count'],
'recordsTotal': results['total_count'],
'data': results['results'],
'draw': int(json_data['draw'])
}
return data
class Libraries(object):
def __init__(self):

View File

@@ -21,6 +21,7 @@ from future.builtins import str
from logutils.queue import QueueHandler, QueueListener
from logging import handlers
import cherrypy
import contextlib
import errno
import logging
@@ -78,6 +79,8 @@ class NoThreadFilter(logging.Filter):
Log filter for the current thread
"""
def __init__(self, threadName):
super(NoThreadFilter, self).__init__()
self.threadName = threadName
def filter(self, record):
@@ -263,7 +266,11 @@ def initLogger(console=False, log_dir=False, verbose=False):
# Close and remove old handlers. This is required to reinit the loggers
# at runtime
for handler in logger.handlers[:] + logger_api.handlers[:] + logger_plex_websocket.handlers[:]:
log_handlers = logger.handlers[:] + \
logger_api.handlers[:] + \
logger_plex_websocket.handlers[:] + \
cherrypy.log.error_log.handlers[:]
for handler in log_handlers:
# Just make sure it is cleaned up.
if isinstance(handler, handlers.RotatingFileHandler):
handler.close()
@@ -273,6 +280,7 @@ def initLogger(console=False, log_dir=False, verbose=False):
logger.removeHandler(handler)
logger_api.removeHandler(handler)
logger_plex_websocket.removeHandler(handler)
cherrypy.log.error_log.removeHandler(handler)
# Configure the logger to accept all messages
logger.propagate = False
@@ -281,6 +289,7 @@ def initLogger(console=False, log_dir=False, verbose=False):
logger_api.setLevel(logging.DEBUG if verbose else logging.INFO)
logger_plex_websocket.propagate = False
logger_plex_websocket.setLevel(logging.DEBUG if verbose else logging.INFO)
cherrypy.log.error_log.propagate = False
# Setup file logger
if log_dir:
@@ -293,6 +302,7 @@ def initLogger(console=False, log_dir=False, verbose=False):
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
cherrypy.log.error_log.addHandler(file_handler)
# Tautulli API logger
filename = os.path.join(log_dir, FILENAME_API)
@@ -318,12 +328,17 @@ def initLogger(console=False, log_dir=False, verbose=False):
console_handler.setLevel(logging.DEBUG)
logger.addHandler(console_handler)
cherrypy.log.error_log.addHandler(console_handler)
# Add filters to log handlers
# Only add filters after the config file has been initialized
# Nothing prior to initialization should contain sensitive information
if not plexpy.DEV and plexpy.CONFIG:
for handler in logger.handlers + logger_api.handlers + logger_plex_websocket.handlers:
log_handlers = logger.handlers + \
logger_api.handlers + \
logger_plex_websocket.handlers + \
cherrypy.log.error_log.handlers
for handler in log_handlers:
handler.addFilter(BlacklistFilter())
handler.addFilter(PublicIPFilter())
handler.addFilter(EmailFilter())

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