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:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
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]') }}
|
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
id: prepare
|
id: prepare
|
||||||
|
@ -38,10 +38,10 @@ jobs:
|
||||||
echo "docker_image=${{ secrets.DOCKER_REPO }}/tautulli" >> $GITHUB_OUTPUT
|
echo "docker_image=${{ secrets.DOCKER_REPO }}/tautulli" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
id: buildx
|
id: buildx
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
@ -55,14 +55,14 @@ jobs:
|
||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
if: success()
|
if: success()
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
if: success()
|
if: success()
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
|
@ -70,7 +70,7 @@ jobs:
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Docker Build and Push
|
- name: Docker Build and Push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
if: success()
|
if: success()
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
|
4
.github/workflows/publish-installers.yml
vendored
4
.github/workflows/publish-installers.yml
vendored
|
@ -24,7 +24,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set Release Version
|
- name: Set Release Version
|
||||||
id: get_version
|
id: get_version
|
||||||
|
@ -103,7 +103,7 @@ jobs:
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set Release Version
|
- name: Set Release Version
|
||||||
id: get_version
|
id: get_version
|
||||||
|
|
4
.github/workflows/publish-snap.yml
vendored
4
.github/workflows/publish-snap.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
||||||
- armhf
|
- armhf
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
id: prepare
|
id: prepare
|
||||||
|
@ -35,7 +35,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Build Snap Package
|
- name: Build Snap Package
|
||||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Comment on Pull Request
|
- name: Comment on Pull Request
|
||||||
uses: mshick/add-pr-comment@v2
|
uses: mshick/add-pr-comment@v2
|
||||||
|
|
|
@ -2984,7 +2984,8 @@ a .home-platforms-list-cover-face:hover
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
.stacked-configs > li > span {
|
.stacked-configs > li > span {
|
||||||
display: block;
|
display: inline-block;
|
||||||
|
width: inherit;
|
||||||
padding: 8px 20px 8px 15px;
|
padding: 8px 20px 8px 15px;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
border-left: 2px solid #444;
|
border-left: 2px solid #444;
|
||||||
|
|
|
@ -137,6 +137,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="row">
|
||||||
<div class="col-md-6">
|
<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>
|
<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',
|
'Live TV': '#19A0D7',
|
||||||
'Direct Play': '#E5A00D',
|
'Direct Play': '#E5A00D',
|
||||||
'Direct Stream': '#FFFFFF',
|
'Direct Stream': '#FFFFFF',
|
||||||
'Transcode': '#F06464'
|
'Transcode': '#F06464',
|
||||||
|
'Max. Concurrent Streams': '#96C83C'
|
||||||
};
|
};
|
||||||
var series_colors = [];
|
var series_colors = [];
|
||||||
$.each(data_series, function(index, series) {
|
$.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_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_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/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_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_stream_resolution.js${cache_param}"></script>
|
||||||
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.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({
|
$.ajax({
|
||||||
url: "get_plays_by_source_resolution",
|
url: "get_plays_by_source_resolution",
|
||||||
type: 'get',
|
type: 'get',
|
||||||
|
@ -754,6 +797,7 @@
|
||||||
|
|
||||||
hc_plays_by_day_options.xAxis.plotBands = [];
|
hc_plays_by_day_options.xAxis.plotBands = [];
|
||||||
hc_plays_by_stream_type_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_day_options.yAxis.labels.formatter = yaxis_format;
|
||||||
hc_plays_by_dayofweek_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>
|
<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
|
% endif
|
||||||
${device['friendly_name'] or device['device_name']} <span class="friendly_name">(${device['id']})</span>
|
${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">
|
||||||
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
|
|
||||||
% if device['last_seen']:
|
% if device['last_seen']:
|
||||||
<script>
|
<span id="device-last_seen-${device['id']}">
|
||||||
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
<script>
|
||||||
</script>
|
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
||||||
|
</script>
|
||||||
|
</span>
|
||||||
% else:
|
% else:
|
||||||
never
|
never
|
||||||
% endif
|
% endif
|
||||||
|
<i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -20,13 +20,28 @@ DOCUMENTATION :: END
|
||||||
% else:
|
% else:
|
||||||
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']})</span>
|
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']})</span>
|
||||||
% endif
|
% endif
|
||||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
<span class="toggle-right friendly_name">
|
||||||
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
|
|
||||||
% if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
|
% if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
|
||||||
<% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
|
<% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
|
||||||
<script>
|
<span id="newsletter-next_run-${newsletter['id']}">
|
||||||
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
|
<script>
|
||||||
</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
|
% endif
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -19,7 +19,20 @@ DOCUMENTATION :: END
|
||||||
% else:
|
% else:
|
||||||
${notifier['agent_label']} <span class="friendly_name">(${notifier['id']})</span>
|
${notifier['agent_label']} <span class="friendly_name">(${notifier['id']})</span>
|
||||||
% endif
|
% 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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
% endfor
|
% endfor
|
||||||
|
|
|
@ -1278,7 +1278,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="help-block">
|
<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>
|
||||||
<p class="help-block">
|
<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.
|
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>
|
</div>
|
||||||
|
|
||||||
<p class="help-block">
|
<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>
|
||||||
<p class="help-block settings-warning" id="newsletter_upload_warning">
|
<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>
|
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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Registered Devices</label>
|
<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>
|
<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 />
|
<br />
|
||||||
<div class="row">
|
<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 = CONFIG.get('header.device', X_PLEX_PLATFORM)
|
||||||
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
|
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
|
||||||
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
|
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
|
||||||
|
X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en')
|
||||||
BASE_HEADERS = reset_base_headers()
|
BASE_HEADERS = reset_base_headers()
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
|
from typing import Callable
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from plexapi import log
|
from plexapi import log
|
||||||
|
@ -32,15 +34,17 @@ class AlertListener(threading.Thread):
|
||||||
callbackError (func): Callback function to call on errors. The callback function
|
callbackError (func): Callback function to call on errors. The callback function
|
||||||
will be sent a single argument 'error' which will contain the Error object.
|
will be sent a single argument 'error' which will contain the Error object.
|
||||||
:samp:`def my_callback(error): ...`
|
: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'
|
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__()
|
super(AlertListener, self).__init__()
|
||||||
self.daemon = True
|
self.daemon = True
|
||||||
self._server = server
|
self._server = server
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
self._callbackError = callbackError
|
self._callbackError = callbackError
|
||||||
|
self._socket = ws_socket
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
@ -52,8 +56,9 @@ class AlertListener(threading.Thread):
|
||||||
# create the websocket connection
|
# create the websocket connection
|
||||||
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
||||||
log.info('Starting AlertListener: %s', url)
|
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()
|
self._ws.run_forever()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
@ -66,10 +71,8 @@ class AlertListener(threading.Thread):
|
||||||
|
|
||||||
def _onMessage(self, *args):
|
def _onMessage(self, *args):
|
||||||
""" Called when websocket message is received.
|
""" 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.
|
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]
|
message = args[-1]
|
||||||
try:
|
try:
|
||||||
|
@ -82,10 +85,8 @@ class AlertListener(threading.Thread):
|
||||||
|
|
||||||
def _onError(self, *args): # pragma: no cover
|
def _onError(self, *args): # pragma: no cover
|
||||||
""" Called when websocket error is received.
|
""" 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.
|
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]
|
err = args[-1]
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
|
@ -240,6 +241,12 @@ class Artist(
|
||||||
key = f'{self.key}?includeStations=1'
|
key = f'{self.key}?includeStations=1'
|
||||||
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
|
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
|
@utils.registerPlexObject
|
||||||
class Album(
|
class Album(
|
||||||
|
@ -359,6 +366,12 @@ class Album(
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return f'{self.parentTitle} - {self.title}'
|
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
|
@utils.registerPlexObject
|
||||||
class Track(
|
class Track(
|
||||||
|
@ -470,6 +483,12 @@ class Track(
|
||||||
""" Get the Plex Web URL with the correct parameters. """
|
""" Get the Plex Web URL with the correct parameters. """
|
||||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
|
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
|
@utils.registerPlexObject
|
||||||
class TrackSession(PlexSession, Track):
|
class TrackSession(PlexSession, Track):
|
||||||
|
|
|
@ -227,7 +227,7 @@ class PlexObject:
|
||||||
|
|
||||||
fetchItem(ekey, viewCount__gte=0)
|
fetchItem(ekey, viewCount__gte=0)
|
||||||
fetchItem(ekey, Media__container__in=["mp4", "mkv"])
|
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")
|
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -502,7 +502,7 @@ class PlexPartialObject(PlexObject):
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if isinstance(other, PlexPartialObject):
|
if isinstance(other, PlexPartialObject):
|
||||||
return other not in [None, []] and self.key == other.key
|
return self.key == other.key
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
|
@ -626,7 +626,8 @@ class PlexPartialObject(PlexObject):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def saveEdits(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.
|
See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
|
||||||
"""
|
"""
|
||||||
if not isinstance(self._edits, dict):
|
if not isinstance(self._edits, dict):
|
||||||
|
@ -635,7 +636,7 @@ class PlexPartialObject(PlexObject):
|
||||||
edits = self._edits
|
edits = self._edits
|
||||||
self._edits = None
|
self._edits = None
|
||||||
self._edit(**edits)
|
self._edit(**edits)
|
||||||
return self.reload()
|
return self
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
""" Refreshing a Library or individual item causes the metadata for the item to be
|
""" 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=''):
|
def stop(self, reason=''):
|
||||||
""" Stop playback for the session.
|
""" Stop playback for the session.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
reason (str): Message displayed to the user for stopping playback.
|
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'
|
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||||
server_session = server._session if server else None
|
server_session = server._session if server else None
|
||||||
self._session = session or server_session or requests.Session()
|
self._session = session or server_session or requests.Session()
|
||||||
|
self._timeout = timeout or TIMEOUT
|
||||||
self._proxyThroughServer = False
|
self._proxyThroughServer = False
|
||||||
self._commandId = 0
|
self._commandId = 0
|
||||||
self._last_call = 0
|
self._last_call = 0
|
||||||
|
@ -94,7 +95,7 @@ class PlexClient(PlexObject):
|
||||||
raise Unsupported('Cannot reload an object not built from a URL.')
|
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||||
self._initpath = self.key
|
self._initpath = self.key
|
||||||
data = self.query(self.key, timeout=timeout)
|
data = self.query(self.key, timeout=timeout)
|
||||||
if not data:
|
if data is None:
|
||||||
raise NotFound(f"Client not found at {self._baseurl}")
|
raise NotFound(f"Client not found at {self._baseurl}")
|
||||||
if self._clientIdentifier:
|
if self._clientIdentifier:
|
||||||
client = next(
|
client = next(
|
||||||
|
@ -179,7 +180,7 @@ class PlexClient(PlexObject):
|
||||||
"""
|
"""
|
||||||
url = self.url(path)
|
url = self.url(path)
|
||||||
method = method or self._session.get
|
method = method or self._session.get
|
||||||
timeout = timeout or TIMEOUT
|
timeout = timeout or self._timeout
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
|
@ -399,7 +400,7 @@ class Collection(
|
||||||
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
|
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
|
||||||
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
|
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
|
||||||
""" Edit the collection.
|
""" Edit the collection.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str, optional): The title of the collection.
|
title (str, optional): The title of the collection.
|
||||||
titleSort (str, optional): The sort title of the collection.
|
titleSort (str, optional): The sort title of the collection.
|
||||||
|
@ -560,3 +561,9 @@ class Collection(
|
||||||
raise Unsupported('Unsupported collection content')
|
raise Unsupported('Unsupported collection content')
|
||||||
|
|
||||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
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': plexapi.X_PLEX_DEVICE,
|
||||||
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
|
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
|
||||||
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
|
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
|
||||||
|
'X-Plex-Language': plexapi.X_PLEX_LANGUAGE,
|
||||||
'X-Plex-Sync-Version': '2',
|
'X-Plex-Sync-Version': '2',
|
||||||
'X-Plex-Features': 'external-media',
|
'X-Plex-Features': 'external-media',
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
# Library version
|
# Library version
|
||||||
MAJOR_VERSION = 4
|
MAJOR_VERSION = 4
|
||||||
MINOR_VERSION = 15
|
MINOR_VERSION = 15
|
||||||
PATCH_VERSION = 0
|
PATCH_VERSION = 4
|
||||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
|
|
|
@ -542,7 +542,7 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
def addLocations(self, location):
|
def addLocations(self, location):
|
||||||
""" Add a location to a library.
|
""" Add a location to a library.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
location (str or list): A single folder path, list of paths.
|
location (str or list): A single folder path, list of paths.
|
||||||
|
|
||||||
|
@ -565,7 +565,7 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
def removeLocations(self, location):
|
def removeLocations(self, location):
|
||||||
""" Remove a location from a library.
|
""" Remove a location from a library.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
location (str or list): A single folder path, list of paths.
|
location (str or list): A single folder path, list of paths.
|
||||||
|
|
||||||
|
@ -744,7 +744,7 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
def lockAllField(self, field, libtype=None):
|
def lockAllField(self, field, libtype=None):
|
||||||
""" Lock a field for all items in the library.
|
""" Lock a field for all items in the library.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
field (str): The field to lock (e.g. thumb, rating, collection).
|
field (str): The field to lock (e.g. thumb, rating, collection).
|
||||||
libtype (str, optional): The library type to lock (movie, show, season, episode,
|
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):
|
def unlockAllField(self, field, libtype=None):
|
||||||
""" Unlock a field for all items in the library.
|
""" Unlock a field for all items in the library.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
field (str): The field to unlock (e.g. thumb, rating, collection).
|
field (str): The field to unlock (e.g. thumb, rating, collection).
|
||||||
libtype (str, optional): The library type to lock (movie, show, season, episode,
|
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'
|
_key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1'
|
||||||
'&X-Plex-Container-Start=0&X-Plex-Container-Size=0')
|
'&X-Plex-Container-Start=0&X-Plex-Container-Size=0')
|
||||||
|
|
||||||
key = _key.format(key=self.key, filter='all')
|
key = _key.format(key=self.key, filter='all')
|
||||||
data = self._server.query(key)
|
data = self._server.query(key)
|
||||||
self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
|
self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
|
||||||
|
@ -894,7 +894,7 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
def getFieldType(self, fieldType):
|
def getFieldType(self, fieldType):
|
||||||
""" Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType.
|
""" Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
||||||
subtitleLanguage, audioLanguage, resolution).
|
subtitleLanguage, audioLanguage, resolution).
|
||||||
|
@ -927,7 +927,7 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self.getFilterType(libtype).filters
|
return self.getFilterType(libtype).filters
|
||||||
|
|
||||||
def listSorts(self, libtype=None):
|
def listSorts(self, libtype=None):
|
||||||
""" Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype.
|
""" Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype.
|
||||||
This is the list of options in the sorting dropdown menu
|
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.
|
""" 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
|
This is the list of options in the custom filter operator dropdown menu
|
||||||
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
||||||
subtitleLanguage, audioLanguage, resolution).
|
subtitleLanguage, audioLanguage, resolution).
|
||||||
|
@ -992,7 +992,7 @@ class LibrarySection(PlexObject):
|
||||||
:class:`~plexapi.library.FilteringFilter` or filter field.
|
:class:`~plexapi.library.FilteringFilter` or filter field.
|
||||||
This is the list of available values for a custom filter
|
This is the list of available values for a custom filter
|
||||||
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
field (str): :class:`~plexapi.library.FilteringFilter` object,
|
field (str): :class:`~plexapi.library.FilteringFilter` object,
|
||||||
or the name of the field (genre, year, contentRating, etc.).
|
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)]
|
availableFilters = [f.filter for f in self.listFilters(libtype)]
|
||||||
raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". '
|
raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". '
|
||||||
f'Available filters: {availableFilters}') from None
|
f'Available filters: {availableFilters}') from None
|
||||||
|
|
||||||
data = self._server.query(field.key)
|
data = self._server.query(field.key)
|
||||||
return self.findItems(data, FilterChoice)
|
return self.findItems(data, FilterChoice)
|
||||||
|
|
||||||
|
@ -1111,7 +1111,7 @@ class LibrarySection(PlexObject):
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", '
|
raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", '
|
||||||
f'value should be type {fieldType.type}') from None
|
f'value should be type {fieldType.type}') from None
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _validateFieldValueDate(self, value):
|
def _validateFieldValueDate(self, value):
|
||||||
|
@ -1345,7 +1345,7 @@ class LibrarySection(PlexObject):
|
||||||
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
|
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
|
||||||
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||||
or the exact id :attr:`MediaTag.id` (*int*).
|
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
|
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.
|
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``
|
* ``w``: ``weeks``
|
||||||
* ``mon``: ``months``
|
* ``mon``: ``months``
|
||||||
* ``y``: ``years``
|
* ``y``: ``years``
|
||||||
|
|
||||||
Multiple values can be ``OR`` together by providing a list of values.
|
Multiple values can be ``OR`` together by providing a list of values.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
@ -1684,12 +1684,12 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
def _validateItems(self, items):
|
def _validateItems(self, items):
|
||||||
""" Validates the specified items are from this library and of the same type. """
|
""" 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.')
|
raise BadRequest('No items specified.')
|
||||||
|
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
items = [items]
|
items = [items]
|
||||||
|
|
||||||
itemType = items[0].type
|
itemType = items[0].type
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.librarySectionID != self.key:
|
if item.librarySectionID != self.key:
|
||||||
|
@ -3102,6 +3102,7 @@ class FirstCharacter(PlexObject):
|
||||||
size (str): Total amount of library items starting with this character.
|
size (str): Total amount of library items starting with this character.
|
||||||
title (str): Character (#, !, A, B, C, ...).
|
title (str): Character (#, !, A, B, C, ...).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import xml
|
import xml
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import log, settings, utils
|
from plexapi import log, settings, utils
|
||||||
|
@ -121,6 +121,7 @@ class MediaPart(PlexObject):
|
||||||
optimizedForStreaming (bool): True if the file is optimized for streaming.
|
optimizedForStreaming (bool): True if the file is optimized for streaming.
|
||||||
packetLength (int): The packet length of the file.
|
packetLength (int): The packet length of the file.
|
||||||
requiredBandwidths (str): The required bandwidths to stream 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).
|
size (int): The size of the file in bytes (ex: 733884416).
|
||||||
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
|
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
|
||||||
syncItemId (int): The unique ID for this media part if it is synced.
|
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. """
|
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
|
||||||
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
|
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
|
||||||
|
|
||||||
def setDefaultAudioStream(self, stream):
|
def setSelectedAudioStream(self, stream):
|
||||||
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
|
""" Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart.
|
||||||
|
|
||||||
Parameters:
|
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):
|
if isinstance(stream, AudioStream):
|
||||||
key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1"
|
params['audioStreamID'] = stream.id
|
||||||
else:
|
else:
|
||||||
key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1"
|
params['audioStreamID'] = stream
|
||||||
self._server.query(key, method=self._server._session.put)
|
|
||||||
|
self._server.query(key, method=self._server._session.put, params=params)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def setDefaultSubtitleStream(self, stream):
|
def setSelectedSubtitleStream(self, stream):
|
||||||
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
""" Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
||||||
|
|
||||||
Parameters:
|
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):
|
if isinstance(stream, SubtitleStream):
|
||||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1"
|
params['subtitleStreamID'] = stream.id
|
||||||
else:
|
else:
|
||||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1"
|
params['subtitleStreamID'] = stream
|
||||||
|
|
||||||
self._server.query(key, method=self._server._session.put)
|
self._server.query(key, method=self._server._session.put)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resetDefaultSubtitleStream(self):
|
def resetSelectedSubtitleStream(self):
|
||||||
""" Set default subtitle of this MediaPart to 'none'. """
|
""" Set the selected subtitle of this MediaPart to 'None'. """
|
||||||
key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1"
|
key = f'/library/parts/{self.id}'
|
||||||
self._server.query(key, method=self._server._session.put)
|
params = {'subtitleStreamID': 0, 'allParts': 1}
|
||||||
|
|
||||||
|
self._server.query(key, method=self._server._session.put, params=params)
|
||||||
return self
|
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):
|
class MediaPartStream(PlexObject):
|
||||||
""" Base class for media streams. These consist of video, audio, subtitles, and lyrics.
|
""" 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.peak = utils.cast(float, data.attrib.get('peak'))
|
||||||
self.startRamp = data.attrib.get('startRamp')
|
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):
|
def setDefault(self):
|
||||||
""" Sets this audio stream as the default audio stream. """
|
return self.setSelected()
|
||||||
return self._parent().setDefaultAudioStream(self)
|
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
|
@ -437,9 +466,15 @@ class SubtitleStream(MediaPartStream):
|
||||||
self.transient = data.attrib.get('transient')
|
self.transient = data.attrib.get('transient')
|
||||||
self.userID = utils.cast(int, data.attrib.get('userID'))
|
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):
|
def setDefault(self):
|
||||||
""" Sets this subtitle stream as the default subtitle stream. """
|
return self.setSelected()
|
||||||
return self._parent().setDefaultSubtitleStream(self)
|
|
||||||
|
|
||||||
|
|
||||||
class LyricStream(MediaPartStream):
|
class LyricStream(MediaPartStream):
|
||||||
|
@ -973,6 +1008,7 @@ class BaseResource(PlexObject):
|
||||||
selected (bool): True if the resource is currently selected.
|
selected (bool): True if the resource is currently selected.
|
||||||
thumb (str): The URL to retrieve the resource thumbnail.
|
thumb (str): The URL to retrieve the resource thumbnail.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
|
@ -989,6 +1025,20 @@ class BaseResource(PlexObject):
|
||||||
except xml.etree.ElementTree.ParseError:
|
except xml.etree.ElementTree.ParseError:
|
||||||
pass
|
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):
|
class Art(BaseResource):
|
||||||
""" Represents a single Art object. """
|
""" Represents a single Art object. """
|
||||||
|
|
|
@ -39,7 +39,7 @@ class AdvancedSettingsMixin:
|
||||||
pref = preferences[settingID]
|
pref = preferences[settingID]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise NotFound(f'{value} not found in {list(preferences.keys())}')
|
raise NotFound(f'{value} not found in {list(preferences.keys())}')
|
||||||
|
|
||||||
enumValues = pref.enumValues
|
enumValues = pref.enumValues
|
||||||
if enumValues.get(value, enumValues.get(str(value))):
|
if enumValues.get(value, enumValues.get(str(value))):
|
||||||
data[settingID] = value
|
data[settingID] = value
|
||||||
|
@ -69,7 +69,7 @@ class SmartFilterMixin:
|
||||||
filters = {}
|
filters = {}
|
||||||
filterOp = 'and'
|
filterOp = 'and'
|
||||||
filterGroups = [[]]
|
filterGroups = [[]]
|
||||||
|
|
||||||
for key, value in parse_qsl(content.query):
|
for key, value in parse_qsl(content.query):
|
||||||
# Move = sign to key when operator is ==
|
# Move = sign to key when operator is ==
|
||||||
if value.startswith('='):
|
if value.startswith('='):
|
||||||
|
@ -96,11 +96,11 @@ class SmartFilterMixin:
|
||||||
filterGroups.pop()
|
filterGroups.pop()
|
||||||
else:
|
else:
|
||||||
filterGroups[-1].append({key: value})
|
filterGroups[-1].append({key: value})
|
||||||
|
|
||||||
if filterGroups:
|
if filterGroups:
|
||||||
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
|
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
def _formatFilterGroups(self, groups):
|
def _formatFilterGroups(self, groups):
|
||||||
""" Formats the filter groups into the advanced search rules. """
|
""" Formats the filter groups into the advanced search rules. """
|
||||||
if len(groups) == 1 and isinstance(groups[0], list):
|
if len(groups) == 1 and isinstance(groups[0], list):
|
||||||
|
@ -131,7 +131,7 @@ class SplitMergeMixin:
|
||||||
|
|
||||||
def merge(self, ratingKeys):
|
def merge(self, ratingKeys):
|
||||||
""" Merge other Plex objects into the current object.
|
""" Merge other Plex objects into the current object.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
ratingKeys (list): A list of rating keys to merge.
|
ratingKeys (list): A list of rating keys to merge.
|
||||||
"""
|
"""
|
||||||
|
@ -320,7 +320,7 @@ class RatingMixin:
|
||||||
|
|
||||||
class ArtUrlMixin:
|
class ArtUrlMixin:
|
||||||
""" Mixin for Plex objects that can have a background artwork url. """
|
""" Mixin for Plex objects that can have a background artwork url. """
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def artUrl(self):
|
def artUrl(self):
|
||||||
""" Return the art url for the Plex object. """
|
""" Return the art url for the Plex object. """
|
||||||
|
@ -349,7 +349,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin):
|
||||||
|
|
||||||
def uploadArt(self, url=None, filepath=None):
|
def uploadArt(self, url=None, filepath=None):
|
||||||
""" Upload a background artwork from a url or filepath.
|
""" Upload a background artwork from a url or filepath.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
url (str): The full URL to the image to upload.
|
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.
|
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):
|
def setArt(self, art):
|
||||||
""" Set the background artwork for a Plex object.
|
""" Set the background artwork for a Plex object.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
art (:class:`~plexapi.media.Art`): The art object to select.
|
art (:class:`~plexapi.media.Art`): The art object to select.
|
||||||
"""
|
"""
|
||||||
|
@ -425,7 +425,7 @@ class PosterMixin(PosterUrlMixin, PosterLockMixin):
|
||||||
|
|
||||||
def setPoster(self, poster):
|
def setPoster(self, poster):
|
||||||
""" Set the poster for a Plex object.
|
""" Set the poster for a Plex object.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
poster (:class:`~plexapi.media.Poster`): The poster object to select.
|
poster (:class:`~plexapi.media.Poster`): The poster object to select.
|
||||||
"""
|
"""
|
||||||
|
@ -491,11 +491,11 @@ class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
|
||||||
|
|
||||||
class EditFieldMixin:
|
class EditFieldMixin:
|
||||||
""" Mixin for editing Plex object fields. """
|
""" Mixin for editing Plex object fields. """
|
||||||
|
|
||||||
def editField(self, field, value, locked=True, **kwargs):
|
def editField(self, field, value, locked=True, **kwargs):
|
||||||
""" Edit the field of a Plex object. All field editing methods can be chained together.
|
""" 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.
|
Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
field (str): The name of the field to edit.
|
field (str): The name of the field to edit.
|
||||||
value (str): The value to edit the field to.
|
value (str): The value to edit the field to.
|
||||||
|
|
|
@ -111,12 +111,14 @@ class MyPlexAccount(PlexObject):
|
||||||
# Hub sections
|
# Hub sections
|
||||||
VOD = 'https://vod.provider.plex.tv' # get
|
VOD = 'https://vod.provider.plex.tv' # get
|
||||||
MUSIC = 'https://music.provider.plex.tv' # get
|
MUSIC = 'https://music.provider.plex.tv' # get
|
||||||
|
DISCOVER = 'https://discover.provider.plex.tv'
|
||||||
METADATA = 'https://metadata.provider.plex.tv'
|
METADATA = 'https://metadata.provider.plex.tv'
|
||||||
key = 'https://plex.tv/api/v2/user'
|
key = 'https://plex.tv/api/v2/user'
|
||||||
|
|
||||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
|
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._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
|
self._timeout = timeout or TIMEOUT
|
||||||
self._sonos_cache = []
|
self._sonos_cache = []
|
||||||
self._sonos_cache_timestamp = 0
|
self._sonos_cache_timestamp = 0
|
||||||
data, initpath = self._signin(username, password, code, remember, timeout)
|
data, initpath = self._signin(username, password, code, remember, timeout)
|
||||||
|
@ -186,7 +188,9 @@ class MyPlexAccount(PlexObject):
|
||||||
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
|
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
|
||||||
self.subscriptionPlan = subscription.attrib.get('plan')
|
self.subscriptionPlan = subscription.attrib.get('plan')
|
||||||
self.subscriptionStatus = subscription.attrib.get('status')
|
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')
|
profile = data.find('profile')
|
||||||
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio'))
|
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):
|
def query(self, url, method=None, headers=None, timeout=None, **kwargs):
|
||||||
method = method or self._session.get
|
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', ''))
|
log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||||
|
@ -239,8 +243,10 @@ class MyPlexAccount(PlexObject):
|
||||||
raise Unauthorized(message)
|
raise Unauthorized(message)
|
||||||
else:
|
else:
|
||||||
raise BadRequest(message)
|
raise BadRequest(message)
|
||||||
if headers.get('Accept') == 'application/json':
|
if 'application/json' in response.headers.get('Content-Type', ''):
|
||||||
return response.json()
|
return response.json()
|
||||||
|
elif 'text/plain' in response.headers.get('Content-Type', ''):
|
||||||
|
return response.text.strip()
|
||||||
data = response.text.encode('utf8')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
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
|
if (invite.username and invite.email and invite.id and username.lower() in
|
||||||
(invite.username.lower(), invite.email.lower(), str(invite.id))):
|
(invite.username.lower(), invite.email.lower(), str(invite.id))):
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
raise NotFound(f'Unable to find invite {username}')
|
raise NotFound(f'Unable to find invite {username}')
|
||||||
|
|
||||||
def pendingInvites(self, includeSent=True, includeReceived=True):
|
def pendingInvites(self, includeSent=True, includeReceived=True):
|
||||||
|
@ -950,7 +956,7 @@ class MyPlexAccount(PlexObject):
|
||||||
"""
|
"""
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
items = [items]
|
items = [items]
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if self.onWatchlist(item):
|
if self.onWatchlist(item):
|
||||||
raise BadRequest(f'"{item.title}" is already on the watchlist')
|
raise BadRequest(f'"{item.title}" is already on the watchlist')
|
||||||
|
@ -971,7 +977,7 @@ class MyPlexAccount(PlexObject):
|
||||||
"""
|
"""
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
items = [items]
|
items = [items]
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if not self.onWatchlist(item):
|
if not self.onWatchlist(item):
|
||||||
raise BadRequest(f'"{item.title}" is not on the watchlist')
|
raise BadRequest(f'"{item.title}" is not on the watchlist')
|
||||||
|
@ -1053,7 +1059,7 @@ class MyPlexAccount(PlexObject):
|
||||||
'includeMetadata': 1
|
'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', [])
|
searchResults = data['MediaContainer'].get('SearchResults', [])
|
||||||
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
|
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
|
||||||
|
|
||||||
|
@ -1135,6 +1141,21 @@ class MyPlexAccount(PlexObject):
|
||||||
|
|
||||||
return objs
|
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):
|
class MyPlexUser(PlexObject):
|
||||||
""" This object represents non-signed in users such as friends and linked
|
""" This object represents non-signed in users such as friends and linked
|
||||||
|
@ -1773,7 +1794,7 @@ class MyPlexPinLogin:
|
||||||
params = None
|
params = None
|
||||||
|
|
||||||
response = self._query(url, self._session.post, params=params)
|
response = self._query(url, self._session.post, params=params)
|
||||||
if not response:
|
if response is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self._id = response.attrib.get('id')
|
self._id = response.attrib.get('id')
|
||||||
|
@ -1790,7 +1811,7 @@ class MyPlexPinLogin:
|
||||||
|
|
||||||
url = self.CHECKPINS.format(pinid=self._id)
|
url = self.CHECKPINS.format(pinid=self._id)
|
||||||
response = self._query(url)
|
response = self._query(url)
|
||||||
if not response:
|
if response is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
token = response.attrib.get('authToken')
|
token = response.attrib.get('authToken')
|
||||||
|
@ -1927,7 +1948,7 @@ class AccountOptOut(PlexObject):
|
||||||
|
|
||||||
def optOutManaged(self):
|
def optOutManaged(self):
|
||||||
""" Sets the Online Media Source to "Disabled for Managed Users".
|
""" Sets the Online Media Source to "Disabled for Managed Users".
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
|
: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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.viewState = data.attrib.get('viewState') == 'complete'
|
self.viewState = data.attrib.get('viewState') == 'complete'
|
||||||
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils, video
|
from plexapi import media, utils, video
|
||||||
|
@ -139,6 +140,12 @@ class Photoalbum(
|
||||||
""" Get the Plex Web URL with the correct parameters. """
|
""" Get the Plex Web URL with the correct parameters. """
|
||||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1)
|
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
|
@utils.registerPlexObject
|
||||||
class Photo(
|
class Photo(
|
||||||
|
@ -249,7 +256,7 @@ class Photo(
|
||||||
List<str> of file paths where the photo is found on disk.
|
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]
|
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):
|
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||||
""" Add current photo as sync item for specified device.
|
""" Add current photo as sync item for specified device.
|
||||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||||
|
@ -290,6 +297,12 @@ class Photo(
|
||||||
""" Get the Plex Web URL with the correct parameters. """
|
""" Get the Plex Web URL with the correct parameters. """
|
||||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
|
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
|
@utils.registerPlexObject
|
||||||
class PhotoSession(PlexSession, Photo):
|
class PhotoSession(PlexSession, Photo):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus, unquote
|
from urllib.parse import quote_plus, unquote
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
|
@ -154,7 +155,7 @@ class Playlist(
|
||||||
sectionKey = int(match.group(1))
|
sectionKey = int(match.group(1))
|
||||||
self._section = self._server.library.sectionByID(sectionKey)
|
self._section = self._server.library.sectionByID(sectionKey)
|
||||||
return self._section
|
return self._section
|
||||||
|
|
||||||
# Try to get the library section from the first item in the playlist
|
# Try to get the library section from the first item in the playlist
|
||||||
if self.items():
|
if self.items():
|
||||||
self._section = self.items()[0].section()
|
self._section = self.items()[0].section()
|
||||||
|
@ -313,7 +314,7 @@ class Playlist(
|
||||||
|
|
||||||
def edit(self, title=None, summary=None):
|
def edit(self, title=None, summary=None):
|
||||||
""" Edit the playlist.
|
""" Edit the playlist.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str, optional): The title of the playlist.
|
title (str, optional): The title of the playlist.
|
||||||
summary (str, optional): The summary of the playlist.
|
summary (str, optional): The summary of the playlist.
|
||||||
|
@ -431,7 +432,7 @@ class Playlist(
|
||||||
|
|
||||||
def copyToUser(self, user):
|
def copyToUser(self, user):
|
||||||
""" Copy playlist to another user account.
|
""" Copy playlist to another user account.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||||
email, or user id of the user to copy the playlist to.
|
email, or user id of the user to copy the playlist to.
|
||||||
|
@ -496,3 +497,9 @@ class Playlist(
|
||||||
def _getWebURL(self, base=None):
|
def _getWebURL(self, base=None):
|
||||||
""" Get the Plex Web URL with the correct parameters. """
|
""" Get the Plex Web URL with the correct parameters. """
|
||||||
return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key)
|
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._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
self._timeout = timeout
|
self._timeout = timeout or TIMEOUT
|
||||||
self._myPlexAccount = None # cached myPlexAccount
|
self._myPlexAccount = None # cached myPlexAccount
|
||||||
self._systemAccounts = None # cached list of SystemAccount
|
self._systemAccounts = None # cached list of SystemAccount
|
||||||
self._systemDevices = None # cached list of SystemDevice
|
self._systemDevices = None # cached list of SystemDevice
|
||||||
|
@ -189,6 +189,11 @@ class PlexServer(PlexObject):
|
||||||
data = self.query(Settings.key)
|
data = self.query(Settings.key)
|
||||||
return Settings(self, data)
|
return Settings(self, data)
|
||||||
|
|
||||||
|
def identity(self):
|
||||||
|
""" Returns the Plex server identity. """
|
||||||
|
data = self.query('/identity')
|
||||||
|
return Identity(self, data)
|
||||||
|
|
||||||
def account(self):
|
def account(self):
|
||||||
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
|
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
|
||||||
data = self.query(Account.key)
|
data = self.query(Account.key)
|
||||||
|
@ -197,7 +202,7 @@ class PlexServer(PlexObject):
|
||||||
def claim(self, account):
|
def claim(self, account):
|
||||||
""" Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`.
|
""" Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`.
|
||||||
This will only work with an unclaimed server on localhost or the same subnet.
|
This will only work with an unclaimed server on localhost or the same subnet.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to
|
account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to
|
||||||
claim the server.
|
claim the server.
|
||||||
|
@ -240,7 +245,7 @@ class PlexServer(PlexObject):
|
||||||
def switchUser(self, user, session=None, timeout=None):
|
def switchUser(self, user, session=None, timeout=None):
|
||||||
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
||||||
Note: Only the admin account can switch to other users.
|
Note: Only the admin account can switch to other users.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||||
email, or user id of the user to log in to the server.
|
email, or user id of the user to log in to the server.
|
||||||
|
@ -585,7 +590,7 @@ class PlexServer(PlexObject):
|
||||||
def runButlerTask(self, task):
|
def runButlerTask(self, task):
|
||||||
""" Manually run a butler task immediately instead of waiting for the scheduled task to run.
|
""" 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.
|
Note: The butler task is run asynchronously. Check Plex Web to monitor activity.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
task (str): The name of the task to run. (e.g. 'BackupDatabase')
|
task (str): The name of the task to run. (e.g. 'BackupDatabase')
|
||||||
|
|
||||||
|
@ -597,7 +602,7 @@ class PlexServer(PlexObject):
|
||||||
print("Available butler tasks:", availableTasks)
|
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:
|
if task not in validTasks:
|
||||||
raise BadRequest(
|
raise BadRequest(
|
||||||
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
|
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
|
||||||
|
@ -610,7 +615,8 @@ class PlexServer(PlexObject):
|
||||||
return self.checkForUpdate(force=force, download=download)
|
return self.checkForUpdate(force=force, download=download)
|
||||||
|
|
||||||
def checkForUpdate(self, force=True, download=False):
|
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:
|
Parameters:
|
||||||
force (bool): Force server to check for new releases
|
force (bool): Force server to check for new releases
|
||||||
|
@ -624,12 +630,19 @@ class PlexServer(PlexObject):
|
||||||
return releases[0]
|
return releases[0]
|
||||||
|
|
||||||
def isLatest(self):
|
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)
|
release = self.checkForUpdate(force=True)
|
||||||
return release is None
|
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):
|
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
|
# We can add this but dunno how useful this is since it sometimes
|
||||||
# requires user action using a gui.
|
# requires user action using a gui.
|
||||||
part = '/updater/apply'
|
part = '/updater/apply'
|
||||||
|
@ -661,7 +674,7 @@ class PlexServer(PlexObject):
|
||||||
args['librarySectionID'] = librarySectionID
|
args['librarySectionID'] = librarySectionID
|
||||||
if mindate:
|
if mindate:
|
||||||
args['viewedAt>'] = int(mindate.timestamp())
|
args['viewedAt>'] = int(mindate.timestamp())
|
||||||
|
|
||||||
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
||||||
return self.fetchItems(key, maxresults=maxresults)
|
return self.fetchItems(key, maxresults=maxresults)
|
||||||
|
|
||||||
|
@ -741,7 +754,7 @@ class PlexServer(PlexObject):
|
||||||
"""
|
"""
|
||||||
url = self.url(key)
|
url = self.url(key)
|
||||||
method = method or self._session.get
|
method = method or self._session.get
|
||||||
timeout = timeout or TIMEOUT
|
timeout = timeout or self._timeout
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||||
|
@ -1253,7 +1266,7 @@ class StatisticsResources(PlexObject):
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class ButlerTask(PlexObject):
|
class ButlerTask(PlexObject):
|
||||||
""" Represents a single scheduled butler task.
|
""" Represents a single scheduled butler task.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'ButlerTask'
|
TAG (str): 'ButlerTask'
|
||||||
description (str): The description of the task.
|
description (str): The description of the task.
|
||||||
|
@ -1273,3 +1286,22 @@ class ButlerTask(PlexObject):
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
|
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
|
||||||
self.title = data.attrib.get('title')
|
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
|
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`.
|
to explicitly specify that your app supports `sync-target`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import plexapi
|
import plexapi
|
||||||
|
|
|
@ -11,13 +11,14 @@ import unicodedata
|
||||||
import warnings
|
import warnings
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
from hashlib import sha1
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from requests.status_codes import _codes as codes
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
|
|
||||||
|
@ -313,33 +314,44 @@ def toDatetime(value, format=None):
|
||||||
value (str): value to return as a datetime
|
value (str): value to return as a datetime
|
||||||
format (str): Format to pass strftime (optional; if value is a str).
|
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:
|
if format:
|
||||||
try:
|
try:
|
||||||
value = datetime.strptime(value, format)
|
return datetime.strptime(value, format)
|
||||||
except ValueError:
|
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
|
return None
|
||||||
else:
|
else:
|
||||||
# https://bugs.python.org/issue30684
|
try:
|
||||||
# And platform support for before epoch seems to be flaky.
|
value = int(value)
|
||||||
# Also limit to max 32-bit integer
|
except ValueError:
|
||||||
value = min(max(int(value), 86400), 2**31 - 1)
|
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
|
||||||
value = datetime.fromtimestamp(int(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
|
return value
|
||||||
|
|
||||||
|
|
||||||
def millisecondToHumanstr(milliseconds):
|
def millisecondToHumanstr(milliseconds):
|
||||||
""" Returns human readable time duration from milliseconds.
|
""" Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
|
||||||
HH:MM:SS:MMMM
|
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
milliseconds (str,int): time duration in milliseconds.
|
milliseconds (str, int): time duration in milliseconds.
|
||||||
"""
|
"""
|
||||||
milliseconds = int(milliseconds)
|
milliseconds = int(milliseconds)
|
||||||
r = datetime.utcfromtimestamp(milliseconds / 1000)
|
if milliseconds < 0:
|
||||||
f = r.strftime("%H:%M:%S.%f")
|
return '-' + millisecondToHumanstr(abs(milliseconds))
|
||||||
return f[:-2]
|
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=','):
|
def toList(value, itemcast=None, delim=','):
|
||||||
|
@ -644,3 +656,8 @@ def openOrRead(file):
|
||||||
return file.read()
|
return file.read()
|
||||||
with open(file, 'rb') as f:
|
with open(file, 'rb') as f:
|
||||||
return f.read()
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
|
from functools import cached_property
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
|
@ -445,6 +447,12 @@ class Movie(
|
||||||
self._server.query(key, params=params, method=self._server._session.put)
|
self._server.query(key, params=params, method=self._server._session.put)
|
||||||
return self
|
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
|
@utils.registerPlexObject
|
||||||
class Show(
|
class Show(
|
||||||
|
@ -655,6 +663,12 @@ class Show(
|
||||||
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
|
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
|
||||||
return filepaths
|
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
|
@utils.registerPlexObject
|
||||||
class Season(
|
class Season(
|
||||||
|
@ -663,7 +677,7 @@ class Season(
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
SeasonEditMixins
|
SeasonEditMixins
|
||||||
):
|
):
|
||||||
""" Represents a single Show Season (including all episodes).
|
""" Represents a single Season.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
|
@ -808,6 +822,12 @@ class Season(
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return f'{self.parentTitle} - {self.title}'
|
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
|
@utils.registerPlexObject
|
||||||
class Episode(
|
class Episode(
|
||||||
|
@ -816,7 +836,7 @@ class Episode(
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
EpisodeEditMixins
|
EpisodeEditMixins
|
||||||
):
|
):
|
||||||
""" Represents a single Shows Episode.
|
""" Represents a single Episode.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Video'
|
TAG (str): 'Video'
|
||||||
|
@ -845,7 +865,7 @@ class Episode(
|
||||||
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
|
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
|
||||||
parentIndex (int): Season number of episode.
|
parentIndex (int): Season number of episode.
|
||||||
parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>).
|
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>).
|
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||||
parentTitle (str): Name of the season for the episode.
|
parentTitle (str): Name of the season for the episode.
|
||||||
parentYear (int): Year the season was released.
|
parentYear (int): Year the season was released.
|
||||||
|
@ -866,7 +886,6 @@ class Episode(
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Video._loadData(self, data)
|
Video._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
self._seasonNumber = None # cached season number
|
|
||||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||||
self.chapters = self.findItems(data, media.Chapter)
|
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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||||
self.parentGuid = data.attrib.get('parentGuid')
|
self.parentGuid = data.attrib.get('parentGuid')
|
||||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
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.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
||||||
self.producers = self.findItems(data, media.Producer)
|
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.
|
# 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
|
# 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:
|
# Use cached properties below to return the correct values if they are missing to avoid auto-reloading.
|
||||||
# Parse the parentRatingKey from the parentThumb
|
self._parentKey = data.attrib.get('parentKey')
|
||||||
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
|
self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||||
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
|
self._parentThumb = data.attrib.get('parentThumb')
|
||||||
# Get the parentRatingKey from the season's ratingKey
|
|
||||||
if not self.parentRatingKey and self.grandparentRatingKey:
|
@cached_property
|
||||||
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
|
def parentKey(self):
|
||||||
if self.parentRatingKey:
|
""" Returns the parentKey. Refer to the Episode attributes. """
|
||||||
self.parentKey = f'/library/metadata/{self.parentRatingKey}'
|
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):
|
def __repr__(self):
|
||||||
return '<{}>'.format(
|
return '<{}>'.format(
|
||||||
|
@ -949,12 +1000,10 @@ class Episode(
|
||||||
""" Returns the episode number. """
|
""" Returns the episode number. """
|
||||||
return self.index
|
return self.index
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def seasonNumber(self):
|
def seasonNumber(self):
|
||||||
""" Returns the episode's season number. """
|
""" Returns the episode's season number. """
|
||||||
if self._seasonNumber is None:
|
return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber
|
||||||
self._seasonNumber = self.parentIndex if isinstance(self.parentIndex, int) else self.season().seasonNumber
|
|
||||||
return utils.cast(int, self._seasonNumber)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def seasonEpisode(self):
|
def seasonEpisode(self):
|
||||||
|
@ -1000,6 +1049,12 @@ class Episode(
|
||||||
self._server.query(key, params=params, method=self._server._session.put)
|
self._server.query(key, params=params, method=self._server._session.put)
|
||||||
return self
|
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
|
@utils.registerPlexObject
|
||||||
class Clip(
|
class Clip(
|
||||||
|
@ -1058,6 +1113,12 @@ class Clip(
|
||||||
""" Returns a filename for use in download. """
|
""" Returns a filename for use in download. """
|
||||||
return self.title
|
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):
|
class Extra(Clip):
|
||||||
""" Represents a single Extra (trailer, behindTheScenes, etc). """
|
""" Represents a single Extra (trailer, behindTheScenes, etc). """
|
||||||
|
|
|
@ -22,7 +22,6 @@ from future.builtins import str
|
||||||
from future.builtins import object
|
from future.builtins import object
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
import plexpy
|
import plexpy
|
||||||
if plexpy.PYTHON2:
|
if plexpy.PYTHON2:
|
||||||
|
@ -272,7 +271,7 @@ class DataFactory(object):
|
||||||
|
|
||||||
item['user_thumb'] = users_lookup.get(item['user_id'])
|
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']:
|
if item['media_type'] == 'episode' and item['parent_thumb']:
|
||||||
thumb = item['parent_thumb']
|
thumb = item['parent_thumb']
|
||||||
|
@ -1218,7 +1217,7 @@ class DataFactory(object):
|
||||||
library_stats.append(library)
|
library_stats.append(library)
|
||||||
|
|
||||||
library_stats = session.mask_session_info(library_stats)
|
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
|
return library_stats
|
||||||
|
|
||||||
|
|
|
@ -371,6 +371,7 @@ class Export(object):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'metadataDirectory': None,
|
||||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||||
'originalTitle': None,
|
'originalTitle': None,
|
||||||
'producers': {
|
'producers': {
|
||||||
|
@ -420,8 +421,6 @@ class Export(object):
|
||||||
'audioLanguage': None,
|
'audioLanguage': None,
|
||||||
'autoDeletionItemPolicyUnwatchedLibrary': None,
|
'autoDeletionItemPolicyUnwatchedLibrary': None,
|
||||||
'autoDeletionItemPolicyWatchedLibrary': None,
|
'autoDeletionItemPolicyWatchedLibrary': None,
|
||||||
'banner': None,
|
|
||||||
'bannerFile': lambda o: self.get_image(o, 'banner'),
|
|
||||||
'childCount': None,
|
'childCount': None,
|
||||||
'collections': {
|
'collections': {
|
||||||
'id': None,
|
'id': None,
|
||||||
|
@ -459,6 +458,7 @@ class Export(object):
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
'locations': None,
|
'locations': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'network': None,
|
'network': None,
|
||||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||||
'originalTitle': None,
|
'originalTitle': None,
|
||||||
|
@ -525,6 +525,7 @@ class Export(object):
|
||||||
'librarySectionID': None,
|
'librarySectionID': None,
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'parentGuid': None,
|
'parentGuid': None,
|
||||||
'parentIndex': None,
|
'parentIndex': None,
|
||||||
'parentKey': None,
|
'parentKey': None,
|
||||||
|
@ -771,6 +772,7 @@ class Export(object):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'metadataDirectory': None,
|
||||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||||
'parentGuid': None,
|
'parentGuid': None,
|
||||||
'parentIndex': None,
|
'parentIndex': None,
|
||||||
|
@ -851,6 +853,7 @@ class Export(object):
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
'locations': None,
|
'locations': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'moods': {
|
'moods': {
|
||||||
'id': None,
|
'id': None,
|
||||||
'tag': None
|
'tag': None
|
||||||
|
@ -920,6 +923,7 @@ class Export(object):
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
'loudnessAnalysisVersion': None,
|
'loudnessAnalysisVersion': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'moods': {
|
'moods': {
|
||||||
'id': None,
|
'id': None,
|
||||||
'tag': None
|
'tag': None
|
||||||
|
@ -1089,6 +1093,7 @@ class Export(object):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'metadataDirectory': None,
|
||||||
'moods': {
|
'moods': {
|
||||||
'id': None,
|
'id': None,
|
||||||
'tag': None
|
'tag': None
|
||||||
|
@ -1135,6 +1140,7 @@ class Export(object):
|
||||||
'librarySectionID': None,
|
'librarySectionID': None,
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'ratingKey': None,
|
'ratingKey': None,
|
||||||
'summary': None,
|
'summary': None,
|
||||||
'thumb': None,
|
'thumb': None,
|
||||||
|
@ -1166,6 +1172,7 @@ class Export(object):
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
'locations': None,
|
'locations': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
|
||||||
'parentGuid': None,
|
'parentGuid': None,
|
||||||
'parentIndex': None,
|
'parentIndex': None,
|
||||||
|
@ -1240,6 +1247,7 @@ class Export(object):
|
||||||
'librarySectionKey': None,
|
'librarySectionKey': None,
|
||||||
'librarySectionTitle': None,
|
'librarySectionTitle': None,
|
||||||
'maxYear': None,
|
'maxYear': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'minYear': None,
|
'minYear': None,
|
||||||
'ratingKey': None,
|
'ratingKey': None,
|
||||||
'subtype': None,
|
'subtype': None,
|
||||||
|
@ -1268,6 +1276,7 @@ class Export(object):
|
||||||
'icon': None,
|
'icon': None,
|
||||||
'key': None,
|
'key': None,
|
||||||
'leafCount': None,
|
'leafCount': None,
|
||||||
|
'metadataDirectory': None,
|
||||||
'playlistType': None,
|
'playlistType': None,
|
||||||
'ratingKey': None,
|
'ratingKey': None,
|
||||||
'smart': None,
|
'smart': None,
|
||||||
|
@ -1382,7 +1391,7 @@ class Export(object):
|
||||||
'fields.name', 'fields.locked', 'guids.id'
|
'fields.name', 'fields.locked', 'guids.id'
|
||||||
],
|
],
|
||||||
3: [
|
3: [
|
||||||
'art', 'thumb', 'banner', 'theme', 'key',
|
'art', 'thumb', 'theme', 'key',
|
||||||
'updatedAt', 'lastViewedAt', 'viewCount', 'lastRatedAt'
|
'updatedAt', 'lastViewedAt', 'viewCount', 'lastRatedAt'
|
||||||
],
|
],
|
||||||
9: self._get_all_metadata_attrs(_media_type)
|
9: self._get_all_metadata_attrs(_media_type)
|
||||||
|
@ -2258,8 +2267,6 @@ class Export(object):
|
||||||
image_url = item.thumbUrl
|
image_url = item.thumbUrl
|
||||||
elif image == 'art':
|
elif image == 'art':
|
||||||
image_url = item.artUrl
|
image_url = item.artUrl
|
||||||
elif image == 'banner':
|
|
||||||
image_url = item.bannerUrl
|
|
||||||
|
|
||||||
if not image_url:
|
if not image_url:
|
||||||
return
|
return
|
||||||
|
|
229
plexpy/graphs.py
229
plexpy/graphs.py
|
@ -22,7 +22,6 @@ from future.builtins import object
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import plexpy
|
import plexpy
|
||||||
if plexpy.PYTHON2:
|
if plexpy.PYTHON2:
|
||||||
import common
|
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)
|
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_day: %s." % e)
|
||||||
return None
|
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
|
# create our date range as some days may not have any data
|
||||||
# but we still want to display them
|
# but we still want to display them
|
||||||
base = datetime.date.today()
|
base = datetime.date.today()
|
||||||
|
@ -116,22 +117,13 @@ class Graphs(object):
|
||||||
for date_item in sorted(date_list):
|
for date_item in sorted(date_list):
|
||||||
date_string = date_item.strftime('%Y-%m-%d')
|
date_string = date_item.strftime('%Y-%m-%d')
|
||||||
categories.append(date_string)
|
categories.append(date_string)
|
||||||
series_1_value = 0
|
|
||||||
series_2_value = 0
|
result_date = result_by_date_played.get(date_string, {})
|
||||||
series_3_value = 0
|
|
||||||
series_4_value = 0
|
series_1_value = result_date.get('tv_count', 0)
|
||||||
for item in result:
|
series_2_value = result_date.get('movie_count', 0)
|
||||||
if date_string == item['date_played']:
|
series_3_value = result_date.get('music_count', 0)
|
||||||
series_1_value = item['tv_count']
|
series_4_value = result_date.get('live_count', 0)
|
||||||
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
|
|
||||||
|
|
||||||
series_1.append(series_1_value)
|
series_1.append(series_1_value)
|
||||||
series_2.append(series_2_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)
|
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_dayofweek: %s." % e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
result_by_dayofweek = {item['dayofweek']: item for item in result}
|
||||||
|
|
||||||
if plexpy.CONFIG.WEEK_START_MONDAY:
|
if plexpy.CONFIG.WEEK_START_MONDAY:
|
||||||
days_list = ['Monday', 'Tuesday', 'Wednesday',
|
days_list = ['Monday', 'Tuesday', 'Wednesday',
|
||||||
'Thursday', 'Friday', 'Saturday', 'Sunday']
|
'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||||
|
@ -249,22 +243,13 @@ class Graphs(object):
|
||||||
|
|
||||||
for day_item in days_list:
|
for day_item in days_list:
|
||||||
categories.append(day_item)
|
categories.append(day_item)
|
||||||
series_1_value = 0
|
|
||||||
series_2_value = 0
|
result_day = result_by_dayofweek.get(day_item, {})
|
||||||
series_3_value = 0
|
|
||||||
series_4_value = 0
|
series_1_value = result_day.get('tv_count', 0)
|
||||||
for item in result:
|
series_2_value = result_day.get('movie_count', 0)
|
||||||
if day_item == item['dayofweek']:
|
series_3_value = result_day.get('music_count', 0)
|
||||||
series_1_value = item['tv_count']
|
series_4_value = result_day.get('live_count', 0)
|
||||||
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
|
|
||||||
|
|
||||||
series_1.append(series_1_value)
|
series_1.append(series_1_value)
|
||||||
series_2.append(series_2_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)
|
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_hourofday: %s." % e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
result_by_hourofday = {item['hourofday']: item for item in result}
|
||||||
|
|
||||||
hours_list = ['00', '01', '02', '03', '04', '05',
|
hours_list = ['00', '01', '02', '03', '04', '05',
|
||||||
'06', '07', '08', '09', '10', '11',
|
'06', '07', '08', '09', '10', '11',
|
||||||
'12', '13', '14', '15', '16', '17',
|
'12', '13', '14', '15', '16', '17',
|
||||||
|
@ -364,22 +351,13 @@ class Graphs(object):
|
||||||
|
|
||||||
for hour_item in hours_list:
|
for hour_item in hours_list:
|
||||||
categories.append(hour_item)
|
categories.append(hour_item)
|
||||||
series_1_value = 0
|
|
||||||
series_2_value = 0
|
result_hour = result_by_hourofday.get(hour_item, {})
|
||||||
series_3_value = 0
|
|
||||||
series_4_value = 0
|
series_1_value = result_hour.get('tv_count', 0)
|
||||||
for item in result:
|
series_2_value = result_hour.get('movie_count', 0)
|
||||||
if hour_item == item['hourofday']:
|
series_3_value = result_hour.get('music_count', 0)
|
||||||
series_1_value = item['tv_count']
|
series_4_value = result_hour.get('live_count', 0)
|
||||||
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
|
|
||||||
|
|
||||||
series_1.append(series_1_value)
|
series_1.append(series_1_value)
|
||||||
series_2.append(series_2_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)
|
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_month: %s." % e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
result_by_datestring = {item['datestring']: item for item in result}
|
||||||
|
|
||||||
# create our date range as some months may not have any data
|
# create our date range as some months may not have any data
|
||||||
# but we still want to display them
|
# but we still want to display them
|
||||||
dt_today = datetime.date.today()
|
dt_today = datetime.date.today()
|
||||||
|
@ -487,22 +467,13 @@ class Graphs(object):
|
||||||
for dt in sorted(month_range):
|
for dt in sorted(month_range):
|
||||||
date_string = dt.strftime('%Y-%m')
|
date_string = dt.strftime('%Y-%m')
|
||||||
categories.append(dt.strftime('%b %Y'))
|
categories.append(dt.strftime('%b %Y'))
|
||||||
series_1_value = 0
|
|
||||||
series_2_value = 0
|
result_date = result_by_datestring.get(date_string, {})
|
||||||
series_3_value = 0
|
|
||||||
series_4_value = 0
|
series_1_value = result_date.get('tv_count', 0)
|
||||||
for item in result:
|
series_2_value = result_date.get('movie_count', 0)
|
||||||
if date_string == item['datestring']:
|
series_3_value = result_date.get('music_count', 0)
|
||||||
series_1_value = item['tv_count']
|
series_4_value = result_date.get('live_count', 0)
|
||||||
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
|
|
||||||
|
|
||||||
series_1.append(series_1_value)
|
series_1.append(series_1_value)
|
||||||
series_2.append(series_2_value)
|
series_2.append(series_2_value)
|
||||||
|
@ -599,6 +570,7 @@ class Graphs(object):
|
||||||
|
|
||||||
for item in result:
|
for item in result:
|
||||||
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
|
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
|
||||||
|
|
||||||
series_1.append(item['tv_count'])
|
series_1.append(item['tv_count'])
|
||||||
series_2.append(item['movie_count'])
|
series_2.append(item['movie_count'])
|
||||||
series_3.append(item['music_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')
|
categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User')
|
||||||
else:
|
else:
|
||||||
categories.append(item['friendly_name'])
|
categories.append(item['friendly_name'])
|
||||||
|
|
||||||
series_1.append(item['tv_count'])
|
series_1.append(item['tv_count'])
|
||||||
series_2.append(item['movie_count'])
|
series_2.append(item['movie_count'])
|
||||||
series_3.append(item['music_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)
|
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e)
|
||||||
return None
|
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
|
# create our date range as some days may not have any data
|
||||||
# but we still want to display them
|
# but we still want to display them
|
||||||
base = datetime.date.today()
|
base = datetime.date.today()
|
||||||
|
@ -797,19 +772,12 @@ class Graphs(object):
|
||||||
for date_item in sorted(date_list):
|
for date_item in sorted(date_list):
|
||||||
date_string = date_item.strftime('%Y-%m-%d')
|
date_string = date_item.strftime('%Y-%m-%d')
|
||||||
categories.append(date_string)
|
categories.append(date_string)
|
||||||
series_1_value = 0
|
|
||||||
series_2_value = 0
|
result_date = result_by_date_played.get(date_string, {})
|
||||||
series_3_value = 0
|
|
||||||
for item in result:
|
series_1_value = result_date.get('dp_count', 0)
|
||||||
if date_string == item['date_played']:
|
series_2_value = result_date.get('ds_count', 0)
|
||||||
series_1_value = item['dp_count']
|
series_3_value = result_date.get('tc_count', 0)
|
||||||
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
|
|
||||||
|
|
||||||
series_1.append(series_1_value)
|
series_1.append(series_1_value)
|
||||||
series_2.append(series_2_value)
|
series_2.append(series_2_value)
|
||||||
|
@ -826,6 +794,100 @@ class Graphs(object):
|
||||||
'series': [series_1_output, series_2_output, series_3_output]}
|
'series': [series_1_output, series_2_output, series_3_output]}
|
||||||
return 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):
|
def get_total_plays_by_source_resolution(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
|
||||||
monitor_db = database.MonitorDatabase()
|
monitor_db = database.MonitorDatabase()
|
||||||
|
|
||||||
|
@ -888,6 +950,7 @@ class Graphs(object):
|
||||||
|
|
||||||
for item in result:
|
for item in result:
|
||||||
categories.append(item['resolution'])
|
categories.append(item['resolution'])
|
||||||
|
|
||||||
series_1.append(item['dp_count'])
|
series_1.append(item['dp_count'])
|
||||||
series_2.append(item['ds_count'])
|
series_2.append(item['ds_count'])
|
||||||
series_3.append(item['tc_count'])
|
series_3.append(item['tc_count'])
|
||||||
|
@ -991,6 +1054,7 @@ class Graphs(object):
|
||||||
|
|
||||||
for item in result:
|
for item in result:
|
||||||
categories.append(item['resolution'])
|
categories.append(item['resolution'])
|
||||||
|
|
||||||
series_1.append(item['dp_count'])
|
series_1.append(item['dp_count'])
|
||||||
series_2.append(item['ds_count'])
|
series_2.append(item['ds_count'])
|
||||||
series_3.append(item['tc_count'])
|
series_3.append(item['tc_count'])
|
||||||
|
@ -1066,6 +1130,7 @@ class Graphs(object):
|
||||||
|
|
||||||
for item in result:
|
for item in result:
|
||||||
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
|
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
|
||||||
|
|
||||||
series_1.append(item['dp_count'])
|
series_1.append(item['dp_count'])
|
||||||
series_2.append(item['ds_count'])
|
series_2.append(item['ds_count'])
|
||||||
series_3.append(item['tc_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')
|
categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User')
|
||||||
else:
|
else:
|
||||||
categories.append(item['friendly_name'])
|
categories.append(item['friendly_name'])
|
||||||
|
|
||||||
series_1.append(item['dp_count'])
|
series_1.append(item['dp_count'])
|
||||||
series_2.append(item['ds_count'])
|
series_2.append(item['ds_count'])
|
||||||
series_3.append(item['tc_count'])
|
series_3.append(item['tc_count'])
|
||||||
|
@ -1169,15 +1235,16 @@ class Graphs(object):
|
||||||
|
|
||||||
return output
|
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.
|
Expects user_id to be a comma-separated list of ints.
|
||||||
"""
|
"""
|
||||||
user_cond = ''
|
user_cond = ''
|
||||||
|
|
||||||
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
|
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:
|
elif user_id:
|
||||||
user_ids = helpers.split_strip(user_id)
|
user_ids = helpers.split_strip(user_id)
|
||||||
if all(id.isdigit() for id in user_ids):
|
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
|
return user_cond
|
||||||
|
|
|
@ -32,6 +32,7 @@ import datetime
|
||||||
from functools import reduce, wraps
|
from functools import reduce, wraps
|
||||||
import hashlib
|
import hashlib
|
||||||
import imghdr
|
import imghdr
|
||||||
|
from itertools import groupby
|
||||||
from future.moves.itertools import islice, zip_longest
|
from future.moves.itertools import islice, zip_longest
|
||||||
from ipaddress import ip_address, ip_network, IPv4Address
|
from ipaddress import ip_address, ip_network, IPv4Address
|
||||||
import ipwhois
|
import ipwhois
|
||||||
|
@ -1193,18 +1194,20 @@ def get_plexpy_url(hostname=None):
|
||||||
scheme = 'http'
|
scheme = 'http'
|
||||||
|
|
||||||
if hostname is None and plexpy.CONFIG.HTTP_HOST in ('0.0.0.0', '::'):
|
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:
|
try:
|
||||||
# Only returns IPv4 address
|
s.connect(('<broadcast>', 1))
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
||||||
s.connect(('<broadcast>', 0))
|
|
||||||
hostname = s.getsockname()[0]
|
hostname = s.getsockname()[0]
|
||||||
except socket.error:
|
except socket.error:
|
||||||
try:
|
try:
|
||||||
hostname = socket.gethostbyname(socket.gethostname())
|
hostname = socket.gethostbyname(socket.gethostname())
|
||||||
except socket.gaierror:
|
except socket.gaierror:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
if not hostname:
|
if not hostname:
|
||||||
hostname = 'localhost'
|
hostname = 'localhost'
|
||||||
|
@ -1242,6 +1245,15 @@ def grouper(iterable, n, fillvalue=None):
|
||||||
return zip_longest(fillvalue=fillvalue, *args)
|
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):
|
def chunk(it, size):
|
||||||
it = iter(it)
|
it = iter(it)
|
||||||
return iter(lambda: tuple(islice(it, size)), ())
|
return iter(lambda: tuple(islice(it, size)), ())
|
||||||
|
|
|
@ -119,13 +119,22 @@ def get_newsletters(newsletter_id=None):
|
||||||
if newsletter_id:
|
if newsletter_id:
|
||||||
where = "WHERE "
|
where = "WHERE "
|
||||||
if newsletter_id:
|
if newsletter_id:
|
||||||
where_id += "id = ?"
|
where_id += "newsletters.id = ?"
|
||||||
args.append(newsletter_id)
|
args.append(newsletter_id)
|
||||||
where += " AND ".join([w for w in [where_id] if w])
|
where += " AND ".join([w for w in [where_id] if w])
|
||||||
|
|
||||||
db = database.MonitorDatabase()
|
db = database.MonitorDatabase()
|
||||||
result = db.select("SELECT id, agent_id, agent_name, agent_label, "
|
result = db.select(
|
||||||
"friendly_name, cron, active FROM newsletters %s" % where, args=args)
|
(
|
||||||
|
"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
|
return result
|
||||||
|
|
||||||
|
|
|
@ -499,7 +499,7 @@ def get_notifiers(notifier_id=None, notify_action=None):
|
||||||
if notifier_id or notify_action:
|
if notifier_id or notify_action:
|
||||||
where = 'WHERE '
|
where = 'WHERE '
|
||||||
if notifier_id:
|
if notifier_id:
|
||||||
where_id += 'id = ?'
|
where_id += 'notifiers.id = ?'
|
||||||
args.append(notifier_id)
|
args.append(notifier_id)
|
||||||
if notify_action and notify_action in notify_actions:
|
if notify_action and notify_action in notify_actions:
|
||||||
where_action = '%s = ?' % notify_action
|
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])
|
where += ' AND '.join([w for w in [where_id, where_action] if w])
|
||||||
|
|
||||||
db = database.MonitorDatabase()
|
db = database.MonitorDatabase()
|
||||||
result = db.select("SELECT id, agent_id, agent_name, agent_label, friendly_name, %s FROM notifiers %s"
|
result = db.select(
|
||||||
% (', '.join(notify_actions), where), args=args)
|
(
|
||||||
|
"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:
|
for item in result:
|
||||||
item['active'] = int(any([item.pop(k) for k in list(item.keys()) if k in notify_actions]))
|
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'],
|
hostname=server['pms_ip'],
|
||||||
port=server['pms_port'])
|
port=server['pms_port'])
|
||||||
|
|
||||||
|
plex_tv.ping()
|
||||||
result = plex_tv.get_server_connections(pms_identifier=server['pms_identifier'],
|
result = plex_tv.get_server_connections(pms_identifier=server['pms_identifier'],
|
||||||
pms_ip=server['pms_ip'],
|
pms_ip=server['pms_ip'],
|
||||||
pms_port=server['pms_port'],
|
pms_port=server['pms_port'],
|
||||||
|
@ -354,6 +355,13 @@ class PlexTV(object):
|
||||||
|
|
||||||
return request
|
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):
|
def get_full_users_list(self):
|
||||||
own_account = self.get_plextv_user_details(output_format='xml')
|
own_account = self.get_plextv_user_details(output_format='xml')
|
||||||
friends_list = self.get_plextv_friends(output_format='xml')
|
friends_list = self.get_plextv_friends(output_format='xml')
|
||||||
|
@ -960,3 +968,18 @@ class PlexTV(object):
|
||||||
}
|
}
|
||||||
|
|
||||||
return geo_info
|
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.")
|
logger.warn("Unable to retrieve data for get_plays_by_stream_type.")
|
||||||
return result
|
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.expose
|
||||||
@cherrypy.tools.json_out()
|
@cherrypy.tools.json_out()
|
||||||
@requireAuth()
|
@requireAuth()
|
||||||
|
|
|
@ -28,7 +28,7 @@ MarkupSafe==2.1.3
|
||||||
musicbrainzngs==0.7.1
|
musicbrainzngs==0.7.1
|
||||||
packaging==23.1
|
packaging==23.1
|
||||||
paho-mqtt==1.6.1
|
paho-mqtt==1.6.1
|
||||||
plexapi==4.15.0
|
plexapi==4.15.4
|
||||||
portend==3.2.0
|
portend==3.2.0
|
||||||
profilehooks==1.12.0
|
profilehooks==1.12.0
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue