From aaea924aaaf9501e70b2277dd0b073cc2cc75e41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:57:18 -0700 Subject: [PATCH 01/19] Bump actions/checkout from 3 to 4 (#2145) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/codeql.yml | 2 +- .github/workflows/publish-docker.yml | 2 +- .github/workflows/publish-installers.yml | 4 ++-- .github/workflows/publish-snap.yml | 2 +- .github/workflows/pull-requests.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e914485d..f9593d5a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 6d91bbf6..80dad13c 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -13,7 +13,7 @@ jobs: if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Prepare id: prepare diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml index 0b6eec36..0e0b1082 100644 --- a/.github/workflows/publish-installers.yml +++ b/.github/workflows/publish-installers.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set Release Version id: get_version @@ -103,7 +103,7 @@ jobs: uses: technote-space/workflow-conclusion-action@v3 - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set Release Version id: get_version diff --git a/.github/workflows/publish-snap.yml b/.github/workflows/publish-snap.yml index dd74c3a3..30ee799b 100644 --- a/.github/workflows/publish-snap.yml +++ b/.github/workflows/publish-snap.yml @@ -20,7 +20,7 @@ jobs: - armhf steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Prepare id: prepare diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index 1a24cf24..ac550fe2 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Comment on Pull Request uses: mshick/add-pr-comment@v2 From 26419f4610e0633498efe0a8229eab061e5eadf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:57:32 -0700 Subject: [PATCH 02/19] Bump docker/login-action from 2 to 3 (#2149) Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/publish-docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 80dad13c..789c65f6 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -55,14 +55,14 @@ jobs: ${{ runner.os }}-buildx- - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 if: success() with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 if: success() with: registry: ghcr.io From 5e75d0ce73b64076a0f65d5cfb5411c759a5bdad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:57:43 -0700 Subject: [PATCH 03/19] Bump docker/setup-qemu-action from 2 to 3 (#2150) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/publish-docker.yml | 2 +- .github/workflows/publish-snap.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 789c65f6..f396bbac 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -38,7 +38,7 @@ jobs: echo "docker_image=${{ secrets.DOCKER_REPO }}/tautulli" >> $GITHUB_OUTPUT - name: Set Up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 diff --git a/.github/workflows/publish-snap.yml b/.github/workflows/publish-snap.yml index 30ee799b..e8a6be7d 100644 --- a/.github/workflows/publish-snap.yml +++ b/.github/workflows/publish-snap.yml @@ -35,7 +35,7 @@ jobs: fi - name: Set Up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Build Snap Package uses: diddlesnaps/snapcraft-multiarch-action@v1 From 76f1335a559d5dbc6e5edd505032b899dc9bb04b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:57:59 -0700 Subject: [PATCH 04/19] Bump docker/build-push-action from 4 to 5 (#2151) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v4...v5) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/publish-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index f396bbac..feb56b68 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -70,7 +70,7 @@ jobs: password: ${{ secrets.GHCR_TOKEN }} - name: Docker Build and Push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 if: success() with: context: . From 4938954c6117a00091d8f78db32d61e27a7e74a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:59:23 -0700 Subject: [PATCH 05/19] Bump docker/setup-buildx-action from 2 to 3 (#2152) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/publish-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index feb56b68..2eccdf5d 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -41,7 +41,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 id: buildx with: version: latest From 59fe34982efc0bcb6fc160fc027231ad6dc81e21 Mon Sep 17 00:00:00 2001 From: herby2212 <12448284+herby2212@users.noreply.github.com> Date: Sat, 7 Oct 2023 22:45:11 +0200 Subject: [PATCH 06/19] Concurrent Streams per Day Graph (#2046) * initial commit * fix grouping in webserve * remove event handler and adapt cursor * optimize most concurrent calculation * update branch from nightly and user filter * max concurrent streams in graph * made several changes mentioned in review --- data/interfaces/default/graphs.html | 46 +++++++- .../concurrent_streams_by_stream_type.js | 76 +++++++++++++ plexpy/graphs.py | 104 +++++++++++++++++- plexpy/helpers.py | 6 + plexpy/webserve.py | 38 +++++++ 5 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 data/interfaces/default/js/graphs/concurrent_streams_by_stream_type.js diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html index 8435df20..8f16f59a 100644 --- a/data/interfaces/default/graphs.html +++ b/data/interfaces/default/graphs.html @@ -137,6 +137,20 @@ +
+
+

Daily concurrent stream count by stream type Last 30 days

+

+ The total count of concurrent streams of tv, movies, and music by the transcode decision. +

+
+
+
Loading chart...
+
+
+
+
+

Play count by source resolution Last 30 days

@@ -312,7 +326,8 @@ 'Live TV': '#19A0D7', 'Direct Play': '#E5A00D', 'Direct Stream': '#FFFFFF', - 'Transcode': '#F06464' + 'Transcode': '#F06464', + 'Max. Concurrent Streams': '#1014FC' }; var series_colors = []; $.each(data_series, function(index, series) { @@ -327,6 +342,7 @@ + @@ -540,6 +556,33 @@ } }); + $.ajax({ + url: "get_concurrent_streams_by_stream_type", + type: 'get', + data: { time_range: time_range, user_id: selected_user_id }, + dataType: "json", + success: function(data) { + var dateArray = []; + $.each(data.categories, function (i, day) { + dateArray.push(moment(day, 'YYYY-MM-DD').valueOf()); + // Highlight the weekend + if ((moment(day, 'YYYY-MM-DD').format('ddd') == 'Sat') || + (moment(day, 'YYYY-MM-DD').format('ddd') == 'Sun')) { + hc_plays_by_day_options.xAxis.plotBands.push({ + from: i-0.5, + to: i+0.5, + color: 'rgba(80,80,80,0.3)' + }); + } + }); + hc_concurrent_streams_by_stream_type_options.yAxis.min = 0; + hc_concurrent_streams_by_stream_type_options.xAxis.categories = dateArray; + hc_concurrent_streams_by_stream_type_options.series = getGraphVisibility(hc_concurrent_streams_by_stream_type_options.chart.renderTo, data.series); + hc_concurrent_streams_by_stream_type_options.colors = getGraphColors(data.series); + var hc_plays_by_stream_type = new Highcharts.Chart(hc_concurrent_streams_by_stream_type_options); + } + }); + $.ajax({ url: "get_plays_by_source_resolution", type: 'get', @@ -754,6 +797,7 @@ hc_plays_by_day_options.xAxis.plotBands = []; hc_plays_by_stream_type_options.xAxis.plotBands = []; + hc_concurrent_streams_by_stream_type_options.xAxis.plotBands = []; hc_plays_by_day_options.yAxis.labels.formatter = yaxis_format; hc_plays_by_dayofweek_options.yAxis.labels.formatter = yaxis_format; diff --git a/data/interfaces/default/js/graphs/concurrent_streams_by_stream_type.js b/data/interfaces/default/js/graphs/concurrent_streams_by_stream_type.js new file mode 100644 index 00000000..623e4735 --- /dev/null +++ b/data/interfaces/default/js/graphs/concurrent_streams_by_stream_type.js @@ -0,0 +1,76 @@ +var formatter_function = function() { + if (moment(this.x, 'X').isValid() && (this.x > 946684800)) { + var s = ''+ moment(this.x).format('ddd MMM D') +''; + } else { + var s = ''+ this.x +''; + } + $.each(this.points, function(i, point) { + s += '
'+point.series.name+': '+point.y; + }); + return s; +}; + +var hc_concurrent_streams_by_stream_type_options = { + chart: { + type: 'line', + backgroundColor: 'rgba(0,0,0,0)', + renderTo: 'graph_concurrent_streams_by_stream_type' + }, + title: { + text: '' + }, + legend: { + enabled: true, + itemStyle: { + font: '9pt "Open Sans", sans-serif', + color: '#A0A0A0' + }, + itemHoverStyle: { + color: '#FFF' + }, + itemHiddenStyle: { + color: '#444' + } + }, + credits: { + enabled: false + }, + plotOptions: { + series: { + events: { + legendItemClick: function() { + setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name); + } + } + } + }, + xAxis: { + type: 'datetime', + labels: { + formatter: function() { + return moment(this.value).format("MMM D"); + }, + style: { + color: '#aaa' + } + }, + categories: [{}], + plotBands: [] + }, + yAxis: { + title: { + text: null + }, + labels: { + style: { + color: '#aaa' + } + } + }, + tooltip: { + shared: true, + crosshairs: true, + formatter: formatter_function + }, + series: [{}] +}; \ No newline at end of file diff --git a/plexpy/graphs.py b/plexpy/graphs.py index 58a199c0..82db9d82 100644 --- a/plexpy/graphs.py +++ b/plexpy/graphs.py @@ -22,7 +22,6 @@ from future.builtins import object import arrow import datetime - import plexpy if plexpy.PYTHON2: import common @@ -826,6 +825,102 @@ class Graphs(object): 'series': [series_1_output, series_2_output, series_3_output]} return output + def get_total_concurrent_streams_per_stream_type(self, time_range='30', user_id=None): + monitor_db = database.MonitorDatabase() + + time_range = helpers.cast_to_int(time_range) or 30 + timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 + + user_cond = self._make_user_cond(user_id, 'WHERE') + + def calc_most_concurrent(result): + times = [] + for item in result: + times.append({'time': str(item['started']) + 'B', 'count': 1}) + times.append({'time': str(item['stopped']) + 'A', 'count': -1}) + times = sorted(times, key=lambda k: k['time']) + + count = 0 + final_count = 0 + last_count = 0 + + for d in times: + if d['count'] == 1: + count += d['count'] + else: + if count >= last_count: + last_count = count + final_count = count + count += d['count'] + + return final_count + + try: + query = 'SELECT sh.date_played, sh.started, sh.stopped, shmi.transcode_decision ' \ + 'FROM (SELECT *, ' \ + 'date(started, "unixepoch", "localtime") AS date_played ' \ + 'FROM session_history %s) AS sh ' \ + 'JOIN session_history_media_info AS shmi ON sh.id = shmi.id ' \ + 'WHERE sh.stopped >= %s ' \ + 'ORDER BY sh.date_played' % (user_cond, timestamp) + + result = monitor_db.select(query) + except Exception as e: + logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e) + return None + + # create our date range as some days may not have any data + # but we still want to display them + base = datetime.date.today() + date_list = [base - datetime.timedelta(days=x) for x in range(0, int(time_range))] + + categories = [] + series_1 = [] + series_2 = [] + series_3 = [] + series_4 = [] + + grouped_result_by_stream_type = helpers.group_by_keys(result, ('date_played','transcode_decision')) + grouped_result_by_day = helpers.group_by_keys(result, ['date_played']) + + for date_item in sorted(date_list): + date_string = date_item.strftime('%Y-%m-%d') + categories.append(date_string) + series_1_value = 0 + series_2_value = 0 + series_3_value = 0 + series_4_value = 0 + + for item in grouped_result_by_stream_type: + if item['key'] == (date_string,'direct play'): + series_1_value = calc_most_concurrent(item['value']) + elif item['key'] == (date_string,'copy'): + series_2_value = calc_most_concurrent(item['value']) + elif item['key'] == (date_string,'transcode'): + series_3_value = calc_most_concurrent(item['value']) + + for item in grouped_result_by_day: + if item['key'] == date_string: + series_4_value = calc_most_concurrent(item['value']) + + series_1.append(series_1_value) + series_2.append(series_2_value) + series_3.append(series_3_value) + series_4.append(series_4_value) + + series_1_output = {'name': 'Direct Play', + 'data': series_1} + series_2_output = {'name': 'Direct Stream', + 'data': series_2} + series_3_output = {'name': 'Transcode', + 'data': series_3} + series_4_output = {'name': 'Max. Concurrent Streams', + 'data': series_4} + + output = {'categories': categories, + 'series': [series_1_output, series_2_output, series_3_output, series_4_output]} + return output + def get_total_plays_by_source_resolution(self, time_range='30', y_axis='plays', user_id=None, grouping=None): monitor_db = database.MonitorDatabase() @@ -1169,15 +1264,16 @@ class Graphs(object): return output - def _make_user_cond(self, user_id): + def _make_user_cond(self, user_id, cond_prefix='AND'): """ Expects user_id to be a comma-separated list of ints. """ user_cond = '' + if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()): - user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id() + user_cond = cond_prefix + ' session_history.user_id = %s ' % session.get_session_user_id() elif user_id: user_ids = helpers.split_strip(user_id) if all(id.isdigit() for id in user_ids): - user_cond = 'AND session_history.user_id IN (%s) ' % ','.join(user_ids) + user_cond =cond_prefix + ' session_history.user_id IN (%s) ' % ','.join(user_ids) return user_cond diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 085dfc12..4512f0b6 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -32,6 +32,7 @@ import datetime from functools import reduce, wraps import hashlib import imghdr +from itertools import groupby from future.moves.itertools import islice, zip_longest from ipaddress import ip_address, ip_network, IPv4Address import ipwhois @@ -1241,6 +1242,11 @@ def grouper(iterable, n, fillvalue=None): args = [iter(iterable)] * n return zip_longest(fillvalue=fillvalue, *args) +def group_by_keys(iterable, keys): + key_function = operator.itemgetter(*keys) + + sorted_iterable = sorted(iterable, key=key_function) + return[{'key': key, 'value': list(group)} for key, group in groupby(sorted_iterable, key_function)] def chunk(it, size): it = iter(it) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index b643f84b..196bbe5b 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -2549,6 +2549,44 @@ class WebInterface(object): logger.warn("Unable to retrieve data for get_plays_by_stream_type.") return result + @cherrypy.expose + @cherrypy.tools.json_out() + @requireAuth() + @addtoapi() + def get_concurrent_streams_by_stream_type(self, time_range='30', user_id=None, **kwargs): + """ Get graph data for concurrent streams by stream type by date. + + ``` + Required parameters: + None + + Optional parameters: + time_range (str): The number of days of data to return + user_id (str): Comma separated list of user id to filter the data + + Returns: + json: + {"categories": + ["YYYY-MM-DD", "YYYY-MM-DD", ...] + "series": + [{"name": "Direct Play", "data": [...]} + {"name": "Direct Stream", "data": [...]}, + {"name": "Transcode", "data": [...]}, + {"name": "Max. Concurrent Streams", "data": [...]} + ] + } + ``` + """ + + graph = graphs.Graphs() + result = graph.get_total_concurrent_streams_per_stream_type(time_range=time_range, user_id=user_id) + + if result: + return result + else: + logger.warn("Unable to retrieve data for get_concurrent_streams_by_stream_type.") + return result + @cherrypy.expose @cherrypy.tools.json_out() @requireAuth() From a668932ea87b73967c716cf0b88a7a39c37f48b0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 8 Sep 2023 15:27:50 -0700 Subject: [PATCH 07/19] Remove banners from exports --- plexpy/exporter.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plexpy/exporter.py b/plexpy/exporter.py index f1c730a4..e1dc989d 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -420,8 +420,6 @@ class Export(object): 'audioLanguage': None, 'autoDeletionItemPolicyUnwatchedLibrary': None, 'autoDeletionItemPolicyWatchedLibrary': None, - 'banner': None, - 'bannerFile': lambda o: self.get_image(o, 'banner'), 'childCount': None, 'collections': { 'id': None, @@ -1382,7 +1380,7 @@ class Export(object): 'fields.name', 'fields.locked', 'guids.id' ], 3: [ - 'art', 'thumb', 'banner', 'theme', 'key', + 'art', 'thumb', 'theme', 'key', 'updatedAt', 'lastViewedAt', 'viewCount', 'lastRatedAt' ], 9: self._get_all_metadata_attrs(_media_type) @@ -2258,8 +2256,6 @@ class Export(object): image_url = item.thumbUrl elif image == 'art': image_url = item.artUrl - elif image == 'banner': - image_url = item.bannerUrl if not image_url: return From b18c31f431571bb3246fdca1380edde26cd7dcba Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:15:54 -0700 Subject: [PATCH 08/19] Update table right flow overflow --- data/interfaces/default/css/tautulli.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index e256d2d7..8cad039f 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -2984,7 +2984,8 @@ a .home-platforms-list-cover-face:hover max-width: 900px; } .stacked-configs > li > span { - display: block; + display: inline-block; + width: inherit; padding: 8px 20px 8px 15px; color: #eee; border-left: 2px solid #444; From 0fa7553d97cbadabe4fd5f9cc7c21fd87659fb6c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:16:16 -0700 Subject: [PATCH 09/19] Fix right float cog icon on mobile devices table --- data/interfaces/default/mobile_devices_table.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/data/interfaces/default/mobile_devices_table.html b/data/interfaces/default/mobile_devices_table.html index e5363885..852fb2dd 100644 --- a/data/interfaces/default/mobile_devices_table.html +++ b/data/interfaces/default/mobile_devices_table.html @@ -28,15 +28,17 @@ DOCUMENTATION :: END % endif ${device['friendly_name'] or device['device_name']}  (${device['id']}) - - + % if device['last_seen']: - + + + % else: never % endif + From 982c893c4989e2733d7e38562c36c128556f908f Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 21 Sep 2023 12:16:01 -0700 Subject: [PATCH 10/19] Use helper function to cast play duration to int --- plexpy/datafactory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index 3db32a51..bd5a7c31 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -270,7 +270,7 @@ class DataFactory(object): item['user_thumb'] = users_lookup.get(item['user_id']) - filter_duration += int(item['play_duration']) + filter_duration += helpers.cast_to_int(item['play_duration']) if item['media_type'] == 'episode' and item['parent_thumb']: thumb = item['parent_thumb'] From 62be48df9c79d6b3de47d16578471290d2ef9206 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 9 Oct 2023 11:33:40 -0700 Subject: [PATCH 11/19] Change colour of max. concurrent stream series --- data/interfaces/default/graphs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html index 8f16f59a..7cd0c98b 100644 --- a/data/interfaces/default/graphs.html +++ b/data/interfaces/default/graphs.html @@ -327,7 +327,7 @@ 'Direct Play': '#E5A00D', 'Direct Stream': '#FFFFFF', 'Transcode': '#F06464', - 'Max. Concurrent Streams': '#1014FC' + 'Max. Concurrent Streams': '#96C83C' }; var series_colors = []; $.each(data_series, function(index, series) { From fdc1dd3525ce752b05bb74147130a9f2cba2765a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 9 Oct 2023 11:37:56 -0700 Subject: [PATCH 12/19] Add trigger time to notification and newsletter tables --- .../interfaces/default/newsletters_table.html | 25 +++++++++++++++---- data/interfaces/default/notifiers_table.html | 15 ++++++++++- data/interfaces/default/settings.html | 6 ++--- plexpy/newsletters.py | 15 ++++++++--- plexpy/notifiers.py | 14 ++++++++--- 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/data/interfaces/default/newsletters_table.html b/data/interfaces/default/newsletters_table.html index 5ceaf0a0..b2c4cb04 100644 --- a/data/interfaces/default/newsletters_table.html +++ b/data/interfaces/default/newsletters_table.html @@ -20,13 +20,28 @@ DOCUMENTATION :: END % else: ${newsletter['agent_label']}  (${newsletter['id']}) % endif - - + % if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])): <% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %> - + + + + % endif + % if newsletter['last_triggered']: + <% icon, icon_tooltip = ('fa-check', 'Success') if newsletter['last_success'] else ('fa-times', 'Failed') %> + + + + % else: + Last: never + % endif diff --git a/data/interfaces/default/notifiers_table.html b/data/interfaces/default/notifiers_table.html index d2bfa55e..1017f7f4 100644 --- a/data/interfaces/default/notifiers_table.html +++ b/data/interfaces/default/notifiers_table.html @@ -19,7 +19,20 @@ DOCUMENTATION :: END % else: ${notifier['agent_label']}  (${notifier['id']}) % endif - + + % if notifier['last_triggered']: + <% icon, icon_tooltip = ('fa-check', 'Success') if notifier['last_success'] else ('fa-times', 'Failed') %> + + + + % else: + never + + % endif % endfor diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index c5d8fe37..6710ff79 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1278,7 +1278,7 @@

- Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right. + Add a new notification agent, or configure an existing notification agent by clicking on the item below.

Please see the Notification Agents Guide for instructions on setting up each notification agent. @@ -1298,7 +1298,7 @@

- Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right. + Add a new newsletter agent, or configure an existing newsletter agent by clicking on the item below.

Warning: The Image Hosting setting must be enabled for images to display on the newsletter. @@ -1630,7 +1630,7 @@

-

Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.

+

Register a new device using a QR code, or configure an existing device by clicking on the item below.

Warning: The API must be enabled under Web Interface to use the app.


diff --git a/plexpy/newsletters.py b/plexpy/newsletters.py index 661a2b42..5fe8448c 100644 --- a/plexpy/newsletters.py +++ b/plexpy/newsletters.py @@ -119,13 +119,22 @@ def get_newsletters(newsletter_id=None): if newsletter_id: where = "WHERE " if newsletter_id: - where_id += "id = ?" + where_id += "newsletters.id = ?" args.append(newsletter_id) where += " AND ".join([w for w in [where_id] if w]) db = database.MonitorDatabase() - result = db.select("SELECT id, agent_id, agent_name, agent_label, " - "friendly_name, cron, active FROM newsletters %s" % where, args=args) + result = db.select( + ( + "SELECT newsletters.id, newsletters.agent_id, newsletters.agent_name, newsletters.agent_label, " + "newsletters.friendly_name, newsletters.cron, newsletters.active, " + "MAX(newsletter_log.timestamp) AS last_triggered, newsletter_log.success AS last_success " + "FROM newsletters " + "LEFT OUTER JOIN newsletter_log ON newsletters.id = newsletter_log.newsletter_id " + "%s " + "GROUP BY newsletters.id" + ) % where, args=args + ) return result diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index ec8ad5b9..1e18644e 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -499,7 +499,7 @@ def get_notifiers(notifier_id=None, notify_action=None): if notifier_id or notify_action: where = 'WHERE ' if notifier_id: - where_id += 'id = ?' + where_id += 'notifiers.id = ?' args.append(notifier_id) if notify_action and notify_action in notify_actions: where_action = '%s = ?' % notify_action @@ -507,8 +507,16 @@ def get_notifiers(notifier_id=None, notify_action=None): where += ' AND '.join([w for w in [where_id, where_action] if w]) db = database.MonitorDatabase() - result = db.select("SELECT id, agent_id, agent_name, agent_label, friendly_name, %s FROM notifiers %s" - % (', '.join(notify_actions), where), args=args) + result = db.select( + ( + "SELECT notifiers.id, notifiers.agent_id, notifiers.agent_name, notifiers.agent_label, notifiers.friendly_name, %s, " + "MAX(notify_log.timestamp) AS last_triggered, notify_log.success AS last_success " + "FROM notifiers " + "LEFT OUTER JOIN notify_log ON notifiers.id = notify_log.notifier_id " + "%s " + "GROUP BY notifiers.id" + ) % (', '.join(notify_actions), where), args=args + ) for item in result: item['active'] = int(any([item.pop(k) for k in list(item.keys()) if k in notify_actions])) From aa4d98ee3406a330d8f30972c24635e4f539b8aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:22:10 -0700 Subject: [PATCH 13/19] Bump plexapi from 4.15.0 to 4.15.4 (#2175) * Bump plexapi from 4.15.0 to 4.15.4 Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.15.0 to 4.15.4. - [Release notes](https://github.com/pkkid/python-plexapi/releases) - [Commits](https://github.com/pkkid/python-plexapi/compare/4.15.0...4.15.4) --- updated-dependencies: - dependency-name: plexapi dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Update plexapi==4.15.4 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> [skip ci] --- lib/plexapi/__init__.py | 1 + lib/plexapi/alert.py | 19 +++---- lib/plexapi/audio.py | 19 +++++++ lib/plexapi/base.py | 11 +++-- lib/plexapi/client.py | 5 +- lib/plexapi/collection.py | 9 +++- lib/plexapi/config.py | 1 + lib/plexapi/const.py | 2 +- lib/plexapi/library.py | 33 +++++++------ lib/plexapi/media.py | 90 +++++++++++++++++++++++++-------- lib/plexapi/mixins.py | 22 ++++----- lib/plexapi/myplex.py | 80 ++++++++++++++++++++++++++---- lib/plexapi/photo.py | 15 +++++- lib/plexapi/playlist.py | 13 +++-- lib/plexapi/server.py | 54 +++++++++++++++----- lib/plexapi/sync.py | 1 - lib/plexapi/utils.py | 49 ++++++++++++------ lib/plexapi/video.py | 101 ++++++++++++++++++++++++++++++-------- requirements.txt | 2 +- 19 files changed, 399 insertions(+), 128 deletions(-) diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index eefc181d..1d4fb471 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -30,6 +30,7 @@ X_PLEX_VERSION = CONFIG.get('header.version', VERSION) X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM) X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1]) X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode()))) +X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en') BASE_HEADERS = reset_base_headers() # Logging Configuration diff --git a/lib/plexapi/alert.py b/lib/plexapi/alert.py index 79ecc445..2d6a18e8 100644 --- a/lib/plexapi/alert.py +++ b/lib/plexapi/alert.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import json +import socket +from typing import Callable import threading from plexapi import log @@ -32,15 +34,17 @@ class AlertListener(threading.Thread): callbackError (func): Callback function to call on errors. The callback function will be sent a single argument 'error' which will contain the Error object. :samp:`def my_callback(error): ...` + ws_socket (socket): Socket to use for the connection. If not specified, a new socket will be created. """ key = '/:/websockets/notifications' - def __init__(self, server, callback=None, callbackError=None): + def __init__(self, server, callback: Callable = None, callbackError: Callable = None, ws_socket: socket = None): super(AlertListener, self).__init__() self.daemon = True self._server = server self._callback = callback self._callbackError = callbackError + self._socket = ws_socket self._ws = None def run(self): @@ -52,8 +56,9 @@ class AlertListener(threading.Thread): # create the websocket connection url = self._server.url(self.key, includeToken=True).replace('http', 'ws') log.info('Starting AlertListener: %s', url) - self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, - on_error=self._onError) + + self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError, socket=self._socket) + self._ws.run_forever() def stop(self): @@ -66,10 +71,8 @@ class AlertListener(threading.Thread): def _onMessage(self, *args): """ Called when websocket message is received. - In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp - object and the message as a STR. Current releases appear to only return the message. + We are assuming the last argument in the tuple is the message. - This is to support compatibility with current and previous releases of websocket-client. """ message = args[-1] try: @@ -82,10 +85,8 @@ class AlertListener(threading.Thread): def _onError(self, *args): # pragma: no cover """ Called when websocket error is received. - In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp - object and the error. Current releases appear to only return the error. + We are assuming the last argument in the tuple is the message. - This is to support compatibility with current and previous releases of websocket-client. """ err = args[-1] try: diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index e1382760..2a169877 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +from pathlib import Path from urllib.parse import quote_plus from plexapi import media, utils @@ -240,6 +241,12 @@ class Artist( key = f'{self.key}?includeStations=1' return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None) + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Album( @@ -359,6 +366,12 @@ class Album( """ Returns str, default title for a new syncItem. """ return f'{self.parentTitle} - {self.title}' + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Track( @@ -470,6 +483,12 @@ class Track( """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.parentGuid) + return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class TrackSession(PlexSession, Track): diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 88a31bbe..822e40ea 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -227,7 +227,7 @@ class PlexObject: fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, Media__container__in=["mp4", "mkv"]) - fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)") + fetchItem(ekey, guid__iregex=r"(imdb://|themoviedb://)") fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") """ @@ -502,7 +502,7 @@ class PlexPartialObject(PlexObject): def __eq__(self, other): if isinstance(other, PlexPartialObject): - return other not in [None, []] and self.key == other.key + return self.key == other.key return NotImplemented def __hash__(self): @@ -626,7 +626,8 @@ class PlexPartialObject(PlexObject): return self def saveEdits(self): - """ Save all the batch edits and automatically reload the object. + """ Save all the batch edits. The object needs to be reloaded manually, + if required. See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details. """ if not isinstance(self._edits, dict): @@ -635,7 +636,7 @@ class PlexPartialObject(PlexObject): edits = self._edits self._edits = None self._edit(**edits) - return self.reload() + return self def refresh(self): """ Refreshing a Library or individual item causes the metadata for the item to be @@ -919,7 +920,7 @@ class PlexSession(object): def stop(self, reason=''): """ Stop playback for the session. - + Parameters: reason (str): Message displayed to the user for stopping playback. """ diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 2b4283c7..279b4974 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -70,6 +70,7 @@ class PlexClient(PlexObject): self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' server_session = server._session if server else None self._session = session or server_session or requests.Session() + self._timeout = timeout or TIMEOUT self._proxyThroughServer = False self._commandId = 0 self._last_call = 0 @@ -94,7 +95,7 @@ class PlexClient(PlexObject): raise Unsupported('Cannot reload an object not built from a URL.') self._initpath = self.key data = self.query(self.key, timeout=timeout) - if not data: + if data is None: raise NotFound(f"Client not found at {self._baseurl}") if self._clientIdentifier: client = next( @@ -179,7 +180,7 @@ class PlexClient(PlexObject): """ url = self.url(path) method = method or self._session.get - timeout = timeout or TIMEOUT + timeout = timeout or self._timeout log.debug('%s %s', method.__name__.upper(), url) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index d4820fe2..8bc5f286 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from pathlib import Path from urllib.parse import quote_plus from plexapi import media, utils @@ -399,7 +400,7 @@ class Collection( @deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead') def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): """ Edit the collection. - + Parameters: title (str, optional): The title of the collection. titleSort (str, optional): The sort title of the collection. @@ -560,3 +561,9 @@ class Collection( raise Unsupported('Unsupported collection content') return myplex.sync(sync_item, client=client, clientId=clientId) + + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Collections' / guid_hash[0] / f'{guid_hash[1:]}.bundle') diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 8bbf1f31..5cfa74c8 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -63,6 +63,7 @@ def reset_base_headers(): 'X-Plex-Device': plexapi.X_PLEX_DEVICE, 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, + 'X-Plex-Language': plexapi.X_PLEX_LANGUAGE, 'X-Plex-Sync-Version': '2', 'X-Plex-Features': 'external-media', } diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index df86ff5d..8a172e98 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 15 -PATCH_VERSION = 0 +PATCH_VERSION = 4 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index cbca4246..87d59eac 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -542,7 +542,7 @@ class LibrarySection(PlexObject): def addLocations(self, location): """ Add a location to a library. - + Parameters: location (str or list): A single folder path, list of paths. @@ -565,7 +565,7 @@ class LibrarySection(PlexObject): def removeLocations(self, location): """ Remove a location from a library. - + Parameters: location (str or list): A single folder path, list of paths. @@ -744,7 +744,7 @@ class LibrarySection(PlexObject): def lockAllField(self, field, libtype=None): """ Lock a field for all items in the library. - + Parameters: field (str): The field to lock (e.g. thumb, rating, collection). libtype (str, optional): The library type to lock (movie, show, season, episode, @@ -754,7 +754,7 @@ class LibrarySection(PlexObject): def unlockAllField(self, field, libtype=None): """ Unlock a field for all items in the library. - + Parameters: field (str): The field to unlock (e.g. thumb, rating, collection). libtype (str, optional): The library type to lock (movie, show, season, episode, @@ -847,7 +847,7 @@ class LibrarySection(PlexObject): """ _key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1' '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') - + key = _key.format(key=self.key, filter='all') data = self._server.query(key) self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') @@ -894,7 +894,7 @@ class LibrarySection(PlexObject): def getFieldType(self, fieldType): """ Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType. - + Parameters: fieldType (str): The data type for the field (tag, integer, string, boolean, date, subtitleLanguage, audioLanguage, resolution). @@ -927,7 +927,7 @@ class LibrarySection(PlexObject): """ return self.getFilterType(libtype).filters - + def listSorts(self, libtype=None): """ Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype. This is the list of options in the sorting dropdown menu @@ -970,7 +970,7 @@ class LibrarySection(PlexObject): """ Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType. This is the list of options in the custom filter operator dropdown menu (`screenshot <../_static/images/LibrarySection.search.png>`__). - + Parameters: fieldType (str): The data type for the field (tag, integer, string, boolean, date, subtitleLanguage, audioLanguage, resolution). @@ -992,7 +992,7 @@ class LibrarySection(PlexObject): :class:`~plexapi.library.FilteringFilter` or filter field. This is the list of available values for a custom filter (`screenshot <../_static/images/LibrarySection.search.png>`__). - + Parameters: field (str): :class:`~plexapi.library.FilteringFilter` object, or the name of the field (genre, year, contentRating, etc.). @@ -1024,7 +1024,7 @@ class LibrarySection(PlexObject): availableFilters = [f.filter for f in self.listFilters(libtype)] raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". ' f'Available filters: {availableFilters}') from None - + data = self._server.query(field.key) return self.findItems(data, FilterChoice) @@ -1111,7 +1111,7 @@ class LibrarySection(PlexObject): except (ValueError, AttributeError): raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", ' f'value should be type {fieldType.type}') from None - + return results def _validateFieldValueDate(self, value): @@ -1345,7 +1345,7 @@ class LibrarySection(PlexObject): Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object, :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), or the exact id :attr:`MediaTag.id` (*int*). - + Date type filter values can be a ``datetime`` object, a relative date using a one of the available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format. @@ -1358,7 +1358,7 @@ class LibrarySection(PlexObject): * ``w``: ``weeks`` * ``mon``: ``months`` * ``y``: ``years`` - + Multiple values can be ``OR`` together by providing a list of values. Examples: @@ -1684,12 +1684,12 @@ class LibrarySection(PlexObject): def _validateItems(self, items): """ Validates the specified items are from this library and of the same type. """ - if not items: + if items is None or items == []: raise BadRequest('No items specified.') - + if not isinstance(items, list): items = [items] - + itemType = items[0].type for item in items: if item.librarySectionID != self.key: @@ -3102,6 +3102,7 @@ class FirstCharacter(PlexObject): size (str): Total amount of library items starting with this character. title (str): Character (#, !, A, B, C, ...). """ + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 8793463f..369bb759 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- - import xml +from pathlib import Path from urllib.parse import quote_plus from plexapi import log, settings, utils @@ -121,6 +121,7 @@ class MediaPart(PlexObject): optimizedForStreaming (bool): True if the file is optimized for streaming. packetLength (int): The packet length of the file. requiredBandwidths (str): The required bandwidths to stream the file. + selected (bool): True if this media part is selected. size (int): The size of the file in bytes (ex: 733884416). streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects. syncItemId (int): The unique ID for this media part if it is synced. @@ -184,38 +185,60 @@ class MediaPart(PlexObject): """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """ return [stream for stream in self.streams if isinstance(stream, LyricStream)] - def setDefaultAudioStream(self, stream): - """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart. + def setSelectedAudioStream(self, stream): + """ Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart. Parameters: - stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default + stream (:class:`~plexapi.media.AudioStream`): Audio stream to set as selected """ + key = f'/library/parts/{self.id}' + params = {'allParts': 1} + if isinstance(stream, AudioStream): - key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1" + params['audioStreamID'] = stream.id else: - key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1" - self._server.query(key, method=self._server._session.put) + params['audioStreamID'] = stream + + self._server.query(key, method=self._server._session.put, params=params) return self - def setDefaultSubtitleStream(self, stream): - """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. + def setSelectedSubtitleStream(self, stream): + """ Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart. Parameters: - stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. + stream (:class:`~plexapi.media.SubtitleStream`): Subtitle stream to set as selected. """ + key = f'/library/parts/{self.id}' + params = {'allParts': 1} + if isinstance(stream, SubtitleStream): - key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1" + params['subtitleStreamID'] = stream.id else: - key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1" + params['subtitleStreamID'] = stream + self._server.query(key, method=self._server._session.put) return self - def resetDefaultSubtitleStream(self): - """ Set default subtitle of this MediaPart to 'none'. """ - key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1" - self._server.query(key, method=self._server._session.put) + def resetSelectedSubtitleStream(self): + """ Set the selected subtitle of this MediaPart to 'None'. """ + key = f'/library/parts/{self.id}' + params = {'subtitleStreamID': 0, 'allParts': 1} + + self._server.query(key, method=self._server._session.put, params=params) return self + @deprecated('Use "setSelectedAudioStream" instead.') + def setDefaultAudioStream(self, stream): + return self.setSelectedAudioStream(stream) + + @deprecated('Use "setSelectedSubtitleStream" instead.') + def setDefaultSubtitleStream(self, stream): + return self.setSelectedSubtitleStream(stream) + + @deprecated('Use "resetSelectedSubtitleStream" instead.') + def resetDefaultSubtitleStream(self): + return self.resetSelectedSubtitleStream() + class MediaPartStream(PlexObject): """ Base class for media streams. These consist of video, audio, subtitles, and lyrics. @@ -399,9 +422,15 @@ class AudioStream(MediaPartStream): self.peak = utils.cast(float, data.attrib.get('peak')) self.startRamp = data.attrib.get('startRamp') + def setSelected(self): + """ Sets this audio stream as the selected audio stream. + Alias for :func:`~plexapi.media.MediaPart.setSelectedAudioStream`. + """ + return self._parent().setSelectedAudioStream(self) + + @deprecated('Use "setSelected" instead.') def setDefault(self): - """ Sets this audio stream as the default audio stream. """ - return self._parent().setDefaultAudioStream(self) + return self.setSelected() @utils.registerPlexObject @@ -437,9 +466,15 @@ class SubtitleStream(MediaPartStream): self.transient = data.attrib.get('transient') self.userID = utils.cast(int, data.attrib.get('userID')) + def setSelected(self): + """ Sets this subtitle stream as the selected subtitle stream. + Alias for :func:`~plexapi.media.MediaPart.setSelectedSubtitleStream`. + """ + return self._parent().setSelectedSubtitleStream(self) + + @deprecated('Use "setSelected" instead.') def setDefault(self): - """ Sets this subtitle stream as the default subtitle stream. """ - return self._parent().setDefaultSubtitleStream(self) + return self.setSelected() class LyricStream(MediaPartStream): @@ -973,6 +1008,7 @@ class BaseResource(PlexObject): selected (bool): True if the resource is currently selected. thumb (str): The URL to retrieve the resource thumbnail. """ + def _loadData(self, data): self._data = data self.key = data.attrib.get('key') @@ -989,6 +1025,20 @@ class BaseResource(PlexObject): except xml.etree.ElementTree.ParseError: pass + @property + def resourceFilepath(self): + """ Returns the file path to the resource in the Plex Media Server data directory. + Note: Returns the URL if the resource is not stored locally. + """ + if self.ratingKey.startswith('media://'): + return str(Path('Media') / 'localhost' / self.ratingKey.split('://')[-1]) + elif self.ratingKey.startswith('metadata://'): + return str(Path(self._parent().metadataDirectory) / 'Contents' / '_combined' / self.ratingKey.split('://')[-1]) + elif self.ratingKey.startswith('upload://'): + return str(Path(self._parent().metadataDirectory) / 'Uploads' / self.ratingKey.split('://')[-1]) + else: + return self.ratingKey + class Art(BaseResource): """ Represents a single Art object. """ diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index f0c21cfe..e1cce54b 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -39,7 +39,7 @@ class AdvancedSettingsMixin: pref = preferences[settingID] except KeyError: raise NotFound(f'{value} not found in {list(preferences.keys())}') - + enumValues = pref.enumValues if enumValues.get(value, enumValues.get(str(value))): data[settingID] = value @@ -69,7 +69,7 @@ class SmartFilterMixin: filters = {} filterOp = 'and' filterGroups = [[]] - + for key, value in parse_qsl(content.query): # Move = sign to key when operator is == if value.startswith('='): @@ -96,11 +96,11 @@ class SmartFilterMixin: filterGroups.pop() else: filterGroups[-1].append({key: value}) - + if filterGroups: filters['filters'] = self._formatFilterGroups(filterGroups.pop()) return filters - + def _formatFilterGroups(self, groups): """ Formats the filter groups into the advanced search rules. """ if len(groups) == 1 and isinstance(groups[0], list): @@ -131,7 +131,7 @@ class SplitMergeMixin: def merge(self, ratingKeys): """ Merge other Plex objects into the current object. - + Parameters: ratingKeys (list): A list of rating keys to merge. """ @@ -320,7 +320,7 @@ class RatingMixin: class ArtUrlMixin: """ Mixin for Plex objects that can have a background artwork url. """ - + @property def artUrl(self): """ Return the art url for the Plex object. """ @@ -349,7 +349,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin): def uploadArt(self, url=None, filepath=None): """ Upload a background artwork from a url or filepath. - + Parameters: url (str): The full URL to the image to upload. filepath (str): The full file path the the image to upload or file-like object. @@ -365,7 +365,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin): def setArt(self, art): """ Set the background artwork for a Plex object. - + Parameters: art (:class:`~plexapi.media.Art`): The art object to select. """ @@ -425,7 +425,7 @@ class PosterMixin(PosterUrlMixin, PosterLockMixin): def setPoster(self, poster): """ Set the poster for a Plex object. - + Parameters: poster (:class:`~plexapi.media.Poster`): The poster object to select. """ @@ -491,11 +491,11 @@ class ThemeMixin(ThemeUrlMixin, ThemeLockMixin): class EditFieldMixin: """ Mixin for editing Plex object fields. """ - + def editField(self, field, value, locked=True, **kwargs): """ Edit the field of a Plex object. All field editing methods can be chained together. Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields. - + Parameters: field (str): The name of the field to edit. value (str): The value to edit the field to. diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index c90b5d33..ede2276d 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -111,12 +111,14 @@ class MyPlexAccount(PlexObject): # Hub sections VOD = 'https://vod.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get + DISCOVER = 'https://discover.provider.plex.tv' METADATA = 'https://metadata.provider.plex.tv' key = 'https://plex.tv/api/v2/user' def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True): self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) self._session = session or requests.Session() + self._timeout = timeout or TIMEOUT self._sonos_cache = [] self._sonos_cache_timestamp = 0 data, initpath = self._signin(username, password, code, remember, timeout) @@ -186,7 +188,9 @@ class MyPlexAccount(PlexObject): self.subscriptionPaymentService = subscription.attrib.get('paymentService') self.subscriptionPlan = subscription.attrib.get('plan') self.subscriptionStatus = subscription.attrib.get('status') - self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z') + self.subscriptionSubscribedAt = utils.toDatetime( + subscription.attrib.get('subscribedAt') or None, '%Y-%m-%d %H:%M:%S %Z' + ) profile = data.find('profile') self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio')) @@ -223,7 +227,7 @@ class MyPlexAccount(PlexObject): def query(self, url, method=None, headers=None, timeout=None, **kwargs): method = method or self._session.get - timeout = timeout or TIMEOUT + timeout = timeout or self._timeout log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', '')) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) @@ -239,8 +243,10 @@ class MyPlexAccount(PlexObject): raise Unauthorized(message) else: raise BadRequest(message) - if headers.get('Accept') == 'application/json': + if 'application/json' in response.headers.get('Content-Type', ''): return response.json() + elif 'text/plain' in response.headers.get('Content-Type', ''): + return response.text.strip() data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -672,7 +678,7 @@ class MyPlexAccount(PlexObject): if (invite.username and invite.email and invite.id and username.lower() in (invite.username.lower(), invite.email.lower(), str(invite.id))): return invite - + raise NotFound(f'Unable to find invite {username}') def pendingInvites(self, includeSent=True, includeReceived=True): @@ -950,7 +956,7 @@ class MyPlexAccount(PlexObject): """ if not isinstance(items, list): items = [items] - + for item in items: if self.onWatchlist(item): raise BadRequest(f'"{item.title}" is already on the watchlist') @@ -971,7 +977,7 @@ class MyPlexAccount(PlexObject): """ if not isinstance(items, list): items = [items] - + for item in items: if not self.onWatchlist(item): raise BadRequest(f'"{item.title}" is not on the watchlist') @@ -1053,7 +1059,7 @@ class MyPlexAccount(PlexObject): 'includeMetadata': 1 } - data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params) + data = self.query(f'{self.DISCOVER}/library/search', headers=headers, params=params) searchResults = data['MediaContainer'].get('SearchResults', []) searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), []) @@ -1135,6 +1141,21 @@ class MyPlexAccount(PlexObject): return objs + def publicIP(self): + """ Returns your public IP address. """ + return self.query('https://plex.tv/:/ip') + + def geoip(self, ip_address): + """ Returns a :class:`~plexapi.myplex.GeoLocation` object with geolocation information + for an IP address using Plex's GeoIP database. + + Parameters: + ip_address (str): IP address to lookup. + """ + params = {'ip_address': ip_address} + data = self.query('https://plex.tv/api/v2/geoip', params=params) + return GeoLocation(self, data) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked @@ -1773,7 +1794,7 @@ class MyPlexPinLogin: params = None response = self._query(url, self._session.post, params=params) - if not response: + if response is None: return None self._id = response.attrib.get('id') @@ -1790,7 +1811,7 @@ class MyPlexPinLogin: url = self.CHECKPINS.format(pinid=self._id) response = self._query(url) - if not response: + if response is None: return False token = response.attrib.get('authToken') @@ -1927,7 +1948,7 @@ class AccountOptOut(PlexObject): def optOutManaged(self): """ Sets the Online Media Source to "Disabled for Managed Users". - + Raises: :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music. """ @@ -1964,3 +1985,42 @@ class UserState(PlexObject): self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewState = data.attrib.get('viewState') == 'complete' self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt')) + + +class GeoLocation(PlexObject): + """ Represents a signle IP address geolocation + + Attributes: + TAG (str): location + city (str): City name + code (str): Country code + continentCode (str): Continent code + coordinates (Tuple): Latitude and longitude + country (str): Country name + europeanUnionMember (bool): True if the country is a member of the European Union + inPrivacyRestrictedCountry (bool): True if the country is privacy restricted + postalCode (str): Postal code + subdivisions (str): Subdivision name + timezone (str): Timezone + """ + TAG = 'location' + + def _loadData(self, data): + self._data = data + self.city = data.attrib.get('city') + self.code = data.attrib.get('code') + self.continentCode = data.attrib.get('continent_code') + self.coordinates = tuple( + utils.cast(float, coord) for coord in (data.attrib.get('coordinates') or ',').split(',')) + self.country = data.attrib.get('country') + self.postalCode = data.attrib.get('postal_code') + self.subdivisions = data.attrib.get('subdivisions') + self.timezone = data.attrib.get('time_zone') + + europeanUnionMember = data.attrib.get('european_union_member') + self.europeanUnionMember = ( + False if europeanUnionMember == 'Unknown' else utils.cast(bool, europeanUnionMember)) + + inPrivacyRestrictedCountry = data.attrib.get('in_privacy_restricted_country') + self.inPrivacyRestrictedCountry = ( + False if inPrivacyRestrictedCountry == 'Unknown' else utils.cast(bool, inPrivacyRestrictedCountry)) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 039ac80c..8737d814 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +from pathlib import Path from urllib.parse import quote_plus from plexapi import media, utils, video @@ -139,6 +140,12 @@ class Photoalbum( """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1) + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Photo( @@ -249,7 +256,7 @@ class Photo( List of file paths where the photo is found on disk. """ return [part.file for item in self.media for part in item.parts if part] - + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): """ Add current photo as sync item for specified device. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. @@ -290,6 +297,12 @@ class Photo( """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.parentGuid) + return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class PhotoSession(PlexSession, Photo): diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index c435613a..44073ee7 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import re +from pathlib import Path from urllib.parse import quote_plus, unquote from plexapi import media, utils @@ -154,7 +155,7 @@ class Playlist( sectionKey = int(match.group(1)) self._section = self._server.library.sectionByID(sectionKey) return self._section - + # Try to get the library section from the first item in the playlist if self.items(): self._section = self.items()[0].section() @@ -313,7 +314,7 @@ class Playlist( def edit(self, title=None, summary=None): """ Edit the playlist. - + Parameters: title (str, optional): The title of the playlist. summary (str, optional): The summary of the playlist. @@ -431,7 +432,7 @@ class Playlist( def copyToUser(self, user): """ Copy playlist to another user account. - + Parameters: user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username, email, or user id of the user to copy the playlist to. @@ -496,3 +497,9 @@ class Playlist( def _getWebURL(self, base=None): """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key) + + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Playlists' / guid_hash[0] / f'{guid_hash[1:]}.bundle') diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 69d5f89a..52a203a8 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -109,7 +109,7 @@ class PlexServer(PlexObject): self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._session = session or requests.Session() - self._timeout = timeout + self._timeout = timeout or TIMEOUT self._myPlexAccount = None # cached myPlexAccount self._systemAccounts = None # cached list of SystemAccount self._systemDevices = None # cached list of SystemDevice @@ -189,6 +189,11 @@ class PlexServer(PlexObject): data = self.query(Settings.key) return Settings(self, data) + def identity(self): + """ Returns the Plex server identity. """ + data = self.query('/identity') + return Identity(self, data) + def account(self): """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """ data = self.query(Account.key) @@ -197,7 +202,7 @@ class PlexServer(PlexObject): def claim(self, account): """ Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`. This will only work with an unclaimed server on localhost or the same subnet. - + Parameters: account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to claim the server. @@ -240,7 +245,7 @@ class PlexServer(PlexObject): def switchUser(self, user, session=None, timeout=None): """ Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username. Note: Only the admin account can switch to other users. - + Parameters: user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username, email, or user id of the user to log in to the server. @@ -585,7 +590,7 @@ class PlexServer(PlexObject): def runButlerTask(self, task): """ Manually run a butler task immediately instead of waiting for the scheduled task to run. Note: The butler task is run asynchronously. Check Plex Web to monitor activity. - + Parameters: task (str): The name of the task to run. (e.g. 'BackupDatabase') @@ -597,7 +602,7 @@ class PlexServer(PlexObject): print("Available butler tasks:", availableTasks) """ - validTasks = [task.name for task in self.butlerTasks()] + validTasks = [_task.name for _task in self.butlerTasks()] if task not in validTasks: raise BadRequest( f'Invalid butler task: {task}. Available tasks are: {validTasks}' @@ -610,7 +615,8 @@ class PlexServer(PlexObject): return self.checkForUpdate(force=force, download=download) def checkForUpdate(self, force=True, download=False): - """ Returns a :class:`~plexapi.base.Release` object containing release info. + """ Returns a :class:`~plexapi.server.Release` object containing release info + if an update is available or None if no update is available. Parameters: force (bool): Force server to check for new releases @@ -624,12 +630,19 @@ class PlexServer(PlexObject): return releases[0] def isLatest(self): - """ Check if the installed version of PMS is the latest. """ + """ Returns True if the installed version of Plex Media Server is the latest. """ release = self.checkForUpdate(force=True) return release is None + def canInstallUpdate(self): + """ Returns True if the newest version of Plex Media Server can be installed automatically. + (e.g. Windows and Mac can install updates automatically, but Docker and NAS devices cannot.) + """ + release = self.query('/updater/status') + return utils.cast(bool, release.get('canInstall')) + def installUpdate(self): - """ Install the newest version of Plex Media Server. """ + """ Automatically install the newest version of Plex Media Server. """ # We can add this but dunno how useful this is since it sometimes # requires user action using a gui. part = '/updater/apply' @@ -661,7 +674,7 @@ class PlexServer(PlexObject): args['librarySectionID'] = librarySectionID if mindate: args['viewedAt>'] = int(mindate.timestamp()) - + key = f'/status/sessions/history/all{utils.joinArgs(args)}' return self.fetchItems(key, maxresults=maxresults) @@ -741,7 +754,7 @@ class PlexServer(PlexObject): """ url = self.url(key) method = method or self._session.get - timeout = timeout or TIMEOUT + timeout = timeout or self._timeout log.debug('%s %s', method.__name__.upper(), url) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) @@ -1253,7 +1266,7 @@ class StatisticsResources(PlexObject): @utils.registerPlexObject class ButlerTask(PlexObject): """ Represents a single scheduled butler task. - + Attributes: TAG (str): 'ButlerTask' description (str): The description of the task. @@ -1273,3 +1286,22 @@ class ButlerTask(PlexObject): self.name = data.attrib.get('name') self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized')) self.title = data.attrib.get('title') + + +class Identity(PlexObject): + """ Represents a server identity. + + Attributes: + claimed (bool): True or False if the server is claimed. + machineIdentifier (str): The Plex server machine identifier. + version (str): The Plex server version. + """ + + def __repr__(self): + return f"<{self.__class__.__name__}:{self.machineIdentifier}>" + + def _loadData(self, data): + self._data = data + self.claimed = utils.cast(bool, data.attrib.get('claimed')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.version = data.attrib.get('version') diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index 66468c30..f57e89d9 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -23,7 +23,6 @@ you can set items to be synced to your app) you need to init some variables. You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have to explicitly specify that your app supports `sync-target`. """ - import requests import plexapi diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index d1882fbb..8478f2d4 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -11,13 +11,14 @@ import unicodedata import warnings import zipfile from collections import deque -from datetime import datetime +from datetime import datetime, timedelta from getpass import getpass +from hashlib import sha1 from threading import Event, Thread from urllib.parse import quote -from requests.status_codes import _codes as codes import requests +from requests.status_codes import _codes as codes from plexapi.exceptions import BadRequest, NotFound, Unauthorized @@ -313,33 +314,44 @@ def toDatetime(value, format=None): value (str): value to return as a datetime format (str): Format to pass strftime (optional; if value is a str). """ - if value and value is not None: + if value is not None: if format: try: - value = datetime.strptime(value, format) + return datetime.strptime(value, format) except ValueError: - log.info('Failed to parse %s to datetime, defaulting to None', value) + log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format) return None else: - # https://bugs.python.org/issue30684 - # And platform support for before epoch seems to be flaky. - # Also limit to max 32-bit integer - value = min(max(int(value), 86400), 2**31 - 1) - value = datetime.fromtimestamp(int(value)) + try: + value = int(value) + except ValueError: + log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value) + return None + try: + return datetime.fromtimestamp(value) + except (OSError, OverflowError): + try: + return datetime.fromtimestamp(0) + timedelta(seconds=value) + except OverflowError: + log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value) + return None return value def millisecondToHumanstr(milliseconds): - """ Returns human readable time duration from milliseconds. - HH:MM:SS:MMMM + """ Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds. Parameters: - milliseconds (str,int): time duration in milliseconds. + milliseconds (str, int): time duration in milliseconds. """ milliseconds = int(milliseconds) - r = datetime.utcfromtimestamp(milliseconds / 1000) - f = r.strftime("%H:%M:%S.%f") - return f[:-2] + if milliseconds < 0: + return '-' + millisecondToHumanstr(abs(milliseconds)) + secs, ms = divmod(milliseconds, 1000) + mins, secs = divmod(secs, 60) + hours, mins = divmod(mins, 60) + days, hours = divmod(hours, 24) + return ('' if days == 0 else f'{days} day{"s" if days > 1 else ""}, ') + f'{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}' def toList(value, itemcast=None, delim=','): @@ -644,3 +656,8 @@ def openOrRead(file): return file.read() with open(file, 'rb') as f: return f.read() + + +def sha1hash(guid): + """ Return the SHA1 hash of a guid. """ + return sha1(guid.encode('utf-8')).hexdigest() diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 486bb5ca..e95b12ff 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import os +from functools import cached_property +from pathlib import Path from urllib.parse import quote_plus from plexapi import media, utils @@ -445,6 +447,12 @@ class Movie( self._server.query(key, params=params, method=self._server._session.put) return self + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Show( @@ -655,6 +663,12 @@ class Show( filepaths += episode.download(_savepath, keep_original_name, **kwargs) return filepaths + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Season( @@ -663,7 +677,7 @@ class Season( ArtMixin, PosterMixin, ThemeUrlMixin, SeasonEditMixins ): - """ Represents a single Show Season (including all episodes). + """ Represents a single Season. Attributes: TAG (str): 'Directory' @@ -808,6 +822,12 @@ class Season( """ Returns str, default title for a new syncItem. """ return f'{self.parentTitle} - {self.title}' + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.parentGuid) + return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Episode( @@ -816,7 +836,7 @@ class Episode( ArtMixin, PosterMixin, ThemeUrlMixin, EpisodeEditMixins ): - """ Represents a single Shows Episode. + """ Represents a single Episode. Attributes: TAG (str): 'Video' @@ -845,7 +865,7 @@ class Episode( parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72). parentIndex (int): Season number of episode. parentKey (str): API URL of the season (/library/metadata/). - parentRatingKey (int): Unique key identifying the season. + parentRatingKey (int): Unique key identifying the season. parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the season for the episode. parentYear (int): Year the season was released. @@ -866,7 +886,6 @@ class Episode( """ Load attribute values from Plex XML response. """ Video._loadData(self, data) Playable._loadData(self, data) - self._seasonNumber = None # cached season number self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRatingImage = data.attrib.get('audienceRatingImage') self.chapters = self.findItems(data, media.Chapter) @@ -890,9 +909,6 @@ class Episode( self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) - self.parentKey = data.attrib.get('parentKey') - self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) - self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.parentYear = utils.cast(int, data.attrib.get('parentYear')) self.producers = self.findItems(data, media.Producer) @@ -906,15 +922,50 @@ class Episode( # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 - if self.skipParent and data.attrib.get('parentRatingKey') is None: - # Parse the parentRatingKey from the parentThumb - if self.parentThumb and self.parentThumb.startswith('/library/metadata/'): - self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) - # Get the parentRatingKey from the season's ratingKey - if not self.parentRatingKey and self.grandparentRatingKey: - self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey - if self.parentRatingKey: - self.parentKey = f'/library/metadata/{self.parentRatingKey}' + # Use cached properties below to return the correct values if they are missing to avoid auto-reloading. + self._parentKey = data.attrib.get('parentKey') + self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self._parentThumb = data.attrib.get('parentThumb') + + @cached_property + def parentKey(self): + """ Returns the parentKey. Refer to the Episode attributes. """ + if self._parentKey: + return self._parentKey + if self.parentRatingKey: + return f'/library/metadata/{self.parentRatingKey}' + return None + + @cached_property + def parentRatingKey(self): + """ Returns the parentRatingKey. Refer to the Episode attributes. """ + if self._parentRatingKey is not None: + return self._parentRatingKey + # Parse the parentRatingKey from the parentThumb + if self._parentThumb and self._parentThumb.startswith('/library/metadata/'): + return utils.cast(int, self._parentThumb.split('/')[3]) + # Get the parentRatingKey from the season's ratingKey if available + if self._season: + return self._season.ratingKey + return None + + @cached_property + def parentThumb(self): + """ Returns the parentThumb. Refer to the Episode attributes. """ + if self._parentThumb: + return self._parentThumb + if self._season: + return self._season.thumb + return None + + @cached_property + def _season(self): + """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ + if not self.grandparentKey: + return None + return self.fetchItem( + f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}' + ) def __repr__(self): return '<{}>'.format( @@ -949,12 +1000,10 @@ class Episode( """ Returns the episode number. """ return self.index - @property + @cached_property def seasonNumber(self): """ Returns the episode's season number. """ - if self._seasonNumber is None: - self._seasonNumber = self.parentIndex if isinstance(self.parentIndex, int) else self.season().seasonNumber - return utils.cast(int, self._seasonNumber) + return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber @property def seasonEpisode(self): @@ -1000,6 +1049,12 @@ class Episode( self._server.query(key, params=params, method=self._server._session.put) return self + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.grandparentGuid) + return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + @utils.registerPlexObject class Clip( @@ -1058,6 +1113,12 @@ class Clip( """ Returns a filename for use in download. """ return self.title + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.guid) + return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + class Extra(Clip): """ Represents a single Extra (trailer, behindTheScenes, etc). """ diff --git a/requirements.txt b/requirements.txt index 8ca67fe6..04578ef9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ MarkupSafe==2.1.3 musicbrainzngs==0.7.1 packaging==23.1 paho-mqtt==1.6.1 -plexapi==4.15.0 +plexapi==4.15.4 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.8.0 From a59e07c07d00fc12caf466b5bb71c1412d8eafa7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:23:26 -0700 Subject: [PATCH 14/19] Add metadataDirectory to exporter fields --- plexpy/exporter.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plexpy/exporter.py b/plexpy/exporter.py index e1dc989d..1232481c 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -371,6 +371,7 @@ class Export(object): } } }, + 'metadataDirectory': None, 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), 'originalTitle': None, 'producers': { @@ -457,6 +458,7 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'locations': None, + 'metadataDirectory': None, 'network': None, 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), 'originalTitle': None, @@ -523,6 +525,7 @@ class Export(object): 'librarySectionID': None, 'librarySectionKey': None, 'librarySectionTitle': None, + 'metadataDirectory': None, 'parentGuid': None, 'parentIndex': None, 'parentKey': None, @@ -769,6 +772,7 @@ class Export(object): } } }, + 'metadataDirectory': None, 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), 'parentGuid': None, 'parentIndex': None, @@ -849,6 +853,7 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'locations': None, + 'metadataDirectory': None, 'moods': { 'id': None, 'tag': None @@ -918,6 +923,7 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'loudnessAnalysisVersion': None, + 'metadataDirectory': None, 'moods': { 'id': None, 'tag': None @@ -1087,6 +1093,7 @@ class Export(object): } } }, + 'metadataDirectory': None, 'moods': { 'id': None, 'tag': None @@ -1133,6 +1140,7 @@ class Export(object): 'librarySectionID': None, 'librarySectionKey': None, 'librarySectionTitle': None, + 'metadataDirectory': None, 'ratingKey': None, 'summary': None, 'thumb': None, @@ -1164,6 +1172,7 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'locations': None, + 'metadataDirectory': None, 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), 'parentGuid': None, 'parentIndex': None, @@ -1238,6 +1247,7 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'maxYear': None, + 'metadataDirectory': None, 'minYear': None, 'ratingKey': None, 'subtype': None, @@ -1266,6 +1276,7 @@ class Export(object): 'icon': None, 'key': None, 'leafCount': None, + 'metadataDirectory': None, 'playlistType': None, 'ratingKey': None, 'smart': None, From b32183b7b6284508a60acd1a17f76a81c1b05738 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Tue, 10 Oct 2023 19:18:18 -0700 Subject: [PATCH 15/19] Speed up graphs data lookup --- plexpy/graphs.py | 162 +++++++++++++++++++--------------------------- plexpy/helpers.py | 4 +- 2 files changed, 69 insertions(+), 97 deletions(-) diff --git a/plexpy/graphs.py b/plexpy/graphs.py index 82db9d82..7596e124 100644 --- a/plexpy/graphs.py +++ b/plexpy/graphs.py @@ -101,6 +101,8 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_day: %s." % e) return None + result_by_date_played = {item['date_played']: item for item in result} + # create our date range as some days may not have any data # but we still want to display them base = datetime.date.today() @@ -115,22 +117,13 @@ class Graphs(object): for date_item in sorted(date_list): date_string = date_item.strftime('%Y-%m-%d') categories.append(date_string) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 - for item in result: - if date_string == item['date_played']: - series_1_value = item['tv_count'] - series_2_value = item['movie_count'] - series_3_value = item['music_count'] - series_4_value = item['live_count'] - break - else: - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 + + result_date = result_by_date_played.get(date_string, {}) + + series_1_value = result_date.get('tv_count', 0) + series_2_value = result_date.get('movie_count', 0) + series_3_value = result_date.get('music_count', 0) + series_4_value = result_date.get('live_count', 0) series_1.append(series_1_value) series_2.append(series_2_value) @@ -233,6 +226,8 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_dayofweek: %s." % e) return None + result_by_dayofweek = {item['dayofweek']: item for item in result} + if plexpy.CONFIG.WEEK_START_MONDAY: days_list = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] @@ -248,22 +243,13 @@ class Graphs(object): for day_item in days_list: categories.append(day_item) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 - for item in result: - if day_item == item['dayofweek']: - series_1_value = item['tv_count'] - series_2_value = item['movie_count'] - series_3_value = item['music_count'] - series_4_value = item['live_count'] - break - else: - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 + + result_day = result_by_dayofweek.get(day_item, {}) + + series_1_value = result_day.get('tv_count', 0) + series_2_value = result_day.get('movie_count', 0) + series_3_value = result_day.get('music_count', 0) + series_4_value = result_day.get('live_count', 0) series_1.append(series_1_value) series_2.append(series_2_value) @@ -350,6 +336,8 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_hourofday: %s." % e) return None + result_by_hourofday = {item['hourofday']: item for item in result} + hours_list = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', @@ -363,22 +351,13 @@ class Graphs(object): for hour_item in hours_list: categories.append(hour_item) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 - for item in result: - if hour_item == item['hourofday']: - series_1_value = item['tv_count'] - series_2_value = item['movie_count'] - series_3_value = item['music_count'] - series_4_value = item['live_count'] - break - else: - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 + + result_hour = result_by_hourofday.get(hour_item, {}) + + series_1_value = result_hour.get('tv_count', 0) + series_2_value = result_hour.get('movie_count', 0) + series_3_value = result_hour.get('music_count', 0) + series_4_value = result_hour.get('live_count', 0) series_1.append(series_1_value) series_2.append(series_2_value) @@ -465,6 +444,8 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_month: %s." % e) return None + result_by_datestring = {item['datestring']: item for item in result} + # create our date range as some months may not have any data # but we still want to display them dt_today = datetime.date.today() @@ -486,22 +467,13 @@ class Graphs(object): for dt in sorted(month_range): date_string = dt.strftime('%Y-%m') categories.append(dt.strftime('%b %Y')) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 - for item in result: - if date_string == item['datestring']: - series_1_value = item['tv_count'] - series_2_value = item['movie_count'] - series_3_value = item['music_count'] - series_4_value = item['live_count'] - break - else: - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 + + result_date = result_by_datestring.get(date_string, {}) + + series_1_value = result_date.get('tv_count', 0) + series_2_value = result_date.get('movie_count', 0) + series_3_value = result_date.get('music_count', 0) + series_4_value = result_date.get('live_count', 0) series_1.append(series_1_value) series_2.append(series_2_value) @@ -598,6 +570,7 @@ class Graphs(object): for item in result: categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform'])) + series_1.append(item['tv_count']) series_2.append(item['movie_count']) series_3.append(item['music_count']) @@ -704,6 +677,7 @@ class Graphs(object): categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User') else: categories.append(item['friendly_name']) + series_1.append(item['tv_count']) series_2.append(item['movie_count']) series_3.append(item['music_count']) @@ -783,6 +757,8 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e) return None + result_by_date_played = {item['date_played']: item for item in result} + # create our date range as some days may not have any data # but we still want to display them base = datetime.date.today() @@ -796,19 +772,12 @@ class Graphs(object): for date_item in sorted(date_list): date_string = date_item.strftime('%Y-%m-%d') categories.append(date_string) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - for item in result: - if date_string == item['date_played']: - series_1_value = item['dp_count'] - series_2_value = item['ds_count'] - series_3_value = item['tc_count'] - break - else: - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 + + result_date = result_by_date_played.get(date_string, {}) + + series_1_value = result_date.get('dp_count', 0) + series_2_value = result_date.get('ds_count', 0) + series_3_value = result_date.get('tc_count', 0) series_1.append(series_1_value) series_2.append(series_2_value) @@ -869,6 +838,9 @@ class Graphs(object): logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e) return None + result_by_date_and_decision = helpers.group_by_keys(result, ('date_played', 'transcode_decision')) + result_by_date = helpers.group_by_keys(result, 'date_played') + # create our date range as some days may not have any data # but we still want to display them base = datetime.date.today() @@ -880,29 +852,23 @@ class Graphs(object): series_3 = [] series_4 = [] - grouped_result_by_stream_type = helpers.group_by_keys(result, ('date_played','transcode_decision')) - grouped_result_by_day = helpers.group_by_keys(result, ['date_played']) - for date_item in sorted(date_list): date_string = date_item.strftime('%Y-%m-%d') categories.append(date_string) - series_1_value = 0 - series_2_value = 0 - series_3_value = 0 - series_4_value = 0 - for item in grouped_result_by_stream_type: - if item['key'] == (date_string,'direct play'): - series_1_value = calc_most_concurrent(item['value']) - elif item['key'] == (date_string,'copy'): - series_2_value = calc_most_concurrent(item['value']) - elif item['key'] == (date_string,'transcode'): - series_3_value = calc_most_concurrent(item['value']) + series_1_value = calc_most_concurrent( + result_by_date_and_decision.get((date_string, 'direct play'), []) + ) + series_2_value = calc_most_concurrent( + result_by_date_and_decision.get((date_string, 'copy'), []) + ) + series_3_value = calc_most_concurrent( + result_by_date_and_decision.get((date_string, 'transcode'), []) + ) + series_4_value = calc_most_concurrent( + result_by_date.get(date_string, []) + ) - for item in grouped_result_by_day: - if item['key'] == date_string: - series_4_value = calc_most_concurrent(item['value']) - series_1.append(series_1_value) series_2.append(series_2_value) series_3.append(series_3_value) @@ -983,6 +949,7 @@ class Graphs(object): for item in result: categories.append(item['resolution']) + series_1.append(item['dp_count']) series_2.append(item['ds_count']) series_3.append(item['tc_count']) @@ -1086,6 +1053,7 @@ class Graphs(object): for item in result: categories.append(item['resolution']) + series_1.append(item['dp_count']) series_2.append(item['ds_count']) series_3.append(item['tc_count']) @@ -1161,6 +1129,7 @@ class Graphs(object): for item in result: categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform'])) + series_1.append(item['dp_count']) series_2.append(item['ds_count']) series_3.append(item['tc_count']) @@ -1248,6 +1217,7 @@ class Graphs(object): categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User') else: categories.append(item['friendly_name']) + series_1.append(item['dp_count']) series_2.append(item['ds_count']) series_3.append(item['tc_count']) @@ -1275,5 +1245,5 @@ class Graphs(object): elif user_id: user_ids = helpers.split_strip(user_id) if all(id.isdigit() for id in user_ids): - user_cond =cond_prefix + ' session_history.user_id IN (%s) ' % ','.join(user_ids) + user_cond = cond_prefix + ' session_history.user_id IN (%s) ' % ','.join(user_ids) return user_cond diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 4512f0b6..e8056eb4 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -1242,11 +1242,13 @@ def grouper(iterable, n, fillvalue=None): args = [iter(iterable)] * n return zip_longest(fillvalue=fillvalue, *args) + def group_by_keys(iterable, keys): key_function = operator.itemgetter(*keys) sorted_iterable = sorted(iterable, key=key_function) - return[{'key': key, 'value': list(group)} for key, group in groupby(sorted_iterable, key_function)] + return {key: list(group) for key, group in groupby(sorted_iterable, key_function)} + def chunk(it, size): it = iter(it) From d8d1f75605fdc1679890837395af4a80d16beee1 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Tue, 10 Oct 2023 19:32:30 -0700 Subject: [PATCH 16/19] Use group_by_keys helper for library stats --- plexpy/datafactory.py | 3 +-- plexpy/helpers.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index bd5a7c31..0937480d 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -22,7 +22,6 @@ from future.builtins import str from future.builtins import object import json -from itertools import groupby import plexpy if plexpy.PYTHON2: @@ -1187,7 +1186,7 @@ class DataFactory(object): library_stats.append(library) library_stats = session.mask_session_info(library_stats) - library_stats = {k: list(v) for k, v in groupby(library_stats, key=lambda x: x['section_type'])} + library_stats = helpers.group_by_keys(library_stats, 'section_type') return library_stats diff --git a/plexpy/helpers.py b/plexpy/helpers.py index e8056eb4..25850a16 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -1244,8 +1244,10 @@ def grouper(iterable, n, fillvalue=None): def group_by_keys(iterable, keys): - key_function = operator.itemgetter(*keys) + if not isinstance(keys, (list, tuple)): + keys = [keys] + key_function = operator.itemgetter(*keys) sorted_iterable = sorted(iterable, key=key_function) return {key: list(group) for key, group in groupby(sorted_iterable, key_function)} From 7245e97726ea6b8ff0427a7b794d32e595c305d8 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Tue, 10 Oct 2023 21:27:13 -0700 Subject: [PATCH 17/19] Fix concurrent streams graph query --- plexpy/graphs.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plexpy/graphs.py b/plexpy/graphs.py index 7596e124..8758f71f 100644 --- a/plexpy/graphs.py +++ b/plexpy/graphs.py @@ -825,13 +825,14 @@ class Graphs(object): return final_count try: - query = 'SELECT sh.date_played, sh.started, sh.stopped, shmi.transcode_decision ' \ - 'FROM (SELECT *, ' \ - 'date(started, "unixepoch", "localtime") AS date_played ' \ - 'FROM session_history %s) AS sh ' \ - 'JOIN session_history_media_info AS shmi ON sh.id = shmi.id ' \ - 'WHERE sh.stopped >= %s ' \ - 'ORDER BY sh.date_played' % (user_cond, timestamp) + query = "SELECT sh.date_played, sh.started, sh.stopped, shmi.transcode_decision " \ + "FROM (SELECT *, " \ + "date(started, 'unixepoch', 'localtime') AS date_played " \ + "FROM session_history %s " \ + "GROUP BY id) AS sh " \ + "JOIN session_history_media_info AS shmi ON sh.id = shmi.id " \ + "WHERE sh.stopped >= %s " \ + "ORDER BY sh.started" % (user_cond, timestamp) result = monitor_db.select(query) except Exception as e: From efdd4156d89b14a7418ae40c104631b0e7de8d09 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 16 Oct 2023 15:04:19 -0700 Subject: [PATCH 18/19] Update IP helper function --- plexpy/helpers.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 25850a16..486be759 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -1194,18 +1194,20 @@ def get_plexpy_url(hostname=None): scheme = 'http' if hostname is None and plexpy.CONFIG.HTTP_HOST in ('0.0.0.0', '::'): - import socket + # Only returns IPv4 address + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + s.settimeout(0) try: - # Only returns IPv4 address - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - s.connect(('', 0)) + s.connect(('', 1)) hostname = s.getsockname()[0] except socket.error: try: hostname = socket.gethostbyname(socket.gethostname()) except socket.gaierror: pass + finally: + s.close() if not hostname: hostname = 'localhost' From 1e4fc05ecfddb6e710afe05e5e55cca6b78799c4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:23:04 -0700 Subject: [PATCH 19/19] Add ping method to refresh token --- plexpy/plextv.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 8bab1d51..d3afb26a 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -80,6 +80,7 @@ def get_server_resources(return_presence=False, return_server=False, return_info port=server['pms_port']) plex_tv = PlexTV() + plex_tv.ping() result = plex_tv.get_server_connections(pms_identifier=server['pms_identifier'], pms_ip=server['pms_ip'], pms_port=server['pms_port'], @@ -347,6 +348,13 @@ class PlexTV(object): return request + def ping_plextv(self, output_format=''): + uri = '/api/v2/ping' + request = self.request_handler.make_request(uri=uri, + request_type='GET', + output_format=output_format) + return request + def get_full_users_list(self): own_account = self.get_plextv_user_details(output_format='xml') friends_list = self.get_plextv_friends(output_format='xml') @@ -950,3 +958,18 @@ class PlexTV(object): } return geo_info + + def ping(self): + logger.info(u"Tautulli PlexTV :: Pinging Plex.tv to refresh token.") + + pong = self.ping_plextv(output_format='xml') + + try: + xml_head = pong.getElementsByTagName('pong') + except Exception as e: + logger.warn(u"Tautulli PlexTV :: Unable to parse XML for ping: %s." % e) + return None + + if xml_head: + return helpers.bool_true(xml_head[0].firstChild.nodeValue) + return False