This commit is contained in:
tyler breese 2023-10-20 20:51:37 -04:00
commit 5b0ee732ee
39 changed files with 841 additions and 256 deletions

View file

@ -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

View file

@ -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: .

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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: [{}]
};

View file

@ -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']} &nbsp;<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']:
<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>

View file

@ -20,13 +20,28 @@ DOCUMENTATION :: END
% else:
${newsletter['agent_label']} &nbsp;<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'])) %>
<span id="newsletter-next_run-${newsletter['id']}">
<script>
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
$("#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>

View file

@ -19,7 +19,20 @@ DOCUMENTATION :: END
% else:
${notifier['agent_label']} &nbsp;<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

View file

@ -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">

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import media, utils
@ -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')

View file

@ -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',
}

View file

@ -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}"

View file

@ -1684,7 +1684,7 @@ 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):
@ -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

View file

@ -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. """

View file

@ -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
@ -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')
@ -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))

View file

@ -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(
@ -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):

View file

@ -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
@ -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')

View file

@ -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)
@ -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'
@ -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)
@ -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')

View file

@ -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

View file

@ -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()

View file

@ -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'
@ -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
# 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:
self.parentKey = f'/library/metadata/{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). """

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
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.settimeout(0)
try:
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)), ())

View file

@ -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

View file

@ -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]))

View file

@ -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

View file

@ -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()

View file

@ -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