mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-08-22 06:13:25 -07:00
Merge branch 'nightly' of https://github.com/Tautulli/Tautulli
This commit is contained in:
commit
5b0ee732ee
39 changed files with 841 additions and 256 deletions
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
@ -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
|
||||
|
|
12
.github/workflows/publish-docker.yml
vendored
12
.github/workflows/publish-docker.yml
vendored
|
@ -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
|
||||
|
@ -38,10 +38,10 @@ 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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
with:
|
||||
version: latest
|
||||
|
@ -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
|
||||
|
@ -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: .
|
||||
|
|
4
.github/workflows/publish-installers.yml
vendored
4
.github/workflows/publish-installers.yml
vendored
|
@ -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
|
||||
|
|
4
.github/workflows/publish-snap.yml
vendored
4
.github/workflows/publish-snap.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
|||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
|
@ -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
|
||||
|
|
2
.github/workflows/pull-requests.yml
vendored
2
.github/workflows/pull-requests.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -137,6 +137,20 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-video-camera"></i> Daily concurrent stream count</span> by stream type <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The total count of concurrent streams of tv, movies, and music by the transcode decision.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_concurrent_streams_by_stream_type">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-expand-arrows-alt"></i> <span class="yaxis-text">Play count</span> by source resolution <small>Last <span class="days">30</span> days</small></h4>
|
||||
|
@ -312,7 +326,8 @@
|
|||
'Live TV': '#19A0D7',
|
||||
'Direct Play': '#E5A00D',
|
||||
'Direct Stream': '#FFFFFF',
|
||||
'Transcode': '#F06464'
|
||||
'Transcode': '#F06464',
|
||||
'Max. Concurrent Streams': '#96C83C'
|
||||
};
|
||||
var series_colors = [];
|
||||
$.each(data_series, function(index, series) {
|
||||
|
@ -327,6 +342,7 @@
|
|||
<script src="${http_root}js/graphs/plays_by_platform.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_user.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_stream_type.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/concurrent_streams_by_stream_type.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_source_resolution.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_stream_resolution.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.js${cache_param}"></script>
|
||||
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
var formatter_function = function() {
|
||||
if (moment(this.x, 'X').isValid() && (this.x > 946684800)) {
|
||||
var s = '<b>'+ moment(this.x).format('ddd MMM D') +'</b>';
|
||||
} else {
|
||||
var s = '<b>'+ this.x +'</b>';
|
||||
}
|
||||
$.each(this.points, function(i, point) {
|
||||
s += '<br/>'+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: [{}]
|
||||
};
|
|
@ -28,15 +28,17 @@ DOCUMENTATION :: END
|
|||
<span class="toggle-left official-tooltip" data-toggle="tooltip" data-placement="top" title="Unofficial or Unknown App"><i class="fa fa-lg fa-fw fa-exclamation-triangle"></i></span>
|
||||
% endif
|
||||
${device['friendly_name'] or device['device_name']} <span class="friendly_name">(${device['id']})</span>
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
|
||||
<span class="toggle-right friendly_name">
|
||||
% if device['last_seen']:
|
||||
<script>
|
||||
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
||||
</script>
|
||||
<span id="device-last_seen-${device['id']}">
|
||||
<script>
|
||||
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
never
|
||||
% endif
|
||||
<i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
|
|
@ -20,13 +20,28 @@ DOCUMENTATION :: END
|
|||
% else:
|
||||
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']})</span>
|
||||
% endif
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
|
||||
<span class="toggle-right friendly_name">
|
||||
% if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
|
||||
<% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
|
||||
<script>
|
||||
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
|
||||
</script>
|
||||
<span id="newsletter-next_run-${newsletter['id']}">
|
||||
<script>
|
||||
$("#newsletter-next_run-${newsletter['id']}").text(
|
||||
"Next: " + moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow() + " | ")
|
||||
</script>
|
||||
</span>
|
||||
% endif
|
||||
% if newsletter['last_triggered']:
|
||||
<% icon, icon_tooltip = ('fa-check', 'Success') if newsletter['last_success'] else ('fa-times', 'Failed') %>
|
||||
<span id="newsletter-last_triggered-${newsletter['id']}">
|
||||
<script>
|
||||
$("#newsletter-last_triggered-${newsletter['id']}").html(
|
||||
"Last: " + moment("${newsletter['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
|
||||
)
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
Last: never
|
||||
<i class="fa fa-lg fa-fw fa-minus"></i>
|
||||
% endif
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
@ -19,7 +19,20 @@ DOCUMENTATION :: END
|
|||
% else:
|
||||
${notifier['agent_label']} <span class="friendly_name">(${notifier['id']})</span>
|
||||
% endif
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name">
|
||||
% if notifier['last_triggered']:
|
||||
<% icon, icon_tooltip = ('fa-check', 'Success') if notifier['last_success'] else ('fa-times', 'Failed') %>
|
||||
<span id="notifier-last_triggered-${notifier['id']}">
|
||||
<script>
|
||||
$("#notifier-last_triggered-${notifier['id']}").html(
|
||||
moment("${notifier['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
|
||||
)
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
never
|
||||
<i class="fa fa-lg fa-fw fa-minus"></i>
|
||||
% endif
|
||||
</span>
|
||||
</li>
|
||||
% endfor
|
||||
|
|
|
@ -1278,7 +1278,7 @@
|
|||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
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.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
Please see the <a href="${anon_url('https://github.com/%s/%s/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">Notification Agents Guide</a> for instructions on setting up each notification agent.
|
||||
|
@ -1298,7 +1298,7 @@
|
|||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
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.
|
||||
</p>
|
||||
<p class="help-block settings-warning" id="newsletter_upload_warning">
|
||||
Warning: The <a data-tab-destination="3rd_party_apis" data-target="notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</span>
|
||||
|
@ -1630,7 +1630,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label>Registered Devices</label>
|
||||
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
|
||||
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking on the item below.</p>
|
||||
<p id="app_api_msg" style="color: #eb8600;">Warning: The API must be enabled under <a data-tab-destination="web_interface" data-target="api_enabled">Web Interface</a> to use the app.</p>
|
||||
<br />
|
||||
<div class="row">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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. """
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<float>): 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))
|
||||
|
|
|
@ -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<str> 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):
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>).
|
||||
parentRatingKey (int): Unique key identifying the season.
|
||||
parentRatingKey (int): Unique key identifying the season.
|
||||
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
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). """
|
||||
|
|
|
@ -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:
|
||||
|
@ -272,7 +271,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']
|
||||
|
@ -1218,7 +1217,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
|
||||
|
||||
|
|
|
@ -371,6 +371,7 @@ class Export(object):
|
|||
}
|
||||
}
|
||||
},
|
||||
'metadataDirectory': None,
|
||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||
'originalTitle': None,
|
||||
'producers': {
|
||||
|
@ -420,8 +421,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,
|
||||
|
@ -459,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,
|
||||
|
@ -525,6 +525,7 @@ class Export(object):
|
|||
'librarySectionID': None,
|
||||
'librarySectionKey': None,
|
||||
'librarySectionTitle': None,
|
||||
'metadataDirectory': None,
|
||||
'parentGuid': None,
|
||||
'parentIndex': None,
|
||||
'parentKey': None,
|
||||
|
@ -771,6 +772,7 @@ class Export(object):
|
|||
}
|
||||
}
|
||||
},
|
||||
'metadataDirectory': None,
|
||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||
'parentGuid': None,
|
||||
'parentIndex': None,
|
||||
|
@ -851,6 +853,7 @@ class Export(object):
|
|||
'librarySectionKey': None,
|
||||
'librarySectionTitle': None,
|
||||
'locations': None,
|
||||
'metadataDirectory': None,
|
||||
'moods': {
|
||||
'id': None,
|
||||
'tag': None
|
||||
|
@ -920,6 +923,7 @@ class Export(object):
|
|||
'librarySectionKey': None,
|
||||
'librarySectionTitle': None,
|
||||
'loudnessAnalysisVersion': None,
|
||||
'metadataDirectory': None,
|
||||
'moods': {
|
||||
'id': None,
|
||||
'tag': None
|
||||
|
@ -1089,6 +1093,7 @@ class Export(object):
|
|||
}
|
||||
}
|
||||
},
|
||||
'metadataDirectory': None,
|
||||
'moods': {
|
||||
'id': None,
|
||||
'tag': None
|
||||
|
@ -1135,6 +1140,7 @@ class Export(object):
|
|||
'librarySectionID': None,
|
||||
'librarySectionKey': None,
|
||||
'librarySectionTitle': None,
|
||||
'metadataDirectory': None,
|
||||
'ratingKey': None,
|
||||
'summary': None,
|
||||
'thumb': None,
|
||||
|
@ -1166,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,
|
||||
|
@ -1240,6 +1247,7 @@ class Export(object):
|
|||
'librarySectionKey': None,
|
||||
'librarySectionTitle': None,
|
||||
'maxYear': None,
|
||||
'metadataDirectory': None,
|
||||
'minYear': None,
|
||||
'ratingKey': None,
|
||||
'subtype': None,
|
||||
|
@ -1268,6 +1276,7 @@ class Export(object):
|
|||
'icon': None,
|
||||
'key': None,
|
||||
'leafCount': None,
|
||||
'metadataDirectory': None,
|
||||
'playlistType': None,
|
||||
'ratingKey': None,
|
||||
'smart': None,
|
||||
|
@ -1382,7 +1391,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 +2267,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
|
||||
|
|
229
plexpy/graphs.py
229
plexpy/graphs.py
|
@ -22,7 +22,6 @@ from future.builtins import object
|
|||
|
||||
import arrow
|
||||
import datetime
|
||||
|
||||
import plexpy
|
||||
if plexpy.PYTHON2:
|
||||
import common
|
||||
|
@ -102,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()
|
||||
|
@ -116,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)
|
||||
|
@ -234,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']
|
||||
|
@ -249,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)
|
||||
|
@ -351,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',
|
||||
|
@ -364,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)
|
||||
|
@ -466,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()
|
||||
|
@ -487,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)
|
||||
|
@ -599,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'])
|
||||
|
@ -705,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'])
|
||||
|
@ -784,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()
|
||||
|
@ -797,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)
|
||||
|
@ -826,6 +794,100 @@ 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 " \
|
||||
"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:
|
||||
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()
|
||||
date_list = [base - datetime.timedelta(days=x) for x in range(0, int(time_range))]
|
||||
|
||||
categories = []
|
||||
series_1 = []
|
||||
series_2 = []
|
||||
series_3 = []
|
||||
series_4 = []
|
||||
|
||||
for date_item in sorted(date_list):
|
||||
date_string = date_item.strftime('%Y-%m-%d')
|
||||
categories.append(date_string)
|
||||
|
||||
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, [])
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
@ -888,6 +950,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'])
|
||||
|
@ -991,6 +1054,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'])
|
||||
|
@ -1066,6 +1130,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'])
|
||||
|
@ -1153,6 +1218,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'])
|
||||
|
@ -1169,15 +1235,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
|
||||
|
|
|
@ -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
|
||||
|
@ -1193,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(('<broadcast>', 0))
|
||||
s.connect(('<broadcast>', 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'
|
||||
|
@ -1242,6 +1245,15 @@ def grouper(iterable, n, fillvalue=None):
|
|||
return zip_longest(fillvalue=fillvalue, *args)
|
||||
|
||||
|
||||
def group_by_keys(iterable, 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)}
|
||||
|
||||
|
||||
def chunk(it, size):
|
||||
it = iter(it)
|
||||
return iter(lambda: tuple(islice(it, size)), ())
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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]))
|
||||
|
|
|
@ -87,6 +87,7 @@ def get_server_resources(return_presence=False, return_server=False, return_info
|
|||
hostname=server['pms_ip'],
|
||||
port=server['pms_port'])
|
||||
|
||||
plex_tv.ping()
|
||||
result = plex_tv.get_server_connections(pms_identifier=server['pms_identifier'],
|
||||
pms_ip=server['pms_ip'],
|
||||
pms_port=server['pms_port'],
|
||||
|
@ -354,6 +355,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')
|
||||
|
@ -960,3 +968,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
|
||||
|
|
|
@ -2572,6 +2572,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()
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue