Compare commits

..

28 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
59 changed files with 504 additions and 5086 deletions

View File

@@ -49,13 +49,21 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-buildx- ${{ runner.os }}-buildx-
- name: Docker Login - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
if: success() if: success()
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
if: success()
with:
registry: ghcr.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Docker Build and Push - name: Docker Build and Push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
if: success() if: success()
@@ -65,19 +73,16 @@ jobs:
push: true push: true
platforms: ${{ steps.prepare.outputs.docker_platforms }} platforms: ${{ steps.prepare.outputs.docker_platforms }}
build-args: | build-args: |
TAG=${{ steps.prepare.outputs.tag }}, TAG=${{ steps.prepare.outputs.tag }}
BRANCH=${{ steps.prepare.outputs.branch }}, BRANCH=${{ steps.prepare.outputs.branch }}
COMMIT=${{ steps.prepare.outputs.commit }}, COMMIT=${{ steps.prepare.outputs.commit }}
BUILD_DATE=${{ steps.prepare.outputs.build_date }} BUILD_DATE=${{ steps.prepare.outputs.build_date }}
tags: ${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }} 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-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
- name: Clear
if: always()
run: |
rm -f ${HOME}/.docker/config.json
- name: Post Status to Discord - name: Post Status to Discord
uses: sarisia/actions-status-discord@v1 uses: sarisia/actions-status-discord@v1
if: always() if: always()

View File

@@ -1,24 +1,38 @@
# Changelog # Changelog
## v2.6.0-beta (2020-10-16) ## 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: * 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. * 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: * UI:
* Fix: Margin on the homepage activity and statistic/library cards. (Thanks @dotsam) * 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. * New: Added ability to browse collections and playlists from the library and user pages.
* Change: Updated platform brand logos and colours.
* API: * API:
* New: Added export_metadata, download_export, and delete_export API commands. * New: Added export_metadata, download_export, and delete_export API commands.
* New: Added get_collections_table, and get_playlists_table 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 min_version parameter to the register_device API command.
* New: Added include_activity parameter to the get_history 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: 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. * 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. * Change: Return Plex server info and Tautulli info from the register_device command.
* Other: * Other:
* New: The Docker container is now also built for the arm32v6 architecture. * 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: 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: Updated systemd script to remove process forking. (Thanks @MichaIng)
* Change: Cache GitHub update check on startup.
## v2.5.6 (2020-10-02) ## v2.5.6 (2020-10-02)

View File

@@ -11,11 +11,14 @@ ENV TZ=UTC
WORKDIR /app WORKDIR /app
RUN \ RUN \
groupadd -g 1000 tautulli && \
useradd -u 1000 -g 1000 tautulli && \
echo ${BRANCH} > /app/branch.txt && \ echo ${BRANCH} > /app/branch.txt && \
echo ${COMMIT} > /app/version.txt echo ${COMMIT} > /app/version.txt
COPY . /app COPY . /app
CMD [ "python", "Tautulli.py", "--datadir", "/config" ]
ENTRYPOINT [ "./start.sh" ] ENTRYPOINT [ "./start.sh" ]
VOLUME /config VOLUME /config

View File

@@ -24,21 +24,21 @@
${next.headIncludes()} ${next.headIncludes()}
<!-- Favicons --> <!-- 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="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.0.5"> <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.0.5"> <link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS --> <!-- ICONS -->
<!-- Android --> <!-- 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"> <meta name="theme-color" content="#282a2d">
<!-- Apple --> <!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5"> <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.0.5" color="#282a2d"> <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"> <meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft --> <!-- Microsoft -->
<meta name="application-name" content="Tautulli"> <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> </head>
<body class="content"> <body class="content">

View File

@@ -750,7 +750,9 @@ a .users-poster-face:hover {
position: relative; position: relative;
} }
#dashboard-checking-activity, #dashboard-checking-activity,
#dashboard-no-activity { #dashboard-no-activity,
#dashboard-checking-recently-added,
#dashboard-no-recently-added {
margin-bottom: 20px; margin-bottom: 20px;
} }
.dashboard-activity-instance { .dashboard-activity-instance {
@@ -1446,9 +1448,6 @@ a:hover .dashboard-stats-square {
-moz-box-shadow: inset 0 0 0 2px #e9a049; -moz-box-shadow: inset 0 0 0 2px #e9a049;
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 { .dashboard-recent-media-row {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@@ -3850,19 +3849,19 @@ a:hover .overlay-refresh-image:hover {
background-position: center !important; background-position: center !important;
} }
.platform-android { .platform-android {
background-color: #a4ca39; background-color: #3ddc84;
background-image: url(../images/platforms/android.svg); background-image: url(../images/platforms/android.svg);
} }
.platform-atv { .platform-atv {
background-color: #858487; background-color: #a2aaad;
background-image: url(../images/platforms/atv.svg); background-image: url(../images/platforms/atv.svg);
} }
.platform-chrome { .platform-chrome {
background-color: #ed5e50; background-color: #db4437;
background-image: url(../images/platforms/chrome.svg); background-image: url(../images/platforms/chrome.svg);
} }
.platform-chromecast { .platform-chromecast {
background-color: #10a4e8; background-color: #4285f4;
background-image: url(../images/platforms/chromecast.svg); background-image: url(../images/platforms/chromecast.svg);
} }
.platform-default { .platform-default {
@@ -3870,11 +3869,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/default.svg); background-image: url(../images/platforms/default.svg);
} }
.platform-dlna { .platform-dlna {
background-color: #0cb14b; background-color: #4ba32f;
background-image: url(../images/platforms/dlna.svg); background-image: url(../images/platforms/dlna.svg);
} }
.platform-firefox { .platform-firefox {
background-color: #e67817; background-color: #ff7139;
background-image: url(../images/platforms/firefox.svg); background-image: url(../images/platforms/firefox.svg);
} }
.platform-gtv { .platform-gtv {
@@ -3882,27 +3881,27 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/gtv.svg); background-image: url(../images/platforms/gtv.svg);
} }
.platform-ie { .platform-ie {
background-color: #00599e; background-color: #18bcef;
background-image: url(../images/platforms/ie.svg); background-image: url(../images/platforms/ie.svg);
} }
.platform-ios { .platform-ios {
background-color: #858487; background-color: #a2aaad;
background-image: url(../images/platforms/ios.svg); background-image: url(../images/platforms/ios.svg);
} }
.platform-kodi { .platform-kodi {
background-color: #31afe1; background-color: #30aada;
background-image: url(../images/platforms/kodi.svg); background-image: url(../images/platforms/kodi.svg);
} }
.platform-lg { .platform-lg {
background-color: #a50034; background-color: #990033;
background-image: url(../images/platforms/lg.svg); background-image: url(../images/platforms/lg.svg);
} }
.platform-linux { .platform-linux {
background-color: #1793d0; background-color: #0099cc;
background-image: url(../images/platforms/linux.svg); background-image: url(../images/platforms/linux.svg);
} }
.platform-macos { .platform-macos {
background-color: #858487; background-color: #a2aaad;
background-image: url(../images/platforms/macos.svg); background-image: url(../images/platforms/macos.svg);
} }
.platform-msedge { .platform-msedge {
@@ -3910,11 +3909,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/msedge.svg); background-image: url(../images/platforms/msedge.svg);
} }
.platform-opera { .platform-opera {
background-color: #ff1b2d; background-color: #fa1e4e;
background-image: url(../images/platforms/opera.svg); background-image: url(../images/platforms/opera.svg);
} }
.platform-playstation { .platform-playstation {
background-color: #034da2; background-color: #003087;
background-image: url(../images/platforms/playstation.svg); background-image: url(../images/platforms/playstation.svg);
} }
.platform-plex { .platform-plex {
@@ -3926,11 +3925,11 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/plexamp.svg); background-image: url(../images/platforms/plexamp.svg);
} }
.platform-roku { .platform-roku {
background-color: #6d3c97; background-color: #673293;
background-image: url(../images/platforms/roku.svg); background-image: url(../images/platforms/roku.svg);
} }
.platform-safari { .platform-safari {
background-color: #00a9ec; background-color: #00d3f9;
background-image: url(../images/platforms/safari.svg); background-image: url(../images/platforms/safari.svg);
} }
.platform-samsung { .platform-samsung {
@@ -3950,7 +3949,7 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/wiiu.svg); background-image: url(../images/platforms/wiiu.svg);
} }
.platform-windows { .platform-windows {
background-color: #2fc0f5; background-color: #0078d7;
background-image: url(../images/platforms/windows.svg); background-image: url(../images/platforms/windows.svg);
} }
.platform-wp { .platform-wp {
@@ -3966,55 +3965,55 @@ a:hover .overlay-refresh-image:hover {
background-image: url(../images/platforms/xbox.svg); background-image: url(../images/platforms/xbox.svg);
} }
.platform-android-rgba { .platform-android-rgba {
background-color: rgba(164, 202, 57, 0.40); background-color: rgba(61, 220, 132, 0.40);
} }
.platform-atv-rgba { .platform-atv-rgba {
background-color: rgba(133, 132, 135, 0.40); background-color: rgba(162, 170, 173, 0.40);
} }
.platform-chrome-rgba { .platform-chrome-rgba {
background-color: rgba(237, 94, 80, 0.40); background-color: rgba(219, 68, 55, 0.40);
} }
.platform-chromecast-rgba { .platform-chromecast-rgba {
background-color: rgba(16, 164, 232, 0.40); background-color: rgba(66, 133, 244, 0.40);
} }
.platform-default-rgba { .platform-default-rgba {
background-color: rgba(229, 160, 13, 0.40); background-color: rgba(229, 160, 13, 0.40);
} }
.platform-dlna-rgba { .platform-dlna-rgba {
background-color: rgba(12, 177, 75, 0.40); background-color: rgba(75, 163, 47, 0.40);
} }
.platform-firefox-rgba { .platform-firefox-rgba {
background-color: rgba(230, 120, 23, 0.40); background-color: rgba(255, 113, 57, 0.40);
} }
.platform-gtv-rgba { .platform-gtv-rgba {
background-color: rgba(0, 139, 207, 0.40); background-color: rgba(0, 139, 207, 0.40);
} }
.platform-ie-rgba { .platform-ie-rgba {
background-color: rgba(0, 89, 158, 0.40); background-color: rgba(24, 188, 239, 0.40);
} }
.platform-ios-rgba { .platform-ios-rgba {
background-color: rgba(133, 132, 135, 0.40); background-color: rgba(162, 170, 173, 0.40);
} }
.platform-kodi-rgba { .platform-kodi-rgba {
background-color: rgba(49, 175, 225, 0.40); background-color: rgba(48, 170, 218, 0.40);
} }
.platform-lg-rgba { .platform-lg-rgba {
background-color: rgba(165, 0, 52, 0.40); background-color: rgba(153, 0, 51, 0.40);
} }
.platform-linux-rgba { .platform-linux-rgba {
background-color: rgba(23, 147, 208, 0.40); background-color: rgba(0, 153, 204, 0.40);
} }
.platform-macos-rgba { .platform-macos-rgba {
background-color: rgba(133, 132, 135, 0.40); background-color: rgba(162, 170, 173, 0.40);
} }
.platform-msedge-rgba { .platform-msedge-rgba {
background-color: rgba(0, 120, 215, 0.40); background-color: rgba(0, 120, 215, 0.40);
} }
.platform-opera-rgba { .platform-opera-rgba {
background-color: rgba(255, 27, 45, 0.40); background-color: rgba(250, 30, 78, 0.40);
} }
.platform-playstation-rgba { .platform-playstation-rgba {
background-color: rgba(3, 77, 162, 0.40); background-color: rgba(0, 48, 135, 0.40);
} }
.platform-plex-rgba { .platform-plex-rgba {
background-color: rgba(229, 160, 13, 0.40); background-color: rgba(229, 160, 13, 0.40);
@@ -4023,10 +4022,10 @@ a:hover .overlay-refresh-image:hover {
background-color: rgba(229, 160, 13, 0.40); background-color: rgba(229, 160, 13, 0.40);
} }
.platform-roku-rgba { .platform-roku-rgba {
background-color: rgba(109, 60, 151, 0.40); background-color: rgba(103, 50, 147, 0.40);
} }
.platform-safari-rgba { .platform-safari-rgba {
background-color: rgba(0, 169, 236, 0.40); background-color: rgba(0, 211, 249, 0.40);
} }
.platform-samsung-rgba { .platform-samsung-rgba {
background-color: rgba(3, 78, 162, 0.40); background-color: rgba(3, 78, 162, 0.40);
@@ -4041,7 +4040,7 @@ a:hover .overlay-refresh-image:hover {
background-color: rgba(3, 169, 244, 0.40); background-color: rgba(3, 169, 244, 0.40);
} }
.platform-windows-rgba { .platform-windows-rgba {
background-color: rgba(47, 192, 245, 0.40); background-color: rgba(0, 120, 215, 0.40);
} }
.platform-wp-rgba { .platform-wp-rgba {
background-color: rgba(104, 33, 122, 0.40); background-color: rgba(104, 33, 122, 0.40);

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> <browserconfig>
<msapplication> <msapplication>
<tile> <tile>
<square150x150logo src="mstile-150x150.png?v=2.0.5"/> <square150x150logo src="mstile-150x150.png?v=2.6.0"/>
<TileColor>#282a2d</TileColor> <TileColor>#282a2d</TileColor>
</tile> </tile>
</msapplication> </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": "../../", "scope": "../../",
"icons": [ "icons": [
{ {
"src": "android-chrome-192x192.png?v=2.0.5", "src": "android-chrome-192x192.png?v=2.6.0",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "android-chrome-256x256.png?v=2.0.5", "src": "android-chrome-256x256.png?v=2.6.0",
"sizes": "256x256", "sizes": "256x256",
"type": "image/png" "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 --> <!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<title>android</title> <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="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>
<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>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -24,17 +24,13 @@
</div> </div>
<div id="currentActivity"> <div id="currentActivity">
% if PLEX_SERVER_UP: % if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div> <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']: % elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div> <div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div>
% elif not config['first_run_complete']: % 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> <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: % else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server. <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>
% if _session['user_group'] == 'admin':
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
% endif
</div>
% endif % endif
</div> </div>
</div> </div>
@@ -65,7 +61,7 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div id="home-stats" class="home-platforms"> <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> <br>
</div> </div>
</div> </div>
@@ -84,7 +80,7 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div id="library-stats" class="library-platforms"> <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> <br>
</div> </div>
</div> </div>
@@ -132,18 +128,13 @@
<div class="col-md-12"> <div class="col-md-12">
<div id="recentlyAdded" style="margin-right: -15px;"> <div id="recentlyAdded" style="margin-right: -15px;">
% if PLEX_SERVER_UP: % 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']: % elif config['pms_is_cloud']:
<div class="text-muted">Plex Cloud server is sleeping.</div> <div class="text-muted">Plex Cloud server is sleeping.</div>
% else: % else:
<div class="text-muted">There was an error communicating with your Plex Server. <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>
% 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 % endif
</div> </div>
% endif
<br>
</div>
</div> </div>
</div> </div>
% endif % endif
@@ -229,7 +220,6 @@
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<% from plexpy import PLEX_SERVER_UP %>
<script src="${http_root}js/jquery.scrollbar.min.js"></script> <script src="${http_root}js/jquery.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script> <script src="${http_root}js/jquery.mousewheel.min.js"></script>
<script> <script>
@@ -259,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> </script>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP: % if 'current_activity' in config['home_sections']:
<script> <script>
var defaultHandler = { var defaultHandler = {
get: function(target, name) { get: function(target, name) {
@@ -297,13 +312,8 @@
} }
if (!(current_activity)) { 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(); $('#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 return
} }
@@ -548,7 +558,7 @@
} }
// Update the progress bars, percent - 3 because of 3px padding-right // 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 + '%'); .attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
if (s.live !== 1) { if (s.live !== 1) {
var progress_bar = $('#progress-bar-' + key); var progress_bar = $('#progress-bar-' + key);
@@ -625,6 +635,7 @@
}); });
} }
function activityConnected() {
getCurrentActivity(); getCurrentActivity();
setInterval(function () { setInterval(function () {
if (!(create_instances.length) && activity_ready) { if (!(create_instances.length) && activity_ready) {
@@ -647,12 +658,13 @@
var stream_duration = parseInt($(this).data('stream_duration')); var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.floor(view_offset / stream_duration * 100); var progress_percent = Math.floor(view_offset / stream_duration * 100);
progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100; progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100;
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%') $(this).css({width: progress_percent - 3 + '%'}).html(progress_percent + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%') .attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration)); .data('view_offset', Math.min(view_offset + 1000, stream_duration));
} }
}); });
}, 1000); }, 1000);
}
$('#currentActivity').on('click', '.external_ip-modal', function () { $('#currentActivity').on('click', '.external_ip-modal', function () {
$.get('get_ip_address_details', { $.get('get_ip_address_details', {
@@ -876,7 +888,7 @@
getLibraryStats(); getLibraryStats();
</script> </script>
% endif % endif
% if 'recently_added' in config['home_sections'] and PLEX_SERVER_UP: % if 'recently_added' in config['home_sections']:
<script> <script>
function recentlyAdded(recently_added_count, recently_added_type) { function recentlyAdded(recently_added_count, recently_added_type) {
showMsg("Loading recently added items...", true, false, 0); showMsg("Loading recently added items...", true, false, 0);
@@ -904,7 +916,9 @@
$('#recently-added-toggle-' + recently_added_type).closest('label').addClass('active'); $('#recently-added-toggle-' + recently_added_type).closest('label').addClass('active');
$('#recently-added-count').val(recently_added_count); $('#recently-added-count').val(recently_added_count);
function recentlyAddedConnected() {
recentlyAdded(recently_added_count, recently_added_type); recentlyAdded(recently_added_count, recently_added_type);
}
function highlightAddedScrollerButton() { function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller"); var scroller = $("#recently-added-row-scroller");

View File

@@ -303,16 +303,17 @@ DOCUMENTATION :: END
</div> </div>
<div class="summary-content"> <div class="summary-content">
<div class="summary-content-details-wrapper"> <div class="summary-content-details-wrapper">
% if data['rating']: <% rating = data['rating'] or data['audience_rating'] %>
% if data['rating_image']: % if rating:
% if data['rating_image'].startswith('imdb://'): % if data['audience_rating_image']:
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}"> % if data['audience_rating_image'].startswith('imdb://'):
<span class="rating-image rating-imdb"><strong>${data['rating']}</strong></span> <div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<span class="rating-image rating-imdb"><strong>${rating}</strong></span>
</div> </div>
% endif % endif
% if data['rating_image'].startswith('themoviedb://'): % if data['audience_rating_image'].startswith('themoviedb://'):
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}"> <div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<span class="rating-image rating-themoviedb"><strong>${get_percent(data['rating'], 10)}%</strong></span> <span class="rating-image rating-themoviedb"><strong>${get_percent(rating, 10)}%</strong></span>
</div> </div>
% endif % endif
% if data['audience_rating_image'].startswith('rottentomatoes://'): % if data['audience_rating_image'].startswith('rottentomatoes://'):
@@ -326,8 +327,8 @@ DOCUMENTATION :: END
</div> </div>
% endif % endif
% else: % else:
<div class="critic-rating hidden-xs hidden-sm" title="${data['rating']}"> <div class="critic-rating hidden-xs hidden-sm" title="${rating}">
<i class="star-icon fa fa-star"></i> <strong>${get_percent(data['rating'], 10)}%</strong> <i class="star-icon fa fa-star"></i> <strong>${get_percent(rating, 10)}%</strong>
</div> </div>
% endif % endif
% endif % endif

View File

@@ -18,21 +18,21 @@
<link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet">
<!-- Favicons --> <!-- 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="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.0.5"> <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.0.5"> <link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS --> <!-- ICONS -->
<!-- Android --> <!-- 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"> <meta name="theme-color" content="#282a2d">
<!-- Apple --> <!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5"> <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.0.5" color="#282a2d"> <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"> <meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft --> <!-- Microsoft -->
<meta name="application-name" content="Tautulli"> <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> </head>
<body style="margin: 0; overflow: auto;"> <body style="margin: 0; overflow: auto;">

View File

@@ -8,9 +8,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Tautulli - ${title} | ${server_name}</title> <title>Tautulli - ${title} | ${server_name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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="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.0.5"> <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.0.5"> <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"> <link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<style> <style>
* { * {

View File

@@ -21,21 +21,21 @@
<link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet">
<!-- Favicons --> <!-- 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="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.0.5"> <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.0.5"> <link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.6.0">
<!-- ICONS --> <!-- ICONS -->
<!-- Android --> <!-- 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"> <meta name="theme-color" content="#282a2d">
<!-- Apple --> <!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5"> <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.0.5" color="#282a2d"> <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"> <meta name="apple-mobile-web-app-title" content="Tautulli">
<!-- Microsoft --> <!-- Microsoft -->
<meta name="application-name" content="Tautulli"> <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> </head>
<body> <body>

View File

@@ -521,7 +521,7 @@
line-height: 100%; line-height: 100%;
} }
.apple-link a { a[x-apple-data-detectors] {
color: inherit !important; color: inherit !important;
font-family: inherit !important; font-family: inherit !important;
font-size: inherit !important; font-size: inherit !important;

View File

@@ -521,7 +521,7 @@
line-height: 100%; line-height: 100%;
} }
.apple-link a { a[x-apple-data-detectors] {
color: inherit !important; color: inherit !important;
font-family: inherit !important; font-family: inherit !important;
font-size: inherit !important; font-size: inherit !important;

View File

@@ -231,7 +231,7 @@ class Album(Audio):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.key = self.key.replace('/children', '') # fixes bug #50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid') self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')

View File

@@ -334,7 +334,7 @@ class PlexPartialObject(PlexObject):
search result for a movie often only contain a portion of the attributes a full search result for a movie often only contain a portion of the attributes a full
object (main url) for that movie contain. 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): def isPartialObject(self):
""" Returns True if this is not a full object. """ """ Returns True if this is not a full object. """

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media
from plexapi.base import PlexObject from plexapi.base import PlexObject, PlexPartialObject
from plexapi.compat import quote, quote_plus, unquote, urlencode from plexapi.compat import quote, quote_plus, unquote, urlencode
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.media import MediaTag from plexapi.media import MediaTag
@@ -765,10 +765,17 @@ class MovieSection(LibrarySection):
METADATA_TYPE = 'movie' METADATA_TYPE = 'movie'
CONTENT_TYPE = 'video' 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): def collection(self, **kwargs):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section.
key = '/library/sections/%s/collections' % self.key See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
return self.fetchItems(key, **kwargs) """
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs): def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """ """ Returns a list of playlists from this library section. """
@@ -851,10 +858,17 @@ class ShowSection(LibrarySection):
""" """
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults) 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): def collection(self, **kwargs):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section.
key = '/library/sections/%s/collections' % self.key See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
return self.fetchItems(key, **kwargs) """
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs): def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """ """ Returns a list of playlists from this library section. """
@@ -938,10 +952,17 @@ class MusicSection(LibrarySection):
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ """ Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='track', **kwargs) 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): def collection(self, **kwargs):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section.
key = '/library/sections/%s/collections' % self.key See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting.
return self.fetchItems(key, **kwargs) """
return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs): def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """ """ Returns a list of playlists from this library section. """
@@ -1009,6 +1030,12 @@ class PhotoSection(LibrarySection):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='photo', title=title, **kwargs) 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): def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """ """ Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key) key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
@@ -1106,7 +1133,35 @@ class Hub(PlexObject):
@utils.registerPlexObject @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' TAG = 'Directory'
TYPE = 'collection' TYPE = 'collection'
@@ -1114,30 +1169,30 @@ class Collections(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) 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').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.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.contentRating = data.attrib.get('contentRating')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key') self.index = utils.cast(int, data.attrib.get('index'))
self.labels = self.findItems(data, media.Label)
self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.type = data.attrib.get('type') self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.title = data.attrib.get('title') self.minYear = utils.cast(int, data.attrib.get('minYear'))
self.titleSort = data.attrib.get('titleSort')
self.subtype = data.attrib.get('subtype') self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.index = utils.cast(int, data.attrib.get('index'))
self.thumb = data.attrib.get('thumb') 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.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 = utils.cast(int, data.attrib.get('collectionMode'))
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort'))
self.labels = self.findItems(data, media.Label)
self.fields = self.findItems(data, media.Field)
@property @property
def children(self): def children(self):
@@ -1162,14 +1217,12 @@ class Collections(PlexObject):
def modeUpdate(self, mode=None): def modeUpdate(self, mode=None):
""" Update Collection Mode """ Update Collection Mode
Parameters: Parameters:
mode: default (Library default) mode: default (Library default)
hide (Hide Collection) hide (Hide Collection)
hideItems (Hide Items in this Collection) hideItems (Hide Items in this Collection)
showItems (Show this Collection and its Items) showItems (Show this Collection and its Items)
Example: Example:
collection = 'plexapi.library.Collections' collection = 'plexapi.library.Collections'
collection.updateMode(mode="hide") collection.updateMode(mode="hide")
""" """
@@ -1185,13 +1238,10 @@ class Collections(PlexObject):
def sortUpdate(self, sort=None): def sortUpdate(self, sort=None):
""" Update Collection Sorting """ Update Collection Sorting
Parameters: Parameters:
sort: realease (Order Collection by realease dates) sort: realease (Order Collection by realease dates)
alpha (Order Collection Alphabetically) alpha (Order Collection Alphabetically)
Example: Example:
colleciton = 'plexapi.library.Collections' colleciton = 'plexapi.library.Collections'
collection.updateSort(mode="alpha") collection.updateSort(mode="alpha")
""" """

View File

@@ -821,6 +821,27 @@ class Chapter(PlexObject):
self.end = cast(int, data.attrib.get('endTimeOffset')) 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 @utils.registerPlexObject
class Field(PlexObject): class Field(PlexObject):
""" Represents a single Field. """ Represents a single Field.

View File

@@ -38,7 +38,7 @@ class Photoalbum(PlexPartialObject):
self.composite = data.attrib.get('composite') self.composite = data.attrib.get('composite')
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.librarySectionTitle = data.attrib.get('librarySectionTitle')

View File

@@ -402,7 +402,7 @@ class Show(Video):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Video._loadData(self, data) Video._loadData(self, data)
# fix key if loaded from search # 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.art = data.attrib.get('art')
self.banner = data.attrib.get('banner') self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount')) self.childCount = utils.cast(int, data.attrib.get('childCount'))
@@ -699,6 +699,7 @@ class Episode(Playable, Video):
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
self.collections = self.findItems(data, media.Collection) self.collections = self.findItems(data, media.Collection)
self.chapters = self.findItems(data, media.Chapter) self.chapters = self.findItems(data, media.Chapter)
self.markers = self.findItems(data, media.Marker)
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<%s>' % ':'.join([p for p in [
@@ -730,6 +731,13 @@ class Episode(Playable, Video):
""" Returns the s00e00 string containing the season and episode. """ """ Returns the s00e00 string containing the season and episode. """
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2)) 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): def season(self):
"""" Return this episodes :func:`~plexapi.video.Season`.. """ """" Return this episodes :func:`~plexapi.video.Season`.. """
return self.fetchItem(self.parentKey) return self.fetchItem(self.parentKey)

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

@@ -300,7 +300,7 @@ def initialize(config_file):
# Check for new versions # Check for new versions
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB: if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
try: try:
versioncheck.check_update() versioncheck.check_update(use_cache=True)
except: except:
logger.exception("Unhandled exception") logger.exception("Unhandled exception")
LATEST_VERSION = CURRENT_VERSION LATEST_VERSION = CURRENT_VERSION
@@ -334,18 +334,6 @@ def initialize(config_file):
logger.error("Unable to write current release to file '%s': %s" % logger.error("Unable to write current release to file '%s': %s" %
(release_file, e)) (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 # Store the original umask
UMASK = os.umask(0) UMASK = os.umask(0)
os.umask(UMASK) os.umask(UMASK)
@@ -523,6 +511,9 @@ def start():
global _STARTED global _STARTED
if _INITIALIZED: if _INITIALIZED:
# Start refreshes on a separate thread
threading.Thread(target=startup_refresh).start()
global SCHED global SCHED
SCHED = BackgroundScheduler(timezone=pytz.UTC) SCHED = BackgroundScheduler(timezone=pytz.UTC)
activity_handler.ACTIVITY_SCHED = BackgroundScheduler(timezone=pytz.UTC) activity_handler.ACTIVITY_SCHED = BackgroundScheduler(timezone=pytz.UTC)
@@ -535,12 +526,13 @@ def start():
notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS) notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS)
notifiers.check_browser_enabled() notifiers.check_browser_enabled()
# Schedule newsletters
newsletter_handler.NEWSLETTER_SCHED.start()
newsletter_handler.schedule_newsletters()
# Cancel processing exports # Cancel processing exports
exporter.cancel_exports() exporter.cancel_exports()
if CONFIG.FIRST_RUN_COMPLETE:
activity_pinger.connect_server(log=True, startup=True)
if CONFIG.SYSTEM_ANALYTICS: if CONFIG.SYSTEM_ANALYTICS:
global TRACKER global TRACKER
TRACKER = initialize_tracker() TRACKER = initialize_tracker()
@@ -554,13 +546,27 @@ def start():
analytics_event(category='system', action='start') analytics_event(category='system', action='start')
# Schedule newsletters
newsletter_handler.NEWSLETTER_SCHED.start()
newsletter_handler.schedule_newsletters()
_STARTED = True _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): def sig_handler(signum=None, frame=None):
if signum is not None: if signum is not None:
logger.info("Signal %i caught, saving and exiting...", signum) logger.info("Signal %i caught, saving and exiting...", signum)
@@ -778,7 +784,7 @@ def dbcheck():
# image_hash_lookup table :: This table keeps record of the image hash lookups # image_hash_lookup table :: This table keeps record of the image hash lookups
c_db.execute( c_db.execute(
'CREATE TABLE IF NOT EXISTS image_hash_lookup (id INTEGER PRIMARY KEY AUTOINCREMENT, ' '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)' 'opacity INTEGER, background TEXT, blur INTEGER, fallback TEXT)'
) )
@@ -2043,7 +2049,7 @@ def dbcheck():
# Update official mobile device flag # Update official mobile device flag
for device_id, in c_db.execute('SELECT device_id FROM mobile_devices').fetchall(): 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 = ?', 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 # Upgrade mobile_devices table from earlier versions
try: try:
@@ -2204,6 +2210,13 @@ def dbcheck():
'ALTER TABLE exports ADD COLUMN total_items INTEGER DEFAULT 0' '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. # Add "Local" user to database as default unauthenticated user.
result = c_db.execute('SELECT id FROM users WHERE username = "Local"') result = c_db.execute('SELECT id FROM users WHERE username = "Local"')
if not result.fetchone(): if not result.fetchone():

View File

@@ -501,7 +501,8 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'}, {'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': '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': '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': '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': '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'}, {'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': (int, 'General', 1),
'CHECK_GITHUB_INTERVAL': (int, 'General', 360), 'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
'CHECK_GITHUB_ON_STARTUP': (int, 'General', 1), 'CHECK_GITHUB_ON_STARTUP': (int, 'General', 1),
'CHECK_GITHUB_CACHE_SECONDS': (int, 'Advanced', 3600),
'CLEANUP_FILES': (int, 'General', 0), 'CLEANUP_FILES': (int, 'General', 0),
'CLOUDINARY_CLOUD_NAME': (str, 'Cloudinary', ''), 'CLOUDINARY_CLOUD_NAME': (str, 'Cloudinary', ''),
'CLOUDINARY_API_KEY': (str, 'Cloudinary', ''), 'CLOUDINARY_API_KEY': (str, 'Cloudinary', ''),

View File

@@ -492,6 +492,7 @@ class Export(object):
'grandparentThumb': None, 'grandparentThumb': None,
'grandparentTitle': None, 'grandparentTitle': None,
'guid': None, 'guid': None,
'hasIntroMarker': None,
'index': None, 'index': None,
'key': None, 'key': None,
'lastViewedAt': helpers.datetime_to_iso, 'lastViewedAt': helpers.datetime_to_iso,
@@ -499,6 +500,11 @@ class Export(object):
'librarySectionKey': None, 'librarySectionKey': None,
'librarySectionTitle': None, 'librarySectionTitle': None,
'locations': None, 'locations': None,
'markers': {
'end': None,
'start': None,
'type': None
},
'media': { 'media': {
'aspectRatio': None, 'aspectRatio': None,
'audioChannels': None, 'audioChannels': None,
@@ -1179,11 +1185,12 @@ class Export(object):
'rating', 'userRating', 'contentRating', 'rating', 'userRating', 'contentRating',
'summary', 'guid', 'duration', 'durationHuman', 'type', 'index', 'summary', 'guid', 'duration', 'durationHuman', 'type', 'index',
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex', 'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex',
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid' 'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', 'hasIntroMarker'
], ],
2: [ 2: [
'directors.tag', 'writers.tag', 'directors.tag', 'writers.tag',
'fields.name', 'fields.locked' 'fields.name', 'fields.locked',
'markers.type', 'markers.start', 'markers.end'
], ],
3: [ 3: [
'art', 'thumb', 'key', 'chapterSource', 'art', 'thumb', 'key', 'chapterSource',

View File

@@ -122,6 +122,7 @@ def add_live_tv_library(refresh=False):
if result and not refresh or not result and refresh: if result and not refresh or not result and refresh:
return return
if not refresh:
logger.info("Tautulli Libraries :: Adding Live TV library to the database.") logger.info("Tautulli Libraries :: Adding Live TV library to the database.")
section_keys = {'server_id': plexpy.CONFIG.PMS_IDENTIFIER, section_keys = {'server_id': plexpy.CONFIG.PMS_IDENTIFIER,

View File

@@ -74,14 +74,6 @@ def blacklist_config(config):
_BLACKLIST_WORDS.update(blacklist) _BLACKLIST_WORDS.update(blacklist)
class CherrypyEngineFilter(logging.Filter):
"""
Log filter for the Cherrypy Engine serving message
"""
def filter(self, record):
return 'ENGINE Serving on' not in record.msg
class NoThreadFilter(logging.Filter): class NoThreadFilter(logging.Filter):
""" """
Log filter for the current thread Log filter for the current thread
@@ -352,9 +344,6 @@ def initLogger(console=False, log_dir=False, verbose=False):
handler.addFilter(EmailFilter()) handler.addFilter(EmailFilter())
handler.addFilter(PlexTokenFilter()) handler.addFilter(PlexTokenFilter())
for handler in cherrypy.log.error_log.handlers:
handler.addFilter(CherrypyEngineFilter())
# Install exception hooks # Install exception hooks
initHooks() initHooks()

View File

@@ -831,12 +831,16 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
child_count = 1 child_count = 1
grandchild_count = 1 grandchild_count = 1
rating = notify_params['rating'] or notify_params['audience_rating']
critic_rating = '' critic_rating = ''
if notify_params['rating_image'].startswith('rottentomatoes://') and notify_params['rating']: if notify_params['rating_image'].startswith('rottentomatoes://') \
and notify_params['rating']:
critic_rating = helpers.get_percent(notify_params['rating'], 10) critic_rating = helpers.get_percent(notify_params['rating'], 10)
audience_rating = '' audience_rating = notify_params['audience_rating']
if notify_params['audience_rating']: if notify_params['audience_rating_image'].startswith(('rottentomatoes://', 'themoviedb://')) \
and audience_rating:
audience_rating = helpers.get_percent(notify_params['audience_rating'], 10) audience_rating = helpers.get_percent(notify_params['audience_rating'], 10)
now = arrow.now() now = arrow.now()
@@ -1013,9 +1017,10 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'collections': ', '.join(notify_params['collections']), 'collections': ', '.join(notify_params['collections']),
'summary': notify_params['summary'], 'summary': notify_params['summary'],
'tagline': notify_params['tagline'], 'tagline': notify_params['tagline'],
'rating': notify_params['rating'], 'rating': rating,
'critic_rating': critic_rating, 'critic_rating': critic_rating,
'audience_rating': audience_rating, 'audience_rating': audience_rating,
'user_rating': notify_params['user_rating'],
'duration': duration, 'duration': duration,
'poster_title': notify_params['poster_title'], 'poster_title': notify_params['poster_title'],
'poster_url': notify_params['poster_url'], 'poster_url': notify_params['poster_url'],

View File

@@ -1819,9 +1819,6 @@ class GROWL(Notifier):
logger.error("Tautulli Notifiers :: {name} notification failed: authentication error".format(name=self.NAME)) logger.error("Tautulli Notifiers :: {name} notification failed: authentication error".format(name=self.NAME))
return False return False
# Fix message
body = body.encode(plexpy.SYS_ENCODING, "replace")
# Send it, including an image # Send it, including an image
image_file = os.path.join(str(plexpy.PROG_DIR), image_file = os.path.join(str(plexpy.PROG_DIR),
"data/interfaces/default/images/logo-circle.png") "data/interfaces/default/images/logo-circle.png")

View File

@@ -48,6 +48,11 @@ def refresh_users():
logger.info("Tautulli Users :: Requesting users list refresh...") logger.info("Tautulli Users :: Requesting users list refresh...")
result = plextv.PlexTV().get_full_users_list() result = plextv.PlexTV().get_full_users_list()
server_id = plexpy.CONFIG.PMS_IDENTIFIER
if not server_id:
logger.error("Tautulli Users :: No PMS identifier, cannot refresh users. Verify server in settings.")
return
if result: if result:
monitor_db = database.MonitorDatabase() monitor_db = database.MonitorDatabase()

View File

@@ -17,5 +17,5 @@
from __future__ import unicode_literals from __future__ import unicode_literals
PLEXPY_BRANCH = "beta" PLEXPY_BRANCH = "master"
PLEXPY_RELEASE_VERSION = "v2.6.0-beta" PLEXPY_RELEASE_VERSION = "v2.6.1"

View File

@@ -20,6 +20,7 @@ from __future__ import unicode_literals
from future.builtins import next from future.builtins import next
from future.builtins import str from future.builtins import str
import json
import os import os
import platform import platform
import re import re
@@ -29,10 +30,12 @@ import tarfile
import plexpy import plexpy
if plexpy.PYTHON2: if plexpy.PYTHON2:
import common import common
import helpers
import logger import logger
import request import request
else: else:
from plexpy import common from plexpy import common
from plexpy import helpers
from plexpy import logger from plexpy import logger
from plexpy import request from plexpy import request
@@ -154,8 +157,8 @@ def get_version_from_file():
return current_version, current_branch return current_version, current_branch
def check_update(scheduler=False, notify=False): def check_update(scheduler=False, notify=False, use_cache=False):
check_github(scheduler=scheduler, notify=notify) check_github(scheduler=scheduler, notify=notify, use_cache=use_cache)
if not plexpy.CURRENT_VERSION: if not plexpy.CURRENT_VERSION:
plexpy.UPDATE_AVAILABLE = None plexpy.UPDATE_AVAILABLE = None
@@ -173,7 +176,7 @@ def check_update(scheduler=False, notify=False):
plexpy.MAC_SYS_TRAY_ICON.change_tray_update_icon() plexpy.MAC_SYS_TRAY_ICON.change_tray_update_icon()
def check_github(scheduler=False, notify=False): def check_github(scheduler=False, notify=False, use_cache=False):
plexpy.COMMITS_BEHIND = 0 plexpy.COMMITS_BEHIND = 0
if plexpy.CONFIG.GIT_TOKEN: if plexpy.CONFIG.GIT_TOKEN:
@@ -181,12 +184,16 @@ def check_github(scheduler=False, notify=False):
else: else:
headers = {} headers = {}
version = github_cache('version', use_cache=use_cache)
if not version:
# Get the latest version available from github # Get the latest version available from github
logger.info('Retrieving latest version information from GitHub') logger.info('Retrieving latest version information from GitHub')
url = 'https://api.github.com/repos/%s/%s/commits/%s' % (plexpy.CONFIG.GIT_USER, url = 'https://api.github.com/repos/%s/%s/commits/%s' % (plexpy.CONFIG.GIT_USER,
plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_REPO,
plexpy.CONFIG.GIT_BRANCH) plexpy.CONFIG.GIT_BRANCH)
version = request.request_json(url, headers=headers, timeout=20, validator=lambda x: type(x) == dict) version = request.request_json(url, headers=headers, timeout=20,
validator=lambda x: type(x) == dict)
github_cache('version', github_data=version)
if version is None: if version is None:
logger.warn('Could not get the latest version from GitHub. Are you running a local development version?') logger.warn('Could not get the latest version from GitHub. Are you running a local development version?')
@@ -204,6 +211,8 @@ def check_github(scheduler=False, notify=False):
logger.info('Tautulli is up to date') logger.info('Tautulli is up to date')
return plexpy.LATEST_VERSION return plexpy.LATEST_VERSION
commits = github_cache('commits', use_cache=use_cache)
if not commits:
logger.info('Comparing currently installed version with latest GitHub version') logger.info('Comparing currently installed version with latest GitHub version')
url = 'https://api.github.com/repos/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, url = 'https://api.github.com/repos/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER,
plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_REPO,
@@ -211,6 +220,7 @@ def check_github(scheduler=False, notify=False):
plexpy.CURRENT_VERSION) plexpy.CURRENT_VERSION)
commits = request.request_json(url, headers=headers, timeout=20, whitelist_status_code=404, commits = request.request_json(url, headers=headers, timeout=20, whitelist_status_code=404,
validator=lambda x: type(x) == dict) validator=lambda x: type(x) == dict)
github_cache('commits', github_data=commits)
if commits is None: if commits is None:
logger.warn('Could not get commits behind from GitHub.') logger.warn('Could not get commits behind from GitHub.')
@@ -226,8 +236,13 @@ def check_github(scheduler=False, notify=False):
if plexpy.COMMITS_BEHIND > 0: if plexpy.COMMITS_BEHIND > 0:
logger.info('New version is available. You are %s commits behind' % plexpy.COMMITS_BEHIND) logger.info('New version is available. You are %s commits behind' % plexpy.COMMITS_BEHIND)
url = 'https://api.github.com/repos/%s/%s/releases' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO) releases = github_cache('releases', use_cache=use_cache)
releases = request.request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == list) if not releases:
url = 'https://api.github.com/repos/%s/%s/releases' % (plexpy.CONFIG.GIT_USER,
plexpy.CONFIG.GIT_REPO)
releases = request.request_json(url, timeout=20, whitelist_status_code=404,
validator=lambda x: type(x) == list)
github_cache('releases', github_data=releases)
if releases is None: if releases is None:
logger.warn('Could not get releases from GitHub.') logger.warn('Could not get releases from GitHub.')
@@ -391,6 +406,30 @@ def checkout_git_branch():
plexpy.CONFIG.GIT_BRANCH)) plexpy.CONFIG.GIT_BRANCH))
def github_cache(cache, github_data=None, use_cache=True):
timestamp = helpers.timestamp()
cache_filepath = os.path.join(plexpy.CONFIG.CACHE_DIR, 'github_{}.json'.format(cache))
if github_data:
cache_data = {'github_data': github_data, '_cache_time': timestamp}
try:
with open(cache_filepath, 'w', encoding='utf-8') as cache_file:
json.dump(cache_data, cache_file)
except:
pass
else:
if not use_cache:
return
try:
with open(cache_filepath, 'r', encoding='utf-8') as cache_file:
cache_data = json.load(cache_file)
if timestamp - cache_data['_cache_time'] < plexpy.CONFIG.CHECK_GITHUB_CACHE_SECONDS:
logger.debug('Using cached GitHub %s data', cache)
return cache_data['github_data']
except:
pass
def read_changelog(latest_only=False, since_prev_release=False): def read_changelog(latest_only=False, since_prev_release=False):
changelog_file = os.path.join(plexpy.PROG_DIR, 'CHANGELOG.md') changelog_file = os.path.join(plexpy.PROG_DIR, 'CHANGELOG.md')

View File

@@ -2649,13 +2649,28 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
def delete_sync_rows(self, client_id, sync_id, **kwargs): @addtoapi("delete_synced_item")
def delete_sync_rows(self, client_id=None, sync_id=None, **kwargs):
""" Delete a synced item from a device.
```
Required parameters:
client_id (str): The client ID of the device to delete from
sync_id (str): The sync ID of the synced item
Optional parameters:
None
Returns:
None
```
"""
if client_id and sync_id: if client_id and sync_id:
plex_tv = plextv.PlexTV() plex_tv = plextv.PlexTV()
delete_row = plex_tv.delete_sync(client_id=client_id, sync_id=sync_id) delete_row = plex_tv.delete_sync(client_id=client_id, sync_id=sync_id)
return {'message': 'Sync deleted'} return {'result': 'success', 'message': 'Synced item deleted successfully.'}
else: else:
return {'message': 'no data received'} return {'result': 'error', 'message': 'Missing client ID and sync ID.'}
##### Logs ##### ##### Logs #####
@@ -5032,12 +5047,13 @@ class WebInterface(object):
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi("get_metadata") @addtoapi("get_metadata")
def get_metadata_details(self, rating_key='', **kwargs): def get_metadata_details(self, rating_key='', sync_id='', **kwargs):
""" Get the metadata for a media item. """ Get the metadata for a media item.
``` ```
Required parameters: Required parameters:
rating_key (str): Rating key of the item rating_key (str): Rating key of the item, OR
sync_id (str): Sync ID of a synced item
Optional parameters: Optional parameters:
None None
@@ -5188,7 +5204,8 @@ class WebInterface(object):
``` ```
""" """
pms_connect = pmsconnect.PmsConnect() pms_connect = pmsconnect.PmsConnect()
metadata = pms_connect.get_metadata_details(rating_key=rating_key) metadata = pms_connect.get_metadata_details(rating_key=rating_key,
sync_id=sync_id)
if metadata: if metadata:
return metadata return metadata
@@ -6528,6 +6545,31 @@ class WebInterface(object):
return status return status
@cherrypy.expose
@cherrypy.tools.json_out()
@addtoapi()
def server_status(self, *args, **kwargs):
""" Get the current status of Tautulli's connection to the Plex server.
```
Required parameters:
None
Optional parameters:
None
Returns:
json:
{"result": "success",
"connected": true,
}
```
"""
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
status = {'result': 'success', 'connected': plexpy.PLEX_SERVER_UP}
return status
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))

View File

@@ -1,21 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$TAUTULLI_DOCKER" == "True" ]]; then if [[ "$TAUTULLI_DOCKER" == "True" ]]; then
if [[ -n $PUID && -n $PGID ]]; then PUID=${PUID:-1000}
getent group "$PGID" 2>&1 > /dev/null || groupadd -g "$PGID" tautulli PGID=${PGID:-1000}
getent passwd "$PUID" 2>&1 > /dev/null || useradd -r -u "$PUID" -g "$PGID" tautulli
user=$(getent passwd "$PUID" | cut -d: -f1) groupmod -o -g "$PGID" tautulli
group=$(getent group "$PGID" | cut -d: -f1) usermod -o -u "$PUID" tautulli
usermod -a -G root "$user"
chown -R "$user":"$group" /config chown -R tautulli:tautulli /config
echo "Running Tautulli using user $user (uid=$PUID) and group $group (gid=$PGID)" echo "Running Tautulli using user tautulli (uid=$(id -u tautulli)) and group tautulli (gid=$(id -g tautulli))"
su "$user" -g "$group" -c "python /app/Tautulli.py --datadir /config" exec gosu tautulli "$@"
else
python Tautulli.py --datadir /config
fi
else else
python_versions=("python3" "python3.8" "python3.7" "python3.6" "python" "python2" "python2.7") python_versions=("python3" "python3.8" "python3.7" "python3.6" "python" "python2" "python2.7")
for cmd in "${python_versions[@]}"; do for cmd in "${python_versions[@]}"; do